@qadanm/opencode-smart-router 0.0.0-stage → 0.6.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +286 -0
- package/CONTRIBUTING.md +103 -0
- package/LICENSE +21 -0
- package/README.md +425 -2
- package/SECURITY.md +174 -0
- package/config.example.json +107 -0
- package/dist/npm/index.js +40 -0
- package/docs/ARCHITECTURE.md +104 -0
- package/docs/DEBUGGING-GUI.md +70 -0
- package/docs/INSTALL.md +151 -0
- package/docs/LIMITATIONS.md +230 -0
- package/docs/MODELS.md +124 -0
- package/docs/OPENDESIGN-INTEGRATION.md +635 -0
- package/docs/QA.md +142 -0
- package/docs/SHARED-CORE.md +114 -0
- package/install +75 -0
- package/package.json +94 -4
- package/scripts/install.sh +324 -0
- package/scripts/uninstall.sh +91 -0
- package/src/allowance.ts +135 -0
- package/src/core/classify/classifier.ts +305 -0
- package/src/core/config-writer.ts +167 -0
- package/src/core/config.ts +1047 -0
- package/src/core/context/strategy.ts +188 -0
- package/src/core/context-health.ts +39 -0
- package/src/core/failures.ts +398 -0
- package/src/core/history/opencode.ts +173 -0
- package/src/core/models/protocol.ts +22 -0
- package/src/core/models/registry.ts +204 -0
- package/src/core/models/seed.ts +781 -0
- package/src/core/offline/mode.ts +321 -0
- package/src/core/plan-page.ts +390 -0
- package/src/core/plans/catalog.ts +487 -0
- package/src/core/policy/calibration.ts +259 -0
- package/src/core/policy/eligibility.ts +630 -0
- package/src/core/providers/enablement.ts +143 -0
- package/src/core/providers/opendesign-credits.ts +206 -0
- package/src/core/providers/opendesign-models.ts +654 -0
- package/src/core/providers/opendesign.ts +474 -0
- package/src/core/quota/calibration.ts +166 -0
- package/src/core/quota/commandcode.ts +471 -0
- package/src/core/quota/credentials.ts +490 -0
- package/src/core/quota/ledger.ts +1304 -0
- package/src/core/quota/opencode-go.ts +148 -0
- package/src/core/quota/opendesign.ts +1043 -0
- package/src/core/quota/reconcile.ts +155 -0
- package/src/core/quota/report.ts +261 -0
- package/src/core/quota/service.ts +273 -0
- package/src/core/reprompt.ts +48 -0
- package/src/core/routing/engine.ts +978 -0
- package/src/core/types.ts +866 -0
- package/src/core/ui.ts +236 -0
- package/src/core/util.ts +114 -0
- package/src/gui/index.tsx +1675 -0
- package/src/gui/viewmodel.ts +456 -0
- package/src/index.ts +33 -0
- package/src/rpc.ts +204 -0
- package/src/server/containment.ts +131 -0
- package/src/server/controller.ts +3405 -0
- package/src/server/route-tracker.ts +60 -0
- package/src/server/shared-core.ts +701 -0
- package/src/tui/index.tsx +168 -0
|
@@ -0,0 +1,490 @@
|
|
|
1
|
+
import { closeSync, constants, fstatSync, openSync, readFileSync } from "node:fs"
|
|
2
|
+
import { homedir } from "node:os"
|
|
3
|
+
import { join } from "node:path"
|
|
4
|
+
import { redact } from "../util.js"
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Credential discovery from the local filesystem.
|
|
8
|
+
*
|
|
9
|
+
* ## Security model
|
|
10
|
+
*
|
|
11
|
+
* This module reads bearer credentials that belong to the user and that the
|
|
12
|
+
* user has already placed on disk for their own tools. It is therefore a
|
|
13
|
+
* *local trust* component, not an authentication component: it never grants
|
|
14
|
+
* access to anything the user could not already reach themselves.
|
|
15
|
+
*
|
|
16
|
+
* Even so, silently harvesting secrets from predictable paths is a pattern
|
|
17
|
+
* that deserves explicit, documented containment, because a router that
|
|
18
|
+
* "finds" credentials automatically is one bad path away from reading
|
|
19
|
+
* something it has no business reading. The rules below are therefore
|
|
20
|
+
* enforced in code and asserted in tests:
|
|
21
|
+
*
|
|
22
|
+
* 1. **Opt-in, never implicit.** Automatic filesystem discovery requires
|
|
23
|
+
* `credentialSource: "auto"` (or the `SMART_ROUTER_ALLOW_KEY_DISCOVERY`
|
|
24
|
+
* environment variable). The default is `"explicit"`, which reads only
|
|
25
|
+
* credentials the user named in configuration or the environment.
|
|
26
|
+
*
|
|
27
|
+
* 2. **No cross-application credential theft.** Only paths that belong to
|
|
28
|
+
* this router's own configuration directory or to the named provider are
|
|
29
|
+
* read. Another application's unified credential store (for example
|
|
30
|
+
* OpenCode's own `auth.json`, which holds OAuth refresh tokens for every
|
|
31
|
+
* provider the user has connected) is explicitly excluded. Reading it
|
|
32
|
+
* would mean handling tokens that are not ours, for providers we were
|
|
33
|
+
* never asked about.
|
|
34
|
+
*
|
|
35
|
+
* 3. **No symlinks, no TOCTOU.** Each file is opened once, with
|
|
36
|
+
* `O_NOFOLLOW`, and the permission and size checks are applied to the
|
|
37
|
+
* resulting file descriptor. The previous implementation checked
|
|
38
|
+
* `statSync(path)` and then read `path`, which allows the path to be
|
|
39
|
+
* swapped for a symlink between the two calls.
|
|
40
|
+
*
|
|
41
|
+
* 4. **Owner-only permissions.** A file readable by group or world is
|
|
42
|
+
* refused, because a shared machine could otherwise influence which
|
|
43
|
+
* credential the router uses.
|
|
44
|
+
*
|
|
45
|
+
* 5. **Bounded reads.** Files are size-capped before a read is attempted and
|
|
46
|
+
* the descriptor is always closed.
|
|
47
|
+
*
|
|
48
|
+
* 6. **Secrets never leave this module.** A discovered key is returned to the
|
|
49
|
+
* caller and placed in an `Authorization` header for the provider it
|
|
50
|
+
* belongs to. It is never logged, never written to the ledger, never
|
|
51
|
+
* embedded in a decision, a session title, a diagnostic bundle or an
|
|
52
|
+
* error message. `redact()` is applied to every error string.
|
|
53
|
+
*/
|
|
54
|
+
|
|
55
|
+
export interface CredentialCandidate {
|
|
56
|
+
readonly source: string
|
|
57
|
+
readonly key: string
|
|
58
|
+
readonly path?: string
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface CredentialDiscovery {
|
|
62
|
+
readonly candidates: CredentialCandidate[]
|
|
63
|
+
readonly warnings: string[]
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Whether the router may look for credentials on disk without being told
|
|
68
|
+
* exactly where.
|
|
69
|
+
*
|
|
70
|
+
* - `explicit` (default) reads only configured paths and the environment.
|
|
71
|
+
* - `auto` also reads the provider's own well-known files.
|
|
72
|
+
* - `off` reads nothing from disk at all; the environment and configuration
|
|
73
|
+
* are the only sources.
|
|
74
|
+
*/
|
|
75
|
+
export type CredentialSourcePolicy = "explicit" | "auto" | "off"
|
|
76
|
+
|
|
77
|
+
export const CREDENTIAL_DISCOVERY_ENV = "SMART_ROUTER_ALLOW_KEY_DISCOVERY"
|
|
78
|
+
|
|
79
|
+
/** Resolve the effective policy, allowing an explicit environment override. */
|
|
80
|
+
export const resolveCredentialSourcePolicy = (
|
|
81
|
+
configured: CredentialSourcePolicy | undefined,
|
|
82
|
+
env: Record<string, string | undefined> = process.env,
|
|
83
|
+
): CredentialSourcePolicy => {
|
|
84
|
+
if (configured) return configured
|
|
85
|
+
const flag = env[CREDENTIAL_DISCOVERY_ENV]?.trim().toLowerCase()
|
|
86
|
+
if (flag === "1" || flag === "true" || flag === "yes") return "auto"
|
|
87
|
+
return "explicit"
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const MAX_CREDENTIAL_FILE_BYTES = 64 * 1024
|
|
91
|
+
|
|
92
|
+
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
|
93
|
+
typeof value === "object" && value !== null && !Array.isArray(value)
|
|
94
|
+
|
|
95
|
+
const pickString = (record: Record<string, unknown>, keys: ReadonlyArray<string>): string | undefined => {
|
|
96
|
+
for (const key of keys) {
|
|
97
|
+
const value = record[key]
|
|
98
|
+
if (typeof value === "string" && value.trim().length > 0) return value.trim()
|
|
99
|
+
}
|
|
100
|
+
return undefined
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const plausibleKey = (value: string): boolean => {
|
|
104
|
+
const trimmed = value.trim()
|
|
105
|
+
if (trimmed.length < 16 || trimmed.length > 4096) return false
|
|
106
|
+
if (/\s/.test(trimmed)) return false
|
|
107
|
+
// Known provider credential prefixes are unambiguous even when they are
|
|
108
|
+
// entirely snake_case, which the placeholder test below would reject.
|
|
109
|
+
if (trimmed.startsWith("sk-") || trimmed.startsWith("vela_")) return true
|
|
110
|
+
if (/^[A-Za-z_][A-Za-z0-9_]*$/.test(trimmed) && !trimmed.startsWith("user_") && !trimmed.startsWith("cmd_")) {
|
|
111
|
+
return false
|
|
112
|
+
}
|
|
113
|
+
return true
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const parseEnvFile = (text: string, envNames: ReadonlyArray<string>): string | undefined => {
|
|
117
|
+
for (const line of text.split(/\r?\n/)) {
|
|
118
|
+
const trimmed = line.trim()
|
|
119
|
+
if (!trimmed || trimmed.startsWith("#")) continue
|
|
120
|
+
const match = /^(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$/.exec(trimmed)
|
|
121
|
+
if (!match) continue
|
|
122
|
+
const [, name, rawValue] = match
|
|
123
|
+
if (!name || !envNames.includes(name)) continue
|
|
124
|
+
let value = (rawValue ?? "").trim()
|
|
125
|
+
if (
|
|
126
|
+
(value.startsWith('"') && value.endsWith('"')) ||
|
|
127
|
+
(value.startsWith("'") && value.endsWith("'"))
|
|
128
|
+
) {
|
|
129
|
+
value = value.slice(1, -1)
|
|
130
|
+
}
|
|
131
|
+
if (plausibleKey(value)) return value
|
|
132
|
+
}
|
|
133
|
+
return undefined
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const parseAuthJson = (value: unknown, outerKeys: ReadonlyArray<string>): string | undefined => {
|
|
137
|
+
if (!isRecord(value)) return undefined
|
|
138
|
+
const direct = pickString(value, ["apiKey", "key", "access", "token"])
|
|
139
|
+
if (direct && plausibleKey(direct)) return direct
|
|
140
|
+
|
|
141
|
+
for (const outer of outerKeys) {
|
|
142
|
+
const nested = value[outer]
|
|
143
|
+
if (isRecord(nested)) {
|
|
144
|
+
const key = pickString(nested, ["apiKey", "key", "access", "token"])
|
|
145
|
+
if (key && plausibleKey(key)) return key
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
for (const outer of ["providers", "auth", "credentials"]) {
|
|
150
|
+
const nested = value[outer]
|
|
151
|
+
if (isRecord(nested)) {
|
|
152
|
+
for (const inner of outerKeys) {
|
|
153
|
+
const entry = nested[inner]
|
|
154
|
+
if (isRecord(entry)) {
|
|
155
|
+
const key = pickString(entry, ["apiKey", "key", "access", "token"])
|
|
156
|
+
if (key && plausibleKey(key)) return key
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
return undefined
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export interface CredentialFileOptions {
|
|
165
|
+
/** Environment variable names accepted inside an env-style file. */
|
|
166
|
+
readonly envNames: ReadonlyArray<string>
|
|
167
|
+
/** Top-level and nested object keys accepted inside a JSON file. */
|
|
168
|
+
readonly jsonKeys: ReadonlyArray<string>
|
|
169
|
+
/** Human-readable provider name used in warnings. */
|
|
170
|
+
readonly label: string
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const COMMAND_CODE_FILE_OPTIONS: CredentialFileOptions = {
|
|
174
|
+
envNames: ["COMMAND_CODE_API_KEY", "COMMANDCODE_API_KEY"],
|
|
175
|
+
jsonKeys: ["commandcode", "command-code", "commandCode", "command_code"],
|
|
176
|
+
label: "Command Code",
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Read a credential file safely.
|
|
181
|
+
*
|
|
182
|
+
* The file is opened once with `O_NOFOLLOW` and every check runs against the
|
|
183
|
+
* open descriptor, so a path cannot be swapped for a symlink between the
|
|
184
|
+
* permission check and the read. The descriptor is closed on every path out.
|
|
185
|
+
*
|
|
186
|
+
* `explicit` marks a path the operator named directly. Discovery applies a
|
|
187
|
+
* stricter rule than configuration does: when scanning well-known locations
|
|
188
|
+
* the router must not accept a file owned by another account, because it did
|
|
189
|
+
* not intend to read that specific file. When an operator names a path, that
|
|
190
|
+
* is already a deliberate trust decision, and refusing it on uid alone breaks
|
|
191
|
+
* containers and bind mounts where the process runs as a different user than
|
|
192
|
+
* the one that owns the mounted secret.
|
|
193
|
+
*/
|
|
194
|
+
export const readCredentialFileWith = (
|
|
195
|
+
path: string,
|
|
196
|
+
warnings: string[],
|
|
197
|
+
options: CredentialFileOptions,
|
|
198
|
+
explicit = false,
|
|
199
|
+
): CredentialCandidate | undefined => {
|
|
200
|
+
let fd: number | undefined
|
|
201
|
+
try {
|
|
202
|
+
// `O_NOFOLLOW` makes a symlink fail with ELOOP rather than being followed.
|
|
203
|
+
fd = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW)
|
|
204
|
+
const stat = fstatSync(fd)
|
|
205
|
+
|
|
206
|
+
if (!stat.isFile()) {
|
|
207
|
+
warnings.push(`${options.label} credential path is not a regular file: ${path}`)
|
|
208
|
+
return undefined
|
|
209
|
+
}
|
|
210
|
+
if (stat.size > MAX_CREDENTIAL_FILE_BYTES) {
|
|
211
|
+
warnings.push(`${options.label} credential file is unexpectedly large: ${path}`)
|
|
212
|
+
return undefined
|
|
213
|
+
}
|
|
214
|
+
// The key is a bearer credential. Refuse group/world-readable files
|
|
215
|
+
// unconditionally. Refuse a file owned by another user only when the path
|
|
216
|
+
// was discovered rather than named: on a shared machine another account's
|
|
217
|
+
// file must not be able to influence which credential we use, but an
|
|
218
|
+
// operator who named this exact path already made that decision.
|
|
219
|
+
if ((stat.mode & 0o077) !== 0) {
|
|
220
|
+
warnings.push(`Ignoring ${options.label} credential file with group/world permissions: ${path}`)
|
|
221
|
+
return undefined
|
|
222
|
+
}
|
|
223
|
+
if (!explicit && typeof process.getuid === "function" && stat.uid !== process.getuid()) {
|
|
224
|
+
warnings.push(`Ignoring ${options.label} credential file owned by another user: ${path}`)
|
|
225
|
+
return undefined
|
|
226
|
+
}
|
|
227
|
+
if (stat.size === 0) return undefined
|
|
228
|
+
|
|
229
|
+
// Bounded read from the descriptor that was actually checked.
|
|
230
|
+
const text = readFileSync(fd, "utf8").trim()
|
|
231
|
+
if (!text) return undefined
|
|
232
|
+
|
|
233
|
+
let key: string | undefined
|
|
234
|
+
if (text.startsWith("{")) {
|
|
235
|
+
try {
|
|
236
|
+
key = parseAuthJson(JSON.parse(text) as unknown, options.jsonKeys)
|
|
237
|
+
} catch (error) {
|
|
238
|
+
warnings.push(`Could not parse ${path}: ${redact(error instanceof Error ? error.message : String(error))}`)
|
|
239
|
+
}
|
|
240
|
+
} else if (text.includes("=")) {
|
|
241
|
+
key = parseEnvFile(text, options.envNames)
|
|
242
|
+
} else if (plausibleKey(text)) {
|
|
243
|
+
key = text
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
if (!key) {
|
|
247
|
+
warnings.push(`No ${options.label} API key found in ${path}`)
|
|
248
|
+
return undefined
|
|
249
|
+
}
|
|
250
|
+
return { source: path, key, path }
|
|
251
|
+
} catch (error) {
|
|
252
|
+
const code = (error as NodeJS.ErrnoException)?.code
|
|
253
|
+
if (code === "ENOENT") return undefined
|
|
254
|
+
if (code === "ELOOP") {
|
|
255
|
+
warnings.push(`Ignoring ${options.label} credential path because it is a symlink: ${path}`)
|
|
256
|
+
return undefined
|
|
257
|
+
}
|
|
258
|
+
warnings.push(`Could not read ${path}: ${redact(error instanceof Error ? error.message : String(error))}`)
|
|
259
|
+
return undefined
|
|
260
|
+
} finally {
|
|
261
|
+
if (fd !== undefined) {
|
|
262
|
+
try {
|
|
263
|
+
closeSync(fd)
|
|
264
|
+
} catch {
|
|
265
|
+
// Closing a descriptor we already failed to read is not actionable.
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
export const readCredentialFile = (
|
|
272
|
+
path: string,
|
|
273
|
+
warnings: string[],
|
|
274
|
+
): CredentialCandidate | undefined => readCredentialFileWith(path, warnings, COMMAND_CODE_FILE_OPTIONS)
|
|
275
|
+
|
|
276
|
+
export interface DiscoverCommandCodeInput {
|
|
277
|
+
readonly home?: string
|
|
278
|
+
readonly env?: Record<string, string | undefined>
|
|
279
|
+
readonly explicitPath?: string
|
|
280
|
+
readonly openCodeIntegrationKey?: string
|
|
281
|
+
readonly providerSettingsKey?: string
|
|
282
|
+
/**
|
|
283
|
+
* Whether unwritten-on-disk discovery is permitted. Defaults to reading
|
|
284
|
+
* `SMART_ROUTER_ALLOW_KEY_DISCOVERY`; without it, only the environment and
|
|
285
|
+
* explicitly configured paths are used.
|
|
286
|
+
*/
|
|
287
|
+
readonly credentialSource?: CredentialSourcePolicy
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* Command Code credential search order.
|
|
292
|
+
*
|
|
293
|
+
* The provider API key configured in OpenCode is deliberately last: it can
|
|
294
|
+
* serve inference but may not be authorized for the `/alpha` endpoints.
|
|
295
|
+
*
|
|
296
|
+
* Note what is *absent*: OpenCode's own `auth.json` is never read. That file is
|
|
297
|
+
* another application's unified credential store and contains OAuth material
|
|
298
|
+
* for every provider the user has connected. Those tokens belong to providers
|
|
299
|
+
* this router was never asked about.
|
|
300
|
+
*/
|
|
301
|
+
export const discoverCommandCodeCredentials = (input: DiscoverCommandCodeInput = {}): CredentialDiscovery => {
|
|
302
|
+
const warnings: string[] = []
|
|
303
|
+
const candidates: CredentialCandidate[] = []
|
|
304
|
+
const seen = new Set<string>()
|
|
305
|
+
const home = input.home ?? homedir()
|
|
306
|
+
const env = input.env ?? process.env
|
|
307
|
+
const policy = resolveCredentialSourcePolicy(input.credentialSource, env)
|
|
308
|
+
|
|
309
|
+
const add = (candidate: CredentialCandidate | undefined) => {
|
|
310
|
+
if (!candidate || seen.has(candidate.key)) return
|
|
311
|
+
seen.add(candidate.key)
|
|
312
|
+
candidates.push(candidate)
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
const envKey = env.COMMAND_CODE_API_KEY?.trim() || env.COMMANDCODE_API_KEY?.trim()
|
|
316
|
+
if (envKey && plausibleKey(envKey)) add({ source: "environment", key: envKey })
|
|
317
|
+
|
|
318
|
+
if (input.explicitPath) add(readCredentialFileWith(input.explicitPath, warnings, COMMAND_CODE_FILE_OPTIONS, true))
|
|
319
|
+
|
|
320
|
+
if (policy === "off") {
|
|
321
|
+
if (candidates.length === 0) {
|
|
322
|
+
warnings.push(
|
|
323
|
+
"Credential discovery is disabled, so no Command Code key was found. Set COMMAND_CODE_API_KEY or configure an explicit path.",
|
|
324
|
+
)
|
|
325
|
+
}
|
|
326
|
+
} else if (policy === "auto") {
|
|
327
|
+
const defaultFiles = [
|
|
328
|
+
join(home, ".commandcode", "auth.json"),
|
|
329
|
+
join(home, ".config", "opencode", "commandcode.key"),
|
|
330
|
+
join(home, ".config", "opencode", "commandcode.env"),
|
|
331
|
+
join(home, ".config", "opencode", "commandcode.json"),
|
|
332
|
+
]
|
|
333
|
+
for (const path of defaultFiles) add(readCredentialFile(path, warnings))
|
|
334
|
+
} else if (candidates.length === 0) {
|
|
335
|
+
warnings.push(
|
|
336
|
+
`No Command Code key was supplied. Automatic discovery is off by default; set ${CREDENTIAL_DISCOVERY_ENV}=1 to let the router read ${join(home, ".config", "opencode", "commandcode.key")}, or set COMMAND_CODE_API_KEY.`,
|
|
337
|
+
)
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
if (input.openCodeIntegrationKey && plausibleKey(input.openCodeIntegrationKey)) {
|
|
341
|
+
add({ source: "OpenCode integration", key: input.openCodeIntegrationKey })
|
|
342
|
+
}
|
|
343
|
+
if (input.providerSettingsKey && plausibleKey(input.providerSettingsKey)) {
|
|
344
|
+
add({ source: "OpenCode provider settings", key: input.providerSettingsKey })
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
return { candidates, warnings }
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
const OPENDESIGN_FILE_OPTIONS: CredentialFileOptions = {
|
|
351
|
+
envNames: ["OPENDESIGN_API_KEY", "OPEN_DESIGN_API_KEY"],
|
|
352
|
+
jsonKeys: ["opendesign", "open-design", "openDesign", "open_design"],
|
|
353
|
+
label: "OpenDesign",
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/**
|
|
357
|
+
* An OpenDesign inference key is a bearer credential for the model gateway.
|
|
358
|
+
* It cannot read quota, and it is not a control key.
|
|
359
|
+
*/
|
|
360
|
+
export const plausibleOpenDesignKey = (value: string): boolean => {
|
|
361
|
+
const trimmed = value.trim()
|
|
362
|
+
if (!plausibleKey(trimmed)) return false
|
|
363
|
+
return /^sk-[A-Za-z0-9._-]{8,}$/.test(trimmed)
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
export interface DiscoverOpenDesignInput {
|
|
367
|
+
readonly home?: string
|
|
368
|
+
readonly env?: Record<string, string | undefined>
|
|
369
|
+
readonly explicitPath?: string
|
|
370
|
+
readonly providerSettingsKey?: string
|
|
371
|
+
readonly credentialSource?: CredentialSourcePolicy
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/**
|
|
375
|
+
* Discover the OpenDesign inference key.
|
|
376
|
+
*
|
|
377
|
+
* Only the OpenDesign hosts ever receive this value. The key is never written
|
|
378
|
+
* into a decision, a log, a session title, or a diagnostic bundle.
|
|
379
|
+
*/
|
|
380
|
+
export const discoverOpenDesignCredentials = (input: DiscoverOpenDesignInput = {}): CredentialDiscovery => {
|
|
381
|
+
const warnings: string[] = []
|
|
382
|
+
const candidates: CredentialCandidate[] = []
|
|
383
|
+
const seen = new Set<string>()
|
|
384
|
+
const home = input.home ?? homedir()
|
|
385
|
+
const env = input.env ?? process.env
|
|
386
|
+
const policy = resolveCredentialSourcePolicy(input.credentialSource, env)
|
|
387
|
+
|
|
388
|
+
const add = (candidate: CredentialCandidate | undefined) => {
|
|
389
|
+
if (!candidate || seen.has(candidate.key)) return
|
|
390
|
+
if (!plausibleOpenDesignKey(candidate.key)) return
|
|
391
|
+
seen.add(candidate.key)
|
|
392
|
+
candidates.push(candidate)
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
const envKey = env.OPENDESIGN_API_KEY?.trim() || env.OPEN_DESIGN_API_KEY?.trim()
|
|
396
|
+
if (envKey) {
|
|
397
|
+
if (plausibleOpenDesignKey(envKey)) add({ source: "environment", key: envKey })
|
|
398
|
+
else warnings.push("The OpenDesign environment key does not look like an inference key and was ignored.")
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
if (input.explicitPath) add(readCredentialFileWith(input.explicitPath, warnings, OPENDESIGN_FILE_OPTIONS, true))
|
|
402
|
+
|
|
403
|
+
if (policy === "off") {
|
|
404
|
+
if (candidates.length === 0) {
|
|
405
|
+
warnings.push(
|
|
406
|
+
"Credential discovery is disabled, so no OpenDesign key was found. Set OPENDESIGN_API_KEY or configure an explicit path.",
|
|
407
|
+
)
|
|
408
|
+
}
|
|
409
|
+
} else if (policy === "auto") {
|
|
410
|
+
const defaultFiles = [
|
|
411
|
+
join(home, ".config", "opencode", "opendesign.key"),
|
|
412
|
+
join(home, ".config", "opencode", "opendesign.env"),
|
|
413
|
+
join(home, ".config", "opencode", "opendesign.json"),
|
|
414
|
+
]
|
|
415
|
+
for (const path of defaultFiles) add(readCredentialFileWith(path, warnings, OPENDESIGN_FILE_OPTIONS))
|
|
416
|
+
} else if (candidates.length === 0) {
|
|
417
|
+
warnings.push(
|
|
418
|
+
`No OpenDesign key was supplied. Automatic discovery is off by default; set ${CREDENTIAL_DISCOVERY_ENV}=1 to let the router read ${join(home, ".config", "opencode", "opendesign.key")}, or set OPENDESIGN_API_KEY.`,
|
|
419
|
+
)
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
if (input.providerSettingsKey) add({ source: "OpenCode provider settings", key: input.providerSettingsKey })
|
|
423
|
+
|
|
424
|
+
return { candidates, warnings }
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
/**
|
|
428
|
+
* Discover an OpenDesign account-control key.
|
|
429
|
+
*
|
|
430
|
+
* The control key can read the account's coding-plan usage windows. It is a
|
|
431
|
+
* privileged credential, it is never discoverable from the environment, and
|
|
432
|
+
* the router never requests one. It is used only when the owner points at a
|
|
433
|
+
* file explicitly.
|
|
434
|
+
*/
|
|
435
|
+
export const discoverOpenDesignControlKey = (input: {
|
|
436
|
+
readonly explicitPath?: string
|
|
437
|
+
} = {}): CredentialDiscovery => {
|
|
438
|
+
const warnings: string[] = []
|
|
439
|
+
const candidates: CredentialCandidate[] = []
|
|
440
|
+
if (!input.explicitPath) {
|
|
441
|
+
warnings.push(
|
|
442
|
+
"No OpenDesign control key path is configured, so quota is reported as an estimate rather than a verified balance.",
|
|
443
|
+
)
|
|
444
|
+
return { candidates, warnings }
|
|
445
|
+
}
|
|
446
|
+
const candidate = readCredentialFileWith(
|
|
447
|
+
input.explicitPath,
|
|
448
|
+
warnings,
|
|
449
|
+
{
|
|
450
|
+
envNames: ["OPENDESIGN_CONTROL_KEY"],
|
|
451
|
+
jsonKeys: ["opendesign-control", "opendesignControl", "control"],
|
|
452
|
+
label: "OpenDesign control",
|
|
453
|
+
},
|
|
454
|
+
true,
|
|
455
|
+
)
|
|
456
|
+
if (candidate) candidates.push(candidate)
|
|
457
|
+
return { candidates, warnings }
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
/**
|
|
461
|
+
* Describe where a resolved credential came from, without revealing it.
|
|
462
|
+
*
|
|
463
|
+
* Diagnostic output needs to answer "did the router find a key, and where from"
|
|
464
|
+
* without ever carrying the secret itself, and without leaking a path beyond
|
|
465
|
+
* the final segment. `source` is caller-supplied for the non-file cases, so it
|
|
466
|
+
* is reduced to a fixed vocabulary rather than echoed: an arbitrary string here
|
|
467
|
+
* would be an unbounded channel into a persisted snapshot note.
|
|
468
|
+
*/
|
|
469
|
+
export const describeCredentialSource = (candidate: CredentialCandidate | undefined): string => {
|
|
470
|
+
if (!candidate) return "not found"
|
|
471
|
+
if (candidate.path) {
|
|
472
|
+
// Only the final path segment is useful for support and leaks no prefix.
|
|
473
|
+
// A trailing separator yields an empty segment rather than undefined, so
|
|
474
|
+
// filter empties before falling back.
|
|
475
|
+
const segments = candidate.path.split("/").filter((segment) => segment.length > 0)
|
|
476
|
+
const base = segments[segments.length - 1] ?? "file"
|
|
477
|
+
return `file (${base})`
|
|
478
|
+
}
|
|
479
|
+
switch (candidate.source) {
|
|
480
|
+
case "environment":
|
|
481
|
+
return "environment variable"
|
|
482
|
+
case "OpenCode integration":
|
|
483
|
+
return "OpenCode integration"
|
|
484
|
+
case "OpenCode provider settings":
|
|
485
|
+
return "OpenCode provider settings"
|
|
486
|
+
default:
|
|
487
|
+
// Never echo an unrecognized source string.
|
|
488
|
+
return "configured"
|
|
489
|
+
}
|
|
490
|
+
}
|