-
Notifications
You must be signed in to change notification settings - Fork 1
Expand file tree
/
Copy pathcredentials.ts
More file actions
251 lines (237 loc) · 9.93 KB
/
Copy pathcredentials.ts
File metadata and controls
251 lines (237 loc) · 9.93 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
/**
* @file Layered provider-credential resolver for AI backends. One call site,
* dev and CI: a provider token resolves from an explicit override, then the
* provider's env var, then the OS keychain — mirroring the
* `readSocketApiToken` env → keychain precedence
* (`secrets/socket-api-token.ts`). Why a single resolver: `ai/http.mts` read
* `process.env[tokenEnv]` inline, so every consumer hard-coded the env-only
* path and none could reach the keychain. Routing skills also need a uniform
* way to ask "do I have a credential for provider X?" without knowing its
* env-var name. This centralizes the provider → { tokenEnv, keychainService }
* map (the HTTP providers reuse `AI_HTTP_PROVIDERS` so the env var isn't
* duplicated) and the precedence. CI vs dev: pass `allowEnvOnly: true` (the
* resolver's existing escape) in headless contexts so a missing token returns
* `undefined` immediately instead of triggering a keychain auth prompt. CI
* sets the token as a GH-secret env var (e.g. `ANTHROPIC_API_KEY`); the same
* `resolveProviderCredential` call reads it there with no keychain. proteus
* hook-point: the forthcoming biometric credential daemon slots in as a
* layer between the env check and the keychain read inside `resolve()`'s
* implementation — call sites here do not change when it lands. This module
* is the stable seam.
*/
import { resolve } from '../secrets/find'
import {
deleteSecret,
getBackendAvailability,
writeSecret,
} from '../secrets/keychain'
/**
* A KEYED provider whose credential this module resolves from a token source:
* the HTTP providers fireworks and synthetic, plus the CLI/first-party
* providers anthropic, openai, and xai for CI env + keychain. Every keyed
* provider has a `{ tokenEnv, keychainService }` entry in
* `PROVIDER_CREDENTIALS`.
*/
export type KeyedCredentialProvider =
| 'anthropic'
| 'fireworks'
| 'openai'
| 'synthetic'
| 'xai'
/**
* The keyless on-device provider: a `local` engine (the `builtin.mts`
* LanguageModel seam / an injected local runner) that needs NO credential — it
* runs on the machine, so it is "always present with no token". It is a
* `CredentialProvider` so routing can name it uniformly, but it is deliberately
* NOT in `PROVIDER_CREDENTIALS` because there is nothing to resolve. Use
* `isKeylessProvider` to branch before a token lookup.
*/
export const KEYLESS_PROVIDER = 'local' as const
/**
* A provider routing can name: every keyed provider, plus the keyless `local`
* on-device engine. Token resolution only applies to the keyed subset; a
* keyless provider resolves as always-present-with-no-token.
*/
export type CredentialProvider =
| KeyedCredentialProvider
| typeof KEYLESS_PROVIDER
export interface ProviderCredentialSpec {
// The env var the token lives in (CI sets this as a secret).
readonly tokenEnv: string
// The OS-keychain service name for the dev-machine keychain entry.
readonly keychainService: string
}
// Single source of truth for provider → { tokenEnv, keychainService }. The
// fireworks/synthetic tokenEnv values MUST match `AI_HTTP_PROVIDERS` in
// `ai/http.mts` (a check keeps them in sync rather than an import — importing
// the runtime const here would create an http ↔ credentials cycle). The
// keychain service is the Socket-uniform `socketsecurity` scope (the daemon /
// keychain stores per-account, account == tokenEnv).
export const PROVIDER_CREDENTIALS: Readonly<
Record<KeyedCredentialProvider, ProviderCredentialSpec>
> = {
__proto__: null,
anthropic: {
keychainService: 'socketsecurity',
tokenEnv: 'ANTHROPIC_API_KEY',
},
fireworks: {
keychainService: 'socketsecurity',
tokenEnv: 'FIREWORKS_API_KEY',
},
openai: { keychainService: 'socketsecurity', tokenEnv: 'OPENAI_API_KEY' },
synthetic: {
keychainService: 'socketsecurity',
tokenEnv: 'SYNTHETIC_API_KEY',
},
xai: { keychainService: 'socketsecurity', tokenEnv: 'XAI_API_KEY' },
} as unknown as Readonly<
Record<KeyedCredentialProvider, ProviderCredentialSpec>
>
export interface DeleteProviderCredentialOptions {
// The provider whose stored credential to remove.
readonly provider: CredentialProvider
}
/**
* Remove a provider's stored credential from the OS keychain — the same
* `{ service, account }` slot `resolveProviderCredential` reads. Returns
* `'removed'` when a value was deleted, `'absent'` when none was stored (or the
* platform has no keychain backend — delete degrades to a no-op rather than
* throwing, since "nothing to remove" is the same outcome either way).
*
* @unused No internal or Socket consumers; exercised only by its unit tests.
*/
export async function deleteProviderCredential(
options: DeleteProviderCredentialOptions,
): Promise<'absent' | 'removed'> {
const opts = { __proto__: null, ...options } as typeof options
if (isKeylessProvider(opts.provider)) {
// A keyless provider stores nothing, so there is never anything to remove.
return 'absent'
}
const spec = PROVIDER_CREDENTIALS[opts.provider]
if (!spec) {
return 'absent'
}
return await deleteSecret({
account: spec.tokenEnv,
service: spec.keychainService,
})
}
/**
* True when `value` names a provider routing can use: a keyed provider with a
* resolvable credential, or the keyless `local` on-device engine.
*/
export function isCredentialProvider(
value: string,
): value is CredentialProvider {
return value in PROVIDER_CREDENTIALS || value === KEYLESS_PROVIDER
}
/**
* True when `value` is the keyless `local` provider — the on-device engine that
* needs no credential. Callers branch on this BEFORE a token lookup: routing
* treats a keyless provider as always-present (no `keyed` membership required),
* and the credential resolvers short-circuit it with no keychain/env access.
*/
export function isKeylessProvider(
value: string,
): value is typeof KEYLESS_PROVIDER {
return value === KEYLESS_PROVIDER
}
export interface ResolveProviderCredentialOptions {
// The provider whose token to resolve.
readonly provider: CredentialProvider
// An explicit token that wins over every other source (e.g. a value the
// caller already holds). Skips env + keychain entirely when set.
readonly explicit?: string | undefined
// Skip the keychain fallback — env var only. Use in headless contexts (CI,
// bootstrap hooks) where a keychain auth prompt is unacceptable.
readonly allowEnvOnly?: boolean | undefined
}
/**
* Resolve a provider's bearer token: explicit override → env var → keychain →
* undefined. The token never appears inline or in logs — callers pass the
* result straight to an `Authorization` header. Returns `undefined` when no
* source has it; the caller decides whether that's fatal.
*/
export async function resolveProviderCredential(
options: ResolveProviderCredentialOptions,
): Promise<string | undefined> {
const opts = { __proto__: null, ...options } as typeof options
if (opts.explicit) {
return opts.explicit
}
if (isKeylessProvider(opts.provider)) {
// Keyless: there is no token to resolve. The provider is "present" for
// routing (see `isKeylessProvider`) but carries no bearer credential, so a
// token lookup honestly returns undefined rather than a placeholder.
return undefined
}
const spec = PROVIDER_CREDENTIALS[opts.provider]
if (!spec) {
return undefined
}
// `resolve` checks each account as an env var first, then the keychain
// (service + account), honoring allowEnvOnly. The account IS the env-var
// name, matching the readSocketApiToken convention. The proteus daemon will
// insert its biometric layer inside `resolve()` without changing this call.
const result = await resolve({
accounts: [spec.tokenEnv],
allowEnvOnly: opts.allowEnvOnly,
service: spec.keychainService,
})
return result?.value
}
export interface WriteProviderCredentialOptions {
// The provider whose token to persist.
readonly provider: CredentialProvider
// The bearer token to store (a non-empty string; writeSecret rejects empty).
readonly value: string
}
/**
* Persist a provider's bearer token to the OS keychain — the SAME `{ service,
* account }` slot `resolveProviderCredential` reads (account == `tokenEnv`,
* service == the Socket-uniform `socketsecurity` scope), so a written token
* resolves on the next read without an env var.
*
* Keychain ONLY — this never writes a shell-rc export. That matters most for
* anthropic: a live `ANTHROPIC_API_KEY` env var overrides a Claude Max-seat
* OAuth session and silently flips the user to metered billing, so its token
* must live only in the keychain and be read on demand, never exported. A setup
* wizard that rc-exports `FIREWORKS_API_KEY`/`SYNTHETIC_API_KEY` for
* convenience must still route anthropic here, keychain-only.
*
* Returns `'written'` | `'unchanged'` (idempotent — an identical stored value
* is a no-op). Throws when the OS has no keychain backend; a caller that wants
* to degrade gracefully should check `getBackendAvailability()` first.
*/
export async function writeProviderCredential(
options: WriteProviderCredentialOptions,
): Promise<'unchanged' | 'written'> {
const opts = { __proto__: null, ...options } as typeof options
if (isKeylessProvider(opts.provider)) {
throw new Error(
`writeProviderCredential: "${opts.provider}" is a keyless provider — ` +
'it has no credential to store.',
)
}
const spec = PROVIDER_CREDENTIALS[opts.provider]
if (!spec) {
throw new Error(
`writeProviderCredential: unknown provider "${opts.provider}".`,
)
}
const backend = getBackendAvailability()
if (!backend.available) {
throw new Error(
`writeProviderCredential: no OS keychain backend (${backend.toolName}) ` +
`available to store the ${opts.provider} credential.` +
(backend.installHint ? ` ${backend.installHint}` : ''),
)
}
return await writeSecret({
account: spec.tokenEnv,
service: spec.keychainService,
value: opts.value,
})
}