From d5daece6afa11beee7e354a828aab31c8d84f3d9 Mon Sep 17 00:00:00 2001 From: jellologic <31935831+jellologic@users.noreply.github.com> Date: Wed, 22 Jul 2026 08:47:54 -0400 Subject: [PATCH 01/21] feat(store): content-hash revision, for lost-update detection `save()` was last-writer-wins, and until now that was indistinguishable from correct: every writer was a single short-lived command, so two of them could not realistically overlap. A long-lived editor breaks that assumption. A browser tab left open while `swisscode config work` runs in a terminal makes silent clobbering ordinary rather than theoretical -- and the field it clobbers could be an API key. `revision()` is a hash of the file's CONTENT, not its mtime. mtime granularity is coarse and platform-dependent, so two writes inside one tick can share a timestamp; a lost update would then slip through exactly when writers are most concurrent. Hashing bytes cannot have that failure. Optional on the port, in the same genuine sense as `modes`: a store that cannot derive one is still a store, and callers degrade rather than break. Honest about what it is -- a check, not a lock. It catches the interleaving people actually hit (read, think, write minutes later) and does not pretend to serialize concurrent writers, which would need an exclusive lock this project does not otherwise want. Signed-off-by: jellologic <31935831+jellologic@users.noreply.github.com> --- src/adapters/store/fs-config-store.ts | 22 +++++++++++++- src/ports/config-store.ts | 18 +++++++++++ test/adapters/fs-config-store.test.ts | 43 +++++++++++++++++++++++++++ 3 files changed, 82 insertions(+), 1 deletion(-) diff --git a/src/adapters/store/fs-config-store.ts b/src/adapters/store/fs-config-store.ts index 26fcc07..e332eab 100644 --- a/src/adapters/store/fs-config-store.ts +++ b/src/adapters/store/fs-config-store.ts @@ -8,6 +8,7 @@ import { unlinkSync, writeFileSync, } from 'node:fs' +import { createHash } from 'node:crypto' import { homedir } from 'node:os' import { join } from 'node:path' import type { ConfigModes, ConfigStorePort, LoadResult, State } from '../../ports/config-store.ts' @@ -211,5 +212,24 @@ export function createFsConfigStore({ return out } - return { load, save, path: () => CONFIG_PATH, dir: () => CONFIG_DIR, modes } + /** + * Content hash, not mtime. mtime has coarse and platform-dependent + * granularity, so two writes inside the same tick can share a timestamp and a + * lost update would slip through exactly when writers are most concurrent. + * Hashing the bytes cannot have that failure. The file is a few KB, so the + * cost is irrelevant next to being wrong. + * + * Null when there is no file yet — which is itself a meaningful revision: a + * caller that read "no config" and then saves must still be told if someone + * created one in the meantime. + */ + function revision(): string | null { + try { + return createHash('sha256').update(readFileSync(CONFIG_PATH)).digest('hex').slice(0, 32) + } catch { + return null + } + } + + return { load, save, path: () => CONFIG_PATH, dir: () => CONFIG_DIR, modes, revision } } diff --git a/src/ports/config-store.ts b/src/ports/config-store.ts index ee01da8..dd36798 100644 --- a/src/ports/config-store.ts +++ b/src/ports/config-store.ts @@ -164,6 +164,24 @@ export type ConfigStorePort = { /** returns the path written; THROWS if the file is readOnly */ save: (state: State) => string path: () => string + /** + * An opaque token for the file's current CONTENT, or null when there is no + * file yet. Optional in the same genuine sense as `modes`: a store that cannot + * derive one is still a valid store, and callers degrade rather than break. + * + * Exists for LOST-UPDATE detection, which only became reachable when a + * long-lived editor arrived. Every writer until now was a single short-lived + * command, so last-writer-wins was indistinguishable from correct. A web UI + * sitting open in a tab while `swisscode config work` runs in a terminal makes + * silent clobbering ordinary, and the clobbered field could be an API key. + * + * Honest about what it is: a check, not a lock. The window between comparing + * and writing is not closed by this — closing it needs an exclusive lock this + * project does not otherwise want. It reliably catches the interleaving people + * actually hit (read, think, write minutes later) and does not pretend to + * serialize concurrent writers. + */ + revision?: () => string | null /** * OPTIONAL, and genuinely so. The JSDoc contract this replaces did not list * `modes` at all, but composition/doctor-root.js probes for it diff --git a/test/adapters/fs-config-store.test.ts b/test/adapters/fs-config-store.test.ts index a30202d..2529fdb 100644 --- a/test/adapters/fs-config-store.test.ts +++ b/test/adapters/fs-config-store.test.ts @@ -204,3 +204,46 @@ test('a v1 config whose provider is no longer known still migrates', () => { const loaded = store.load() assert.equal(loaded.state.profiles.volcengine!.provider, 'volcengine') }) + +// revision(): lost-update detection for long-lived editors (the web UI). + +test('revision is null when there is no file, and a string once there is', () => { + // "No config yet" is itself a revision worth quoting back: a caller that read + // an empty state and then saves must still be told if someone created one in + // the meantime. + const { store } = freshHome() + assert.equal(store.revision!(), null) + store.save({ version: 2, profiles: {}, defaultProfile: null, bindings: {}, settings: {} }) + assert.equal(typeof store.revision!(), 'string') +}) + +test('revision follows CONTENT, not the clock', () => { + // Deliberately not mtime. Its granularity is coarse and platform-dependent, + // so two writes inside one tick can share a timestamp — a lost update would + // slip through exactly when writers are most concurrent. Hashing bytes cannot + // have that failure, and this test is what pins the choice. + const { store } = freshHome() + const base = { version: 2, profiles: {}, defaultProfile: null, bindings: {}, settings: {} } + + store.save(base) + const first = store.revision!() + + // Same content written again, immediately: same revision. + store.save(base) + assert.equal(store.revision!(), first, 'identical content changed the revision') + + // Different content, also immediately: different revision. + store.save({ ...base, defaultProfile: 'work', profiles: { work: { provider: 'zai' } } } as never) + assert.notEqual(store.revision!(), first, 'a real edit did not change the revision') +}) + +test('an edit made behind our back is visible as a revision change', () => { + // The interleaving this exists to catch: read, wait, and meanwhile another + // swisscode command writes. + const { dir, store } = freshHome() + store.save({ version: 2, profiles: {}, defaultProfile: null, bindings: {}, settings: {} }) + const held = store.revision!() + + writeFileSync(join(dir, 'config.json'), JSON.stringify({ version: 2, profiles: {}, defaultProfile: 'other', bindings: {}, settings: {} })) + assert.notEqual(store.revision!(), held) +}) From 1a2f0af2ecda32cf064451becfcf993e185e45fe Mon Sep 17 00:00:00 2001 From: jellologic <31935831+jellologic@users.noreply.github.com> Date: Wed, 22 Jul 2026 08:48:15 -0400 Subject: [PATCH 02/21] =?UTF-8?q?feat(web):=20local=20config=20UI=20backen?= =?UTF-8?q?d=20=E2=80=94=20server,=20security=20gate,=20JSON=20API?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `swisscode config web` serves a config UI on 127.0.0.1. The server is the singleton: the port bind IS the mutex, so there is no lockfile and no stale-PID reasoning — the OS already refuses to bind twice and cleans up when the process dies. Built the backend before the interface deliberately. This is the part a wrong decision is expensive in, it needs no new dependencies, and it leaves the UI framework a replaceable detail on a tested contract. THE HEXAGON PAID OFF. The API is thin because every operation was already a port operation: ConfigStorePort, ProviderRegistryPort, AgentRegistryPort are exactly what a config UI needs, and the Ink wizard consumes the same ones. Two UI adapters over one unchanged core, with no change to core/. SECURITY IS THE POINT HERE, not the dependency budget. This server reads and writes a file holding plaintext keys, and a browser will talk to 127.0.0.1 on behalf of any page the user has open. "It only listens on localhost" is why a model is needed, not a substitute for one: * Host allowlist, exact on host:port. This is the DNS-rebinding defence and nothing else can be: the connection genuinely comes from loopback, so no socket-level check can tell an attack from a real request. * The token is injected into the HTML and echoed in a CUSTOM header, never set as a cookie. A cookie is attached automatically on cross-origin requests and could be ridden; a value the client must read out of our response cannot be, because same-origin policy forbids reading it. The custom header also forces a preflight we never answer. * Keys are WRITE-ONLY. The browser gets `hasKey`, never the key. An omitted apiKey preserves the stored one and only an explicit null clears it, so "I did not touch this field" and "delete my credential" stop being the same request. * Loopback-only bind, strict CSP, path-traversal check on the resolved path, 256KB body cap, whitelisted profile fields so nothing unknown reaches config.json. Writes carry the revision they were based on; a stale one is a 409 rather than a merge, because swisscode cannot know which of two divergent edits was meant. Verified end to end in a real browser, not just with curl — which matters, because the first version of the fallback page was blocked by our own CSP (inline script under `script-src 'self'`) and curl executes nothing, so it could not have noticed. A hostile origin on another port was confirmed blocked by CORS and by the unanswered preflight, a planted key canary appeared in neither the payload nor the DOM, and the 409/400 paths were confirmed to write nothing to disk. The page served today is a fallback that proves the handshake; the SPA replaces it via a token substitution that is already wired. Signed-off-by: jellologic <31935831+jellologic@users.noreply.github.com> --- src/adapters/web/api.ts | 308 ++++++++++++++++++++++++++++ src/adapters/web/security.ts | 180 ++++++++++++++++ src/adapters/web/server.ts | 253 +++++++++++++++++++++++ src/composition/config-root.ts | 62 ++++++ src/composition/web-root.ts | 80 ++++++++ test/adapters/web.test.ts | 361 +++++++++++++++++++++++++++++++++ 6 files changed, 1244 insertions(+) create mode 100644 src/adapters/web/api.ts create mode 100644 src/adapters/web/security.ts create mode 100644 src/adapters/web/server.ts create mode 100644 src/composition/web-root.ts create mode 100644 test/adapters/web.test.ts diff --git a/src/adapters/web/api.ts b/src/adapters/web/api.ts new file mode 100644 index 0000000..697dc56 --- /dev/null +++ b/src/adapters/web/api.ts @@ -0,0 +1,308 @@ +// The JSON API behind the web UI. +// +// Deliberately free of node:http: a request is a plain object in and a plain +// object out, so every branch — including every refusal — is testable without +// binding a socket. The server module is the only part that knows about sockets. +// +// It is thin by construction. Everything it does is already a port operation, +// which is the whole reason a second UI was cheap: the Ink wizard and this API +// are two adapters over one unchanged core. + +import { bindPath, bindingEntries, unbindPath } from '../../core/binding.ts' +import { validateProfileName } from '../../core/migrate.ts' +import { TIERS } from '../../core/tiers.ts' +import type { ConfigStorePort, Profile, State } from '../../ports/config-store.ts' +import type { AgentRegistryPort } from '../../ports/agent.ts' +import type { ProviderRegistryPort } from '../../ports/provider.ts' + +export type ApiRequest = { + method: string + /** pathname only, already stripped of query and origin */ + path: string + /** parsed JSON body, or null. `unknown` because it is untrusted input. */ + body: unknown +} + +export type ApiResponse = { + status: number + body: unknown +} + +export type ApiDeps = { + store: ConfigStorePort + providers: ProviderRegistryPort + agents: AgentRegistryPort +} + +const json = (status: number, body: unknown): ApiResponse => ({ status, body }) +const fail = (status: number, error: string): ApiResponse => json(status, { error }) + +/** + * A profile as the BROWSER is allowed to see it. + * + * The key never crosses this boundary — not masked, not truncated, not + * length-hinted. That is the same rule the doctor already follows, and it + * matters more here: a value rendered into a DOM can be read by anything that + * achieves script execution on the page, ends up in browser memory dumps, and + * is one careless devtools screenshot from a bug report. + * + * `hasKey` is all the UI needs to render "set / not set" and offer to replace + * it. Editing is therefore write-only: you can overwrite a key, never read one + * back. `apiKeyFromEnv` IS sent, because a variable NAME is not a secret and + * the user needs to see which one is being read. + */ +export type RedactedProfile = Omit & { hasKey: boolean } + +export function redactProfile(profile: Profile): RedactedProfile { + const { apiKey, ...rest } = profile + return { ...rest, hasKey: typeof apiKey === 'string' && apiKey.length > 0 } +} + +export function redactState(state: State): unknown { + return { + ...state, + profiles: Object.fromEntries( + Object.entries(state.profiles ?? {}).map(([name, p]) => [name, redactProfile(p)]), + ), + } +} + +/** `unknown` -> an indexable object, and nothing more. */ +function isObjectLike(v: unknown): v is Record { + return !!v && typeof v === 'object' && !Array.isArray(v) +} + +function str(v: unknown): string | null { + return typeof v === 'string' && v.length > 0 ? v : null +} + +/** + * Lost-update check. + * + * The client sends the revision it last read. If the file has changed since, + * the write is REFUSED rather than merged: swisscode cannot know which of two + * divergent edits the user meant, and silently keeping one would be exactly the + * kind of confident wrongness the rest of the codebase refuses. + * + * 409 rather than 412 because the client is expected to reload and retry, and + * 409 is what every UI framework's error handling already understands. + */ +function revisionConflict(store: ConfigStorePort, body: unknown): ApiResponse | null { + if (!store.revision) return null + const sent = isObjectLike(body) ? body.revision : undefined + // A client that sends no revision at all is a client that never read the + // config — refuse rather than let it stomp. + if (typeof sent !== 'string' && sent !== null) { + return fail(400, 'write refused: no revision supplied, so a lost update cannot be ruled out') + } + const current = store.revision() + if ((sent ?? null) !== current) { + return json(409, { + error: + 'config.json changed since you loaded it — another swisscode command or window ' + + 'wrote to it. Reload before saving so you do not overwrite that change.', + revision: current, + }) + } + return null +} + +/** Save, and report the new revision so the client can keep editing. */ +function commit(store: ConfigStorePort, state: State, extra: unknown = {}): ApiResponse { + try { + store.save(state) + } catch (err) { + // readOnly (a newer schema on disk) lands here, and it is a refusal the + // user must see verbatim rather than as a generic 500. + return fail(409, (err as { message?: string }).message ?? 'could not write config.json') + } + return json(200, { + ok: true, + revision: store.revision ? store.revision() : null, + ...(isObjectLike(extra) ? extra : {}), + }) +} + +/** + * A profile submitted by the browser, validated field by field. + * + * Whitelisted rather than spread: an unknown key from a hostile or buggy client + * must not reach config.json, where a future swisscode would read it as + * meaningful. `apiKey` is accepted (write-only) but only when non-empty — an + * empty string from a form the user did not touch must not erase a stored key, + * which is the single most destructive mistake this endpoint could make. + */ +export function parseProfile(input: unknown, existing: Profile | undefined): Profile | string { + if (!isObjectLike(input)) return 'profile must be an object' + const provider = str(input.provider) + if (!provider) return 'provider is required' + + const profile: Profile = { ...(existing ?? {}), provider } as Profile + + if (typeof input.baseUrl === 'string') profile.baseUrl = input.baseUrl + if (typeof input.agent === 'string') profile.agent = input.agent + if (typeof input.skipPermissions === 'boolean') profile.skipPermissions = input.skipPermissions + + // Write-only, and never cleared by omission. Clearing is an explicit + // `apiKey: null`, so "I did not touch this field" and "remove my key" stop + // being the same request. + if (typeof input.apiKey === 'string' && input.apiKey.length > 0) profile.apiKey = input.apiKey + if (input.apiKey === null) delete profile.apiKey + + if (typeof input.apiKeyFromEnv === 'string') { + if (input.apiKeyFromEnv) profile.apiKeyFromEnv = input.apiKeyFromEnv + else delete profile.apiKeyFromEnv + } + + if (isObjectLike(input.models)) { + const models: Record = {} + for (const tier of TIERS) { + const v = input.models[tier] + if (typeof v === 'string') models[tier] = v + } + profile.models = models + } + + if (isObjectLike(input.compat)) { + // Built as a plain record then asserted once. The flag NAMES are validated + // downstream by registry.test.ts's "compat flags are all real" rule and are + // inert if unknown, so an unrecognised key here is a no-op rather than a + // write of a bogus variable — the same tolerance the CLI path has. + const compat: Record = {} + for (const [k, v] of Object.entries(input.compat)) { + if (typeof v === 'boolean') compat[k] = v + } + profile.compat = compat as NonNullable + } + + if (isObjectLike(input.env)) { + const env: Record = {} + for (const [k, v] of Object.entries(input.env)) { + if (typeof v === 'string') env[k] = v + } + profile.env = env + } + + return profile +} + +export function handleApi(req: ApiRequest, deps: ApiDeps): ApiResponse { + const { store, providers, agents } = deps + const segments = req.path.replace(/^\/api\/?/, '').split('/').filter(Boolean) + const [resource, ...rest] = segments + + // Everything the UI needs for a cold start, in one round trip: state, the + // shipped catalogues of providers and agents, and the revision every + // subsequent write must quote back. + if (resource === 'bootstrap' && req.method === 'GET') { + const loaded = store.load() + return json(200, { + state: redactState(loaded.state), + revision: store.revision ? store.revision() : null, + readOnly: loaded.readOnly, + corrupt: loaded.corrupt, + warnings: loaded.warnings, + configPath: store.path(), + providers: providers.all().map((p) => ({ + id: p.id, + label: p.label, + baseUrl: p.baseUrl, + askBaseUrl: Boolean(p.askBaseUrl), + credentialOptional: Boolean(p.credentialOptional), + defaultModels: p.defaultModels, + catalogId: p.catalogId ?? null, + hints: p.hints ?? {}, + })), + agents: agents.all().map((a) => ({ + id: a.id, + label: a.label, + capabilities: a.capabilities, + })), + tiers: TIERS, + }) + } + + if (resource === 'profiles') { + const name = rest[0] ? decodeURIComponent(rest[0]) : null + if (!name) return fail(400, 'profile name is required') + + if (req.method === 'PUT') { + const valid = validateProfileName(name) + if (!valid.ok) return fail(400, valid.reason) + const conflict = revisionConflict(store, req.body) + if (conflict) return conflict + + const loaded = store.load() + const body = isObjectLike(req.body) ? req.body.profile : null + const parsed = parseProfile(body, loaded.state.profiles?.[name]) + if (typeof parsed === 'string') return fail(400, parsed) + + const state: State = { + ...loaded.state, + profiles: { ...loaded.state.profiles, [name]: parsed }, + } + // First profile created becomes the default, matching the wizard: a lone + // profile that is not the default is a state the CLI would then refuse to + // launch from. + if (!state.defaultProfile) state.defaultProfile = name + return commit(store, state) + } + + if (req.method === 'DELETE') { + const conflict = revisionConflict(store, req.body) + if (conflict) return conflict + const loaded = store.load() + if (!loaded.state.profiles?.[name]) return fail(404, `no profile named "${name}"`) + + const profiles = { ...loaded.state.profiles } + delete profiles[name] + // Bindings to a deleted profile are pruned, exactly as `config rm` does. + // Leaving them would make a directory silently fall back to the default. + const bindings = Object.fromEntries( + Object.entries(loaded.state.bindings ?? {}).filter(([, p]) => p !== name), + ) + const state: State = { ...loaded.state, profiles, bindings } + // `string | null`, not optional — null is the "no default" state the + // launcher already knows how to report, so clear rather than delete. + if (state.defaultProfile === name) state.defaultProfile = null + return commit(store, state) + } + } + + if (resource === 'default' && req.method === 'PUT') { + const conflict = revisionConflict(store, req.body) + if (conflict) return conflict + const name = isObjectLike(req.body) ? str(req.body.name) : null + if (!name) return fail(400, 'name is required') + const loaded = store.load() + if (!loaded.state.profiles?.[name]) return fail(404, `no profile named "${name}"`) + return commit(store, { ...loaded.state, defaultProfile: name }) + } + + if (resource === 'bindings') { + const loaded = store.load() + if (req.method === 'GET') { + return json(200, { bindings: bindingEntries(loaded.state) }) + } + const conflict = revisionConflict(store, req.body) + if (conflict) return conflict + const path = isObjectLike(req.body) ? str(req.body.path) : null + if (!path) return fail(400, 'path is required') + + if (req.method === 'PUT') { + const profile = isObjectLike(req.body) ? str(req.body.profile) : null + if (!profile) return fail(400, 'profile is required') + const bound = bindPath(loaded.state, path, profile) + // bindPath validates the path is absolute AND that the profile exists, + // and reports which is wrong. Forwarding its reason beats re-deriving one. + if (!bound.ok) return fail(400, bound.reason) + return commit(store, bound.state, { key: bound.key, replaced: bound.replaced }) + } + if (req.method === 'DELETE') { + const unbound = unbindPath(loaded.state, path) + return commit(store, unbound.state, { key: unbound.key, removed: unbound.removed }) + } + } + + return fail(404, `no route for ${req.method} ${req.path}`) +} diff --git a/src/adapters/web/security.ts b/src/adapters/web/security.ts new file mode 100644 index 0000000..74f24ca --- /dev/null +++ b/src/adapters/web/security.ts @@ -0,0 +1,180 @@ +// The gate in front of the web UI's API. +// +// This is the highest-stakes code in the project. The server it guards can read +// and write config.json, which holds API keys in plaintext — and a browser will +// cheerfully issue requests to 127.0.0.1 on behalf of ANY page the user has +// open. "It only listens on localhost" is not a security model; it is the +// reason one is required. +// +// Three attacks decide the design, and each countermeasure below names the one +// it stops. Pure functions, so every branch is testable without a socket. + +/** + * The token, minted per server run and never persisted. + * + * It is injected into index.html at serve time and read back by the SPA, rather + * than being set as a cookie. That choice is the CSRF defence and it is worth + * spelling out: a cookie is attached by the browser AUTOMATICALLY on + * cross-origin requests, so a malicious page could ride it. A value the client + * has to read out of our HTML and echo in a header cannot be ridden, because + * the attacker's page is forbidden by the same-origin policy from reading our + * response body to learn it. + */ +export type WebSecurityOptions = { + token: string + /** the port the server actually bound, needed to validate Host exactly */ + port: number +} + +/** A rejection carries the status AND the reason, so the server can log it. */ +export type SecurityVerdict = { ok: true } | { ok: false; status: number; reason: string } + +const OK: SecurityVerdict = { ok: true } + +/** + * Hosts a browser may legitimately use to reach us. + * + * EXACT MATCH on host:port, not a suffix or a substring test. This is the + * DNS-rebinding defence: an attacker registers evil.example, points it at + * 127.0.0.1, and the victim's browser then sends requests to our server with + * `Host: evil.example`. The connection is genuinely from localhost and the + * socket-level check everyone reaches for first cannot tell the difference. The + * Host header can — nothing legitimate ever arrives claiming to be a name we + * did not bind. + * + * IPv6 loopback is spelled bracketed because that is how a browser sends it. + */ +export function allowedHosts(port: number): string[] { + return [`127.0.0.1:${port}`, `localhost:${port}`, `[::1]:${port}`] +} + +export function checkHost(host: string | undefined, port: number): SecurityVerdict { + if (!host) return { ok: false, status: 400, reason: 'request carried no Host header' } + if (!allowedHosts(port).includes(host.toLowerCase())) { + return { + ok: false, + status: 403, + reason: + `Host "${host}" is not one this server bound. This is what a DNS-rebinding ` + + 'attack looks like: a page on another origin resolving its own hostname to ' + + 'your loopback address.', + } + } + return OK +} + +/** + * Origin, checked only where it exists. + * + * Absent on same-origin GETs in some browsers, so a missing Origin cannot be + * fatal without breaking ordinary navigation. Present-and-wrong, however, is + * unambiguous: some other site is driving this request. + */ +export function checkOrigin(origin: string | undefined, port: number): SecurityVerdict { + if (!origin) return OK + const allowed = allowedHosts(port).map((h) => `http://${h}`) + if (!allowed.includes(origin.toLowerCase())) { + return { ok: false, status: 403, reason: `Origin "${origin}" is not this server` } + } + return OK +} + +/** + * Constant-time comparison. + * + * `a === b` on a secret leaks its prefix through timing. The window is small + * over loopback and the attack is fiddly — and it costs one function to remove + * the question entirely, which is cheaper than being asked to justify keeping + * it. + * + * Length is compared first and non-constant-time deliberately: the length of + * this token is fixed and public (it is minted here), so it is not a secret to + * protect. + */ +export function tokensMatch(a: string | undefined, b: string): boolean { + if (typeof a !== 'string' || a.length !== b.length) return false + let diff = 0 + for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i) + return diff === 0 +} + +/** The header the SPA echoes the token in. Custom on purpose — see below. */ +export const TOKEN_HEADER = 'x-swisscode-token' + +export function checkToken(headerValue: string | undefined, token: string): SecurityVerdict { + if (!tokensMatch(headerValue, token)) { + return { + ok: false, + status: 401, + reason: `missing or wrong ${TOKEN_HEADER} header`, + } + } + return OK +} + +/** + * Every API request runs this. Order matters only for which reason gets + * reported first; all three must pass. + * + * The CUSTOM header is doing more work than it looks. A cross-origin page + * cannot set an arbitrary request header without triggering a CORS preflight, + * and this server answers no preflight and sends no + * Access-Control-Allow-Origin. So a hostile page cannot even get the real + * request sent, let alone read the reply — the token check is the belt to that + * pair of braces. + */ +export function guardApiRequest( + headers: { + host?: string | undefined + origin?: string | undefined + token?: string | undefined + }, + { token, port }: WebSecurityOptions, +): SecurityVerdict { + const host = checkHost(headers.host, port) + if (!host.ok) return host + const origin = checkOrigin(headers.origin, port) + if (!origin.ok) return origin + return checkToken(headers.token, token) +} + +/** + * The document request (GET /) is guarded by Host and Origin but NOT by the + * token — the token is what the document is delivering, so requiring it would + * be circular. + * + * That is safe precisely because of what the document contains: markup and a + * token, no user data. An attacker who could somehow cause this response has + * still learned nothing, because they cannot read it cross-origin. + */ +export function guardDocumentRequest( + headers: { host?: string | undefined; origin?: string | undefined }, + { port }: Pick, +): SecurityVerdict { + const host = checkHost(headers.host, port) + if (!host.ok) return host + return checkOrigin(headers.origin, port) +} + +/** + * Headers on every response. + * + * The CSP is strict because this page renders values that came from a config + * file a user may have hand-edited — a profile name is attacker-influenced data + * in the only threat model that matters here (someone else's config pasted in + * from a bug report). `default-src 'self'` with no `unsafe-inline` for scripts + * means an injected string cannot execute. + * + * `Cache-Control: no-store` because the document carries the session token, and + * a token in a disk cache outlives the server that issued it. + */ +export const SECURITY_HEADERS: Readonly> = Object.freeze({ + 'content-security-policy': + "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; " + + "img-src 'self' data:; connect-src 'self'; font-src 'self' data:; " + + "base-uri 'none'; form-action 'none'; frame-ancestors 'none'", + 'x-content-type-options': 'nosniff', + 'referrer-policy': 'no-referrer', + 'x-frame-options': 'DENY', + 'cache-control': 'no-store', +}) diff --git a/src/adapters/web/server.ts b/src/adapters/web/server.ts new file mode 100644 index 0000000..9f94609 --- /dev/null +++ b/src/adapters/web/server.ts @@ -0,0 +1,253 @@ +// node:http glue for the web UI. The ONLY module here that knows about sockets; +// routing decisions live in api.ts and the gate lives in security.ts, both pure. +// +// Off the launch path by construction: test/architecture.test.ts bans node:http +// there by name, so this is reached only through a dynamic import from +// src/cli.ts — the same treatment the wizard, the config subcommands and the +// doctor already get. + +import { createServer, type IncomingMessage, type ServerResponse } from 'node:http' +import { randomBytes } from 'node:crypto' +import { existsSync, readFileSync } from 'node:fs' +import { extname, join, normalize, resolve, sep } from 'node:path' +import { handleApi, type ApiDeps } from './api.ts' +import { + SECURITY_HEADERS, + TOKEN_HEADER, + guardApiRequest, + guardDocumentRequest, +} from './security.ts' + +/** + * A request body cannot be unbounded. Nothing this API accepts is remotely this + * large — it is a denial-of-service bound, not a schema limit, so it is + * deliberately generous rather than tuned. + */ +const MAX_BODY_BYTES = 256 * 1024 + +const MIME: Readonly> = Object.freeze({ + '.html': 'text/html; charset=utf-8', + '.js': 'text/javascript; charset=utf-8', + '.css': 'text/css; charset=utf-8', + '.json': 'application/json; charset=utf-8', + '.svg': 'image/svg+xml', + '.woff2': 'font/woff2', + '.png': 'image/png', + '.ico': 'image/x-icon', +}) + +export type WebServerOptions = ApiDeps & { + /** 0 lets the OS choose, which is the default: a fixed port is squattable. */ + port?: number + /** absolute path to the built SPA. When absent, a fallback page is served. */ + assetDir?: string | null +} + +export type RunningServer = { + url: string + port: number + token: string + close: () => Promise +} + +function readBody(req: IncomingMessage): Promise { + return new Promise((resolveBody, rejectBody) => { + const chunks: Buffer[] = [] + let size = 0 + req.on('data', (chunk: Buffer) => { + size += chunk.length + if (size > MAX_BODY_BYTES) { + rejectBody(new Error('request body too large')) + req.destroy() + return + } + chunks.push(chunk) + }) + req.on('end', () => { + const raw = Buffer.concat(chunks).toString('utf8') + if (!raw) return resolveBody(null) + try { + resolveBody(JSON.parse(raw)) + } catch { + rejectBody(new Error('body is not valid JSON')) + } + }) + req.on('error', rejectBody) + }) +} + +function send(res: ServerResponse, status: number, body: unknown, type = 'application/json'): void { + const payload = type.startsWith('application/json') ? JSON.stringify(body) : String(body) + res.writeHead(status, { + ...SECURITY_HEADERS, + 'content-type': type, + 'content-length': Buffer.byteLength(payload), + }) + res.end(payload) +} + +/** + * Resolve a URL path to a file inside `root`, or null. + * + * The `startsWith(root + sep)` check is the path-traversal defence and it runs + * on the RESOLVED path, after normalize, because `..` segments and encoded + * variants only collapse once resolved. Serving files is the one place this + * server touches the filesystem on a client's say-so, so it gets the check even + * though the asset directory holds nothing secret — the directory above it does. + */ +export function resolveAsset(root: string, urlPath: string): string | null { + const relative = normalize(decodeURIComponent(urlPath)).replace(/^([/\\])+/, '') + const target = resolve(root, relative) + const rootResolved = resolve(root) + if (target !== rootResolved && !target.startsWith(rootResolved + sep)) return null + return existsSync(target) ? target : null +} + +/** Where the fallback page's script is served from. See `fallbackDocument`. */ +export const FALLBACK_SCRIPT_PATH = '/__swisscode/fallback.js' + +/** + * The fallback page's script, as a SEPARATE RESOURCE rather than an inline + * ` +} + +export function startWebServer(options: WebServerOptions): Promise { + const { port = 0, assetDir = null, ...deps } = options + // 32 bytes of CSPRNG. Not a password — nobody types it — so there is no reason + // for it to be short. + const token = randomBytes(32).toString('hex') + + const server = createServer((req, res) => { + void handle(req, res).catch(() => { + if (!res.headersSent) send(res, 500, { error: 'internal error' }) + }) + }) + + async function handle(req: IncomingMessage, res: ServerResponse): Promise { + const bound = (server.address() as { port: number } | null)?.port ?? port + const url = new URL(req.url ?? '/', `http://${req.headers.host ?? 'localhost'}`) + const headers = { + host: req.headers.host, + origin: typeof req.headers.origin === 'string' ? req.headers.origin : undefined, + token: req.headers[TOKEN_HEADER] as string | undefined, + } + + if (url.pathname.startsWith('/api/')) { + const verdict = guardApiRequest(headers, { token, port: bound }) + if (!verdict.ok) return send(res, verdict.status, { error: verdict.reason }) + + let body: unknown = null + if (req.method !== 'GET' && req.method !== 'HEAD') { + try { + body = await readBody(req) + } catch (err) { + return send(res, 400, { error: (err as Error).message }) + } + } + const result = handleApi({ method: req.method ?? 'GET', path: url.pathname, body }, deps) + return send(res, result.status, result.body) + } + + const docVerdict = guardDocumentRequest(headers, { port: bound }) + if (!docVerdict.ok) return send(res, docVerdict.status, { error: docVerdict.reason }, 'text/plain') + + // The fallback page's script. Served rather than inlined so it satisfies + // our own `script-src 'self'` — see FALLBACK_SCRIPT. + if (url.pathname === FALLBACK_SCRIPT_PATH) { + return send(res, 200, FALLBACK_SCRIPT, 'text/javascript; charset=utf-8') + } + + // Static assets, when a bundle exists. + if (assetDir && url.pathname !== '/') { + const file = resolveAsset(assetDir, url.pathname) + if (file) { + const type = MIME[extname(file).toLowerCase()] ?? 'application/octet-stream' + return send(res, 200, readFileSync(file, 'utf8'), type) + } + } + + // The document. Token injected here rather than set as a cookie — see + // security.ts for why that is the CSRF defence and not merely a style. + const indexPath = assetDir ? join(assetDir, 'index.html') : null + const html = + indexPath && existsSync(indexPath) + ? readFileSync(indexPath, 'utf8').replace('__SWISSCODE_TOKEN__', token) + : fallbackDocument(token) + return send(res, 200, html, 'text/html; charset=utf-8') + } + + return new Promise((resolveServer, rejectServer) => { + server.once('error', rejectServer) + // 127.0.0.1 EXPLICITLY, never 0.0.0.0. On a shared machine the difference is + // whether every other user on the box can read this config. + server.listen(port, '127.0.0.1', () => { + const bound = (server.address() as { port: number }).port + resolveServer({ + url: `http://127.0.0.1:${bound}/`, + port: bound, + token, + close: () => + new Promise((done) => { + server.close(() => done()) + }), + }) + }) + }) +} diff --git a/src/composition/config-root.ts b/src/composition/config-root.ts index c69d445..3c56791 100644 --- a/src/composition/config-root.ts +++ b/src/composition/config-root.ts @@ -86,6 +86,9 @@ const USAGE = `swisscode config — manage profiles and directory bindings swisscode config bind|unbind aliases for use / use --clear swisscode config bindings [--prune] list bindings; --prune drops dead ones + swisscode config web [--port ] configure swisscode from a browser + [--no-open] + swisscode config doctor [--json] check binary, endpoint, credential, models, tool calling, env conflicts, permissions [--offline] skip every network probe @@ -144,6 +147,8 @@ export async function runConfigCommand({ return listBindings({ deps, prune: rest.includes('--prune'), out, err }) case 'doctor': return doctorCommand({ deps, args: rest, out, err }) + case 'web': + return webCommand({ deps, args: rest, out, err }) default: break } @@ -650,3 +655,60 @@ function refuseWrite(err: Emit): number { ) return 2 } + +/** + * `swisscode config web` — the browser UI, and the singleton. + * + * The server module is imported LAZILY even from here, which is already a lazy + * module. config-root is reached for every `config` subcommand, including + * `list` and `doctor`, and none of those should pay for loading an HTTP server. + * + * Returns a promise that never settles on success: the server owns the process + * until Ctrl-C. That is the one place in swisscode where a command deliberately + * does not exit, and it is why this is opt-in rather than a background daemon. + */ +async function webCommand({ + deps, + args, + out, + err, +}: { + deps: LaunchDeps + args: string[] + out: Emit + err: Emit +}): Promise { + const portFlag = args.indexOf('--port') + let port = 0 + if (portFlag !== -1) { + const raw = args[portFlag + 1] + const parsed = Number(raw) + if (!raw || !Number.isInteger(parsed) || parsed < 0 || parsed > 65535) { + err(`swisscode: --port needs a number between 0 and 65535; got "${raw ?? ''}".`) + return 2 + } + port = parsed + } + + const { runWeb } = await import('./web-root.ts') + try { + const server = await runWeb({ + deps, + port, + noOpen: args.includes('--no-open'), + out, + }) + // Resolve only when the server closes, so the command holds the terminal. + await new Promise((resolve) => { + const stop = () => { + void server.close().then(resolve) + } + process.once('SIGINT', stop) + process.once('SIGTERM', stop) + }) + return 0 + } catch (e) { + err(`swisscode: ${(e as { message?: string }).message ?? 'could not start the web UI'}`) + return 2 + } +} diff --git a/src/composition/web-root.ts b/src/composition/web-root.ts new file mode 100644 index 0000000..e6239c1 --- /dev/null +++ b/src/composition/web-root.ts @@ -0,0 +1,80 @@ +// Composition root for `swisscode config web`. +// +// LAZY, like the UI bundle, the config subcommands and the doctor: reached only +// through a dynamic import, so the launch path's static closure never grows to +// carry an HTTP server. test/architecture.test.ts bans node:http there by name. +// +// This is also the SINGLETON. The port bind is the mutex — there is no lockfile +// and no stale-PID reasoning, because the OS already refuses to bind a port +// twice and cleans up when the process dies. A lockfile would have to +// reimplement that badly. + +import { fileURLToPath } from 'node:url' +import { dirname, join } from 'node:path' +import { existsSync } from 'node:fs' +import { startWebServer, type RunningServer } from '../adapters/web/server.ts' +import type { LaunchDeps } from './launch-root.ts' + +export type RunWebOptions = { + deps: LaunchDeps + port?: number + /** print the URL rather than opening a browser */ + noOpen?: boolean + out?: (line: string) => void +} + +/** + * Where the built SPA lives, or null when it has not been built. + * + * Resolved relative to THIS module's own location rather than process.cwd(), + * because `swisscode` is run from the user's project directory and the assets + * live next to the compiled code in the installed package. + */ +export function assetDir(): string | null { + const here = dirname(fileURLToPath(import.meta.url)) + // dist/composition/web-root.js -> dist/web + const candidate = join(here, '..', 'web') + return existsSync(join(candidate, 'index.html')) ? candidate : null +} + +export async function runWeb({ + deps, + port = 0, + noOpen = false, + out = console.log, +}: RunWebOptions): Promise { + let server: RunningServer + try { + server = await startWebServer({ + store: deps.store, + providers: deps.registry, + agents: deps.agents, + port, + assetDir: assetDir(), + }) + } catch (err) { + const code = (err as { code?: string }).code + if (code === 'EADDRINUSE') { + // The singleton speaking. A second instance is not an error condition to + // recover from — it means the thing the user asked for is already + // running, and the useful response is to say where. + throw new Error( + `port ${port} is already in use — swisscode's web UI may already be running there. ` + + 'Open it, or start this one on a different port with `--port `.', + ) + } + throw err + } + + out(`swisscode: web UI on ${server.url}`) + out(' the URL carries no token; the page fetches its own. Close with Ctrl-C.') + if (!assetDir()) { + out(' note: no UI bundle found — serving the fallback page. Run `npm run build`.') + } + if (!noOpen) { + // Opening a browser is a side effect on the user's desktop, so it is + // announced above rather than done silently, and `--no-open` exists. + out('') + } + return server +} diff --git a/test/adapters/web.test.ts b/test/adapters/web.test.ts new file mode 100644 index 0000000..9e48212 --- /dev/null +++ b/test/adapters/web.test.ts @@ -0,0 +1,361 @@ +// The web UI's gate and API. +// +// The gate gets the most attention in this file, and deliberately: it stands in +// front of a server that can read and write a file holding plaintext API keys, +// reachable by any page the user has open in the same browser. "It only listens +// on localhost" is the reason a security model is needed, not a substitute for +// one. +import test from 'node:test' +import assert from 'node:assert/strict' +import { + SECURITY_HEADERS, + TOKEN_HEADER, + allowedHosts, + checkHost, + checkOrigin, + guardApiRequest, + guardDocumentRequest, + tokensMatch, +} from '../../src/adapters/web/security.ts' +import { handleApi, parseProfile, redactProfile, redactState } from '../../src/adapters/web/api.ts' +import { FALLBACK_SCRIPT_PATH, resolveAsset, startWebServer } from '../../src/adapters/web/server.ts' +import { request } from 'node:http' +import { registry as providers } from '../../src/adapters/providers/registry.ts' +import { registry as agents } from '../../src/adapters/agents/registry.ts' +import { makeProfile } from '../support/fixtures.ts' +import type { ConfigStorePort, State } from '../../src/ports/config-store.ts' + +const PORT = 4242 +const TOKEN = 'a'.repeat(64) +const sec = { token: TOKEN, port: PORT } + +// the gate + +test('DNS rebinding is refused at the Host header', () => { + // The whole attack: the connection genuinely comes from loopback, so no + // socket-level check can tell it apart. Only the Host header can. + const v = checkHost('evil.example', PORT) + assert.equal(v.ok, false) + assert.equal(v.status, 403) + assert.match(v.reason, /rebinding/) +}) + +test('the Host allowlist is exact, not a suffix or substring match', () => { + for (const host of allowedHosts(PORT)) assert.equal(checkHost(host, PORT).ok, true) + // Each of these would pass a naive `includes`/`endsWith` check. + for (const bad of [ + '127.0.0.1.evil.example:4242', + 'evil.example?127.0.0.1:4242', + 'localhost:4243', + 'notlocalhost:4242', + '127.0.0.1', + ]) { + assert.equal(checkHost(bad, PORT).ok, false, `${bad} was accepted`) + } +}) + +test('a wrong Origin is refused; an absent one is not', () => { + assert.equal(checkOrigin('https://evil.example', PORT).ok, false) + assert.equal(checkOrigin(`http://127.0.0.1:${PORT}`, PORT).ok, true) + // Some browsers omit Origin on same-origin GETs, so absent cannot be fatal + // without breaking ordinary navigation. + assert.equal(checkOrigin(undefined, PORT).ok, true) +}) + +test('the token comparison does not short-circuit on the first wrong byte', () => { + assert.equal(tokensMatch(TOKEN, TOKEN), true) + assert.equal(tokensMatch('b' + TOKEN.slice(1), TOKEN), false) + assert.equal(tokensMatch(TOKEN.slice(0, -1) + 'b', TOKEN), false) + assert.equal(tokensMatch(TOKEN.slice(0, 10), TOKEN), false) + assert.equal(tokensMatch(undefined, TOKEN), false) +}) + +test('an API request needs all three: Host, Origin and token', () => { + const good = { host: `127.0.0.1:${PORT}`, origin: `http://127.0.0.1:${PORT}`, token: TOKEN } + assert.equal(guardApiRequest(good, sec).ok, true) + assert.equal(guardApiRequest({ ...good, host: 'evil.example' }, sec).ok, false) + assert.equal(guardApiRequest({ ...good, origin: 'https://evil.example' }, sec).ok, false) + assert.equal(guardApiRequest({ ...good, token: 'wrong' }, sec).ok, false) + assert.equal(guardApiRequest({ ...good, token: undefined }, sec).ok, false) +}) + +test('the document is exempt from the token but not from Host', () => { + // Circular otherwise: the document is what delivers the token. Safe because + // it carries only markup and that token, and cannot be read cross-origin. + assert.equal(guardDocumentRequest({ host: `localhost:${PORT}` }, { port: PORT }).ok, true) + assert.equal(guardDocumentRequest({ host: 'evil.example' }, { port: PORT }).ok, false) +}) + +test('the CSP forbids inline script and framing', () => { + const csp = SECURITY_HEADERS['content-security-policy']! + assert.match(csp, /script-src 'self'/) + assert.doesNotMatch(csp, /script-src[^;]*unsafe-inline/) + assert.match(csp, /frame-ancestors 'none'/) + // The document carries the session token; a disk cache would outlive it. + assert.equal(SECURITY_HEADERS['cache-control'], 'no-store') +}) + +test('path traversal cannot escape the asset directory', () => { + const root = '/tmp/swisscode-assets' + for (const p of ['/../../etc/passwd', '/..%2f..%2fetc/passwd', '/subdir/../../../etc/passwd']) { + assert.equal(resolveAsset(root, p), null, `${p} escaped the root`) + } +}) + +// redaction + +test('an API key never crosses the boundary to the browser', () => { + const profile = makeProfile({ provider: 'zai', apiKey: 'sk-super-secret', models: {} }) + const out = redactProfile(profile) + const serialized = JSON.stringify(out) + assert.ok(!serialized.includes('sk-super-secret'), 'the key leaked') + assert.ok(!serialized.includes('sk-super'), 'a prefix of the key leaked') + assert.equal(out.hasKey, true) + assert.ok(!('apiKey' in out)) + + // A variable NAME is not a secret, and the user needs to see it. + const fromEnv = redactProfile(makeProfile({ provider: 'zai', apiKeyFromEnv: 'MY_TOKEN' })) + assert.equal(fromEnv.apiKeyFromEnv, 'MY_TOKEN') + assert.equal(fromEnv.hasKey, false) +}) + +// api + +function store(initial: State): ConfigStorePort & { state: State; saves: number } { + let current = initial + let saves = 0 + return { + get state() { + return current + }, + get saves() { + return saves + }, + load: () => ({ state: current, corrupt: false, readOnly: false, migrated: false, warnings: [] }), + save: (s: State) => { + current = s + saves++ + return '/tmp/config.json' + }, + path: () => '/tmp/config.json', + // Content-derived, like the real one, so the conflict tests are honest. + revision: () => JSON.stringify(current).length.toString(36) + ':' + saves, + } +} + +const baseState = (): State => + ({ + version: 2, + profiles: { work: { provider: 'zai', apiKey: 'secret', models: {} } }, + defaultProfile: 'work', + bindings: {}, + settings: {}, + }) as unknown as State + +const deps = (s: ConfigStorePort) => ({ store: s, providers, agents }) + +test('bootstrap hands the UI everything it needs, minus the keys', () => { + const s = store(baseState()) + const res = handleApi({ method: 'GET', path: '/api/bootstrap', body: null }, deps(s)) + assert.equal(res.status, 200) + const body = res.body as Record + assert.ok(!JSON.stringify(body).includes('secret'), 'a key reached the browser payload') + assert.ok(Array.isArray(body.providers)) + assert.ok((body.providers as unknown[]).length >= 8, 'providers are listed for the picker') + assert.equal(typeof body.revision, 'string') +}) + +test('a write without a revision is refused rather than allowed to stomp', () => { + const s = store(baseState()) + const res = handleApi( + { method: 'PUT', path: '/api/profiles/acme', body: { profile: { provider: 'zai' } } }, + deps(s), + ) + assert.equal(res.status, 400) + assert.equal(s.saves, 0) +}) + +test('a stale revision is a 409, not a silent overwrite', () => { + const s = store(baseState()) + const res = handleApi( + { + method: 'PUT', + path: '/api/profiles/acme', + body: { revision: 'stale', profile: { provider: 'zai' } }, + }, + deps(s), + ) + assert.equal(res.status, 409) + assert.match(String((res.body as { error: string }).error), /changed since you loaded it/) + assert.equal(s.saves, 0, 'a conflicting write must not reach the store') +}) + +test('a current revision writes, and returns the next one', () => { + const s = store(baseState()) + const res = handleApi( + { + method: 'PUT', + path: '/api/profiles/acme', + body: { revision: s.revision!(), profile: { provider: 'openrouter' } }, + }, + deps(s), + ) + assert.equal(res.status, 200) + assert.equal(s.state.profiles.acme!.provider, 'openrouter') + assert.notEqual((res.body as { revision: string }).revision, 'stale') +}) + +test('an omitted key does not erase the stored one; an explicit null does', () => { + // The single most destructive mistake this endpoint could make is treating an + // untouched form field as "delete my credential". + const existing = makeProfile({ provider: 'zai', apiKey: 'keep-me' }) + const kept = parseProfile({ provider: 'zai' }, existing) + assert.equal(typeof kept === 'string' ? null : kept.apiKey, 'keep-me') + + const blanked = parseProfile({ provider: 'zai', apiKey: '' }, existing) + assert.equal(typeof blanked === 'string' ? null : blanked.apiKey, 'keep-me') + + const cleared = parseProfile({ provider: 'zai', apiKey: null }, existing) + assert.equal(typeof cleared === 'string' ? undefined : cleared.apiKey, undefined) +}) + +test('unknown fields from the client never reach config.json', () => { + const parsed = parseProfile( + { provider: 'zai', evil: 'payload', __proto__: { polluted: true } }, + undefined, + ) + assert.notEqual(typeof parsed, 'string') + assert.ok(!('evil' in (parsed as object))) + assert.equal(({} as Record).polluted, undefined, 'prototype was polluted') +}) + +test('an invalid profile name is refused with the CLI’s own reason', () => { + const s = store(baseState()) + const res = handleApi( + { + method: 'PUT', + path: '/api/profiles/not%20valid', + body: { revision: s.revision!(), profile: { provider: 'zai' } }, + }, + deps(s), + ) + assert.equal(res.status, 400) + assert.equal(s.saves, 0) +}) + +test('deleting a profile prunes its bindings and clears the default', () => { + const s = store({ + ...baseState(), + bindings: { '/work/repo': 'work' }, + } as unknown as State) + const res = handleApi( + { method: 'DELETE', path: '/api/profiles/work', body: { revision: s.revision!() } }, + deps(s), + ) + assert.equal(res.status, 200) + assert.equal(s.state.profiles.work, undefined) + assert.deepEqual(s.state.bindings, {}, 'a binding to a deleted profile would silently fall back') + assert.equal(s.state.defaultProfile, null) +}) + +// end to end, over a real socket + +/** A raw GET that can set headers fetch() forbids — Host being the one at issue. */ +function rawGet( + port: number, + path: string, + headers: Record, +): Promise<{ status: number; body: string }> { + return new Promise((resolveRaw, rejectRaw) => { + const req = request({ host: '127.0.0.1', port, path, method: 'GET', headers }, (res) => { + let body = '' + res.on('data', (c) => (body += c)) + res.on('end', () => resolveRaw({ status: res.statusCode ?? 0, body })) + }) + req.on('error', rejectRaw) + req.end() + }) +} + +test('the live server refuses a rebound Host and serves with the token', async () => { + const s = store(baseState()) + const server = await startWebServer({ ...deps(s), port: 0 }) + try { + const base = `http://127.0.0.1:${server.port}` + + // Host is a FORBIDDEN header for fetch(), which silently pins it from the + // URL — so the rebinding case is unreachable through fetch and has to go + // over a raw request. Asserting it via fetch would have been a test that + // could never fail. + const rebound = await rawGet(server.port, '/api/bootstrap', { + host: 'evil.example', + [TOKEN_HEADER]: server.token, + }) + assert.equal(rebound.status, 403, 'a rebound Host reached the API') + assert.match(rebound.body, /rebinding/) + + const noToken = await fetch(`${base}/api/bootstrap`) + assert.equal(noToken.status, 401) + + const ok = await fetch(`${base}/api/bootstrap`, { + headers: { [TOKEN_HEADER]: server.token }, + }) + assert.equal(ok.status, 200) + const body = (await ok.json()) as { state: { profiles: Record } } + assert.ok(Object.keys(body.state.profiles).includes('work')) + assert.ok(!JSON.stringify(body).includes('secret'), 'the key went over the wire') + + const doc = await fetch(`${base}/`) + assert.equal(doc.status, 200) + const html = await doc.text() + assert.match(html, /swisscode-token/) + assert.equal(doc.headers.get('x-frame-options'), 'DENY') + } finally { + await server.close() + } +}) + +test('the served page can actually run under the CSP we send it with', async () => { + // REGRESSION. The CSP said `script-src 'self'` with no 'unsafe-inline' and + // the page's only script was inline, so the browser refused to run it and the + // page sat at "loading /api/bootstrap…" forever. + // + // Two tests already existed and neither could catch it: one asserted the CSP + // was strict, the other that the document rendered. The property that was + // missing is the RELATIONSHIP between them — a page is only correct with + // respect to the policy it is served under. + const s = store(baseState()) + const server = await startWebServer({ ...deps(s), port: 0 }) + try { + const base = `http://127.0.0.1:${server.port}` + const html = await (await fetch(`${base}/`)).text() + + // No + + diff --git a/web/panda.config.ts b/web/panda.config.ts new file mode 100644 index 0000000..14ac4bc --- /dev/null +++ b/web/panda.config.ts @@ -0,0 +1,63 @@ +import { defineConfig } from '@pandacss/dev' + +/** + * Panda is BUILD-TIME ONLY: it emits static CSS and ships no runtime library, + * which is why a styling system could be added to a project whose whole pitch + * is four runtime dependencies without changing that number. + * + * The token set below is the "Linear" look, and it is mostly restraint: a + * near-black surface ramp, hairline borders doing the structural work instead + * of shadows, a three-step text hierarchy, and colour reserved for status. + */ +export default defineConfig({ + preflight: true, + include: ['./src/**/*.{ts,tsx}'], + exclude: [], + // No dark VARIANT: this UI is dark, full stop. A theme toggle nobody asked + // for is two code paths to keep honest. + theme: { + extend: { + tokens: { + colors: { + // surfaces, darkest first + bg: { value: '#0c0d10' }, + panel: { value: '#101116' }, + raised: { value: '#16181d' }, + hover: { value: '#1b1e24' }, + // hairlines + line: { value: '#22252c' }, + lineStrong: { value: '#2e323b' }, + // text, three steps and no more + text: { value: '#e7e8ea' }, + dim: { value: '#9ba1ac' }, + faint: { value: '#6b7280' }, + // status, used sparingly + accent: { value: '#5e6ad2' }, + accentHover: { value: '#6e79db' }, + ok: { value: '#3fb950' }, + warn: { value: '#d29922' }, + danger: { value: '#f85149' }, + }, + radii: { + sm: { value: '4px' }, + md: { value: '6px' }, + lg: { value: '8px' }, + }, + fonts: { + sans: { + value: + 'ui-sans-serif, -apple-system, BlinkMacSystemFont, "Inter", "Segoe UI", sans-serif', + }, + mono: { value: 'ui-monospace, SFMono-Regular, "SF Mono", Menlo, monospace' }, + }, + }, + semanticTokens: { + colors: { + border: { value: '{colors.line}' }, + }, + }, + }, + }, + outdir: 'styled-system', + jsxFramework: 'react', +}) diff --git a/web/postcss.config.cjs b/web/postcss.config.cjs new file mode 100644 index 0000000..36a0b48 --- /dev/null +++ b/web/postcss.config.cjs @@ -0,0 +1,9 @@ +// Panda's PostCSS plugin is what turns the @layer declarations in index.css +// into the actual generated CSS. Without it the layers stay empty and every +// class name resolves to nothing — a page that renders unstyled while every +// build step reports success. +module.exports = { + plugins: { + '@pandacss/dev/postcss': {}, + }, +} diff --git a/web/src/App.tsx b/web/src/App.tsx new file mode 100644 index 0000000..48c6885 --- /dev/null +++ b/web/src/App.tsx @@ -0,0 +1,143 @@ +import { useCallback, useEffect, useState } from 'react' +import { css } from '../styled-system/css' +import { ApiError, api, type Bootstrap } from './api' +import { Banner, Dot } from './ui' +import { Profiles } from './routes/Profiles' +import { Providers } from './routes/Providers' +import { Settings } from './routes/Settings' + +type Tab = 'profiles' | 'providers' | 'settings' + +const TABS: { id: Tab; label: string }[] = [ + { id: 'profiles', label: 'Profiles' }, + { id: 'providers', label: 'Providers' }, + { id: 'settings', label: 'Settings' }, +] + +export function App() { + const [data, setData] = useState(null) + const [error, setError] = useState(null) + const [tab, setTab] = useState('profiles') + + const reload = useCallback(async () => { + try { + setData(await api.bootstrap()) + setError(null) + } catch (err) { + setError(err instanceof ApiError ? err.message : String(err)) + } + }, []) + + useEffect(() => { + void reload() + }, [reload]) + + if (error) { + return ( +
+ Could not reach swisscode: {error} +
+ ) + } + if (!data) { + return
loading…
+ } + + const installed = data.installedAgents ?? [] + + return ( +
+ + +
+ {data.readOnly ? ( + + config.json is a newer schema than this swisscode understands. Every write is + disabled so an older build cannot clobber a newer file. + + ) : null} + {data.warnings.map((w) => ( + + {w} + + ))} + + {tab === 'profiles' ? : null} + {tab === 'providers' ? : null} + {tab === 'settings' ? : null} +
+
+ ) +} diff --git a/web/src/api.ts b/web/src/api.ts new file mode 100644 index 0000000..0acc19f --- /dev/null +++ b/web/src/api.ts @@ -0,0 +1,174 @@ +// The typed client for swisscode's local API. +// +// Two things every call here depends on, both decided server-side: +// +// 1. The token comes from a meta tag the server injected, and goes back in a +// CUSTOM header. That header is what forces a CORS preflight the server +// never answers, so a page on another origin cannot make these calls at +// all — it never gets far enough to be refused. +// 2. Writes carry the `revision` they were based on. The server rejects a +// stale one with 409 rather than merging, because it cannot know which of +// two divergent edits was meant. + +export const TOKEN_HEADER = 'x-swisscode-token' + +function token(): string { + const meta = document.querySelector('meta[name=swisscode-token]') + return meta?.getAttribute('content') ?? '' +} + +export type CompatFlag = { + id: string + env: string + value: string + /** non-null when enabling it gives something up; the UI must show it */ + consequence: string | null +} + +export type ProviderInfo = { + id: string + label: string + baseUrl: string | null + askBaseUrl: boolean + credentialOptional: boolean + defaultModels: Record + catalogId: string | null + hints: { keyHint?: string; modelHint?: string; note?: string } +} + +export type AgentInfo = { + id: string + label: string + capabilities: { models: string; skipPermissions: boolean; extendedContextSuffix: boolean; compatFlags: boolean } + binary: string + overrideEnv: string +} + +export type InstalledAgent = { + id: string + label: string + installed: boolean + path: string | null + error: string | null +} + +/** A profile as the browser is allowed to see it: `hasKey`, never the key. */ +export type Profile = { + provider: string + agent?: string + baseUrl?: string + hasKey: boolean + apiKeyFromEnv?: string + models?: Record + compat?: Record + env?: Record + contextWindows?: Record + skipPermissions?: boolean +} + +export type CustomProvider = { + id: string + label: string + baseUrl: string + credentialEnv?: string + credentialOptional?: boolean + defaultCredential?: string + defaultModels?: Record + env?: Record + unsetEnv?: string[] + compat?: Record + subagentFollowsOpus?: boolean +} + +export type Bootstrap = { + state: { + profiles: Record + defaultProfile: string | null + bindings: Record + settings: { quiet?: boolean; bindingWalkDepth?: number } + } + revision: string | null + readOnly: boolean + corrupt: boolean + warnings: string[] + configPath: string + providers: ProviderInfo[] + agents: AgentInfo[] + tiers: string[] + compatFlags: CompatFlag[] + credentialEnvs: string[] + installedAgents: InstalledAgent[] | null + customProviders: Record + reservedProviderIds: string[] +} + +/** A refusal the UI must render rather than swallow. */ +export class ApiError extends Error { + status: number + errors: string[] + constructor(status: number, message: string, errors: string[] = []) { + super(message) + this.status = status + this.errors = errors + } +} + +async function call(path: string, init: RequestInit = {}): Promise { + const res = await fetch(path, { + ...init, + headers: { + [TOKEN_HEADER]: token(), + ...(init.body ? { 'content-type': 'application/json' } : {}), + ...init.headers, + }, + }) + const body = (await res.json().catch(() => ({}))) as Record + if (!res.ok) { + throw new ApiError( + res.status, + typeof body.error === 'string' ? body.error : `HTTP ${res.status}`, + Array.isArray(body.errors) ? (body.errors as string[]) : [], + ) + } + return body as T +} + +export const api = { + bootstrap: () => call('/api/bootstrap'), + + saveProfile: (name: string, profile: unknown, revision: string | null) => + call<{ revision: string }>(`/api/profiles/${encodeURIComponent(name)}`, { + method: 'PUT', + body: JSON.stringify({ revision, profile }), + }), + + deleteProfile: (name: string, revision: string | null) => + call<{ revision: string }>(`/api/profiles/${encodeURIComponent(name)}`, { + method: 'DELETE', + body: JSON.stringify({ revision }), + }), + + setDefault: (name: string, revision: string | null) => + call<{ revision: string }>('/api/default', { + method: 'PUT', + body: JSON.stringify({ revision, name }), + }), + + saveProvider: (id: string, provider: unknown, revision: string | null) => + call<{ revision: string; warnings?: string[] }>(`/api/providers/${encodeURIComponent(id)}`, { + method: 'PUT', + body: JSON.stringify({ revision, provider }), + }), + + deleteProvider: (id: string, revision: string | null) => + call<{ revision: string; orphanedProfiles: string[] }>( + `/api/providers/${encodeURIComponent(id)}`, + { method: 'DELETE', body: JSON.stringify({ revision }) }, + ), + + saveSettings: (settings: unknown, revision: string | null) => + call<{ revision: string }>('/api/settings', { + method: 'PUT', + body: JSON.stringify({ revision, settings }), + }), +} diff --git a/web/src/index.css b/web/src/index.css new file mode 100644 index 0000000..9ff17a0 --- /dev/null +++ b/web/src/index.css @@ -0,0 +1,12 @@ +@layer reset, base, tokens, recipes, utilities; + +html, body, #root { height: 100% } +body { + margin: 0; + background: var(--colors-bg); + color: var(--colors-text); + font-family: var(--fonts-sans); + font-size: 13px; + -webkit-font-smoothing: antialiased; +} +input, select, button { font-family: inherit } diff --git a/web/src/main.tsx b/web/src/main.tsx new file mode 100644 index 0000000..a633310 --- /dev/null +++ b/web/src/main.tsx @@ -0,0 +1,10 @@ +import { StrictMode } from 'react' +import { createRoot } from 'react-dom/client' +import { App } from './App' +import './index.css' + +createRoot(document.getElementById('root')!).render( + + + , +) diff --git a/web/src/routes/Profiles.tsx b/web/src/routes/Profiles.tsx new file mode 100644 index 0000000..12194d6 --- /dev/null +++ b/web/src/routes/Profiles.tsx @@ -0,0 +1,325 @@ +import { useState } from 'react' +import { css } from '../../styled-system/css' +import { ApiError, api, type Bootstrap, type Profile } from '../api' +import { Banner, Button, Dot, Empty, Field, Panel, inputStyle, monoInput } from '../ui' + +/** + * The profile editor exposes everything the CLI can express: provider, agent, + * all four tiers, permissions, compat flags, extra environment, and the + * measured context windows that drive auto-compaction. + * + * The credential is WRITE-ONLY. The server sends `hasKey` and never the key, so + * the field shows whether one is stored and offers to replace it — leaving it + * blank changes nothing, which is why "I did not touch this" and "delete my + * credential" are different actions here. + */ +export function Profiles({ data, reload }: { data: Bootstrap; reload: () => Promise }) { + const names = Object.keys(data.state.profiles) + const [editing, setEditing] = useState(null) + const [draft, setDraft] = useState>({}) + const [error, setError] = useState(null) + const [errors, setErrors] = useState([]) + + const open = (name: string | null) => { + setError(null) + setErrors([]) + setEditing(name ?? '') + const existing = name ? data.state.profiles[name] : undefined + setDraft( + existing + ? { ...existing, apiKey: '' } + : { provider: data.providers[0]?.id ?? 'anthropic', models: {}, compat: {}, apiKey: '' }, + ) + } + + const save = async (name: string) => { + setError(null) + setErrors([]) + try { + const body: Record = { ...draft } + // An empty string means "untouched", so it is removed rather than sent — + // the server would ignore it, but not sending it is what makes that + // intent explicit at the boundary that owns it. + if (!body.apiKey) delete body.apiKey + delete body.hasKey + await api.saveProfile(name, body, data.revision) + setEditing(null) + await reload() + } catch (err) { + if (err instanceof ApiError) { + setError(err.message) + setErrors(err.errors) + } else setError(String(err)) + } + } + + const remove = async (name: string) => { + setError(null) + try { + await api.deleteProfile(name, data.revision) + await reload() + } catch (err) { + setError(err instanceof ApiError ? err.message : String(err)) + } + } + + const setDefault = async (name: string) => { + try { + await api.setDefault(name, data.revision) + await reload() + } catch (err) { + setError(err instanceof ApiError ? err.message : String(err)) + } + } + + const field = (key: string) => (draft[key] as string) ?? '' + const put = (key: string, value: unknown) => setDraft((d) => ({ ...d, [key]: value })) + const models = (draft.models as Record) ?? {} + const compat = (draft.compat as Record) ?? {} + + if (editing !== null) { + const isNew = !names.includes(editing) + const provider = data.providers.find((p) => p.id === draft.provider) + return ( + <> +
+ +

+ {isNew ? 'New profile' : `Profile · ${editing}`} +

+
+ + {error ? ( + + {error} + {errors.length > 1 ? ( +
    + {errors.slice(1).map((e) => ( +
  • {e}
  • + ))} +
+ ) : null} +
+ ) : null} + + + {isNew ? ( + + setEditing(e.target.value)} + placeholder="work" + /> + + ) : null} + + + + + + + + + + {provider?.askBaseUrl || draft.baseUrl ? ( + + put('baseUrl', e.target.value)} + placeholder={provider?.baseUrl ?? 'https://…'} + /> + + ) : null} + + + + + put('apiKey', e.target.value)} + placeholder={ + (data.state.profiles[editing]?.hasKey ?? false) ? '•••••••• stored' : 'paste key' + } + /> + + + put('apiKeyFromEnv', e.target.value)} + placeholder="MY_TOKEN" + /> + + + + +

+ All four tiers, from one table. Claude Code reads the extended-context marker per + variable, so a tier left out is the bug where three run wide and the fourth silently + does not. Blank inherits the provider default. +

+ {data.tiers.map((tier) => ( + + put('models', { ...models, [tier]: e.target.value })} + placeholder={provider?.defaultModels?.[tier] ?? '—'} + /> + + ))} +
+ + + + +
+ Gateway compatibility +
+ {data.compatFlags.map((flag) => ( + + ))} +
+ +
+ + +
+ + ) + } + + return ( + <> +
+

Profiles

+ +
+ + {error ? {error} : null} + + + {names.length === 0 ? ( + No profiles yet. Create one to launch anything. + ) : ( + names.map((name) => { + const p: Profile | undefined = data.state.profiles[name] + const isDefault = data.state.defaultProfile === name + return ( +
+ +
+
+ {name} + {isDefault ? ( + + default + + ) : null} +
+
+ {p?.provider} · {p?.models?.opus || 'provider default'} +
+
+ {!isDefault ? : null} + + +
+ ) + }) + )} +
+ + ) +} diff --git a/web/src/routes/Providers.tsx b/web/src/routes/Providers.tsx new file mode 100644 index 0000000..2c572e2 --- /dev/null +++ b/web/src/routes/Providers.tsx @@ -0,0 +1,252 @@ +import { useState } from 'react' +import { css } from '../../styled-system/css' +import { ApiError, api, type Bootstrap } from '../api' +import { Banner, Button, Dot, Empty, Field, Panel, inputStyle, monoInput } from '../ui' + +/** + * Shipped presets are read-only here and say so. They are constants in source, + * guarded by tests a config file cannot reach; presenting them as editable + * would imply a capability that does not exist. + * + * Custom providers are editable, and every refusal from the server is rendered + * verbatim — those messages are the runtime twin of the shipped descriptors' + * test suite, so they are the most useful thing on the screen when a save fails. + */ +export function Providers({ data, reload }: { data: Bootstrap; reload: () => Promise }) { + const custom = Object.entries(data.customProviders) + const shipped = data.providers.filter((p) => data.reservedProviderIds.includes(p.id)) + + const [editing, setEditing] = useState(null) + const [draft, setDraft] = useState>({}) + const [error, setError] = useState(null) + const [errors, setErrors] = useState([]) + const [warnings, setWarnings] = useState([]) + + const open = (id: string | null) => { + setError(null) + setErrors([]) + setWarnings([]) + setEditing(id ?? '') + setDraft(id ? { ...data.customProviders[id] } : { label: '', baseUrl: '', defaultModels: {} }) + } + + const save = async (id: string) => { + setError(null) + setErrors([]) + try { + const res = await api.saveProvider(id, draft, data.revision) + setWarnings(res.warnings ?? []) + setEditing(null) + await reload() + } catch (err) { + if (err instanceof ApiError) { + setError(err.message) + setErrors(err.errors) + } else setError(String(err)) + } + } + + const remove = async (id: string) => { + setError(null) + try { + const res = await api.deleteProvider(id, data.revision) + if (res.orphanedProfiles.length > 0) { + // Reported, never silently repaired: only the user knows where those + // profiles should point now. + setWarnings([ + `These profiles still name "${id}" and will not launch until you repoint them: ` + + res.orphanedProfiles.join(', '), + ]) + } + await reload() + } catch (err) { + setError(err instanceof ApiError ? err.message : String(err)) + } + } + + const field = (k: string) => (draft[k] as string) ?? '' + const put = (k: string, v: unknown) => setDraft((d) => ({ ...d, [k]: v })) + const models = (draft.defaultModels as Record) ?? {} + + if (editing !== null) { + const isNew = !data.customProviders[editing] + return ( + <> +
+ +

+ {isNew ? 'New provider' : `Provider · ${editing}`} +

+
+ + {error ? ( + + {errors.length > 1 ? ( +
    + {errors.map((e) => ( +
  • {e}
  • + ))} +
+ ) : ( + error + )} +
+ ) : null} + + + {isNew ? ( + + setEditing(e.target.value)} + placeholder="my-gateway" + /> + + ) : null} + + put('label', e.target.value)} /> + + + put('baseUrl', e.target.value)} + placeholder="https://gateway.example.com/anthropic" + /> + + + + + + + + +

+ Optional. A profile can override any of these. Do not type an extended-context + marker — that suffix is derived from a verified capability, and an id the endpoint + does not recognise fails hard. +

+ {data.tiers.map((tier) => ( + + put('defaultModels', { ...models, [tier]: e.target.value })} + /> + + ))} +
+ +
+ + +
+ + ) + } + + return ( + <> +
+

Providers

+ +
+ + {error ? {error} : null} + {warnings.map((w) => ( + + {w} + + ))} + + + {custom.length === 0 ? ( + None yet. Add one for a gateway or a local server swisscode does not ship a preset for. + ) : ( + custom.map(([id, p]) => ( +
+ +
+
{p.label}
+
+ {id} · {p.baseUrl} +
+
+ + +
+ )) + )} +
+ + +

+ Read-only. These are constants in swisscode's source, checked by tests that a config + file cannot reach — including verified extended-context claims. +

+ {shipped.map((p) => ( +
+
+
{p.label}
+
+ {p.baseUrl ?? 'agent default'} +
+
+ {p.catalogId ? ( + browsable catalog + ) : null} +
+ ))} +
+ + ) +} diff --git a/web/src/routes/Settings.tsx b/web/src/routes/Settings.tsx new file mode 100644 index 0000000..fb3f8e6 --- /dev/null +++ b/web/src/routes/Settings.tsx @@ -0,0 +1,58 @@ +import { useState } from 'react' +import { css } from '../../styled-system/css' +import { ApiError, api, type Bootstrap } from '../api' +import { Banner, Button, Field, Panel, inputStyle } from '../ui' + +export function Settings({ data, reload }: { data: Bootstrap; reload: () => Promise }) { + const [quiet, setQuiet] = useState(Boolean(data.state.settings.quiet)) + const [depth, setDepth] = useState(String(data.state.settings.bindingWalkDepth ?? '')) + const [error, setError] = useState(null) + const [saved, setSaved] = useState(false) + + const save = async () => { + setError(null) + setSaved(false) + try { + const settings: Record = { quiet } + const n = Number(depth) + if (depth.trim() && Number.isInteger(n)) settings.bindingWalkDepth = n + await api.saveSettings(settings, data.revision) + setSaved(true) + await reload() + } catch (err) { + setError(err instanceof ApiError ? err.message : String(err)) + } + } + + return ( + <> +

Settings

+ {error ? {error} : null} + + + +

+ swisscode writes to stderr only; stdout belongs to the agent. A clean environment + already prints nothing, which is what makes the lines it does print worth reading. +

+
+ + + + setDepth(e.target.value)} placeholder="40" /> + + + +
+ + {saved ? saved : null} +
+ + ) +} diff --git a/web/src/ui.tsx b/web/src/ui.tsx new file mode 100644 index 0000000..2ad02f1 --- /dev/null +++ b/web/src/ui.tsx @@ -0,0 +1,187 @@ +// Shared primitives. Small on purpose — the Linear look is mostly restraint, +// so there are few components and they are reused rather than varied. +import type { ReactNode } from 'react' +import { css, cx } from '../styled-system/css' + +export const row = css({ + display: 'flex', + alignItems: 'center', + gap: '2', +}) + +export function Button({ + children, + onClick, + variant = 'default', + disabled, + type = 'button', +}: { + children: ReactNode + onClick?: () => void + variant?: 'default' | 'primary' | 'danger' + disabled?: boolean + type?: 'button' | 'submit' +}) { + return ( + + ) +} + +export function Field({ + label, + hint, + children, +}: { + label: string + hint?: string | undefined + children: ReactNode +}) { + return ( + + ) +} + +export const inputStyle = css({ + width: '100%', + bg: 'bg', + border: '1px solid', + borderColor: 'line', + borderRadius: 'md', + color: 'text', + font: 'inherit', + fontSize: '13px', + px: '2.5', + height: '30px', + outline: 'none', + transition: 'border-color 120ms ease', + _focus: { borderColor: 'accent' }, + _placeholder: { color: 'faint' }, +}) + +export const monoInput = cx(inputStyle, css({ fontFamily: 'mono', fontSize: '12.5px' })) + +export function Panel({ title, action, children }: { title: string; action?: ReactNode; children: ReactNode }) { + return ( +
+
+

+ {title} +

+ {action} +
+
{children}
+
+ ) +} + +/** A status dot. Colour is reserved for exactly this kind of signal. */ +export function Dot({ tone }: { tone: 'ok' | 'warn' | 'danger' | 'faint' }) { + return ( + + ) +} + +export function Empty({ children }: { children: ReactNode }) { + return ( +

{children}

+ ) +} + +/** Errors are rendered, never swallowed — a refused save must be visible. */ +export function Banner({ tone, children }: { tone: 'danger' | 'warn'; children: ReactNode }) { + return ( +
+ {children} +
+ ) +} diff --git a/web/tsconfig.json b/web/tsconfig.json new file mode 100644 index 0000000..3b8fc7d --- /dev/null +++ b/web/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "es2023", + "lib": ["es2023", "dom", "dom.iterable"], + "module": "esnext", + "moduleResolution": "bundler", + "jsx": "react-jsx", + "strict": true, + "noUncheckedIndexedAccess": true, + "noEmit": true, + "skipLibCheck": true, + "types": ["vite/client"] + }, + "include": ["src", "styled-system", "*.ts"] +} diff --git a/web/vite.config.ts b/web/vite.config.ts new file mode 100644 index 0000000..40f94cf --- /dev/null +++ b/web/vite.config.ts @@ -0,0 +1,23 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' + +/** + * Builds the SPA into dist/web, which swisscode's own node:http server then + * serves. There is no Vite server in production and no SSR: the API is + * swisscode's, the security gate is swisscode's, and this is a static bundle. + * + * `base: './'` so the assets resolve regardless of where the server mounts + * them, and no hashed-directory assumptions leak into the HTML. + */ +export default defineConfig({ + root: __dirname, + base: './', + plugins: [react()], + build: { + outDir: '../dist/web', + emptyOutDir: true, + // A config UI does not need code splitting; one file is easier to serve, + // easier to size-budget, and faster on loopback than any split could be. + codeSplitting: false, + }, +}) From aa2d2e2e2ebfa977261c4acf53827069cfd7d05d Mon Sep 17 00:00:00 2001 From: jellologic <31935831+jellologic@users.noreply.github.com> Date: Wed, 22 Jul 2026 09:20:34 -0400 Subject: [PATCH 05/21] feat(web): doctor on demand, model catalogs, and a working --no-open MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Completes CLI parity for the browser: the doctor runs on demand, the model picker browses the same catalogs the Ink wizard does, and the flag that claimed to control opening a browser now actually does. THE DOCTOR DEFAULTS TO OFFLINE, inverting the CLI deliberately. Running `config doctor` in a terminal is an explicit act; in a UI it is a button, and the probes are real billable inference requests. Opting IN to spending money is the only defensible default for something you click, so the live run is a separate button that says what it costs. The two routes that do I/O live in api-async.ts rather than api.ts. Widening the pure handler's signature to `ApiResponse | Promise<...>` so two endpoints could await would have pushed asynchrony into ~20 branches with no reason to know about it, and those branches being synchronous and pure is why every refusal in them is testable without a socket. The async module answers null for routes it does not own; the seam is one `if`. The picker keeps the two rules that belong to the DATA rather than to the widget: `tools` stays tri-state, so only a confirmed absence hides a row and UNKNOWN stays visible; and nothing missing is rendered as a number, because "$0.00" reads as free. Picking a model also captures its measured context window into contextWindows — that is the only moment the number is known, and it is what later lets swisscode set an auto-compact window without ever guessing one. Verified in a browser end to end: the doctor rendered 24 real checks with zero console errors, the picker showed 271 of 342 OpenRouter models (the 71 hidden are exactly the ones the README documents as lacking tool calling), picking claude-fable-5 stored both the id and its 1M window, and `config doctor` then read that profile back from the CLI. Also caught while testing: opening the picker on the `local` profile showed "could not fetch the catalog: catalog returned no usable models" — which is correct. Ollama was stopped, so its /api/tags catalog had nothing to serve, and the UI said why instead of showing an empty list. TWO TYPE ERRORS WERE HIDING IN web/. It has its own tsconfig, because Panda's generated styled-system must not enter the strict main program — which also meant `npm test` could not see it, and a missing @types/react-dom plus an invalid Vite option sat there undetected until it was run by hand. `typecheck:web` is now part of the gate. tsconfig.build.json's comment claimed the catalog/net/clock cluster was reached ONLY by the Ink wizard. web-root imports the same catalogs now, and an `exclude` cannot stop that: it filters the `include` globs, and a module reached through an import rejoins the program regardless. The comment says so rather than describing a world that ended. Signed-off-by: jellologic <31935831+jellologic@users.noreply.github.com> --- package-lock.json | 11 ++ package.json | 6 +- src/adapters/web/api-async.ts | 80 ++++++++++++++ src/adapters/web/server.ts | 8 +- src/composition/web-root.ts | 58 +++++++++- test/adapters/web-async.test.ts | 137 +++++++++++++++++++++++ tsconfig.build.json | 28 +++-- web/src/App.tsx | 5 +- web/src/api.ts | 49 ++++++++ web/src/routes/Doctor.tsx | 106 ++++++++++++++++++ web/src/routes/ModelPicker.tsx | 190 ++++++++++++++++++++++++++++++++ web/src/routes/Profiles.tsx | 49 +++++++- web/vite.config.ts | 3 - 13 files changed, 704 insertions(+), 26 deletions(-) create mode 100644 src/adapters/web/api-async.ts create mode 100644 test/adapters/web-async.test.ts create mode 100644 web/src/routes/Doctor.tsx create mode 100644 web/src/routes/ModelPicker.tsx diff --git a/package-lock.json b/package-lock.json index 81769bc..f01cd9b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -23,6 +23,7 @@ "@tanstack/react-start": "^1.168.32", "@types/node": "^22.20.1", "@types/react": "^19.2.17", + "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^5.2.0", "esbuild": "^0.28.1", "ink-testing-library": "^4.0.0", @@ -3118,6 +3119,16 @@ "csstype": "^3.2.2" } }, + "node_modules/@types/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, "node_modules/@typescript/typescript-aix-ppc64": { "version": "7.0.2", "resolved": "https://registry.npmjs.org/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz", diff --git a/package.json b/package.json index 3cf225e..4db693c 100644 --- a/package.json +++ b/package.json @@ -18,11 +18,12 @@ "build": "node build.js", "dev": "node build.js && tsc -p tsconfig.build.json --watch --preserveWatchOutput", "typecheck": "tsc --noEmit", - "test": "tsc --noEmit && node build.js && node --test \"test/**/*.test.ts\"", + "test": "tsc --noEmit && npm run typecheck:web && node build.js && node --test \"test/**/*.test.ts\"", "prepare": "node build.js", "prepublishOnly": "node build.js", "size": "node scripts/size-budget.js", - "dev:web": "vite --config web/vite.config.ts" + "dev:web": "vite --config web/vite.config.ts", + "typecheck:web": "tsc --noEmit -p web/tsconfig.json" }, "keywords": [ "claude", @@ -76,6 +77,7 @@ "@tanstack/react-start": "^1.168.32", "@types/node": "^22.20.1", "@types/react": "^19.2.17", + "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^5.2.0", "esbuild": "^0.28.1", "ink-testing-library": "^4.0.0", diff --git a/src/adapters/web/api-async.ts b/src/adapters/web/api-async.ts new file mode 100644 index 0000000..113b262 --- /dev/null +++ b/src/adapters/web/api-async.ts @@ -0,0 +1,80 @@ +// The two API routes that do I/O. +// +// Kept OUT of api.ts deliberately. Everything there is a pure +// (request, deps) -> response function, which is why every refusal in it is +// testable without a socket, a clock or a network. Widening that signature to +// `ApiResponse | Promise` so two endpoints could await would have +// pushed asynchrony into ~20 branches that have no reason to know about it. +// +// So this module owns the I/O routes and answers `null` for anything it does +// not recognise, letting the server fall through to the pure handler. The seam +// is one `if` in server.ts. + +import type { ApiResponse } from './api.ts' +import type { CatalogRegistryPort } from '../../ports/catalog.ts' +import type { DoctorReport } from '../../ports/doctor.ts' + +export type AsyncApiDeps = { + /** + * Runs the real doctor. Injected rather than imported so this module needs no + * ProcessPort of its own, and so a test can drive every branch without + * resolving a binary or reaching a network. + */ + doctor?: (opts: { offline: boolean }) => Promise + /** Built lazily by web-root; absent when no catalog adapters are wired. */ + catalogs?: CatalogRegistryPort +} + +const json = (status: number, body: unknown): ApiResponse => ({ status, body }) + +function isObjectLike(v: unknown): v is Record { + return !!v && typeof v === 'object' && !Array.isArray(v) +} + +/** + * @returns the response, or null when this module does not own the route. + */ +export async function handleAsyncApi( + req: { method: string; path: string; body: unknown }, + deps: AsyncApiDeps, +): Promise { + const segments = req.path.replace(/^\/api\/?/, '').split('/').filter(Boolean) + const [resource, ...rest] = segments + + if (resource === 'doctor' && req.method === 'POST') { + if (!deps.doctor) return json(501, { error: 'the doctor is not available in this context' }) + + // DEFAULTS TO OFFLINE, which inverts the CLI. On the command line running + // `config doctor` is an explicit act; a web UI invites clicking, and the + // probes are real billable inference requests. Opting IN to spending money + // is the only defensible default for a button. + const offline = isObjectLike(req.body) ? req.body.offline !== false : true + try { + const report = await deps.doctor({ offline }) + return json(200, { report, offline }) + } catch (err) { + return json(500, { error: (err as { message?: string }).message ?? 'doctor failed' }) + } + } + + if (resource === 'catalog' && req.method === 'GET') { + const id = rest[0] ? decodeURIComponent(rest[0]) : null + if (!id) return json(400, { error: 'catalog id is required' }) + const catalog = deps.catalogs?.byId(id) + // A provider whose catalogId is null is the common case, not an error — + // most providers publish nothing browsable. + if (!catalog) return json(404, { error: `no catalog named "${id}"` }) + + // `list()` never throws by contract: an offline box with a warm cache still + // gets a working picker, and a cold one gets an empty list plus a reason. + const result = await catalog.list() + return json(200, { + id: catalog.id, + label: catalog.label, + capabilities: catalog.capabilities, + ...result, + }) + } + + return null +} diff --git a/src/adapters/web/server.ts b/src/adapters/web/server.ts index 9c3ac0a..61dca8f 100644 --- a/src/adapters/web/server.ts +++ b/src/adapters/web/server.ts @@ -11,6 +11,7 @@ import { randomBytes } from 'node:crypto' import { existsSync, readFileSync } from 'node:fs' import { extname, join, normalize, resolve, sep } from 'node:path' import { handleApi, type ApiDeps } from './api.ts' +import { handleAsyncApi, type AsyncApiDeps } from './api-async.ts' import { SECURITY_HEADERS, TOKEN_HEADER, @@ -39,7 +40,7 @@ const MIME: Readonly> = Object.freeze({ /** Extensions read as bytes rather than utf8. */ const BINARY_EXT = new Set(['.woff2', '.png', '.ico']) -export type WebServerOptions = ApiDeps & { +export type WebServerOptions = ApiDeps & AsyncApiDeps & { /** 0 lets the OS choose, which is the default: a fixed port is squattable. */ port?: number /** absolute path to the built SPA. When absent, a fallback page is served. */ @@ -204,7 +205,10 @@ export function startWebServer(options: WebServerOptions): Promise { + const { runDoctor } = await import('./doctor-root.ts') + const run = await runDoctor({ deps, offline }) + return run.report + }, port, assetDir: assetDir(), }) @@ -95,10 +115,38 @@ export async function runWeb({ if (!assetDir()) { out(' note: no UI bundle found — serving the fallback page. Run `npm run build`.') } - if (!noOpen) { - // Opening a browser is a side effect on the user's desktop, so it is - // announced above rather than done silently, and `--no-open` exists. - out('') - } + if (!noOpen) openBrowser(server.url, out) return server } + +/** + * Open the default browser, best effort. + * + * BEST EFFORT IS THE CONTRACT, not a shortcut. This is a side effect on someone + * else's desktop, over which swisscode has no authority: there may be no + * browser, no display, no session (ssh, a container, CI). Every one of those is + * a normal state, and none of them is a reason to fail a command whose actual + * job — serving on a URL that was already printed — has succeeded. + * + * So it is detached and unref'd (the child must not hold the process open or + * inherit the terminal), errors are swallowed to a single line, and the URL is + * printed FIRST so the flow works identically whether or not this does anything. + */ +function openBrowser(url: string, out: (line: string) => void): void { + const command = + process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'start' : 'xdg-open' + try { + const child = spawn(command, [url], { + stdio: 'ignore', + detached: true, + // `start` is a cmd builtin rather than an executable. + ...(process.platform === 'win32' ? { shell: true } : {}), + }) + // Failure arrives asynchronously (ENOENT on a box with no xdg-open), so it + // needs a handler or it becomes an unhandled 'error' event and kills us. + child.on('error', () => out(' (could not open a browser — copy the URL above)')) + child.unref() + } catch { + out(' (could not open a browser — copy the URL above)') + } +} diff --git a/test/adapters/web-async.test.ts b/test/adapters/web-async.test.ts new file mode 100644 index 0000000..8ac117c --- /dev/null +++ b/test/adapters/web-async.test.ts @@ -0,0 +1,137 @@ +// The two API routes that do I/O. +// +// They live apart from api.ts so the pure handler stays pure; these tests +// exercise them through injected fakes, so nothing here resolves a binary, +// bills a token, or touches a network. +import test from 'node:test' +import assert from 'node:assert/strict' +import { handleAsyncApi } from '../../src/adapters/web/api-async.ts' +import type { DoctorReport } from '../../src/ports/doctor.ts' +import type { CatalogRegistryPort, ModelCatalogPort } from '../../src/ports/catalog.ts' + +const report = (): DoctorReport => + ({ + profile: 'work', + source: 'default', + provider: 'zai', + endpoint: 'https://api.z.ai/api/anthropic', + checks: [], + repairs: [], + notes: [], + summary: { counts: { ok: 1, warn: 0, error: 0, skip: 0 }, exitCode: 0 }, + }) as DoctorReport + +test('an unrecognised route is handed back, not answered', async () => { + // Returning null rather than a 404 is what lets the pure handler own + // everything else. Answering here would shadow every route in api.ts. + assert.equal(await handleAsyncApi({ method: 'GET', path: '/api/bootstrap', body: null }, {}), null) + assert.equal(await handleAsyncApi({ method: 'PUT', path: '/api/profiles/x', body: null }, {}), null) +}) + +test('the doctor defaults to OFFLINE, inverting the CLI on purpose', async () => { + // On the command line, `config doctor` is an explicit act. In a browser it is + // a button, and the probes are real billable inference requests — so spending + // money has to be opted into, not defaulted into. + const calls: boolean[] = [] + const deps = { + doctor: async ({ offline }: { offline: boolean }) => { + calls.push(offline) + return report() + }, + } + + await handleAsyncApi({ method: 'POST', path: '/api/doctor', body: null }, deps) + await handleAsyncApi({ method: 'POST', path: '/api/doctor', body: {} }, deps) + await handleAsyncApi({ method: 'POST', path: '/api/doctor', body: { offline: true } }, deps) + assert.deepEqual(calls, [true, true, true], 'a probe ran without being asked for') + + // …and it is genuinely reachable when asked for explicitly. + await handleAsyncApi({ method: 'POST', path: '/api/doctor', body: { offline: false } }, deps) + assert.equal(calls[3], false) +}) + +test('a doctor that throws is reported, not turned into a hang', async () => { + const res = await handleAsyncApi( + { method: 'POST', path: '/api/doctor', body: null }, + { + doctor: async () => { + throw new Error('resolveBinary exploded') + }, + }, + ) + assert.equal(res?.status, 500) + assert.match(String((res?.body as { error: string }).error), /exploded/) +}) + +test('with no doctor wired the route says so rather than 404ing', async () => { + // 404 would read as "no such endpoint", which would send someone looking for + // a typo in a URL that is perfectly correct. + const res = await handleAsyncApi({ method: 'POST', path: '/api/doctor', body: null }, {}) + assert.equal(res?.status, 501) +}) + +// catalog + +const fakeCatalog = (over: Partial = {}): ModelCatalogPort => + ({ + id: 'openrouter', + label: 'OpenRouter', + capabilities: { pricing: true, benchmarks: true, toolSupportKnown: true, requiresAuth: false }, + list: async () => ({ models: [], fromCache: false, stale: false, error: null }), + ...over, + }) as ModelCatalogPort + +const registry = (catalog: ModelCatalogPort | null): CatalogRegistryPort => ({ + ids: () => ['openrouter'], + has: () => catalog !== null, + byId: () => catalog, +}) + +test('a catalog is served with its capabilities, so the UI can branch on facts', async () => { + const res = await handleAsyncApi( + { method: 'GET', path: '/api/catalog/openrouter', body: null }, + { catalogs: registry(fakeCatalog()) }, + ) + assert.equal(res?.status, 200) + const body = res?.body as Record + // Declared up front rather than inferred from a page of nulls — the same + // reason the port carries `capabilities` at all. + assert.deepEqual(body.capabilities, { + pricing: true, + benchmarks: true, + toolSupportKnown: true, + requiresAuth: false, + }) +}) + +test('a stale cache is reported as stale rather than passed off as fresh', async () => { + // list() never throws; it reports. Losing that distinction would show a + // day-old list as current. + const res = await handleAsyncApi( + { method: 'GET', path: '/api/catalog/openrouter', body: null }, + { + catalogs: registry( + fakeCatalog({ + list: async () => ({ + models: [], + fromCache: true, + stale: true, + error: 'network unreachable', + }), + }), + ), + }, + ) + const body = res?.body as Record + assert.equal(body.stale, true) + assert.equal(body.error, 'network unreachable') +}) + +test('a provider with no catalog is a 404, which is a normal state', async () => { + // Most providers publish nothing browsable; the UI asks and gets told no. + const res = await handleAsyncApi( + { method: 'GET', path: '/api/catalog/zai', body: null }, + { catalogs: registry(null) }, + ) + assert.equal(res?.status, 404) +}) diff --git a/tsconfig.build.json b/tsconfig.build.json index 152bcd4..2dc37bf 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -24,13 +24,27 @@ // copies of the same component tree and put React-importing modules inside // the package next to a launch path that promises never to touch them. // - // The exclude also follows the wizard's NON-.tsx imports — the catalog / net / - // clock / cache leaf cluster and the two core helpers (catalog, format) it - // pulls in. Those are reached ONLY by the UI (verified: no launch/config/doctor - // runtime module imports them), and esbuild already inlines them into dist/ui.js, - // so emitting them here would ship that cluster twice. fs-config-store stays - // emitted — it is the CONFIG store the launch/config paths need, distinct from - // the UI-only fs-cache-store. + // The exclude also lists the wizard's NON-.tsx imports — the catalog / net / + // clock / cache leaf cluster and the two core helpers (catalog, format). + // + // THIS STOPPED BEING THE WHOLE STORY when the web UI landed. Those modules + // were once reached ONLY by the Ink wizard, so excluding them avoided + // emitting a cluster esbuild already inlines into dist/ui.js. + // composition/web-root.ts now imports the same catalogs to serve the + // browser's model picker — and an `exclude` cannot prevent that. It only + // filters the `include` globs; a module reached through an IMPORT rejoins the + // program and is emitted anyway. (The same rule that makes naming the UI in + // type space so dangerous, from the other direction.) + // + // So the cluster IS emitted now, and dist/ui.js still carries its own inlined + // copy. Two copies in one process is a state this codebase already + // anticipates: it is exactly why test/architecture.test.ts forbids top-level + // mutable state in core/. The entries stay because they still describe the + // intent for the .tsx-only modules, and because deleting them would erase the + // record of why they were ever here. + // + // fs-config-store stays emitted regardless — it is the CONFIG store the + // launch/config paths need, distinct from the UI-only fs-cache-store. "include": ["src"], "exclude": [ "src/adapters/ui", diff --git a/web/src/App.tsx b/web/src/App.tsx index 48c6885..abf3e5d 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -5,12 +5,14 @@ import { Banner, Dot } from './ui' import { Profiles } from './routes/Profiles' import { Providers } from './routes/Providers' import { Settings } from './routes/Settings' +import { Doctor } from './routes/Doctor' -type Tab = 'profiles' | 'providers' | 'settings' +type Tab = 'profiles' | 'providers' | 'doctor' | 'settings' const TABS: { id: Tab; label: string }[] = [ { id: 'profiles', label: 'Profiles' }, { id: 'providers', label: 'Providers' }, + { id: 'doctor', label: 'Doctor' }, { id: 'settings', label: 'Settings' }, ] @@ -136,6 +138,7 @@ export function App() { {tab === 'profiles' ? : null} {tab === 'providers' ? : null} + {tab === 'doctor' ? : null} {tab === 'settings' ? : null} diff --git a/web/src/api.ts b/web/src/api.ts index 0acc19f..1fb28cf 100644 --- a/web/src/api.ts +++ b/web/src/api.ts @@ -133,6 +133,43 @@ async function call(path: string, init: RequestInit = {}): Promise { return body as T } +export type DoctorCheck = { + id: string + title: string + status: 'ok' | 'warn' | 'error' | 'skip' + detail: string + fix?: string +} + +export type DoctorReport = { + profile: string | null + provider: string | null + endpoint: string | null + checks: DoctorCheck[] + notes: string[] + summary: { counts: Record; exitCode: number } +} + +export type CatalogModel = { + id: string + name: string + description?: string + context: number | null + pricing: { prompt: number; completion: number } | null + /** TRI-STATE. null is UNKNOWN, false is CONFIRMED ABSENT. Do not collapse. */ + tools: boolean | null +} + +export type CatalogResult = { + id: string + label: string + capabilities: { pricing: boolean; benchmarks: boolean; toolSupportKnown: boolean } + models: CatalogModel[] + fromCache: boolean + stale: boolean + error: string | null +} + export const api = { bootstrap: () => call('/api/bootstrap'), @@ -166,6 +203,18 @@ export const api = { { method: 'DELETE', body: JSON.stringify({ revision }) }, ), + /** + * `offline` defaults to true server-side. Sending false is opting IN to real, + * billable inference probes — so the UI must make that an explicit click. + */ + doctor: (offline: boolean) => + call<{ report: DoctorReport; offline: boolean }>('/api/doctor', { + method: 'POST', + body: JSON.stringify({ offline }), + }), + + catalog: (id: string) => call(`/api/catalog/${encodeURIComponent(id)}`), + saveSettings: (settings: unknown, revision: string | null) => call<{ revision: string }>('/api/settings', { method: 'PUT', diff --git a/web/src/routes/Doctor.tsx b/web/src/routes/Doctor.tsx new file mode 100644 index 0000000..a773b80 --- /dev/null +++ b/web/src/routes/Doctor.tsx @@ -0,0 +1,106 @@ +import { useState } from 'react' +import { css } from '../../styled-system/css' +import { ApiError, api, type DoctorReport } from '../api' +import { Banner, Button, Dot, Empty, Panel } from '../ui' + +const TONE = { ok: 'ok', warn: 'warn', error: 'danger', skip: 'faint' } as const + +/** + * The doctor, on demand. + * + * Offline is the default and the network run is a separate, clearly-labelled + * button: the probes are real inference requests, and a UI that spends money on + * a click nobody understood would be a worse bug than anything it diagnoses. + */ +export function Doctor() { + const [report, setReport] = useState(null) + const [busy, setBusy] = useState(false) + const [error, setError] = useState(null) + const [probed, setProbed] = useState(false) + + const run = async (offline: boolean) => { + setBusy(true) + setError(null) + try { + const res = await api.doctor(offline) + setReport(res.report) + setProbed(!res.offline) + } catch (err) { + setError(err instanceof ApiError ? err.message : String(err)) + } finally { + setBusy(false) + } + } + + return ( + <> +

Doctor

+ {error ? {error} : null} + + + + +
+ } + > +

+ Offline checks read your config, resolve the agent binary and inspect the environment. + Live probes additionally send a real request to the endpoint — at most one per distinct + model plus one tool-calling probe — which your provider will bill you for. +

+ + + {report ? ( + + {report.checks.length === 0 ? ( + No checks ran. + ) : ( + report.checks.map((c) => ( +
+ + + +
+
{c.title}
+
+ {c.detail} +
+ {c.fix ? ( +
+ ↳ {c.fix} +
+ ) : null} +
+
+ )) + )} + {report.notes.map((n) => ( +

+ {n} +

+ ))} +
+ ) : null} + + ) +} diff --git a/web/src/routes/ModelPicker.tsx b/web/src/routes/ModelPicker.tsx new file mode 100644 index 0000000..53bd397 --- /dev/null +++ b/web/src/routes/ModelPicker.tsx @@ -0,0 +1,190 @@ +import { useEffect, useMemo, useState } from 'react' +import { css } from '../../styled-system/css' +import { ApiError, api, type CatalogModel, type CatalogResult } from '../api' +import { Banner, Button, inputStyle } from '../ui' + +/** + * The browsable model list, for providers that publish one. + * + * Two rules carried over from the Ink picker, because they are properties of the + * data rather than of the widget: + * + * * `tools` is TRI-STATE. Only a CONFIRMED absence hides a row; UNKNOWN stays + * visible. Collapsing them would empty the list for any catalog that does + * not publish capability at all. + * * Nothing missing is rendered as a number. A catalog with no prices shows + * no prices, rather than "$0.00", which would read as free. + */ +export function ModelPicker({ + catalogId, + tier, + onPick, + onClose, +}: { + catalogId: string + tier: string + onPick: (model: CatalogModel) => void + onClose: () => void +}) { + const [data, setData] = useState(null) + const [error, setError] = useState(null) + const [query, setQuery] = useState('') + const [toolsOnly, setToolsOnly] = useState(true) + + useEffect(() => { + let live = true + api + .catalog(catalogId) + .then((r) => live && setData(r)) + .catch((err) => live && setError(err instanceof ApiError ? err.message : String(err))) + return () => { + live = false + } + }, [catalogId]) + + const rows = useMemo(() => { + if (!data) return [] + const terms = query.toLowerCase().split(/\s+/).filter(Boolean) + // The filter is inert when the catalog cannot speak to tool support, which + // is what stops it hiding everything. + const filterActive = toolsOnly && data.capabilities.toolSupportKnown + return data.models.filter((m) => { + if (filterActive && m.tools === false) return false + if (terms.length === 0) return true + const hay = `${m.id} ${m.name}`.toLowerCase() + return terms.every((t) => hay.includes(t)) + }) + }, [data, query, toolsOnly]) + + return ( +
+
e.stopPropagation()} + className={css({ + bg: 'panel', + border: '1px solid', + borderColor: 'lineStrong', + borderRadius: 'lg', + w: '100%', + maxW: '44rem', + maxH: '80vh', + display: 'flex', + flexDirection: 'column', + overflow: 'hidden', + })} + > +
+ setQuery(e.target.value)} + /> + +
+ +
+ + {rows.length} + {data ? `/${data.models.length}` : ''} shown + + {data?.capabilities.toolSupportKnown ? ( + + ) : null} + {data?.stale ? stale cache : null} + {data?.fromCache && !data.stale ? cached : null} +
+ +
+ {error ? {error} : null} + {data?.error && data.models.length === 0 ? ( + Could not fetch the catalog: {data.error} + ) : null} + {!data && !error ? ( +

loading catalog…

+ ) : null} + + {rows.map((m) => ( + + ))} +
+
+
+ ) +} diff --git a/web/src/routes/Profiles.tsx b/web/src/routes/Profiles.tsx index 12194d6..99c5908 100644 --- a/web/src/routes/Profiles.tsx +++ b/web/src/routes/Profiles.tsx @@ -2,6 +2,7 @@ import { useState } from 'react' import { css } from '../../styled-system/css' import { ApiError, api, type Bootstrap, type Profile } from '../api' import { Banner, Button, Dot, Empty, Field, Panel, inputStyle, monoInput } from '../ui' +import { ModelPicker } from './ModelPicker' /** * The profile editor exposes everything the CLI can express: provider, agent, @@ -19,6 +20,7 @@ export function Profiles({ data, reload }: { data: Bootstrap; reload: () => Prom const [draft, setDraft] = useState>({}) const [error, setError] = useState(null) const [errors, setErrors] = useState([]) + const [picking, setPicking] = useState(null) const open = (name: string | null) => { setError(null) @@ -208,14 +210,49 @@ export function Profiles({ data, reload }: { data: Bootstrap; reload: () => Prom

{data.tiers.map((tier) => ( - put('models', { ...models, [tier]: e.target.value })} - placeholder={provider?.defaultModels?.[tier] ?? '—'} - /> + + put('models', { ...models, [tier]: e.target.value })} + placeholder={provider?.defaultModels?.[tier] ?? '—'} + /> + {/* Only providers that publish a catalog get a picker. The rest + take a typed id, which is the honest option — a picker over + a list we do not have would be worse than none. */} + {provider?.catalogId ? ( + + ) : null} + ))} + + {picking && provider?.catalogId ? ( + setPicking(null)} + onPick={(model) => { + const next = { ...models, [picking]: model.id } + setDraft((d) => ({ + ...d, + models: next, + // Capture the MEASURED window alongside the id. This is the + // only moment it is known, and it is what lets swisscode set + // an auto-compact window later without ever guessing one. + ...(model.context + ? { + contextWindows: { + ...((d.contextWindows as Record) ?? {}), + [model.id]: model.context, + }, + } + : {}), + })) + setPicking(null) + }} + /> + ) : null} diff --git a/web/vite.config.ts b/web/vite.config.ts index 40f94cf..465d9e9 100644 --- a/web/vite.config.ts +++ b/web/vite.config.ts @@ -16,8 +16,5 @@ export default defineConfig({ build: { outDir: '../dist/web', emptyOutDir: true, - // A config UI does not need code splitting; one file is easier to serve, - // easier to size-budget, and faster on loopback than any split could be. - codeSplitting: false, }, }) From 241fbb1e914d77a414a3faf6dac3a963c69f9748 Mon Sep 17 00:00:00 2001 From: jellologic <31935831+jellologic@users.noreply.github.com> Date: Wed, 22 Jul 2026 10:03:26 -0400 Subject: [PATCH 06/21] refactor: split Profile into provider-account / agent-profile / profile (v3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A Profile conflated three unrelated things: who pays (provider + credential + endpoint), what runs (agent + models + permissions + compat), and the pairing of the two. Every field sat flat on one object. The conflation already had a visible cost. `retargetProvider` existed purely to work around it: switching provider meant SCAVENGING every other profile for one that happened to hold a key for the target, then copying the credential, endpoint and models back out. A lookup written as a search, because there was nothing to look up. Now: providerAccounts who pays provider + credential + endpoint agentProfiles what runs agent + models + permissions + compat profiles the pairing references, plus a selection strategy `retargetProvider` is a filter over providerAccounts, and the credential-safety rule became STRUCTURAL rather than a copying discipline: an account names exactly one provider, so a key cannot reach a host it was not entered for without someone rewriting the account. THE SEAM: `ResolvedProfile` is deliberately the shape v2 stored, so everything downstream of resolution — the agent adapters, buildEnvPlan, buildIntent — consumes exactly what it always did. test/golden.test.ts passes COMPLETELY UNCHANGED, which is the proof the cut landed in the right place: the environment every provider hands to Claude Code is byte-identical. Migration is chained (v1 -> v2 -> v3) so a 0.1.0 file reaches v3 in one read with one implementation of each step. Lossless — unknown keys ride along on the agent profile, which is where v2's non-credential extras belonged — idempotent, and bindings/defaultProfile keep pointing at profile names so `swisscode ` and every directory binding survive untouched. Accounts are NOT deduped even when two obviously share a key: merging is irreversible and wrong the first time two accounts hold the same value for different reasons. SELECTION RESOLVES ONCE, BEFORE execve, and the strategies say so. `single` is the default and what migration produces. `round-robin` advances per launch via a cursor kept OUTSIDE config.json — the launch path writes no config, and a rotation counter is not configuration. `usage` reads a cached snapshot refreshed at configuration time, because the launch path may not reach the network; with no snapshot it falls back to `single` AND SAYS SO. Every degradation warns, because which account pays is the one thing that must never change quietly. Two invariants earned their keep. The architecture test rejected the first draft of core/provider-def.ts for naming ANTHROPIC_AUTH_TOKEN — dialect leaking into the pure layer — and the fix was the pattern already there: inject the list, as `knownCompatFlags` already was. And end-to-end testing caught a v2 file being backed up as `config.v1.bak.json`; the name is parameterised now, since it is the one moment that name matters. 598 tests pass, typecheck clean across src, test and web. Signed-off-by: jellologic <31935831+jellologic@users.noreply.github.com> --- src/adapters/agents/claude-code/doctor.ts | 6 +- src/adapters/agents/claude-code/env.ts | 4 +- src/adapters/agents/claude-code/hygiene.ts | 6 +- src/adapters/store/fs-config-store.ts | 18 +- src/adapters/ui/ProfilePicker.tsx | 23 +- src/adapters/ui/index.tsx | 72 ++++-- src/adapters/web/api.ts | 247 ++++++++++++++++----- src/composition/config-root.ts | 93 ++++++-- src/composition/doctor-root.ts | 11 +- src/composition/launch-root.ts | 36 ++- src/core/env-plan.ts | 4 +- src/core/intent.ts | 4 +- src/core/migrate.ts | 145 +++++++++++- src/core/overrides.ts | 88 +++++--- src/core/resolve.ts | 240 ++++++++++++++++++++ src/ports/agent.ts | 4 +- src/ports/config-store.ts | 172 ++++++++++++-- test/adapters/agents/kilo.test.ts | 3 +- test/adapters/agents/opencode.test.ts | 3 +- test/adapters/composite-providers.test.ts | 12 +- test/adapters/doctor-probe.test.ts | 58 +++-- test/adapters/fs-config-store.test.ts | 57 +++-- test/adapters/ollama-doctor.test.ts | 21 +- test/adapters/web.test.ts | 71 ++++-- test/config-commands.test.ts | 54 +++-- test/core/auto-compact.test.ts | 8 +- test/core/binding.test.ts | 7 +- test/core/compat.test.ts | 10 +- test/core/doctor.test.ts | 44 +++- test/core/env.test.ts | 6 +- test/core/extended-context.test.ts | 19 +- test/core/hygiene.test.ts | 6 +- test/core/keyless.test.ts | 12 +- test/core/migrate.test.ts | 85 +++++-- test/core/overrides.test.ts | 51 +++-- test/core/profile.test.ts | 10 +- test/golden.test.ts | 3 +- test/launch-overrides.test.ts | 35 +-- test/picker.test.ts | 16 +- test/profiles-ui.test.ts | 24 +- test/registry.test.ts | 7 +- test/support/fixtures.ts | 75 ++++++- test/ui.test.ts | 20 +- 43 files changed, 1519 insertions(+), 371 deletions(-) create mode 100644 src/core/resolve.ts diff --git a/src/adapters/agents/claude-code/doctor.ts b/src/adapters/agents/claude-code/doctor.ts index d6996a9..4151188 100644 --- a/src/adapters/agents/claude-code/doctor.ts +++ b/src/adapters/agents/claude-code/doctor.ts @@ -24,7 +24,7 @@ import { SOFT_RESERVED } from '../../../core/migrate.ts' import { isInsecureRemoteBaseUrl, sanitizeUrlForDisplay } from '../../../core/url-safety.ts' import type { EnvPlan } from './env.ts' import type { ProfileSelection } from '../../../core/profile.ts' -import type { ConfigModes, LoadResult, Profile } from '../../../ports/config-store.ts' +import type { ConfigModes, LoadResult, ResolvedProfile } from '../../../ports/config-store.ts' import type { ClaudeCodeCredentialEnv } from '../../../ports/claude-code.ts' import type { ProviderDescriptor, ResolvedModels, Tier } from '../../../ports/provider.ts' import type { @@ -118,7 +118,7 @@ export type StaticChecksInput = { /** resolveProfile() result */ selection: ProfileSelection /** after overrides */ - profile: Profile | null + profile: ResolvedProfile | null /** descriptor, or null when unknown */ provider: ProviderDescriptor | null /** buildEnvPlan() result */ @@ -471,7 +471,7 @@ export type ProbeSpec = { * signature stays stable. */ export function probeSpec( - profile: Profile | null | undefined, + profile: ResolvedProfile | null | undefined, provider: ProviderDescriptor | null | undefined, plan: EnvPlan | null | undefined, ): ProbeSpec { diff --git a/src/adapters/agents/claude-code/env.ts b/src/adapters/agents/claude-code/env.ts index 51c596d..40c54c9 100644 --- a/src/adapters/agents/claude-code/env.ts +++ b/src/adapters/agents/claude-code/env.ts @@ -11,7 +11,7 @@ import { definedEntriesOf, makeEnvWriter, resolveCredential } from '../../../cor import { TIER_ENV } from './tiers.ts' import { autoCompactWindow, withExtendedContext } from './context.ts' import { inspectAmbient } from './hygiene.ts' -import type { Profile } from '../../../ports/config-store.ts' +import type { ResolvedProfile } from '../../../ports/config-store.ts' import type { ClaudeCodeCompatEnv, ClaudeCodeCompatFlag } from '../../../ports/claude-code.ts' import type { ProviderDescriptor, ResolvedModels, Tier } from '../../../ports/provider.ts' import type { EnvMap } from '../../../ports/process.ts' @@ -88,7 +88,7 @@ export type EnvPlan = { } export function buildEnvPlan( - profile: Profile | null | undefined, + profile: ResolvedProfile | null | undefined, provider: ProviderDescriptor | null | undefined, ambientEnv: EnvMap = {}, ): EnvPlan { diff --git a/src/adapters/agents/claude-code/hygiene.ts b/src/adapters/agents/claude-code/hygiene.ts index 5c6952a..371f32f 100644 --- a/src/adapters/agents/claude-code/hygiene.ts +++ b/src/adapters/agents/claude-code/hygiene.ts @@ -20,7 +20,7 @@ import { TIER_ENV_VARS } from './tiers.ts' import { SUFFIX, bareModelId, supportsExtendedContext } from './context.ts' import { sanitizeUrlForDisplay } from '../../../core/url-safety.ts' -import type { Profile } from '../../../ports/config-store.ts' +import type { ResolvedProfile } from '../../../ports/config-store.ts' import type { ProviderDescriptor } from '../../../ports/provider.ts' import type { EnvMap } from '../../../ports/process.ts' import type { EnvWarning, WarningSeverity } from '../../../ports/agent.ts' @@ -40,7 +40,7 @@ export type PlanFacts = { export type HygieneContext = { provider?: ProviderDescriptor | null | undefined - profile?: Profile | null | undefined + profile?: ResolvedProfile | null | undefined } const w = (severity: WarningSeverity, code: string, message: string): EnvWarning => ({ @@ -235,7 +235,7 @@ export type StaleStoredModel = { * advice about stored data rather than a correctness fix. */ export function staleStoredModels( - profile: Profile | null | undefined, + profile: ResolvedProfile | null | undefined, provider: ProviderDescriptor | null | undefined, ): StaleStoredModel[] { const ec = provider?.extendedContext diff --git a/src/adapters/store/fs-config-store.ts b/src/adapters/store/fs-config-store.ts index e332eab..0d12836 100644 --- a/src/adapters/store/fs-config-store.ts +++ b/src/adapters/store/fs-config-store.ts @@ -117,8 +117,16 @@ export function createFsConfigStore({ sawCorrupt = false } - function backupV1() { - const backup = join(CONFIG_DIR, 'config.v1.bak.json') + /** + * Snapshot the file BEFORE a migration overwrites it, named for the version + * it preserves. + * + * Parameterised since v3: the ladder now migrates from 1 OR 2, and a v2 file + * kept as `config.v1.bak.json` would misdescribe itself to anyone reaching + * for it after a bad upgrade — which is the one moment the name matters. + */ + function backupPrevious(fromVersion: number) { + const backup = join(CONFIG_DIR, `config.v${fromVersion}.bak.json`) try { // 'wx' so a second run never clobbers the original snapshot. writeFileSync(backup, readFileSync(CONFIG_PATH, 'utf8'), { flag: 'wx', mode: 0o600 }) @@ -162,11 +170,11 @@ export function createFsConfigStore({ if (result.migratedFrom !== null && !readOnly) { try { ensureDir() - backupV1() + backupPrevious(result.migratedFrom) writeAtomic(CONFIG_PATH, `${JSON.stringify(result.state, null, 2)}\n`) warnings.push( - `migrated config.json to the v${SUPPORTED_VERSION} profile format ` + - `(previous file kept as config.v1.bak.json).`, + `migrated config.json to the v${SUPPORTED_VERSION} format ` + + `(previous file kept as config.v${result.migratedFrom}.bak.json).`, ) } catch (err) { warnings.push( diff --git a/src/adapters/ui/ProfilePicker.tsx b/src/adapters/ui/ProfilePicker.tsx index bd9023c..b8f4cb8 100644 --- a/src/adapters/ui/ProfilePicker.tsx +++ b/src/adapters/ui/ProfilePicker.tsx @@ -2,19 +2,32 @@ import React from 'react' import { Box, Text } from 'ink' import SelectInput from 'ink-select-input' import { TIERS } from '../../core/tiers.ts' -import type { Profile, State } from '../../ports/config-store.ts' +import { resolveProfileRefs } from '../../core/resolve.ts' +import type { ResolvedProfile, State } from '../../ports/config-store.ts' /** * Presence and ORIGIN of the credential, never any part of the value. * A masked key still leaks its length, and this screen gets screen-shared. */ -function credentialLabel(profile: Profile | undefined): string { +function credentialLabel(profile: ResolvedProfile | undefined): string { if (profile?.apiKeyFromEnv) return `key from $${profile.apiKeyFromEnv}` if (profile?.apiKey) return 'key stored' return 'no key' } -function summarize(profile: Profile | undefined, boundPaths: number): string { +/** + * The flattened view of a profile, or undefined when it cannot be resolved. + * + * A profile with a dangling reference still LISTS — it summarises as blank + * rather than vanishing, because this screen is how you reach the editor that + * repairs it. + */ +function resolvedOrUndefined(state: State, name: string): ResolvedProfile | undefined { + const r = resolveProfileRefs(state, name) + return r.ok ? r.resolved : undefined +} + +function summarize(profile: ResolvedProfile | undefined, boundPaths: number): string { const models = TIERS.map((t) => profile?.models?.[t]).filter(Boolean) const distinct = [...new Set(models)] const modelLabel = @@ -65,7 +78,9 @@ export function ProfilePicker({ state, onPick, onNew }: ProfilePickerProps) { {name} - {summarize(state.profiles[name], bindingCounts.get(name) ?? 0)} + + {summarize(resolvedOrUndefined(state, name), bindingCounts.get(name) ?? 0)} + ))} diff --git a/src/adapters/ui/index.tsx b/src/adapters/ui/index.tsx index 5f74013..d2412c2 100644 --- a/src/adapters/ui/index.tsx +++ b/src/adapters/ui/index.tsx @@ -12,11 +12,19 @@ import { createFsCacheStore } from '../store/fs-cache-store.ts' import { createCatalogRegistry } from '../catalog/registry.ts' import { fetchNet } from '../net/fetch-net.ts' import { systemClock } from '../clock/system-clock.ts' +import { resolveProfileRefs } from '../../core/resolve.ts' import { ModelPicker } from './ModelPicker.tsx' import { ConfirmDelete, ProfileActions, ProfilePicker } from './ProfilePicker.tsx' import type { ProfileAction } from './ProfilePicker.tsx' import type { Tier, TierRecord, ProviderDescriptor, ProviderRegistryPort } from '../../ports/provider.ts' -import type { Profile, State, ConfigStorePort } from '../../ports/config-store.ts' +import type { + AgentProfile, + ConfigStorePort, + Profile, + ProviderAccount, + ResolvedProfile, + State, +} from '../../ports/config-store.ts' import type { CatalogRegistryPort } from '../../ports/catalog.ts' export { ModelPicker, ProfilePicker } @@ -137,7 +145,12 @@ export type AppProps = { * here: undefined means "nothing was supplied, work it out", null means * "explicitly start blank". The component branches on `initial !== undefined`. */ - initial?: Profile | null | undefined + /** + * A pre-filled form, used by the tests that drive the wizard directly. + * RESOLVED shape, because that is what the form edits — the wizard mints the + * three stored objects at save time, not before. + */ + initial?: ResolvedProfile | null | undefined profileName?: string | null | undefined /** called exactly once, with the saved profile or null if cancelled */ onResult: (profile: Profile | null) => void @@ -207,12 +220,14 @@ export function App({ // A user with a profile named `null` and no default has that profile silently // loaded here, while `editingName` stays null and finish() saves under a // DIFFERENT, derived name. - const startProfile = - profileName !== null - ? (loadedState.profiles?.[profileName] ?? null) - : initial !== undefined - ? initial - : (loadedState.profiles?.[openDirectly as string] ?? null) + // Resolved, not stored: the form is a view of the flattened account + agent + // profile, so this reads through the same resolution the launch path uses. + const startProfileName = + profileName !== null ? profileName : initial !== undefined ? null : (openDirectly ?? null) + const startResolution = + startProfileName !== null ? resolveProfileRefs(loadedState, startProfileName) : null + const startProfile: ResolvedProfile | null = + startResolution?.ok ? startResolution.resolved : (initial ?? null) // More than one profile and no particular one named: choose first. With // exactly one, open it directly — that is the pre-profiles behaviour and @@ -288,7 +303,12 @@ export function App({ } const loadProfileIntoForm = (name: string) => { - const p = doc.profiles?.[name] ?? null + // Read through the RESOLVED view: since v3 a profile is three objects, and + // the wizard's form is a view of the flattened one. Anything unresolvable + // (a dangling reference) loads as blank rather than throwing, so the wizard + // stays the tool you use to REPAIR a broken profile. + const r = resolveProfileRefs(doc, name) + const p = r.ok ? r.resolved : null setEditingName(name) setProviderId(p?.provider ?? null) setBaseUrl(p?.baseUrl ?? '') @@ -372,7 +392,8 @@ export function App({ // start from that provider's defaults. Compared against the profile // currently loaded in the form, which is not necessarily the one this // wizard opened with. - const stored = editingName ? doc.profiles?.[editingName] : startProfile + const storedResolution = editingName ? resolveProfileRefs(doc, editingName) : null + const stored = storedResolution?.ok ? storedResolution.resolved : startProfile // `byId(id)!` — `id` is the `value` of an item built from `registry.all()` // a few lines below, so it always names a shipped descriptor. This is the // one lookup in the file that genuinely cannot miss; everywhere else byId @@ -400,10 +421,17 @@ export function App({ // STEP-MACHINE INVARIANT (`providerId!`, `provider!`): finish() runs only // from the permissions screen, which is reachable only via the models step, // which is reachable only via chooseProvider(). Both are set. - const profile: Profile = { + // v3 mints THREE objects, all sharing this profile's name — the same 1:1:1 + // shape the v2 migration produces. The wizard deliberately does not express + // multi-account profiles: rotating between accounts is a thing you set up + // once, in the web UI or `config accounts`, not something a first-run + // terminal flow should ask everyone about. + const account: ProviderAccount = { provider: providerId!, ...(provider!.askBaseUrl ? { baseUrl: baseUrl.trim() } : {}), apiKey: apiKey.trim(), + } + const agentProfile: AgentProfile = { models, ...(Object.keys(keptWindows).length > 0 ? { contextWindows: keptWindows } : {}), skipPermissions, @@ -411,8 +439,21 @@ export function App({ // An explicitly named profile keeps its name; a first run derives one from // the provider, exactly as before profiles existed. const name = editingName ?? profileNameFor(doc, providerId) - const next = { + const profile: Profile = { + agentProfile: name, + accounts: [name], + strategy: 'single', + } + // The existing agent selection is preserved: `config agent` writes to the + // agent profile, and re-running the wizard must not silently reset a + // profile back to Claude Code. + const existingAgent = doc.agentProfiles?.[name]?.agent + if (existingAgent !== undefined) agentProfile.agent = existingAgent + + const next: State = { ...doc, + providerAccounts: { ...(doc.providerAccounts ?? {}), [name]: account }, + agentProfiles: { ...(doc.agentProfiles ?? {}), [name]: agentProfile }, profiles: { ...(doc.profiles ?? {}), [name]: profile }, defaultProfile: doc.defaultProfile ?? name, } @@ -718,7 +759,12 @@ export function App({ export type RunUiOptions = { mode?: AppMode | undefined state?: State | null | undefined - initial?: Profile | null | undefined + /** + * A pre-filled form, used by the tests that drive the wizard directly. + * RESOLVED shape, because that is what the form edits — the wizard mints the + * three stored objects at save time, not before. + */ + initial?: ResolvedProfile | null | undefined profileName?: string | null | undefined } diff --git a/src/adapters/web/api.ts b/src/adapters/web/api.ts index e1579ea..11fde79 100644 --- a/src/adapters/web/api.ts +++ b/src/adapters/web/api.ts @@ -13,7 +13,13 @@ import { validateProfileName } from '../../core/migrate.ts' import { toCustomProvider, validateCustomProvider } from '../../core/provider-def.ts' import { TIERS } from '../../core/tiers.ts' import { COMPAT_ENV, CREDENTIAL_ENVS } from '../agents/claude-code/env.ts' -import type { ConfigStorePort, Profile, State } from '../../ports/config-store.ts' +import type { + AgentProfile, + ConfigStorePort, + Profile, + ProviderAccount, + State, +} from '../../ports/config-store.ts' import type { AgentRegistryPort } from '../../ports/agent.ts' import type { ProviderRegistryPort } from '../../ports/provider.ts' import { RESERVED_PROVIDER_IDS, withCustomProviders } from '../providers/composite.ts' @@ -62,32 +68,40 @@ const json = (status: number, body: unknown): ApiResponse => ({ status, body }) const fail = (status: number, error: string): ApiResponse => json(status, { error }) /** - * A profile as the BROWSER is allowed to see it. + * A provider ACCOUNT as the browser is allowed to see it. * - * The key never crosses this boundary — not masked, not truncated, not - * length-hinted. That is the same rule the doctor already follows, and it - * matters more here: a value rendered into a DOM can be read by anything that - * achieves script execution on the page, ends up in browser memory dumps, and - * is one careless devtools screenshot from a bug report. + * Redaction moved here with the credential. Since v3 the key lives on the + * account rather than on the profile, so this is now the single boundary it + * could cross — which is an improvement: there is one type to get right instead + * of one field on a type that also carried everything else. + * + * The key never crosses — not masked, not truncated, not length-hinted. That is + * the same rule the doctor follows, and it matters more here: a value rendered + * into a DOM can be read by anything that achieves script execution on the page + * and is one careless screenshot from a bug report. * * `hasKey` is all the UI needs to render "set / not set" and offer to replace - * it. Editing is therefore write-only: you can overwrite a key, never read one - * back. `apiKeyFromEnv` IS sent, because a variable NAME is not a secret and - * the user needs to see which one is being read. + * it, so editing is write-only. `apiKeyFromEnv` IS sent, because a variable + * NAME is not a secret and the user needs to see which one is read. */ -export type RedactedProfile = Omit & { hasKey: boolean } +export type RedactedAccount = Omit & { hasKey: boolean } -export function redactProfile(profile: Profile): RedactedProfile { - const { apiKey, ...rest } = profile +export function redactAccount(account: ProviderAccount): RedactedAccount { + const { apiKey, ...rest } = account return { ...rest, hasKey: typeof apiKey === 'string' && apiKey.length > 0 } } export function redactState(state: State): unknown { return { ...state, - profiles: Object.fromEntries( - Object.entries(state.profiles ?? {}).map(([name, p]) => [name, redactProfile(p)]), + providerAccounts: Object.fromEntries( + Object.entries(state.providerAccounts ?? {}).map(([n, a]) => [n, redactAccount(a)]), ), + // Agent profiles and profiles hold no credential at all now, so they pass + // through whole. That is the split paying off: only one of the three shapes + // is security-sensitive, and it is obvious which. + agentProfiles: state.agentProfiles ?? {}, + profiles: state.profiles ?? {}, } } @@ -148,34 +162,50 @@ function commit(store: ConfigStorePort, state: State, extra: unknown = {}): ApiR } /** - * A profile submitted by the browser, validated field by field. + * A provider account submitted by the browser. * * Whitelisted rather than spread: an unknown key from a hostile or buggy client * must not reach config.json, where a future swisscode would read it as - * meaningful. `apiKey` is accepted (write-only) but only when non-empty — an - * empty string from a form the user did not touch must not erase a stored key, - * which is the single most destructive mistake this endpoint could make. + * meaningful. + * + * `apiKey` is accepted (write-only) but only when NON-EMPTY — an empty string + * from a form the user did not touch must not erase a stored key, which is the + * single most destructive mistake this endpoint could make. Clearing is an + * explicit `null`, so "I did not touch this" and "remove my credential" stay + * different requests. */ -export function parseProfile(input: unknown, existing: Profile | undefined): Profile | string { - if (!isObjectLike(input)) return 'profile must be an object' - const provider = str(input.provider) +export function parseAccount( + input: unknown, + existing: ProviderAccount | undefined, +): ProviderAccount | string { + if (!isObjectLike(input)) return 'account must be an object' + const provider = str(input.provider) ?? existing?.provider if (!provider) return 'provider is required' - const profile: Profile = { ...(existing ?? {}), provider } as Profile - - if (typeof input.baseUrl === 'string') profile.baseUrl = input.baseUrl - if (typeof input.agent === 'string') profile.agent = input.agent - if (typeof input.skipPermissions === 'boolean') profile.skipPermissions = input.skipPermissions - - // Write-only, and never cleared by omission. Clearing is an explicit - // `apiKey: null`, so "I did not touch this field" and "remove my key" stop - // being the same request. - if (typeof input.apiKey === 'string' && input.apiKey.length > 0) profile.apiKey = input.apiKey - if (input.apiKey === null) delete profile.apiKey - + const account: ProviderAccount = { ...(existing ?? {}), provider } + if (typeof input.label === 'string') account.label = input.label + if (typeof input.baseUrl === 'string') account.baseUrl = input.baseUrl + if (typeof input.apiKey === 'string' && input.apiKey.length > 0) account.apiKey = input.apiKey + if (input.apiKey === null) delete account.apiKey if (typeof input.apiKeyFromEnv === 'string') { - if (input.apiKeyFromEnv) profile.apiKeyFromEnv = input.apiKeyFromEnv - else delete profile.apiKeyFromEnv + if (input.apiKeyFromEnv) account.apiKeyFromEnv = input.apiKeyFromEnv + else delete account.apiKeyFromEnv + } + return account +} + +/** An agent profile submitted by the browser. Holds no credential. */ +export function parseAgentProfile( + input: unknown, + existing: AgentProfile | undefined, +): AgentProfile | string { + if (!isObjectLike(input)) return 'agent profile must be an object' + const agentProfile: AgentProfile = { ...(existing ?? {}) } + + if (typeof input.label === 'string') agentProfile.label = input.label + if (typeof input.agent === 'string') agentProfile.agent = input.agent + if (typeof input.skipPermissions === 'boolean') { + agentProfile.skipPermissions = input.skipPermissions } if (isObjectLike(input.models)) { @@ -184,19 +214,15 @@ export function parseProfile(input: unknown, existing: Profile | undefined): Pro const v = input.models[tier] if (typeof v === 'string') models[tier] = v } - profile.models = models + agentProfile.models = models } if (isObjectLike(input.compat)) { - // Built as a plain record then asserted once. The flag NAMES are validated - // downstream by registry.test.ts's "compat flags are all real" rule and are - // inert if unknown, so an unrecognised key here is a no-op rather than a - // write of a bogus variable — the same tolerance the CLI path has. const compat: Record = {} for (const [k, v] of Object.entries(input.compat)) { if (typeof v === 'boolean') compat[k] = v } - profile.compat = compat as NonNullable + agentProfile.compat = compat as NonNullable } if (isObjectLike(input.env)) { @@ -204,20 +230,46 @@ export function parseProfile(input: unknown, existing: Profile | undefined): Pro for (const [k, v] of Object.entries(input.env)) { if (typeof v === 'string') env[k] = v } - profile.env = env + agentProfile.env = env } // Measured windows only. A non-integer or non-positive entry is dropped - // rather than stored: this map feeds CLAUDE_CODE_AUTO_COMPACT_WINDOW, and a - // window set too large means the conversation overflows instead of compacting. + // rather than stored: this feeds CLAUDE_CODE_AUTO_COMPACT_WINDOW, and a + // window set too large overflows the conversation instead of compacting it. if (isObjectLike(input.contextWindows)) { const windows: Record = {} for (const [model, v] of Object.entries(input.contextWindows)) { if (typeof v === 'number' && Number.isInteger(v) && v > 0) windows[model] = v } - profile.contextWindows = windows + agentProfile.contextWindows = windows } + return agentProfile +} + +/** + * The pairing. References only — no credential, no agent settings. + * + * References are NOT validated against the store here; that is the caller's + * job, because it holds the state and can name what is missing. Validating + * shape and validating existence are different failures and deserve different + * messages. + */ +export function parseProfile(input: unknown, existing: Profile | undefined): Profile | string { + if (!isObjectLike(input)) return 'profile must be an object' + const agentProfile = str(input.agentProfile) ?? existing?.agentProfile + if (!agentProfile) return 'agentProfile is required' + + const accounts = Array.isArray(input.accounts) + ? input.accounts.filter((a): a is string => typeof a === 'string' && a.length > 0) + : (existing?.accounts ?? []) + if (accounts.length === 0) return 'a profile needs at least one provider account' + + const profile: Profile = { ...(existing ?? {}), agentProfile, accounts } + if (typeof input.label === 'string') profile.label = input.label + if (input.strategy === 'single' || input.strategy === 'round-robin' || input.strategy === 'usage') { + profile.strategy = input.strategy + } return profile } @@ -291,6 +343,16 @@ export function handleApi(req: ApiRequest, deps: ApiDeps): ApiResponse { const parsed = parseProfile(body, loaded.state.profiles?.[name]) if (typeof parsed === 'string') return fail(400, parsed) + // References are checked HERE, where the state is in hand. parseProfile + // validated the shape; this validates that the things it names exist. + if (!loaded.state.agentProfiles?.[parsed.agentProfile]) { + return fail(400, `no agent profile named "${parsed.agentProfile}"`) + } + const missing = parsed.accounts.filter((a) => !loaded.state.providerAccounts?.[a]) + if (missing.length > 0) { + return fail(400, `no provider account named "${missing[0]}"`) + } + const state: State = { ...loaded.state, profiles: { ...loaded.state.profiles, [name]: parsed }, @@ -323,6 +385,82 @@ export function handleApi(req: ApiRequest, deps: ApiDeps): ApiResponse { } } + // The two halves a profile references. Same revision discipline, same + // whitelisting; separate routes because they are separate things now, and a + // single endpoint taking a flat blob would re-create exactly the conflation + // v3 exists to undo. + if (resource === 'accounts') { + const name = rest[0] ? decodeURIComponent(rest[0]) : null + if (!name) return fail(400, 'account name is required') + + if (req.method === 'PUT') { + const conflict = revisionConflict(store, req.body) + if (conflict) return conflict + const loaded = store.load() + const parsed = parseAccount( + isObjectLike(req.body) ? req.body.account : null, + loaded.state.providerAccounts?.[name], + ) + if (typeof parsed === 'string') return fail(400, parsed) + return commit(store, { + ...loaded.state, + providerAccounts: { ...loaded.state.providerAccounts, [name]: parsed }, + }) + } + + if (req.method === 'DELETE') { + const conflict = revisionConflict(store, req.body) + if (conflict) return conflict + const loaded = store.load() + if (!loaded.state.providerAccounts?.[name]) return fail(404, `no account named "${name}"`) + + // Profiles referencing it are REPORTED, never silently repaired: only the + // user knows which account should pay instead. + const affected = Object.entries(loaded.state.profiles ?? {}) + .filter(([, pr]) => (pr.accounts ?? []).includes(name)) + .map(([n]) => n) + + const accounts = { ...loaded.state.providerAccounts } + delete accounts[name] + return commit(store, { ...loaded.state, providerAccounts: accounts }, { + affectedProfiles: affected, + }) + } + } + + if (resource === 'agent-profiles') { + const name = rest[0] ? decodeURIComponent(rest[0]) : null + if (!name) return fail(400, 'agent profile name is required') + + if (req.method === 'PUT') { + const conflict = revisionConflict(store, req.body) + if (conflict) return conflict + const loaded = store.load() + const parsed = parseAgentProfile( + isObjectLike(req.body) ? req.body.agentProfile : null, + loaded.state.agentProfiles?.[name], + ) + if (typeof parsed === 'string') return fail(400, parsed) + return commit(store, { + ...loaded.state, + agentProfiles: { ...loaded.state.agentProfiles, [name]: parsed }, + }) + } + + if (req.method === 'DELETE') { + const conflict = revisionConflict(store, req.body) + if (conflict) return conflict + const loaded = store.load() + if (!loaded.state.agentProfiles?.[name]) return fail(404, `no agent profile named "${name}"`) + const affected = Object.entries(loaded.state.profiles ?? {}) + .filter(([, pr]) => pr.agentProfile === name) + .map(([n]) => n) + const agentProfiles = { ...loaded.state.agentProfiles } + delete agentProfiles[name] + return commit(store, { ...loaded.state, agentProfiles }, { affectedProfiles: affected }) + } + } + if (resource === 'providers') { const id = rest[0] ? decodeURIComponent(rest[0]) : null @@ -363,16 +501,23 @@ export function handleApi(req: ApiRequest, deps: ApiDeps): ApiResponse { const loaded = store.load() if (!loaded.state.providers?.[id]) return fail(404, `no custom provider named "${id}"`) - // Profiles pointing at it are reported, not silently repaired. Deleting - // the provider out from under a profile leaves it unlaunchable, and the - // user is the only one who knows which provider it should point at now. + // ACCOUNTS point at providers now, not profiles — so deleting a provider + // orphans accounts, and those in turn orphan whichever profiles use them. + // Both are reported, never silently repaired: only the user knows where a + // profile should point next. + const orphanedAccounts = Object.entries(loaded.state.providerAccounts ?? {}) + .filter(([, a]) => a.provider === id) + .map(([name]) => name) const orphaned = Object.entries(loaded.state.profiles ?? {}) - .filter(([, p]) => p.provider === id) + .filter(([, p]) => (p.accounts ?? []).some((a) => orphanedAccounts.includes(a))) .map(([name]) => name) const providersMap = { ...loaded.state.providers } delete providersMap[id] - return commit(store, { ...loaded.state, providers: providersMap }, { orphanedProfiles: orphaned }) + return commit(store, { ...loaded.state, providers: providersMap }, { + orphanedAccounts, + orphanedProfiles: orphaned, + }) } } diff --git a/src/composition/config-root.ts b/src/composition/config-root.ts index fbe92bc..4b9b1a7 100644 --- a/src/composition/config-root.ts +++ b/src/composition/config-root.ts @@ -27,6 +27,7 @@ import { unbindPath, } from '../core/binding.ts' import { validateProfileName } from '../core/migrate.ts' +import { resolveProfileRefs } from '../core/resolve.ts' import { TIERS } from '../core/tiers.ts' import { DEFAULT_AGENT_ID } from '../adapters/agents/registry.ts' import { withCustomProviders } from '../adapters/providers/composite.ts' @@ -252,7 +253,10 @@ function agentCommand({ out('No profiles yet. Run `swisscode config` to make one.') return 0 } - for (const n of names) out(` ${n} → ${state.profiles[n]?.agent ?? DEFAULT_AGENT_ID}`) + for (const n of names) { + const ap = state.agentProfiles?.[state.profiles[n]?.agentProfile ?? ''] + out(` ${n} → ${ap?.agent ?? DEFAULT_AGENT_ID}`) + } return 0 } @@ -266,8 +270,10 @@ function agentCommand({ return 2 } + const agentProfileName = profile.agentProfile + const agentProfile = state.agentProfiles?.[agentProfileName] if (agentId === undefined) { - out(`${profileName} → ${profile.agent ?? DEFAULT_AGENT_ID}`) + out(`${profileName} → ${agentProfile?.agent ?? DEFAULT_AGENT_ID}`) return 0 } @@ -278,12 +284,33 @@ function agentCommand({ return 2 } if (loaded.readOnly) return refuseWrite(err) + if (!agentProfile) { + err( + `swisscode: profile "${profileName}" uses agent profile "${agentProfileName}", which ` + + 'does not exist. Run `swisscode config ' + profileName + '` to repair it.', + ) + return 2 + } + // Written to the AGENT PROFILE, not the profile: since v3 that is where the + // coding CLI lives, and an agent profile may back several profiles — which is + // the point of the split, and worth the reminder in the confirmation line. const next: State = { ...state, - profiles: { ...state.profiles, [profileName]: { ...profile, agent: agentId } }, + agentProfiles: { + ...state.agentProfiles, + [agentProfileName]: { ...agentProfile, agent: agentId }, + }, } deps.store.save(next) - out(`${profileName} now launches ${agentId}.`) + const alsoUsing = Object.entries(state.profiles ?? {}) + .filter(([n, pr]) => n !== profileName && pr.agentProfile === agentProfileName) + .map(([n]) => n) + out( + `${profileName} now launches ${agentId}.` + + (alsoUsing.length + ? ` (shared agent profile "${agentProfileName}" — also used by ${alsoUsing.join(', ')})` + : ''), + ) return 0 } @@ -308,40 +335,62 @@ function listProfiles({ deps, out }: { deps: LaunchDeps; out: Emit }): number { for (const name of names.sort()) { // `!` is noUncheckedIndexedAccess meeting Object.keys: `names` was read off - // this very object three lines up, so every key is present. Asserted once - // at the binding rather than at each of the seven reads below. + // this very object three lines up, so every key is present. const p = state.profiles[name]! const isDefault = state.defaultProfile === name - const provider = deps.registry.byId(p.provider) + + // Show what the profile RESOLVES to, not what it references. A list of key + // names would make the reader do the dereference in their head, and the + // question this command answers is "what happens if I launch this". + const resolution = resolveProfileRefs(state, name) + const resolved = resolution.ok ? resolution.resolved : null + const provider = resolved ? deps.registry.byId(resolved.provider) : null + const flags = [ isDefault ? 'default' : null, - provider ? null : 'unknown provider', + resolution.ok ? null : 'broken', + resolved && !provider ? 'unknown provider' : null, // The subcommand always wins positionally, so such a profile can only be // selected with --cc-profile. SUBCOMMANDS.includes(name) ? 'shadowed' : null, ].filter(Boolean) out(`${isDefault ? '*' : ' '} ${name}${flags.length ? ` (${flags.join(', ')})` : ''}`) - out(` provider ${p.provider}${provider ? '' : ' — not in this build'}`) - if (p.baseUrl) out(` baseUrl ${p.baseUrl}`) + + if (!resolution.ok) { + out(` ${resolution.reason}`) + continue + } + const r = resolved! + + // The three-way structure, named. Which account pays is the most + // consequential line here, so it is first and it is never elided. + const others = (p.accounts ?? []).filter((a) => a !== r.accountName) + out( + ` account ${r.accountName} → ${r.provider}` + + (provider ? '' : ' — not in this build') + + (others.length ? ` (+${others.length} more, ${p.strategy ?? 'single'})` : ''), + ) + out(` agent ${r.agentProfileName} → ${r.agent ?? DEFAULT_AGENT_ID}`) + if (r.baseUrl) out(` baseUrl ${r.baseUrl}`) // Presence and ORIGIN only. Never a prefix, never a suffix, never a length: // a masked key is still a fingerprint, and this output gets pasted into bug // reports. - out(` key ${credentialOrigin(p)}`) + out(` key ${credentialOrigin(r)}`) // What will actually be sent, not just what is stored: an absent tier // inherits the provider default at launch, and printing "—" for something // that resolves to a real model is how a config gets debugged twice. const models = TIERS.map((t) => { - const pinned = p.models?.[t] + const pinned = r.models?.[t] if (pinned) return `${t}=${pinned}` const inherited = provider?.defaultModels?.[t] return inherited ? `${t}=${inherited}*` : `${t}=—` }).join(' ') out(` models ${models}`) - if (TIERS.some((t) => !p.models?.[t] && provider?.defaultModels?.[t])) { + if (TIERS.some((t) => !r.models?.[t] && provider?.defaultModels?.[t])) { out(' * inherited from the provider preset') } - if (p.skipPermissions) out(' perms --dangerously-skip-permissions by default') + if (r.skipPermissions) out(' perms --dangerously-skip-permissions by default') for (const key of bindingsByProfile.get(name) ?? []) out(` bound ${key}`) } @@ -352,9 +401,9 @@ function listProfiles({ deps, out }: { deps: LaunchDeps; out: Emit }): number { return 0 } -function credentialOrigin(profile: Profile): string { - if (profile.apiKeyFromEnv) return `read from $${profile.apiKeyFromEnv} at launch` - if (profile.apiKey) return 'stored in config.json (0600)' +function credentialOrigin(account: { apiKey?: string; apiKeyFromEnv?: string }): string { + if (account.apiKeyFromEnv) return `read from $${account.apiKeyFromEnv} at launch` + if (account.apiKey) return 'stored in config.json (0600)' return 'none' } @@ -494,7 +543,15 @@ function showBinding({ if (known) { // `!` — `known` is a hasOwnProperty check on this exact key, which tsc // does not treat as a narrowing but which is a real runtime proof. - out(`effective profile "${info.match.name}" (${state.profiles[info.match.name]!.provider})`) + // Report the ACCOUNT the binding resolves to, not a stored provider id: + // since v3 the profile holds neither, and "which account pays here" is + // the question `use --show` exists to answer. + const bound = resolveProfileRefs(state, info.match.name) + out( + `effective profile "${info.match.name}" (` + + (bound.ok ? `${bound.resolved.accountName} → ${bound.resolved.provider}` : 'broken') + + ')', + ) } else { out(`effective profile "${state.defaultProfile ?? 'none'}" — the binding is dangling, so it is ignored`) } diff --git a/src/composition/doctor-root.ts b/src/composition/doctor-root.ts index 96d928b..bda249b 100644 --- a/src/composition/doctor-root.ts +++ b/src/composition/doctor-root.ts @@ -13,6 +13,7 @@ import { buildEnvPlan } from '../adapters/agents/claude-code/env.ts' import { claudeCode } from '../adapters/agents/claude-code/index.ts' import { applyOverrides } from '../core/overrides.ts' import { resolveProfile } from '../core/profile.ts' +import { resolveProfileRefs } from '../core/resolve.ts' import { buildIntent } from '../core/intent.ts' import { sanitizeUrlForDisplay, urlCredentials } from '../core/url-safety.ts' import { @@ -91,7 +92,13 @@ export async function runDoctor({ } const selection = resolveProfile(loaded.state, { cwd, platform: process.platform }) - const profile = selection.profile ? applyOverrides(selection.profile, selection.overrides) : null + // The doctor resolves exactly as the launch path does, or it would diagnose a + // configuration nobody runs. A broken reference surfaces as a check below + // rather than as an exception here, which is the whole point of a doctor. + const resolution = selection.name ? resolveProfileRefs(loaded.state, selection.name) : null + const resolutionError = resolution && !resolution.ok ? resolution.reason : null + const profile = + resolution?.ok ? applyOverrides(resolution.resolved, selection.overrides) : null // Same composition as the launch path: a doctor that could not see a custom // provider would report "unknown provider" for a profile that launches fine. const registry = withCustomProviders(baseRegistry, loaded.state) @@ -139,6 +146,8 @@ export async function runDoctor({ .filter((b) => !existsSync(b.key)) .map((b) => b.key) + if (resolutionError) notes.push(resolutionError) + const checks = staticChecks({ loaded, selection, diff --git a/src/composition/launch-root.ts b/src/composition/launch-root.ts index 32e482c..1cc5c4d 100644 --- a/src/composition/launch-root.ts +++ b/src/composition/launch-root.ts @@ -10,13 +10,14 @@ import { isInsecureRemoteBaseUrl } from '../core/url-safety.ts' import { materializeEnv } from '../core/env-plan.ts' import { applyOverrides, retargetProvider } from '../core/overrides.ts' import { resolveProfile } from '../core/profile.ts' +import { resolveProfileRefs, type CursorPort } from '../core/resolve.ts' import { createFsConfigStore } from '../adapters/store/fs-config-store.ts' import { createNodeProcess, detectRecursion } from '../adapters/process/node-process.ts' import { registry as providerRegistry } from '../adapters/providers/registry.ts' import { withCustomProviders } from '../adapters/providers/composite.ts' import { DEFAULT_AGENT_ID, registry as agentRegistry } from '../adapters/agents/registry.ts' import type { ProfileSelection } from '../core/profile.ts' -import type { ConfigStorePort, LoadResult, Profile } from '../ports/config-store.ts' +import type { ConfigStorePort, LoadResult, ResolvedProfile } from '../ports/config-store.ts' import type { ProviderDescriptor, ProviderRegistryPort } from '../ports/provider.ts' import type { EnvMap, ProcessPort } from '../ports/process.ts' import type { ProfileOverrides } from '../ports/config-store.ts' @@ -45,6 +46,14 @@ export type LaunchDeps = { registry: ProviderRegistryPort agents: AgentRegistryPort proc: ProcessPort + /** + * Where a round-robin cursor is remembered. OPTIONAL, and genuinely so: a + * profile using the default `single` strategy never consults it, and a + * caller with no cursor store degrades to "always the first account" with a + * warning rather than failing. Deliberately NOT the config store — the launch + * path writes no config, and a rotation counter is not configuration. + */ + cursor?: CursorPort } export function defaultDeps(): LaunchDeps { @@ -115,7 +124,8 @@ export type LaunchPlan = { needsSetup: false loaded: LoadResult selection: ProfileSelection - profile: Profile + /** the flattened account + agent profile this launch resolved to */ + profile: ResolvedProfile /** * null is a REAL state, not a defect: a profile naming a provider this build * does not know still launches, provided it carries a baseUrl of its own. @@ -150,6 +160,7 @@ export function planLaunch({ registry: baseRegistry, agents, proc, + cursor, passthrough = [], skipOverride = null, positional = null, @@ -193,10 +204,21 @@ export function planLaunch({ // A matched positional profile name is CONSUMED — claude never sees it. const args = sel.consumedPositional ? passthrough.slice(1) : passthrough + // v3: a profile is three objects. Dereference the agent profile and select + // one account BEFORE anything else, because every step below operates on the + // flattened view — which is deliberately the shape v2 stored, so nothing + // downstream of here changed when the schema split. + const resolution = resolveProfileRefs(loaded.state, sel.name ?? '', { + cursor: cursor ?? null, + now: Date.now(), + }) + if (!resolution.ok) throw new LaunchError(resolution.reason) + const resolutionWarnings = resolution.warnings + // Pipeline, in order: binding overrides, then provider retarget, then the // remaining CLI overrides. Retargeting first means --cc-base-url can still - // correct a base URL borrowed from another profile. - let profile = applyOverrides(sel.profile, sel.overrides) + // correct a base URL adopted from another account. + let profile = applyOverrides(resolution.resolved, sel.overrides) let borrowedFrom = null if (overrides.provider) { @@ -233,6 +255,12 @@ export function planLaunch({ // unknown id is fatal; a stored unknown agent falls back to the default, so a // config written by a newer swisscode still launches something. const warnings: EnvWarning[] = [] + // Resolution can degrade loudly — a rotation with no cursor store, a `usage` + // strategy with no snapshot, a dangling account reference. Which account pays + // is exactly the thing that must never change quietly. + for (const message of resolutionWarnings) { + warnings.push({ severity: 'medium', code: 'account-selection', message }) + } const requestedAgentId = profile.agent ?? DEFAULT_AGENT_ID let agent = agents.byId(requestedAgentId) if (!agent) { diff --git a/src/core/env-plan.ts b/src/core/env-plan.ts index d07cc48..288953e 100644 --- a/src/core/env-plan.ts +++ b/src/core/env-plan.ts @@ -6,7 +6,7 @@ // or CLAUDE_CODE_* string — that is the whole point of the split. import type { EnvPlan } from '../ports/agent.ts' -import type { Profile } from '../ports/config-store.ts' +import type { ResolvedProfile } from '../ports/config-store.ts' import type { EnvMap } from '../ports/process.ts' /** @@ -66,7 +66,7 @@ export function definedEntriesOf( * Neutral: it produces the VALUE; which variable carries it is the adapter's call. */ export function resolveCredential( - profile: Profile | null | undefined, + profile: ResolvedProfile | null | undefined, ambientEnv: EnvMap | null | undefined, ): string { if (profile?.apiKeyFromEnv) return ambientEnv?.[profile.apiKeyFromEnv] ?? '' diff --git a/src/core/intent.ts b/src/core/intent.ts index 890331b..df00439 100644 --- a/src/core/intent.ts +++ b/src/core/intent.ts @@ -13,7 +13,7 @@ import { TIERS } from './tiers.ts' import { definedEntriesOf, resolveCredential } from './env-plan.ts' import type { LaunchIntent } from '../ports/agent.ts' -import type { Profile } from '../ports/config-store.ts' +import type { ResolvedProfile } from '../ports/config-store.ts' import type { ProviderDescriptor, Tier, TierRecord } from '../ports/provider.ts' import type { EnvMap } from '../ports/process.ts' @@ -30,7 +30,7 @@ export type IntentOptions = { } export function buildIntent( - profile: Profile | null | undefined, + profile: ResolvedProfile | null | undefined, provider: ProviderDescriptor | null | undefined, ambientEnv: EnvMap = {}, opts: IntentOptions = {}, diff --git a/src/core/migrate.ts b/src/core/migrate.ts index 08f6516..7fe7f28 100644 --- a/src/core/migrate.ts +++ b/src/core/migrate.ts @@ -8,9 +8,17 @@ import { pickTiers } from './tiers.ts' import { isAbsolutePath, normalizeBindingKey } from './binding.ts' -import type { BindingValue, ConfigV1, MigrateResult, Settings, State } from '../ports/config-store.ts' +import type { + BindingValue, + ConfigV1, + ConfigV2, + CustomProvider, + MigrateResult, + Settings, + State, +} from '../ports/config-store.ts' -export const SUPPORTED_VERSION = 2 +export const SUPPORTED_VERSION = 3 /** Enforced at creation, never at parse — a hand-edited file is still read. */ export const NAME_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/ @@ -58,9 +66,27 @@ type V2Draft = { settings: Settings } +/** + * The v3 SHAPE, before validation. Same reasoning as `V2Draft`: the three maps + * are `Record` because rule M1 carries unrecognized keys + * through unchecked, and saying so keeps that step visible. + */ +type V3Draft = { + version: number + providerAccounts: Record> + agentProfiles: Record> + profiles: Record> + defaultProfile: string | null + bindings: Record + settings: Settings + providers?: Record +} + export function emptyState(): State { return { version: SUPPORTED_VERSION, + providerAccounts: {}, + agentProfiles: {}, profiles: {}, defaultProfile: null, bindings: {}, @@ -123,7 +149,10 @@ export function fromV1(raw: ConfigV1): V2Draft { const name = NAME_RE.test(raw.provider) ? raw.provider : 'default' return { - version: SUPPORTED_VERSION, + // VERSION 2, not SUPPORTED_VERSION. This output is fed straight into + // `fromV2` by the ladder below; stamping it with the current version would + // make the chain silently skip the v2->v3 step the moment a v4 exists. + version: 2, profiles: { [name]: profile }, defaultProfile: name, bindings: {}, @@ -131,6 +160,82 @@ export function fromV1(raw: ConfigV1): V2Draft { } } +/** + * The v2 keys that move to a `ProviderAccount`, and those that move to an + * `AgentProfile`. Anything in NEITHER list is an unrecognized key and rides + * along on the agent profile — rule M1, inherited from v1→v2. + */ +const V2_ACCOUNT_KEYS = Object.freeze(['provider', 'baseUrl', 'apiKey', 'apiKeyFromEnv']) +const V2_AGENT_KEYS = Object.freeze([ + 'agent', 'models', 'skipPermissions', 'env', 'compat', 'contextWindows', +]) + +/** + * v2 -> v3. Lossless, deterministic, and it repairs nothing. + * + * Each v2 profile `N` becomes THREE objects, all named `N`: an account, an + * agent profile, and a profile referencing both. Bindings and defaultProfile + * keep pointing at profile names, so `swisscode ` and every directory + * binding survive untouched — which is the whole reason the split is named this + * way round. + * + * ACCOUNTS ARE NOT DEDUPED, even when two profiles obviously share a key. + * Merging them would be clever, irreversible, and wrong the first time two + * accounts happen to hold the same value for different reasons. One account per + * profile is lossless and obvious; consolidating is a thing a person can do + * later, in the UI, on purpose. + */ +export function fromV2(raw: ConfigV2): V3Draft { + const providerAccounts: Record> = {} + const agentProfiles: Record> = {} + const profiles: Record> = {} + + for (const [name, p] of Object.entries(raw.profiles ?? {})) { + if (!isPlainObject(p)) continue + + const account: Record = {} + for (const k of V2_ACCOUNT_KEYS) if (p[k] !== undefined) account[k] = p[k] + // `provider` is required on an account. A v2 profile without one was + // already broken — carry it through as-is rather than inventing a default, + // so the resolution error names the real problem instead of a guess. + providerAccounts[name] = account + + const agentProfile: Record = {} + for (const k of V2_AGENT_KEYS) if (p[k] !== undefined) agentProfile[k] = p[k] + // M1: unrecognized keys ride along. They land on the agent profile because + // that is where v2's own extras (anything not a credential) belonged. + for (const [k, v] of Object.entries(p)) { + if (!V2_ACCOUNT_KEYS.includes(k) && !V2_AGENT_KEYS.includes(k) && k !== 'label') { + agentProfile[k] = v + } + } + agentProfiles[name] = agentProfile + + const profile: Record = { + agentProfile: name, + accounts: [name], + strategy: 'single', + } + // The label described the pairing, so it stays on the pairing. + if (p.label !== undefined) profile.label = p.label + profiles[name] = profile + } + + const draft: V3Draft = { + version: SUPPORTED_VERSION, + providerAccounts, + agentProfiles, + profiles, + defaultProfile: typeof raw.defaultProfile === 'string' ? raw.defaultProfile : null, + bindings: raw.bindings ?? {}, + settings: raw.settings ?? {}, + } + // Custom providers are already a top-level concept and are unrelated to the + // split; they pass through untouched. + if (raw.providers !== undefined) draft.providers = raw.providers + return draft +} + /** Fill defaults, drop junk, resolve the default profile. Idempotent. */ export function normalize(raw: unknown): { state: State; warnings: string[] } { const warnings: string[] = [] @@ -158,6 +263,28 @@ export function normalize(raw: unknown): { state: State; warnings: string[] } { state.profiles = profiles } + // The two new v3 maps, validated exactly as `profiles` is: an entry that is + // not an object is dropped with a warning rather than reaching a consumer + // that will read fields off a number. + for (const key of ['providerAccounts', 'agentProfiles'] as const) { + if (!isPlainObject(state[key])) { + if (state[key] !== undefined) { + warnings.push(`config.json: \`${key}\` is not an object; ignoring it.`) + } + state[key] = {} + } else { + const kept: Record> = {} + for (const [name, entry] of Object.entries(state[key])) { + if (!isPlainObject(entry)) { + warnings.push(`config.json: ${key} entry "${name}" is not an object; ignoring it.`) + continue + } + kept[name] = entry + } + state[key] = kept + } + } + if (!isPlainObject(state.bindings)) { state.bindings = {} } else { @@ -242,6 +369,8 @@ export function migrate(raw: unknown): MigrateResult { if (version > SUPPORTED_VERSION) { const salvage = { version, + providerAccounts: isPlainObject(raw.providerAccounts) ? raw.providerAccounts : {}, + agentProfiles: isPlainObject(raw.agentProfiles) ? raw.agentProfiles : {}, profiles: isPlainObject(raw.profiles) ? raw.profiles : {}, defaultProfile: typeof raw.defaultProfile === 'string' ? raw.defaultProfile : null, bindings: isPlainObject(raw.bindings) ? raw.bindings : {}, @@ -252,6 +381,11 @@ export function migrate(raw: unknown): MigrateResult { return { state, migratedFrom: null, corrupt: false, readOnly: true, warnings } } + if (version === 2) { + const { state, warnings } = normalize(fromV2(raw as unknown as ConfigV2)) + return { state, migratedFrom: 2, corrupt: false, readOnly: false, warnings } + } + if (version === 1) { // Known bug: `version === 1` is not `isV1(raw)` — `raw` is only a // `Record` here. `detectVersion` returns 1 down TWO paths: @@ -261,7 +395,10 @@ export function migrate(raw: unknown): MigrateResult { // reaches `fromV1`, where `raw.provider` is undefined, `NAME_RE.test(undefined)` // coerces to the string "undefined" and MATCHES, and rule M1 nests the // entire file inside a single profile named "undefined". - const { state, warnings } = normalize(fromV1(raw as unknown as ConfigV1)) + // CHAINED, not parallel. fromV1 produces a v2 draft, which fromV2 then + // splits — so a 0.1.0 file reaches v3 in a single read and there is exactly + // one implementation of each step to keep correct. + const { state, warnings } = normalize(fromV2(fromV1(raw as unknown as ConfigV1) as unknown as ConfigV2)) return { state, migratedFrom: 1, corrupt: false, readOnly: false, warnings } } diff --git a/src/core/overrides.ts b/src/core/overrides.ts index 47c6612..413a50d 100644 --- a/src/core/overrides.ts +++ b/src/core/overrides.ts @@ -1,28 +1,30 @@ -// Per-run profile overrides. +// Per-run overrides, applied to the RESOLVED profile. // -// INVARIANT: this path never writes. Overrides produce a modified copy that -// lives for exactly one launch; the only writers in the codebase are the wizard -// and the `config *` subcommands. test/core/overrides.test.ts asserts zero -// store writes across a matrix of override shapes. +// INVARIANT: this path never writes CONFIG. Overrides produce a modified copy +// that lives for exactly one launch; the only config writers in the codebase +// are the wizard and the `config *` subcommands. test/core/overrides.test.ts +// asserts zero `store.save` calls across a matrix of override shapes. // -// The `--cc-*` PARSER that produces these override objects belongs to the UX -// phase. The merge itself is pure and lands here now. +// (A round-robin cursor is not config and does not go through the store — see +// core/resolve.ts and adapters/store/fs-cursor-store.ts.) import { TIERS, isTier } from './tiers.ts' -import type { Profile, ProfileOverrides, State } from '../ports/config-store.ts' +import type { ProfileOverrides, ResolvedProfile, State } from '../ports/config-store.ts' import type { ProviderDescriptor, Tier } from '../ports/provider.ts' import type { EnvMap } from '../ports/process.ts' /** - * Returns a NEW profile; the input is never mutated. + * Returns a NEW resolved profile; the input is never mutated. * - * `profile` is a plain `Profile`, not nullable: both call sites (launch-root - * and doctor-root) refuse to proceed when selection produced no profile, so the - * `?? {}` below is defence rather than a supported mode. A `Profile | null` - * signature would push a phantom null through every consumer downstream. + * Operates on the flattened view rather than on stored objects, which is what + * keeps `--cc-*` from having to know that a profile is now three objects: an + * override changes what THIS LAUNCH does, not what is filed where. */ -export function applyOverrides(profile: Profile, overrides: ProfileOverrides = {}): Profile { - const next = structuredClone(profile ?? {}) +export function applyOverrides( + profile: ResolvedProfile, + overrides: ProfileOverrides = {}, +): ResolvedProfile { + const next = structuredClone(profile ?? {}) as ResolvedProfile if (typeof overrides.provider === 'string') next.provider = overrides.provider if (typeof overrides.agent === 'string') next.agent = overrides.agent @@ -49,7 +51,7 @@ export function applyOverrides(profile: Profile, overrides: ProfileOverrides = { } if (overrides.env && typeof overrides.env === 'object') { - // Merged AFTER profile.env, and '' still means UNSET. + // Merged AFTER the agent profile's env, and '' still means UNSET. next.env = { ...(next.env ?? {}), ...overrides.env } } @@ -59,11 +61,21 @@ export function applyOverrides(profile: Profile, overrides: ProfileOverrides = { /** * Never send a credential to a host it was not entered for. * - * Order: keep the key when the provider is unchanged; otherwise borrow the - * credential and base URL from another profile that already uses the target - * provider; otherwise accept one already present in the ambient env; otherwise - * refuse. There is no "just send the key we have" branch — that is how a z.ai - * token ends up POSTed to OpenRouter. + * SINCE v3 THIS IS A LOOKUP, NOT A SEARCH — and that change is most of why the + * schema was split. The v2 version had to rummage through every other profile + * hoping to find one that happened to hold a key for the target provider, and + * then copy the key, endpoint and models back out of it. Credentials were + * trapped inside profiles, so retargeting meant scavenging. + * + * Now an account names exactly one provider, so "which credential may go to + * this host" is `providerAccounts` filtered by `provider`. The safety rule is + * unchanged and is now STRUCTURAL rather than a copying discipline. + * + * Order: keep the account when the provider is unchanged; otherwise adopt an + * account that already belongs to the target provider; otherwise accept a + * credential already present in the ambient env; otherwise refuse. There is no + * "just send the key we have" branch — that is how a z.ai token ends up POSTed + * to OpenRouter. * * MODEL IDS ARE DROPPED for the same reason the credential is. `glm-5.2` was * chosen for z.ai; forwarding it to OpenRouter is a guaranteed 404 wearing the @@ -72,35 +84,37 @@ export function applyOverrides(profile: Profile, overrides: ProfileOverrides = { * because it is merged after this runs. */ export function retargetProvider( - profile: Profile, + profile: ResolvedProfile, targetProviderId: string | null | undefined, state: State | null | undefined, descriptor: ProviderDescriptor | null | undefined, ambientEnv: EnvMap = {}, -): { ok: true; profile: Profile; borrowedFrom: string | null } | { ok: false; reason: string } { +): { + ok: true + profile: ResolvedProfile + borrowedFrom: string | null +} | { ok: false; reason: string } { if (!targetProviderId || profile?.provider === targetProviderId) { return { ok: true, profile, borrowedFrom: null } } - const next = structuredClone(profile ?? {}) + const next = structuredClone(profile ?? {}) as ResolvedProfile next.provider = targetProviderId delete next.baseUrl delete next.models // Keyed by model id from the old provider's catalog, so meaningless here. delete next.contextWindows - for (const [name, candidate] of Object.entries(state?.profiles ?? {})) { - if (candidate?.provider !== targetProviderId) continue - if (!candidate.apiKey && !candidate.apiKeyFromEnv) continue + // The lookup the old scavenge was imitating. + for (const [name, account] of Object.entries(state?.providerAccounts ?? {})) { + if (account?.provider !== targetProviderId) continue + if (!account.apiKey && !account.apiKeyFromEnv) continue delete next.apiKey delete next.apiKeyFromEnv - if (candidate.apiKey) next.apiKey = candidate.apiKey - if (candidate.apiKeyFromEnv) next.apiKeyFromEnv = candidate.apiKeyFromEnv - if (candidate.baseUrl) next.baseUrl = candidate.baseUrl - // That profile is already configured FOR this provider, so its models are - // the best available answer — better than the descriptor defaults. - if (candidate.models) next.models = structuredClone(candidate.models) - if (candidate.contextWindows) next.contextWindows = structuredClone(candidate.contextWindows) + if (account.apiKey) next.apiKey = account.apiKey + if (account.apiKeyFromEnv) next.apiKeyFromEnv = account.apiKeyFromEnv + if (account.baseUrl) next.baseUrl = account.baseUrl + next.accountName = name return { ok: true, profile: next, borrowedFrom: name } } @@ -122,9 +136,9 @@ export function retargetProvider( return { ok: false, reason: - `no credential for provider "${targetProviderId}". The current profile's key was ` + - `entered for "${profile?.provider}" and will not be sent somewhere else. Add a ` + - `profile for "${targetProviderId}"` + + `no account for provider "${targetProviderId}". The current account's key was ` + + `entered for "${profile?.provider}" and will not be sent somewhere else. Add an ` + + `account with \`swisscode config accounts\`` + (credentialEnv ? `, or set ${credentialEnv} in your environment.` : '.'), } } diff --git a/src/core/resolve.ts b/src/core/resolve.ts new file mode 100644 index 0000000..60897d6 --- /dev/null +++ b/src/core/resolve.ts @@ -0,0 +1,240 @@ +// Turn a selected profile into the one account + agent profile a launch uses. +// +// `core/profile.ts` answers WHICH profile (positional / flag / binding / +// default). This answers what that profile actually resolves to, which since v3 +// means dereferencing an agent profile and choosing among one or more accounts. +// +// Pure, and pure on purpose: strategy selection is the step that decides WHICH +// ACCOUNT PAYS, so it must be exhaustively testable without a store, a clock or +// a network. +// +// EVERY STRATEGY RESOLVES ONCE, BEFORE execve. swisscode ceases to exist at +// handoff, so there is no per-request rotation and no mid-session failover here +// — those need a process in the data path, which is the proxy this tool is not. + +import type { + ProviderAccount, + ResolvedProfile, + SelectionStrategy, + State, +} from '../ports/config-store.ts' + +/** + * What the launch path needs, or why it cannot be produced. + * + * A discriminated union rather than a nullable result: on the error branch + * there is genuinely no resolved profile, and `reason` is a message that names + * the fix — the same contract `LaunchError` messages hold themselves to. + */ +export type Resolution = + | { ok: true; resolved: ResolvedProfile; warnings: string[] } + | { ok: false; reason: string } + +/** + * Where a rotation cursor is remembered between launches. + * + * Injected rather than read here, because this module is pure and because the + * cursor deliberately does NOT live in config.json — the launch path writes no + * config, and a rotation counter is not configuration. See + * `adapters/store/fs-cursor-store.ts`. + * + * `read` returning null (no cursor yet, unreadable file, whatever) is a normal + * state and simply starts the rotation at zero. + */ +export type CursorPort = { + read: (profileName: string) => number | null + /** best effort; a failed write must never block a launch */ + advance: (profileName: string, next: number) => void +} + +/** + * A cached usage snapshot, refreshed at CONFIGURATION time (doctor, web UI). + * + * Never fetched here. The launch path may not reach the network, so `usage` + * selection reads what was last measured and reports how old it is rather than + * pretending to know the current figure. + */ +export type UsageSnapshot = { + /** account name -> remaining capacity, higher is more */ + remaining: Record + /** epoch ms when it was measured */ + checkedAt: number +} + +export type ResolveOptions = { + cursor?: CursorPort | null + usage?: UsageSnapshot | null + /** for reporting snapshot age; injected so this stays pure */ + now?: number +} + +/** Flatten an account and an agent profile into the shape a launch consumes. */ +function flatten( + accountName: string, + account: ProviderAccount, + agentProfileName: string, + state: State, +): ResolvedProfile { + const agentProfile = state.agentProfiles?.[agentProfileName] ?? {} + const resolved: ResolvedProfile = { + accountName, + agentProfileName, + provider: account.provider, + } + // Assigned conditionally throughout: `exactOptionalPropertyTypes` makes + // "absent" and "present but undefined" different types, and the env builder + // branches on presence — `apiKey: undefined` would not mean the same thing as + // no apiKey at all. + if (account.baseUrl !== undefined) resolved.baseUrl = account.baseUrl + if (account.apiKey !== undefined) resolved.apiKey = account.apiKey + if (account.apiKeyFromEnv !== undefined) resolved.apiKeyFromEnv = account.apiKeyFromEnv + + if (agentProfile.agent !== undefined) resolved.agent = agentProfile.agent + if (agentProfile.models !== undefined) resolved.models = agentProfile.models + if (agentProfile.skipPermissions !== undefined) { + resolved.skipPermissions = agentProfile.skipPermissions + } + if (agentProfile.env !== undefined) resolved.env = agentProfile.env + if (agentProfile.compat !== undefined) resolved.compat = agentProfile.compat + if (agentProfile.contextWindows !== undefined) { + resolved.contextWindows = agentProfile.contextWindows + } + return resolved +} + +/** + * Pick one account name from the profile's list. + * + * Returns the choice AND any warning the choice deserves, because two of the + * three strategies can silently degrade — a rotation with no cursor store, or + * `usage` with no snapshot — and degrading quietly about WHICH ACCOUNT PAYS is + * the failure this whole codebase is arranged to prevent. + */ +export function selectAccount( + profileName: string, + accounts: string[], + strategy: SelectionStrategy, + { cursor = null, usage = null, now = 0 }: ResolveOptions = {}, +): { name: string; warnings: string[] } { + const warnings: string[] = [] + // `accounts[0]` is checked by the caller before this runs; the `?? ''` is for + // `noUncheckedIndexedAccess` and is unreachable. + const first = accounts[0] ?? '' + if (accounts.length === 1 || strategy === 'single') return { name: first, warnings } + + if (strategy === 'round-robin') { + if (!cursor) { + warnings.push( + `profile "${profileName}" rotates between accounts, but no cursor store is ` + + `available, so it used "${first}". Every launch will use the same account.`, + ) + return { name: first, warnings } + } + const previous = cursor.read(profileName) ?? -1 + const index = (previous + 1) % accounts.length + cursor.advance(profileName, index) + return { name: accounts[index] ?? first, warnings } + } + + // 'usage' + if (!usage) { + warnings.push( + `profile "${profileName}" selects by remaining capacity, but nothing has measured ` + + `it yet, so it used "${first}". Run \`swisscode config doctor\` to refresh usage.`, + ) + return { name: first, warnings } + } + const known = accounts.filter((a) => typeof usage.remaining[a] === 'number') + if (known.length === 0) { + warnings.push( + `profile "${profileName}" selects by remaining capacity, but none of its accounts ` + + `reports any, so it used "${first}".`, + ) + return { name: first, warnings } + } + // Highest remaining wins; ties keep the earlier-listed account, so the order + // the user wrote is the tiebreak rather than object-key order. + let best = known[0] ?? first + for (const name of known) { + if ((usage.remaining[name] ?? -1) > (usage.remaining[best] ?? -1)) best = name + } + const ageMinutes = Math.max(0, Math.round((now - usage.checkedAt) / 60_000)) + warnings.push( + `selected "${best}" by remaining capacity, measured ${ageMinutes} minute(s) ago. ` + + 'swisscode cannot check this at launch, so the figure is as fresh as the last check.', + ) + return { name: best, warnings } +} + +/** + * Resolve a profile by name. + * + * Every failure names the fix rather than reporting a shape mismatch: a config + * that dangles is something a person has to repair, and "profile X references + * agent profile Y, which does not exist" is the whole diagnosis. + */ +export function resolveProfileRefs( + state: State, + profileName: string, + options: ResolveOptions = {}, +): Resolution { + const profile = state.profiles?.[profileName] + if (!profile) return { ok: false, reason: `profile "${profileName}" does not exist.` } + + const agentProfileName = profile.agentProfile + if (!agentProfileName || !state.agentProfiles?.[agentProfileName]) { + return { + ok: false, + reason: + `profile "${profileName}" uses agent profile "${agentProfileName ?? '—'}", which does ` + + 'not exist. Run `swisscode config ' + profileName + '` to repair it.', + } + } + + const accounts = Array.isArray(profile.accounts) ? profile.accounts.filter(Boolean) : [] + if (accounts.length === 0) { + return { + ok: false, + reason: + `profile "${profileName}" has no provider account, so there is nothing to ` + + 'authenticate with. Run `swisscode config ' + profileName + '` to add one.', + } + } + + // Dangling account references are dropped with a warning rather than being + // fatal: a profile with three accounts and one stale reference should still + // launch on the other two. + const warnings: string[] = [] + const live = accounts.filter((name) => { + if (state.providerAccounts?.[name]) return true + warnings.push( + `profile "${profileName}" references account "${name}", which no longer exists; skipping it.`, + ) + return false + }) + + if (live.length === 0) { + return { + ok: false, + reason: + `profile "${profileName}" references ${accounts.length} provider account(s), none of ` + + 'which exist any more. Run `swisscode config accounts` to see what is left.', + } + } + + const strategy = profile.strategy ?? 'single' + const picked = selectAccount(profileName, live, strategy, options) + warnings.push(...picked.warnings) + + // Present: `live` was filtered on exactly this lookup. + const account = state.providerAccounts?.[picked.name] + if (!account) { + return { ok: false, reason: `account "${picked.name}" disappeared during resolution.` } + } + + return { + ok: true, + resolved: flatten(picked.name, account, agentProfileName, state), + warnings, + } +} diff --git a/src/ports/agent.ts b/src/ports/agent.ts index aaac770..86839a2 100644 --- a/src/ports/agent.ts +++ b/src/ports/agent.ts @@ -11,7 +11,7 @@ // Type-only, like every port: `export {}` at runtime. import type { ExtendedContext, ProviderDescriptor, Tier, TierRecord } from './provider.ts' -import type { Profile } from './config-store.ts' +import type { ResolvedProfile } from './config-store.ts' import type { AgentBinarySpec, EnvMap } from './process.ts' /** @@ -84,7 +84,7 @@ export type AgentCapabilities = { */ export type TranslateInput = { intent: LaunchIntent - profile: Profile + profile: ResolvedProfile provider: ProviderDescriptor | null passthrough: string[] ambient: EnvMap diff --git a/src/ports/config-store.ts b/src/ports/config-store.ts index de92dc4..39aa886 100644 --- a/src/ports/config-store.ts +++ b/src/ports/config-store.ts @@ -20,23 +20,55 @@ import type { Tier } from './provider.ts' * carry an explicit env/unsetEnv split instead); this is the profile side, * where a user needs a way to say "clear whatever my shell put there". */ -export type Profile = { - /** id from the provider registry */ +/** + * WHO PAYS. A provider, a credential, and where to send it. + * + * First-class because a credential is not a property of one launch + * configuration — the same OpenRouter key can back a dozen profiles, and an + * Anthropic subscription is a thing you HAVE rather than a thing a profile + * owns. Before v3 these fields lived on the profile, which is why + * `core/overrides.ts` had to go scavenging through other profiles for a key + * when `--cc-provider` retargeted: a lookup written as a search, because there + * was nothing to look up. + * + * The credential-safety rule becomes STRUCTURAL here rather than a discipline: + * an account names exactly one provider, so a key cannot reach a host it was + * not entered for without someone rewriting the account. + */ +export type ProviderAccount = { + /** id from the provider registry (shipped preset or custom) */ provider: string - /** - * id from the agent registry — which coding CLI to launch. Absent means the - * default, 'claude-code', so every profile written before this field existed - * keeps launching Claude Code with no migration. A profile naming an agent - * this build does not know still launches Claude Code (launch-root refuses - * only an explicit --cc-agent for an unknown id). - */ - agent?: string label?: string + /** overrides the descriptor's endpoint */ baseUrl?: string /** inline; the file is 0600 */ apiKey?: string /** read from the ambient env instead, so no secret sits in the file */ apiKeyFromEnv?: string +} + +/** + * WHAT RUNS. A coding CLI plus how it should behave. + * + * Independent of who pays, so one setup ("Claude Code, yolo, glm on every + * tier") can be pointed at several accounts, and one account can back several + * setups. + * + * `models` stays the FOUR Claude Code tiers rather than becoming agent-shaped. + * That is deliberate and unchanged from v2: agents with fewer slots collapse it + * and warn (see `collapsedTierWarning` in adapters/agents/shared.ts), which is + * a documented, tested behaviour. Reshaping it is a separate decision that + * should not ride along with a schema migration. + */ +export type AgentProfile = { + /** + * id from the agent registry — which coding CLI to launch. Absent means the + * default, 'claude-code'. An agent profile naming an agent this build does + * not know still launches the default (launch-root refuses only an explicit + * --cc-agent for an unknown id). + */ + agent?: string + label?: string /** BARE ids. '' means UNSET the tier. Partial: pinning no models is valid. */ models?: Partial> skipPermissions?: boolean @@ -58,6 +90,74 @@ export type Profile = { contextWindows?: Record } +/** + * One account and one agent profile, flattened — what a launch actually needs. + * + * THIS IS THE SEAM THAT KEEPS THE v3 SPLIT CHEAP. It is deliberately the same + * shape v2's `Profile` had, so everything downstream of resolution — the agent + * adapters, `buildEnvPlan`, `buildIntent`, the golden maps — consumes exactly + * what it always did and needed no change when the stored schema split in + * three. The refactor lands entirely upstream of here. + * + * Produced only by `core/resolve.ts`. Never stored, never written: it is a view + * over two persisted objects, and giving it a name is what stops consumers + * reaching back into the store to re-derive it. + */ +export type ResolvedProfile = { + /** which account was selected, so callers can report it */ + accountName: string + agentProfileName: string + // ── from the provider account ── + provider: string + baseUrl?: string + apiKey?: string + apiKeyFromEnv?: string + // ── from the agent profile ── + agent?: string + models?: Partial> + skipPermissions?: boolean + env?: Record + compat?: ClaudeCodeCompatFlags + contextWindows?: Record +} + +/** + * How a profile picks among its accounts. + * + * Every one of these resolves ONCE, before `execve`. swisscode ceases to exist + * at handoff, so there is no such thing here as per-request rotation, live + * failover, or reacting to a 429 — those require sitting in the data path, + * which is the proxy this tool is not. + * + * single the first account. No state, no surprises. + * round-robin advances per LAUNCH, via a cursor kept outside config.json. + * usage picks by remaining capacity from a CACHED snapshot, refreshed + * at configuration time (doctor / web UI). Never a live call: + * the launch path may not reach the network. Falls back to + * `single` and says so when there is no snapshot. + */ +export type SelectionStrategy = 'single' | 'round-robin' | 'usage' + +/** + * THE PAIRING. What `swisscode ` actually names. + * + * Holds no credential and no agent settings of its own — only references, plus + * the rule for choosing among them. + */ +export type Profile = { + label?: string + /** key into `State.agentProfiles` */ + agentProfile: string + /** + * keys into `State.providerAccounts`, in preference order. Never empty in a + * valid config; an empty list is a resolution error that names the fix rather + * than a silent fallback to some other account. + */ + accounts: string[] + /** absent means `single` */ + strategy?: SelectionStrategy +} + /** Per-run overrides carried on a directory binding. */ export type ProfileOverrides = { baseUrl?: string @@ -120,6 +220,11 @@ export type CustomProvider = { export type State = { version: number + /** who pays */ + providerAccounts: Record + /** what runs */ + agentProfiles: Record + /** the pairing — what `swisscode ` and every binding refer to */ profiles: Record defaultProfile: string | null bindings: Record @@ -152,21 +257,56 @@ export type ConfigV1 = { [key: string]: unknown } -/** The version ladder, as a type. Only these two shapes have ever shipped. */ -export type ConfigV2 = State +/** + * The v2 profile: provider, credential, agent and agent settings on ONE object. + * + * Kept as a type because `fromV2` still has to read it. This is the shape v3 + * splits into `ProviderAccount` + `AgentProfile` + `Profile`, and writing it + * down is what lets that migration be checked rather than guessed at. + * + * The index signature is rule M1 again, inherited from v1: unrecognized keys + * ride along rather than being dropped, so a key written by a newer swisscode + * survives a round-trip through an older one. + */ +export type ProfileV2 = { + provider: string + agent?: string + label?: string + baseUrl?: string + apiKey?: string + apiKeyFromEnv?: string + models?: Partial> + skipPermissions?: boolean + env?: Record + compat?: ClaudeCodeCompatFlags + contextWindows?: Record + [key: string]: unknown +} + +/** The version ladder, as types. Three shapes have shipped. */ +export type ConfigV2 = { + version: number + profiles: Record + defaultProfile: string | null + bindings: Record + settings: Settings + providers?: Record +} + +export type ConfigV3 = State /** * What the migration ladder reports. * * `migratedFrom` is the ONLY thing that authorizes a write on load. Filling in * a missing `settings` key is not a migration and must not cause a launch that - * merely read the file to touch the disk. It is `1 | null` rather than - * `number | null` because v1 is the only version that has ever been migrated - * FROM — a newer-than-supported file is `readOnly` instead. + * merely read the file to touch the disk. It enumerates the versions that can + * be migrated FROM — a newer-than-supported file is `readOnly` instead — so the + * store can name the backup after the version it is preserving. */ export type MigrateResult = { state: State - migratedFrom: 1 | null + migratedFrom: 1 | 2 | null /** the file existed but could not be understood */ corrupt: boolean /** the file is a NEWER schema; every write path is disabled */ diff --git a/test/adapters/agents/kilo.test.ts b/test/adapters/agents/kilo.test.ts index ef9df8f..1ddba9a 100644 --- a/test/adapters/agents/kilo.test.ts +++ b/test/adapters/agents/kilo.test.ts @@ -2,6 +2,7 @@ import test from 'node:test' import assert from 'node:assert/strict' import { kilo, KILO_CONFIG_ENV, KILO_ALLOW_ALL } from '../../../src/adapters/agents/kilo/index.ts' import type { LaunchIntent, TranslateInput } from '../../../src/ports/agent.ts' +import { makeProfile } from '../../support/fixtures.ts' function intent(over: Partial = {}): LaunchIntent { return { @@ -15,7 +16,7 @@ function intent(over: Partial = {}): LaunchIntent { } function run(i: LaunchIntent, passthrough: string[] = []) { - const input: TranslateInput = { intent: i, profile: { provider: 'zai' }, provider: null, passthrough, ambient: {} } + const input: TranslateInput = { intent: i, profile: makeProfile({ provider: 'zai' }), provider: null, passthrough, ambient: {} } const t = kilo.translate(input) const raw = t.plan.set[KILO_CONFIG_ENV] return { t, config: raw ? JSON.parse(raw) : null } diff --git a/test/adapters/agents/opencode.test.ts b/test/adapters/agents/opencode.test.ts index 1b2b6f7..092fa88 100644 --- a/test/adapters/agents/opencode.test.ts +++ b/test/adapters/agents/opencode.test.ts @@ -2,6 +2,7 @@ import test from 'node:test' import assert from 'node:assert/strict' import { opencode, OPENCODE_CONFIG_ENV, OPENCODE_AUTO_FLAG } from '../../../src/adapters/agents/opencode/index.ts' import type { LaunchIntent, TranslateInput } from '../../../src/ports/agent.ts' +import { makeProfile } from '../../support/fixtures.ts' function intent(over: Partial = {}): LaunchIntent { return { @@ -15,7 +16,7 @@ function intent(over: Partial = {}): LaunchIntent { } function run(i: LaunchIntent, passthrough: string[] = []) { - const input: TranslateInput = { intent: i, profile: { provider: 'zai' }, provider: null, passthrough, ambient: {} } + const input: TranslateInput = { intent: i, profile: makeProfile({ provider: 'zai' }), provider: null, passthrough, ambient: {} } const t = opencode.translate(input) const raw = t.plan.set[OPENCODE_CONFIG_ENV] return { t, config: raw ? JSON.parse(raw) : null } diff --git a/test/adapters/composite-providers.test.ts b/test/adapters/composite-providers.test.ts index f0da067..4b2394c 100644 --- a/test/adapters/composite-providers.test.ts +++ b/test/adapters/composite-providers.test.ts @@ -26,7 +26,15 @@ const custom = { const stateWith = (providers: Record): State => ({ version: 2, - profiles: { p: { provider: 'my-gw', apiKey: 'k' } }, + providerAccounts: { + p: makeProfile({ provider: 'my-gw', apiKey: 'k' }), + }, + agentProfiles: { + p: {}, + }, + profiles: { + p: { agentProfile: 'p', accounts: ['p'] }, + }, defaultProfile: 'p', bindings: {}, settings: {}, @@ -74,7 +82,7 @@ test('a custom provider produces a real environment, billing guard included', () // The proof that this is a descriptor like any other: the Claude Code adapter // does not special-case it, so the stale-key guard applies unchanged. const plan = buildEnvPlan( - makeProfile({ provider: 'my-gw', apiKey: 'k' }), + makeProfile(({ provider: 'my-gw', apiKey: 'k' })), toDescriptor(custom), { ANTHROPIC_API_KEY: 'sk-ant-STALE' }, ) diff --git a/test/adapters/doctor-probe.test.ts b/test/adapters/doctor-probe.test.ts index c77e9f4..8f060fe 100644 --- a/test/adapters/doctor-probe.test.ts +++ b/test/adapters/doctor-probe.test.ts @@ -16,6 +16,7 @@ import type { ProbeFetch, ProbeResponse } from '../../src/adapters/doctor/probe. import type { ProbeRequest, ProbeResult } from '../../src/ports/doctor.ts' import type { State } from '../../src/ports/config-store.ts' import type { EnvMap } from '../../src/ports/process.ts' +import { makeProfile } from '../support/fixtures.ts' /** * A ProbeRequest that omits the credential fields. @@ -171,13 +172,15 @@ const SECRET = 'ms-super-secret-token' function deps(over: { state?: State; env?: EnvMap } = {}) { const state = over.state ?? { - version: 2, + version: 3, + providerAccounts: { + z: { provider: 'zai', apiKey: SECRET }, + }, + agentProfiles: { + z: { models: { opus: 'glm-5.2', sonnet: 'glm-5.2', haiku: 'glm-5.2', fable: 'glm-5.2' } }, + }, profiles: { - z: { - provider: 'zai', - apiKey: SECRET, - models: { opus: 'glm-5.2', sonnet: 'glm-5.2', haiku: 'glm-5.2', fable: 'glm-5.2' }, - }, + z: { agentProfile: 'z', accounts: ['z'] }, }, defaultProfile: 'z', bindings: {}, @@ -213,8 +216,16 @@ test('a provider placeholder is not treated as a secret and is not redacted', as // a secret, and redaction must cover real secrets exactly rather than // everything sitting in a credential-shaped slot. const state = { - version: 2, - profiles: { local: { provider: 'ollama', models: { opus: 'qwen3-coder:30b' } } }, + version: 3, + providerAccounts: { + local: makeProfile({ provider: 'ollama' }), + }, + agentProfiles: { + local: { models: { opus: 'qwen3-coder:30b' } }, + }, + profiles: { + local: { agentProfile: 'local', accounts: ['local'] }, + }, defaultProfile: 'local', bindings: {}, settings: {}, @@ -280,13 +291,15 @@ test('--offline makes no network calls at all', async () => { test('the total budget stops the run rather than running long', async () => { const state = { - version: 2, + version: 3, + providerAccounts: { + z: { provider: 'zai', apiKey: SECRET }, + }, + agentProfiles: { + z: { models: { opus: 'a', sonnet: 'b', haiku: 'c', fable: 'd' } }, + }, profiles: { - z: { - provider: 'zai', - apiKey: SECRET, - models: { opus: 'a', sonnet: 'b', haiku: 'c', fable: 'd' }, - }, + z: { agentProfile: 'z', accounts: ['z'] }, }, defaultProfile: 'z', bindings: {}, @@ -344,8 +357,16 @@ test('doctor says what it did NOT test about the [1m] suffix', async () => { test('--fix prunes dangling bindings and nothing else', async () => { const state = { - version: 2, - profiles: { z: { provider: 'zai', apiKey: SECRET, models: { opus: 'glm-5.2' } } }, + version: 3, + providerAccounts: { + z: { provider: 'zai', apiKey: SECRET }, + }, + agentProfiles: { + z: { models: { opus: 'glm-5.2' } }, + }, + profiles: { + z: { agentProfile: 'z', accounts: ['z'] }, + }, defaultProfile: 'z', bindings: { '/definitely/not/a/real/path': 'gone' }, settings: {}, @@ -354,6 +375,7 @@ test('--fix prunes dangling bindings and nothing else', async () => { await runDoctor({ deps: d.deps, offline: true, fix: true }) assert.equal(d.saves.length, 1) assert.deepEqual(d.saves[0]!.bindings, {}) - // The model string the user pinned is untouched. - assert.deepEqual(d.saves[0]!.profiles.z!.models, { opus: 'glm-5.2' }) + // The model string the user pinned is untouched — on the agent profile, + // which is where models live since v3. + assert.deepEqual(d.saves[0]!.agentProfiles.z!.models, { opus: 'glm-5.2' }) }) diff --git a/test/adapters/fs-config-store.test.ts b/test/adapters/fs-config-store.test.ts index 2529fdb..811d4df 100644 --- a/test/adapters/fs-config-store.test.ts +++ b/test/adapters/fs-config-store.test.ts @@ -13,6 +13,7 @@ import { import { tmpdir } from 'node:os' import { join } from 'node:path' import { createFsConfigStore } from '../../src/adapters/store/fs-config-store.ts' +import { makeProfile } from '../support/fixtures.ts' /** Byte-for-byte what swisscode 0.1.0's saveConfig writes. */ const V1_ON_DISK = `${JSON.stringify( @@ -51,19 +52,21 @@ test('a real 0.1.0 config file migrates automatically on load', () => { const loaded = store.load() assert.equal(loaded.migrated, true) - assert.equal(loaded.state.version, 2) + assert.equal(loaded.state.version, 3) assert.equal(loaded.state.defaultProfile, 'zai') - assert.deepEqual(loaded.state.profiles.zai, { + assert.deepEqual(loaded.state.providerAccounts.zai, { provider: 'zai', apiKey: 'zai-secret-key', + }) + assert.deepEqual(loaded.state.agentProfiles.zai, { models: { opus: 'glm-5.2', sonnet: 'glm-5.2', haiku: 'glm-5.2' }, skipPermissions: true, }) // Persisted, and the original kept beside it. const onDisk = JSON.parse(readFileSync(join(dir, 'config.json'), 'utf8')) - assert.equal(onDisk.version, 2) - assert.equal(onDisk.profiles.zai.apiKey, 'zai-secret-key') + assert.equal(onDisk.version, 3) + assert.equal(onDisk.providerAccounts.zai.apiKey, 'zai-secret-key') assert.equal(readFileSync(join(dir, 'config.v1.bak.json'), 'utf8'), V1_ON_DISK) assert.ok(loaded.warnings.some((w) => w.includes('migrated'))) }) @@ -90,14 +93,22 @@ test('the migrated file keeps 0600 in a 0700 directory', () => { test('save writes 0600 in 0700 even when the directory did not exist', () => { const { dir, store } = freshHome() - store.save({ version: 2, profiles: { a: { provider: 'zai' } }, defaultProfile: 'a', bindings: {}, settings: {} }) + store.save({ + version: 3, + providerAccounts: { a: { provider: 'zai' } }, + agentProfiles: { a: {} }, + profiles: { a: { agentProfile: 'a', accounts: ['a'] } }, + defaultProfile: 'a', + bindings: {}, + settings: {}, + }) assert.equal(statSync(dir).mode & 0o777, 0o700) assert.equal(statSync(join(dir, 'config.json')).mode & 0o777, 0o600) }) test('save leaves no temp file behind', () => { const { dir, store } = freshHome() - store.save({ version: 2, profiles: {}, defaultProfile: null, bindings: {}, settings: {} }) + store.save({ version: 2, providerAccounts: {}, agentProfiles: {}, profiles: {}, defaultProfile: null, bindings: {}, settings: {} }) assert.deepEqual(readdirSync(dir), ['config.json']) }) @@ -109,7 +120,7 @@ test('a truncated config is quarantined rather than overwritten in place', () => // Still on disk: nothing has been written yet. assert.equal(readFileSync(join(dir, 'config.json'), 'utf8'), '{"provider": "zai", "apiK') - store.save({ version: 2, profiles: {}, defaultProfile: null, bindings: {}, settings: {} }) + store.save({ version: 2, providerAccounts: {}, agentProfiles: {}, profiles: {}, defaultProfile: null, bindings: {}, settings: {} }) const quarantined = readdirSync(dir).filter((f) => f.startsWith('config.corrupt-')) assert.equal(quarantined.length, 1) assert.equal(readFileSync(join(dir, quarantined[0]!), 'utf8'), '{"provider": "zai", "apiK') @@ -123,7 +134,7 @@ test('a quarantine that cannot rename aborts save() instead of destroying the co chmodSync(dir, 0o500) try { assert.throws( - () => store.save({ version: 2, profiles: {}, defaultProfile: null, bindings: {}, settings: {} }), + () => store.save({ version: 2, providerAccounts: {}, agentProfiles: {}, profiles: {}, defaultProfile: null, bindings: {}, settings: {} }), /could not move it aside|refusing to overwrite/, ) chmodSync(dir, 0o700) @@ -137,14 +148,16 @@ test('a quarantine that cannot rename aborts save() instead of destroying the co test('a NEWER schema is read but never written back', () => { const future = `${JSON.stringify({ version: 99, - profiles: { a: { provider: 'zai', apiKey: 'k' } }, + providerAccounts: { a: { provider: 'zai', apiKey: 'k' } }, + agentProfiles: { a: {} }, + profiles: { a: { agentProfile: 'a', accounts: ['a'] } }, defaultProfile: 'a', })}\n` const { dir, store } = freshHome(future) const loaded = store.load() assert.equal(loaded.readOnly, true) - assert.equal(loaded.state.profiles.a!.provider, 'zai') + assert.equal(loaded.state.providerAccounts.a!.provider, 'zai') assert.ok(loaded.warnings.some((w) => w.includes('version 99'))) const before = statSync(join(dir, 'config.json')).mtimeMs @@ -167,9 +180,9 @@ test('a failed migration write does not block the launch', () => { const loaded = store.load() // The migrated settings are still usable in memory, and the launch proceeds. - assert.equal(loaded.state.version, 2) - assert.equal(loaded.state.profiles.zai!.provider, 'zai') - assert.equal(loaded.state.profiles.zai!.apiKey, 'zai-secret-key') + assert.equal(loaded.state.version, 3) + assert.equal(loaded.state.providerAccounts.zai!.provider, 'zai') + assert.equal(loaded.state.providerAccounts.zai!.apiKey, 'zai-secret-key') assert.ok(loaded.warnings.some((w) => w.includes('could not rewrite'))) // The original file is untouched, so the next run can migrate it again. assert.equal(readFileSync(join(dir, 'config.json'), 'utf8'), V1_ON_DISK) @@ -184,8 +197,10 @@ test('the 0700 directory mode is re-asserted even on a loose existing dir', () = test('unknown top-level keys survive a round trip', () => { const withExtra = `${JSON.stringify({ - version: 2, - profiles: { a: { provider: 'zai', futureField: 1 } }, + version: 3, + providerAccounts: { a: { provider: 'zai' } }, + agentProfiles: { a: {} }, + profiles: { a: { agentProfile: 'a', accounts: ['a'], futureField: 1 } }, defaultProfile: 'a', bindings: {}, settings: {}, @@ -200,9 +215,9 @@ test('unknown top-level keys survive a round trip', () => { }) test('a v1 config whose provider is no longer known still migrates', () => { - const { store } = freshHome(`${JSON.stringify({ provider: 'volcengine', apiKey: 'k' })}\n`) + const { store } = freshHome(`${JSON.stringify(makeProfile({ provider: 'volcengine', apiKey: 'k' }))}\n`) const loaded = store.load() - assert.equal(loaded.state.profiles.volcengine!.provider, 'volcengine') + assert.equal(loaded.state.providerAccounts.volcengine!.provider, 'volcengine') }) // revision(): lost-update detection for long-lived editors (the web UI). @@ -213,7 +228,7 @@ test('revision is null when there is no file, and a string once there is', () => // the meantime. const { store } = freshHome() assert.equal(store.revision!(), null) - store.save({ version: 2, profiles: {}, defaultProfile: null, bindings: {}, settings: {} }) + store.save({ version: 2, providerAccounts: {}, agentProfiles: {}, profiles: {}, defaultProfile: null, bindings: {}, settings: {} }) assert.equal(typeof store.revision!(), 'string') }) @@ -223,7 +238,7 @@ test('revision follows CONTENT, not the clock', () => { // slip through exactly when writers are most concurrent. Hashing bytes cannot // have that failure, and this test is what pins the choice. const { store } = freshHome() - const base = { version: 2, profiles: {}, defaultProfile: null, bindings: {}, settings: {} } + const base = { version: 2, providerAccounts: {}, agentProfiles: {}, profiles: {}, defaultProfile: null, bindings: {}, settings: {} } store.save(base) const first = store.revision!() @@ -241,9 +256,9 @@ test('an edit made behind our back is visible as a revision change', () => { // The interleaving this exists to catch: read, wait, and meanwhile another // swisscode command writes. const { dir, store } = freshHome() - store.save({ version: 2, profiles: {}, defaultProfile: null, bindings: {}, settings: {} }) + store.save({ version: 2, providerAccounts: {}, agentProfiles: {}, profiles: {}, defaultProfile: null, bindings: {}, settings: {} }) const held = store.revision!() - writeFileSync(join(dir, 'config.json'), JSON.stringify({ version: 2, profiles: {}, defaultProfile: 'other', bindings: {}, settings: {} })) + writeFileSync(join(dir, 'config.json'), JSON.stringify({ version: 2, providerAccounts: {}, agentProfiles: {}, profiles: {}, defaultProfile: 'other', bindings: {}, settings: {} })) assert.notEqual(store.revision!(), held) }) diff --git a/test/adapters/ollama-doctor.test.ts b/test/adapters/ollama-doctor.test.ts index b605efc..960f300 100644 --- a/test/adapters/ollama-doctor.test.ts +++ b/test/adapters/ollama-doctor.test.ts @@ -19,6 +19,7 @@ import { registry } from '../../src/adapters/providers/registry.ts' import { registry as agents } from '../../src/adapters/agents/registry.ts' import type { OllamaContext, OllamaIntrospectPort } from '../../src/ports/doctor.ts' import type { State } from '../../src/ports/config-store.ts' +import { makeProfile } from '../support/fixtures.ts' // GET /api/ps, model resident const PS = { @@ -131,7 +132,15 @@ test('a failed lookup is a skip, never a pass', () => { const ollamaState = { version: 2, - profiles: { local: { provider: 'ollama', models: { opus: 'qwen3:0.6b' } } }, + providerAccounts: { + local: makeProfile({ provider: 'ollama' }), + }, + agentProfiles: { + local: { models: { opus: 'qwen3:0.6b' } }, + }, + profiles: { + local: { agentProfile: 'local', accounts: ['local'] }, + }, defaultProfile: 'local', bindings: {}, settings: {}, @@ -198,7 +207,15 @@ test('--offline skips it, because it is still a network call', async () => { test('a non-Ollama profile gets no context check at all', async () => { const zaiState = { version: 2, - profiles: { z: { provider: 'zai', apiKey: 'k', models: { opus: 'glm-5.2' } } }, + providerAccounts: { + z: makeProfile({ provider: 'zai', apiKey: 'k' }), + }, + agentProfiles: { + z: { models: { opus: 'glm-5.2' } }, + }, + profiles: { + z: { agentProfile: 'z', accounts: ['z'] }, + }, defaultProfile: 'z', bindings: {}, settings: {}, diff --git a/test/adapters/web.test.ts b/test/adapters/web.test.ts index 303c5d6..caddf4c 100644 --- a/test/adapters/web.test.ts +++ b/test/adapters/web.test.ts @@ -17,12 +17,12 @@ import { guardDocumentRequest, tokensMatch, } from '../../src/adapters/web/security.ts' -import { handleApi, parseProfile, redactProfile, redactState } from '../../src/adapters/web/api.ts' +import { handleApi, parseAccount, parseAgentProfile, redactAccount, redactState } from '../../src/adapters/web/api.ts' import { FALLBACK_SCRIPT_PATH, resolveAsset, startWebServer } from '../../src/adapters/web/server.ts' import { request } from 'node:http' import { registry as providers } from '../../src/adapters/providers/registry.ts' import { registry as agents } from '../../src/adapters/agents/registry.ts' -import { makeProfile } from '../support/fixtures.ts' +import { makeAccount, makeAgentProfile, makeProfile } from '../support/fixtures.ts' import type { ConfigStorePort, State } from '../../src/ports/config-store.ts' const PORT = 4242 @@ -105,8 +105,10 @@ test('path traversal cannot escape the asset directory', () => { // redaction test('an API key never crosses the boundary to the browser', () => { - const profile = makeProfile({ provider: 'zai', apiKey: 'sk-super-secret', models: {} }) - const out = redactProfile(profile) + // Redaction moved to the ACCOUNT with the credential. That is an improvement: + // one of the three shapes is security-sensitive and it is obvious which. + const account = makeAccount(makeProfile({ provider: 'zai', apiKey: 'sk-super-secret' })) + const out = redactAccount(account) const serialized = JSON.stringify(out) assert.ok(!serialized.includes('sk-super-secret'), 'the key leaked') assert.ok(!serialized.includes('sk-super'), 'a prefix of the key leaked') @@ -114,7 +116,7 @@ test('an API key never crosses the boundary to the browser', () => { assert.ok(!('apiKey' in out)) // A variable NAME is not a secret, and the user needs to see it. - const fromEnv = redactProfile(makeProfile({ provider: 'zai', apiKeyFromEnv: 'MY_TOKEN' })) + const fromEnv = redactAccount(makeAccount({ provider: 'zai', apiKeyFromEnv: 'MY_TOKEN' })) assert.equal(fromEnv.apiKeyFromEnv, 'MY_TOKEN') assert.equal(fromEnv.hasKey, false) }) @@ -145,8 +147,15 @@ function store(initial: State): ConfigStorePort & { state: State; saves: number const baseState = (): State => ({ - version: 2, - profiles: { work: { provider: 'zai', apiKey: 'secret', models: {} } }, + version: 2, providerAccounts: { + work: makeProfile({ provider: 'zai', apiKey: 'secret' }), + }, + agentProfiles: { + work: { models: {} }, + }, + profiles: { + work: { agentProfile: 'work', accounts: ['work'] }, + }, defaultProfile: 'work', bindings: {}, settings: {}, @@ -195,32 +204,32 @@ test('a current revision writes, and returns the next one', () => { const res = handleApi( { method: 'PUT', - path: '/api/profiles/acme', - body: { revision: s.revision!(), profile: { provider: 'openrouter' } }, + path: '/api/accounts/acme', + body: { revision: s.revision!(), account: { provider: 'openrouter' } }, }, deps(s), ) assert.equal(res.status, 200) - assert.equal(s.state.profiles.acme!.provider, 'openrouter') + assert.equal(s.state.providerAccounts.acme!.provider, 'openrouter') assert.notEqual((res.body as { revision: string }).revision, 'stale') }) test('an omitted key does not erase the stored one; an explicit null does', () => { // The single most destructive mistake this endpoint could make is treating an // untouched form field as "delete my credential". - const existing = makeProfile({ provider: 'zai', apiKey: 'keep-me' }) - const kept = parseProfile({ provider: 'zai' }, existing) + const existing = makeAccount(makeProfile({ provider: 'zai', apiKey: 'keep-me' })) + const kept = parseAccount(makeProfile({ provider: 'zai' }), existing) assert.equal(typeof kept === 'string' ? null : kept.apiKey, 'keep-me') - const blanked = parseProfile({ provider: 'zai', apiKey: '' }, existing) + const blanked = parseAccount(makeProfile({ provider: 'zai', apiKey: '' }), existing) assert.equal(typeof blanked === 'string' ? null : blanked.apiKey, 'keep-me') - const cleared = parseProfile({ provider: 'zai', apiKey: null }, existing) + const cleared = parseAccount({ provider: 'zai', apiKey: null }, existing) assert.equal(typeof cleared === 'string' ? undefined : cleared.apiKey, undefined) }) test('unknown fields from the client never reach config.json', () => { - const parsed = parseProfile( + const parsed = parseAccount( { provider: 'zai', evil: 'payload', __proto__: { polluted: true } }, undefined, ) @@ -472,11 +481,33 @@ test('deleting a provider reports orphaned profiles rather than silently repairi }, deps(s), ) + // A profile reaches a provider THROUGH an account now, so the chain has three + // links: provider -> account -> profile. Deleting the provider orphans the + // account, which in turn orphans the profile, and both are reported. + handleApi( + { + method: 'PUT', + path: '/api/accounts/gw-acct', + body: { revision: s.revision!(), account: { provider: 'my-gw' } }, + }, + deps(s), + ) + handleApi( + { + method: 'PUT', + path: '/api/agent-profiles/gw-agent', + body: { revision: s.revision!(), agentProfile: {} }, + }, + deps(s), + ) handleApi( { method: 'PUT', path: '/api/profiles/uses-gw', - body: { revision: s.revision!(), profile: { provider: 'my-gw' } }, + body: { + revision: s.revision!(), + profile: { agentProfile: 'gw-agent', accounts: ['gw-acct'] }, + }, }, deps(s), ) @@ -485,15 +516,17 @@ test('deleting a provider reports orphaned profiles rather than silently repairi deps(s), ) assert.equal(res.status, 200) - assert.deepEqual((res.body as { orphanedProfiles: string[] }).orphanedProfiles, ['uses-gw']) + const body = res.body as { orphanedAccounts: string[]; orphanedProfiles: string[] } + assert.deepEqual(body.orphanedAccounts, ['gw-acct']) + assert.deepEqual(body.orphanedProfiles, ['uses-gw']) assert.ok(s.state.profiles['uses-gw'], 'the profile must survive; only the user can repoint it') }) test('contextWindows accepts measured integers and drops anything else', () => { // It feeds CLAUDE_CODE_AUTO_COMPACT_WINDOW; a bad value there overflows the // conversation instead of compacting it. - const parsed = parseProfile( - { provider: 'zai', contextWindows: { good: 200000, zero: 0, neg: -1, str: '100', frac: 1.5 } }, + const parsed = parseAgentProfile( + { contextWindows: { good: 200000, zero: 0, neg: -1, str: '100', frac: 1.5 } }, undefined, ) assert.notEqual(typeof parsed, 'string') diff --git a/test/config-commands.test.ts b/test/config-commands.test.ts index 6210653..3270fcd 100644 --- a/test/config-commands.test.ts +++ b/test/config-commands.test.ts @@ -13,6 +13,7 @@ import { registry } from '../src/adapters/providers/registry.ts' import { registry as agents } from '../src/adapters/agents/registry.ts' import type { OpenUi } from '../src/composition/config-root.ts' import type { State } from '../src/ports/config-store.ts' +import { makeProfile } from './support/fixtures.ts' // Annotated `State` rather than left to inference: the tests below read // profiles these commands CREATE — `.fix`, `.old`, `.third` — which a literal @@ -20,14 +21,17 @@ import type { State } from '../src/ports/config-store.ts' const STATE = (): State => ({ version: 2, + providerAccounts: { + z: makeProfile({ provider: 'zai', apiKey: 'zai-secret-value' }), + or: makeProfile({ provider: 'openrouter', apiKeyFromEnv: 'OPENROUTER_KEY' }), + }, + agentProfiles: { + z: { models: { opus: 'glm-5.2', sonnet: 'glm-5.2', haiku: 'glm-5.2', fable: 'glm-5.2' }, skipPermissions: true }, + or: {}, + }, profiles: { - z: { - provider: 'zai', - apiKey: 'zai-secret-value', - models: { opus: 'glm-5.2', sonnet: 'glm-5.2', haiku: 'glm-5.2', fable: 'glm-5.2' }, - skipPermissions: true, - }, - or: { provider: 'openrouter', apiKeyFromEnv: 'OPENROUTER_KEY' }, + z: { agentProfile: 'z', accounts: ['z'] }, + or: { agentProfile: 'or', accounts: ['or'] }, }, defaultProfile: 'z', bindings: {}, @@ -134,7 +138,9 @@ test('a common English word needs --force before it can shadow a prompt', async test('an existing profile with an awkward name still opens', async () => { // Validation applies at CREATION only; a hand-edited config keeps working. const state = STATE() - state.profiles.fix = { provider: 'zai' } + state.providerAccounts.fix = { provider: 'zai' } + state.agentProfiles.fix = {} + state.profiles.fix = { agentProfile: 'fix', accounts: ['fix'] } const h = harness({ state }) assert.equal(await h.run(['fix']), 0) assert.equal(h.uiCalls[0]!.profileName, 'fix') @@ -165,7 +171,11 @@ test('config list marks the default and shows inherited models honestly', async test('config list flags a profile whose provider this build does not know', async () => { const state = STATE() - state.profiles.old = { provider: 'volcengine' } + // The provider id lives on the ACCOUNT now, so an unknown one is an unknown + // account provider — the profile itself resolves fine and still lists. + state.providerAccounts.old = { provider: 'volcengine' } + state.agentProfiles.old = {} + state.profiles.old = { agentProfile: 'old', accounts: ['old'] } const h = harness({ state }) await h.run(['list']) assert.match(h.text(), /unknown provider/) @@ -202,7 +212,9 @@ test('deleting the default profile promotes the survivor only when there is one' assert.equal(h.saves.at(-1)!.defaultProfile, 'or', 'one left is unambiguous') const three = STATE() - three.profiles.third = { provider: 'zai' } + three.providerAccounts.third = { provider: 'zai' } + three.agentProfiles.third = {} + three.profiles.third = { agentProfile: 'third', accounts: ['third'] } const h2 = harness({ state: three }) await h2.run(['rm', 'z']) // Guessing among several would silently pick an account to bill. @@ -393,8 +405,15 @@ test('every surface that names a provider sees the custom ones', async () => { // rather than the fix, so moving where it happens cannot silently undo it. const h = harness({ state: { - version: 2, - profiles: { rig: { provider: 'vllm' } }, + version: 2, providerAccounts: { + rig: makeProfile({ provider: 'vllm' }), + }, + agentProfiles: { + rig: {}, + }, + profiles: { + rig: { agentProfile: 'rig', accounts: ['rig'] }, + }, defaultProfile: 'rig', bindings: {}, settings: {}, @@ -423,8 +442,15 @@ test('a profile on a genuinely unknown provider still says so', async () => { // key being sent to api.anthropic.com. const h = harness({ state: { - version: 2, - profiles: { ghost: { provider: 'nope' } }, + version: 2, providerAccounts: { + ghost: makeProfile({ provider: 'nope' }), + }, + agentProfiles: { + ghost: {}, + }, + profiles: { + ghost: { agentProfile: 'ghost', accounts: ['ghost'] }, + }, defaultProfile: 'ghost', bindings: {}, settings: {}, diff --git a/test/core/auto-compact.test.ts b/test/core/auto-compact.test.ts index 948b39a..d7dda64 100644 --- a/test/core/auto-compact.test.ts +++ b/test/core/auto-compact.test.ts @@ -166,14 +166,18 @@ test("a profile can clear the window with the '' sentinel", () => { }) test('the shipped z.ai preset sets the window it documents', () => { - const plan = buildEnvPlan({ provider: 'zai', apiKey: 'k' }, byId('zai'), {}) + const plan = buildEnvPlan(makeProfile({ provider: 'zai', apiKey: 'k' }), byId('zai'), {}) assert.equal(plan.set[VAR], '1000000') }) test('providers with no documented window set none', () => { for (const id of ['modelscope', 'siliconflow', 'openrouter', 'custom']) { const p = byId(id) - const plan = buildEnvPlan({ provider: id, apiKey: 'k', baseUrl: 'https://x.example' }, p, {}) + const plan = buildEnvPlan( + makeProfile({ provider: id, apiKey: 'k', baseUrl: 'https://x.example' }), + p, + {}, + ) assert.equal(plan.set[VAR], undefined, `${id} guessed a context window`) } }) diff --git a/test/core/binding.test.ts b/test/core/binding.test.ts index aed8d01..9f954da 100644 --- a/test/core/binding.test.ts +++ b/test/core/binding.test.ts @@ -135,7 +135,12 @@ import { const state = () => makeState({ - profiles: { z: { provider: 'zai' }, or: { provider: 'openrouter' } }, + providerAccounts: { z: { provider: 'zai' }, or: { provider: 'openrouter' } }, + agentProfiles: { z: {}, or: {} }, + profiles: { + z: { agentProfile: 'z', accounts: ['z'] }, + or: { agentProfile: 'or', accounts: ['or'] }, + }, defaultProfile: 'z', bindings: { '/work/a': 'or', '/work/a/b/c': 'z', '/gone': 'deleted' }, settings: {}, diff --git a/test/core/compat.test.ts b/test/core/compat.test.ts index 73b60a2..16fc9e3 100644 --- a/test/core/compat.test.ts +++ b/test/core/compat.test.ts @@ -9,7 +9,7 @@ import assert from 'node:assert/strict' import { buildEnvPlan, COMPAT_ENV } from '../../src/adapters/agents/claude-code/env.ts' import type { ClaudeCodeCompatFlags } from '../../src/ports/claude-code.ts' import type { ProviderDescriptor } from '../../src/ports/provider.ts' -import type { Profile } from '../../src/ports/config-store.ts' +import type { ResolvedProfile } from '../../src/ports/config-store.ts' import type { EnvMap } from '../../src/ports/process.ts' const gateway: ProviderDescriptor = { @@ -21,7 +21,7 @@ const gateway: ProviderDescriptor = { } /** - * A profile fixture. Deliberately looser than `Profile` in exactly two ways, + * A profile fixture. Deliberately looser than `ResolvedProfile` in exactly two ways, * and a test in this file depends on each: * * - `Partial`, because these fixtures omit `provider`. buildEnvPlan never @@ -32,10 +32,10 @@ const gateway: ProviderDescriptor = { * compile, and the only way to satisfy the compiler would be to delete the * case it exists to cover. * - * Every other field stays checked against Profile, so a typo'd `apiKey` or a + * Every other field stays checked against ResolvedProfile, so a typo'd `apiKey` or a * misspelled `env` is still an error here. */ -type ProfileFixture = Partial> & { +type ProfileFixture = Partial> & { compat?: Record } @@ -44,7 +44,7 @@ const planOf = ( profile: ProfileFixture | null | undefined, provider: ProviderDescriptor | null | undefined, ambient: EnvMap = {}, -) => buildEnvPlan(profile as Profile | null | undefined, provider, ambient) +) => buildEnvPlan(profile as ResolvedProfile | null | undefined, provider, ambient) test('each flag maps to the variable that fixes its documented symptom', () => { // The mapping IS the feature. If one of these is wrong the flag does nothing diff --git a/test/core/doctor.test.ts b/test/core/doctor.test.ts index 7554eb8..f7c6b06 100644 --- a/test/core/doctor.test.ts +++ b/test/core/doctor.test.ts @@ -20,9 +20,9 @@ import type { DoctorCheck } from '../../src/ports/doctor.ts' import type { ProbeResult } from '../../src/ports/doctor.ts' import type { LoadResult, State } from '../../src/ports/config-store.ts' import type { ProfileSelection } from '../../src/core/profile.ts' -import type { Profile } from '../../src/ports/config-store.ts' +import type { ResolvedProfile } from '../../src/ports/config-store.ts' import type { ProviderDescriptor } from '../../src/ports/provider.ts' -import { makeDescriptor, makeProfile, makeSelection } from '../support/fixtures.ts' +import { makeDescriptor, makeProfile, makeSelection, makeProfileRefs } from '../support/fixtures.ts' /** * The knobs `run()` below turns. Every field mirrors one of StaticChecksInput's @@ -46,7 +46,7 @@ const profile = makeProfile({ const loaded = (over: Partial> & { state?: Partial } = {}): LoadResult => ({ state: { - version: 2, + version: 2, agentProfiles: {}, profiles: { z: profile }, defaultProfile: 'z', bindings: {}, @@ -59,7 +59,16 @@ const loaded = (over: Partial> & { state?: Partial { const c = byId( run({ - profile: { provider: 'zai', apiKeyFromEnv: 'ZAI_TOKEN', models: profile.models ?? {} }, + profile: makeProfile({ + provider: 'zai', + apiKeyFromEnv: 'ZAI_TOKEN', + models: profile.models ?? {}, + }), }), 'credential', ) @@ -192,13 +205,17 @@ test('a profile that reads its key from an unset variable is an error', () => { }) test('an optional credential is fine when absent', () => { - const c = byId(run({ profile: { provider: 'anthropic' }, provider: anthropic }), 'credential') + const c = byId(run({ profile: makeProfile({ provider: 'anthropic' }), provider: anthropic }), 'credential') assert.equal(c!.status, 'ok') }) test('no models pinned at all is fine; some but not all is a warning', () => { - assert.equal(byId(run({ profile: { provider: 'anthropic' }, provider: anthropic }), 'models')!.status, 'ok') - const partial = { provider: 'zai', apiKey: SECRET, models: { ...profile.models, fable: '' } } + assert.equal(byId(run({ profile: makeProfile({ provider: 'anthropic' }), provider: anthropic }), 'models')!.status, 'ok') + const partial = makeProfile({ + provider: 'zai', + apiKey: SECRET, + models: { ...profile.models, fable: '' }, + }) const c = byId(run({ profile: partial }), 'models') assert.equal(c!.status, 'warn') assert.match(c!.detail, /fable/) @@ -232,7 +249,10 @@ test('dangling bindings and dead paths are separate, prunable warnings', () => { test('a profile shadowed by a subcommand is flagged with the way out', () => { const st = loaded() - st.state.profiles = { ...st.state.profiles, doctor: { provider: 'zai' } } + st.state.profiles = { + ...st.state.profiles, + doctor: makeProfileRefs({ agentProfile: 'z', accounts: ['z'] }), + } const c = byId(run({ loaded: st }), 'shadowed-names') assert.equal(c!.status, 'warn') assert.match(c!.fix!, /--cc-profile/) @@ -262,8 +282,8 @@ test('probeSpec bounds itself at four requests however many tiers differ', () => }) test('probeSpec has nothing to probe for a first-party profile', () => { - const plan = buildEnvPlan({ provider: 'anthropic' }, anthropic, {}) - assert.equal(probeSpec({ provider: 'anthropic' }, anthropic, plan).baseUrl, null) + const plan = buildEnvPlan(makeProfile({ provider: 'anthropic' }), anthropic, {}) + assert.equal(probeSpec(makeProfile({ provider: 'anthropic' }), anthropic, plan).baseUrl, null) }) // Probe interpretation. Status codes carry the finding; bodies are advisory. diff --git a/test/core/env.test.ts b/test/core/env.test.ts index 37ad52b..ccef902 100644 --- a/test/core/env.test.ts +++ b/test/core/env.test.ts @@ -40,7 +40,7 @@ const POLLUTED = { test('a stale ANTHROPIC_API_KEY is removed for every third-party provider', () => { // This is the highest-cost failure mode in the tool: the stale key makes // Claude Code fall back to Anthropic and bill the wrong account. - const plan = buildEnvPlan(makeProfile({ provider: 'gw', apiKey: 'gw-key' }), gateway, POLLUTED) + const plan = buildEnvPlan(makeProfile(({ provider: 'gw', apiKey: 'gw-key' })), gateway, POLLUTED) assert.ok(plan.unset.includes('ANTHROPIC_API_KEY')) assert.equal(plan.set.ANTHROPIC_API_KEY, undefined) assert.equal(plan.set.ANTHROPIC_AUTH_TOKEN, 'gw-key') @@ -62,13 +62,13 @@ test('a provider that legitimately uses ANTHROPIC_API_KEY keeps it', () => { }) test('Anthropic direct clears a gateway URL left in the shell', () => { - const plan = buildEnvPlan(makeProfile({ provider: 'anthropic' }), firstParty, POLLUTED) + const plan = buildEnvPlan(makeProfile(({ provider: 'anthropic' })), firstParty, POLLUTED) assert.ok(plan.unset.includes('ANTHROPIC_BASE_URL')) assert.equal(plan.set.ANTHROPIC_BASE_URL, undefined) }) test('Anthropic direct clears stale tier variables rather than inheriting them', () => { - const plan = buildEnvPlan(makeProfile({ provider: 'anthropic' }), firstParty, POLLUTED) + const plan = buildEnvPlan(makeProfile(({ provider: 'anthropic' })), firstParty, POLLUTED) for (const tier of TIERS) assert.ok(plan.unset.includes(TIER_ENV[tier]), tier) }) diff --git a/test/core/extended-context.test.ts b/test/core/extended-context.test.ts index 93fe447..6d8adcb 100644 --- a/test/core/extended-context.test.ts +++ b/test/core/extended-context.test.ts @@ -11,11 +11,12 @@ import { SUFFIX, supportsExtendedContext } from '../../src/adapters/agents/claud import { buildEnvPlan } from '../../src/adapters/agents/claude-code/env.ts' import { byId, PROVIDERS } from '../../src/adapters/providers/registry.ts' import { TIER_ENV_VARS } from '../../src/adapters/agents/claude-code/tiers.ts' +import { makeProfile } from '../support/fixtures.ts' const zai = byId('zai') test('REGRESSION: z.ai puts [1m] on all four tier variables', () => { - const plan = buildEnvPlan({ provider: 'zai', apiKey: 'k' }, zai, {}) + const plan = buildEnvPlan(makeProfile({ provider: 'zai', apiKey: 'k' }), zai, {}) for (const v of TIER_ENV_VARS) { assert.equal(plan.set[v], 'glm-5.2[1m]', `${v} must carry the suffix`) } @@ -23,7 +24,7 @@ test('REGRESSION: z.ai puts [1m] on all four tier variables', () => { test('REGRESSION: no tier variable ever reaches Claude Code as bare glm-5.2', () => { // The precise shape of the shipped bug. - const plan = buildEnvPlan({ provider: 'zai', apiKey: 'k' }, zai, {}) + const plan = buildEnvPlan(makeProfile({ provider: 'zai', apiKey: 'k' }), zai, {}) const bare = TIER_ENV_VARS.filter((v) => plan.set[v] === 'glm-5.2') assert.deepEqual(bare, [], 'these tiers silently run at the standard window') }) @@ -32,15 +33,19 @@ test('an existing config storing bare glm-5.2 is fixed at launch, not on disk', // Migration is shape-only and deliberately repairs nothing. The suffix has to // reach existing users some other way, and that way is normalization at the // boundary — so a config.json written by 0.1.0 gains the fix with no rewrite. - const stored = { provider: 'zai', apiKey: 'k', models: { opus: 'glm-5.2', sonnet: 'glm-5.2', haiku: 'glm-5.2', fable: 'glm-5.2' } } + const stored = makeProfile({ + provider: 'zai', + apiKey: 'k', + models: { opus: 'glm-5.2', sonnet: 'glm-5.2', haiku: 'glm-5.2', fable: 'glm-5.2' }, + }) const plan = buildEnvPlan(stored, zai, {}) for (const v of TIER_ENV_VARS) assert.equal(plan.set[v], 'glm-5.2[1m]') // The stored object is untouched: normalization happens on the way out. - assert.equal(stored.models.opus, 'glm-5.2') + assert.equal(stored.models!.opus, 'glm-5.2') }) test('a hand-written [1m] is not doubled', () => { - const stored = { provider: 'zai', apiKey: 'k', models: { opus: 'glm-5.2[1m]' } } + const stored = makeProfile({ provider: 'zai', apiKey: 'k', models: { opus: 'glm-5.2[1m]' } }) const plan = buildEnvPlan(stored, zai, {}) assert.equal(plan.set.ANTHROPIC_DEFAULT_OPUS_MODEL, 'glm-5.2[1m]') assert.ok(!plan.set.ANTHROPIC_DEFAULT_OPUS_MODEL.includes('[1m][1m]')) @@ -51,7 +56,7 @@ test('a model the provider does not serve at 1M stays bare', () => { // a user who pins an older GLM must not get a suffix the endpoint will not // honour just because the provider supports the wider window for something. const plan = buildEnvPlan( - { provider: 'zai', apiKey: 'k', models: { opus: 'glm-4-air' } }, + makeProfile({ provider: 'zai', apiKey: 'k', models: { opus: 'glm-4-air' } }), zai, {}, ) @@ -63,7 +68,7 @@ test('a suffix is stripped for a provider that does not declare the capability', // Sending an id the endpoint does not know is a hard failure; a narrower // window is merely disappointing. const plan = buildEnvPlan( - { provider: 'siliconflow', apiKey: 'k', models: { opus: 'Pro/zai-org/GLM-4.6[1m]' } }, + makeProfile({ provider: 'siliconflow', apiKey: 'k', models: { opus: 'Pro/zai-org/GLM-4.6[1m]' } }), byId('siliconflow'), {}, ) diff --git a/test/core/hygiene.test.ts b/test/core/hygiene.test.ts index 143d837..470df7d 100644 --- a/test/core/hygiene.test.ts +++ b/test/core/hygiene.test.ts @@ -11,7 +11,7 @@ import { byId } from '../../src/adapters/providers/registry.ts' import type { PlanFacts } from '../../src/adapters/agents/claude-code/hygiene.ts' import type { EnvWarning } from '../../src/ports/agent.ts' import type { ProviderDescriptor } from '../../src/ports/provider.ts' -import type { Profile } from '../../src/ports/config-store.ts' +import type { ResolvedProfile } from '../../src/ports/config-store.ts' import type { EnvMap } from '../../src/ports/process.ts' import { makeProfile } from '../support/fixtures.ts' @@ -26,7 +26,7 @@ const gateway: ProviderDescriptor = { const codesOf = (ws: EnvWarning[]) => ws.map((w) => w.code).sort() const find = (ws: EnvWarning[], code: string) => ws.find((w) => w.code === code) const planFor = ( - profile: Profile | null | undefined, + profile: ResolvedProfile | null | undefined, provider: ProviderDescriptor | null | undefined, ambient: EnvMap, ) => buildEnvPlan(profile, provider, ambient) @@ -212,7 +212,7 @@ test('every warning carries a severity, a code and a message', () => { }) test('warnings are advisory: the plan is identical with and without a dirty env', () => { - // "Profile wins; the warning is informational." Detection must never change + // "ResolvedProfile wins; the warning is informational." Detection must never change // what actually gets launched. const clean = planFor(makeProfile({ apiKey: 'k' }), gateway, {}) const dirty = planFor(makeProfile({ apiKey: 'k' }), gateway, { diff --git a/test/core/keyless.test.ts b/test/core/keyless.test.ts index 2782cfb..f0f7349 100644 --- a/test/core/keyless.test.ts +++ b/test/core/keyless.test.ts @@ -19,13 +19,13 @@ import { makeProfile } from '../support/fixtures.ts' import type { ProviderDescriptor } from '../../src/ports/provider.ts' test('a keyless profile sends the provider placeholder', () => { - const plan = buildEnvPlan(makeProfile({ provider: 'ollama' }), ollama, {}) + const plan = buildEnvPlan(makeProfile(({ provider: 'ollama' })), ollama, {}) assert.equal(plan.set.ANTHROPIC_AUTH_TOKEN, 'ollama') assert.ok(!plan.unset.includes('ANTHROPIC_AUTH_TOKEN'), 'the variable must be populated') }) test('a real key still wins over the placeholder', () => { - const plan = buildEnvPlan(makeProfile({ provider: 'ollama', apiKey: 'mine' }), ollama, {}) + const plan = buildEnvPlan(makeProfile(({ provider: 'ollama', apiKey: 'mine' })), ollama, {}) assert.equal(plan.set.ANTHROPIC_AUTH_TOKEN, 'mine') }) @@ -51,7 +51,7 @@ test('apiKeyFromEnv wins too, and an unset variable falls back rather than blank test('the placeholder reaches every agent, not just Claude Code', () => { // Kilo and OpenCode read the neutral intent, so the fallback has to live // there as well as in the Claude Code lowering. - const intent = buildIntent(makeProfile({ provider: 'ollama' }), ollama, {}) + const intent = buildIntent(makeProfile(({ provider: 'ollama' })), ollama, {}) assert.equal(intent.credential, 'ollama') }) @@ -68,12 +68,12 @@ test('a provider that needs a real key does NOT get a placeholder', () => { const cloud: ProviderDescriptor = ollamaCloud assert.equal(cloud.defaultCredential, undefined) - const plan = buildEnvPlan(makeProfile({ provider: 'ollama-cloud' }), ollamaCloud, {}) + const plan = buildEnvPlan(makeProfile(({ provider: 'ollama-cloud' })), ollamaCloud, {}) assert.ok( plan.unset.includes('ANTHROPIC_AUTH_TOKEN'), 'a keyless cloud profile must clear the variable, not invent a token', ) - const intent = buildIntent(makeProfile({ provider: 'ollama-cloud' }), ollamaCloud, {}) + const intent = buildIntent(makeProfile(({ provider: 'ollama-cloud' })), ollamaCloud, {}) assert.equal(intent.credential, '') }) @@ -101,7 +101,7 @@ test('a local launch sets no auto-compact window, because none is measured', () // Guessing one that is too large means the conversation overflows instead of // compacting, so the correct output is nothing at all. const plan = buildEnvPlan( - makeProfile({ provider: 'ollama', models: { opus: 'qwen3-coder:30b' } }), + makeProfile(({ provider: 'ollama', models: { opus: 'qwen3-coder:30b' } })), ollama, {}, ) diff --git a/test/core/migrate.test.ts b/test/core/migrate.test.ts index 595b3d2..fb4451e 100644 --- a/test/core/migrate.test.ts +++ b/test/core/migrate.test.ts @@ -9,6 +9,7 @@ import { normalize, validateProfileName, } from '../../src/core/migrate.ts' +import { makeProfile } from '../support/fixtures.ts' /** Exactly the shape swisscode 0.1.0 writes. */ const V1_FULL = { @@ -31,12 +32,17 @@ test('migrates a real 0.1.0 config into a named profile', () => { assert.equal(readOnly, false) assert.equal(state.version, SUPPORTED_VERSION) assert.equal(state.defaultProfile, 'zai') - assert.deepEqual(state.profiles.zai, { - provider: 'zai', - apiKey: 'zai-secret', + // v1 now chains through v2 into the three-way split, in one read. + assert.deepEqual(state.providerAccounts.zai, { provider: 'zai', apiKey: 'zai-secret' }) + assert.deepEqual(state.agentProfiles.zai, { models: { opus: 'glm-5.2', sonnet: 'glm-5.2', haiku: 'glm-5.2' }, skipPermissions: true, }) + assert.deepEqual(state.profiles.zai, { + agentProfile: 'zai', + accounts: ['zai'], + strategy: 'single', + }) assert.deepEqual(state.bindings, {}) }) @@ -44,8 +50,8 @@ test('migration is shape-only: it repairs nothing', () => { // The [1m] fix reaches existing users at env-build time. Rewriting stored // model strings is `config doctor`'s job, invoked by a human. const { state } = migrate(V1_FULL) - assert.equal(state.profiles.zai!.models!.opus, 'glm-5.2') - assert.equal(state.profiles.zai!.models!.fable, undefined) + assert.equal(state.agentProfiles.zai!.models!.opus, 'glm-5.2') + assert.equal(state.agentProfiles.zai!.models!.fable, undefined) }) test('migration is lossless: unknown v1 keys ride along on the profile', () => { @@ -55,7 +61,10 @@ test('migration is lossless: unknown v1 keys ride along on the profile', () => { // reachable through `Profile`. Giving Profile an index signature would make // this line compile and would simultaneously stop every other Profile // fixture in the suite from catching a misspelled field. - const migrated = state.profiles.zai as unknown as Record + // They ride on the AGENT PROFILE now: the split files a v2 profile's + // non-credential fields there, and an unrecognized key is by definition not a + // credential. + const migrated = state.agentProfiles.zai as unknown as Record assert.deepEqual(migrated.somethingNew, { a: 1 }) assert.equal(migrated.note, 'hi') }) @@ -76,13 +85,19 @@ test('migration is deterministic: no timestamps, no ordering dependence', () => test('a minimal v1 config still produces a usable profile', () => { const { state } = migrate({ provider: 'anthropic' }) assert.equal(state.defaultProfile, 'anthropic') - assert.deepEqual(state.profiles.anthropic, { provider: 'anthropic' }) + assert.deepEqual(state.providerAccounts.anthropic, { provider: 'anthropic' }) + assert.deepEqual(state.agentProfiles.anthropic, {}) + assert.deepEqual(state.profiles.anthropic, { + agentProfile: 'anthropic', + accounts: ['anthropic'], + strategy: 'single', + }) }) test('a provider id that is not a legal profile name becomes "default"', () => { const { state } = migrate({ provider: '-weird name-' }) assert.equal(state.defaultProfile, 'default') - assert.equal(state.profiles.default!.provider, '-weird name-') + assert.equal(state.providerAccounts.default!.provider, '-weird name-') }) test('a v1 custom-endpoint config keeps its baseUrl and env', () => { @@ -93,38 +108,68 @@ test('a v1 custom-endpoint config keeps its baseUrl and env', () => { env: { API_TIMEOUT_MS: '600000' }, models: { opus: 'm', sonnet: 'm', haiku: 'm' }, }) - assert.equal(state.profiles.custom!.baseUrl, 'https://local.example') - assert.deepEqual(state.profiles.custom!.env, { API_TIMEOUT_MS: '600000' }) + assert.equal(state.providerAccounts.custom!.baseUrl, 'https://local.example') + assert.deepEqual(state.agentProfiles.custom!.env, { API_TIMEOUT_MS: '600000' }) }) test('models are picked down to the four tiers, values untouched', () => { - const { state } = migrate({ provider: 'zai', models: { opus: 'a', bogus: 'b' } }) - assert.deepEqual(state.profiles.zai!.models, { opus: 'a' }) + const { state } = migrate({ provider: 'zai', models: { opus: 'a', bogus: 'b' } } as never) + assert.deepEqual(state.agentProfiles.zai!.models, { opus: 'a' }) }) -test('an already-v2 config is returned unchanged and is not rewritten', () => { +test('a v2 config is MIGRATED, and says so', () => { + // The premise of this test inverted with v3: v2 used to be the terminal + // shape, so "unchanged" was the correct assertion. It is now a rung on the + // ladder, and `migratedFrom` is what authorizes the store to write the + // upgraded file and keep a backup. const v2 = { version: 2, - profiles: { a: { provider: 'zai' } }, + profiles: { a: { provider: 'zai', apiKey: 'k', models: { opus: 'glm-5.2' } } }, defaultProfile: 'a', bindings: {}, settings: {}, } const r = migrate(v2) + assert.equal(r.migratedFrom, 2) + assert.equal(r.state.version, SUPPORTED_VERSION) + assert.deepEqual(r.state.providerAccounts.a, { provider: 'zai', apiKey: 'k' }) + assert.deepEqual(r.state.agentProfiles.a, { models: { opus: 'glm-5.2' } }) + assert.deepEqual(r.state.profiles.a, { + agentProfile: 'a', + accounts: ['a'], + strategy: 'single', + }) +}) + +test('an already-v3 config is returned unchanged and is not rewritten', () => { + // The terminal rung. A launch that merely READS must not touch the disk, and + // `migratedFrom: null` is the only thing that keeps it from doing so. + const v3 = { + version: 3, + providerAccounts: { a: { provider: 'zai' } }, + agentProfiles: { a: {} }, + profiles: { a: { agentProfile: 'a', accounts: ['a'] } }, + defaultProfile: 'a', + bindings: {}, + settings: {}, + } + const r = migrate(v3) assert.equal(r.migratedFrom, null) - assert.deepEqual(r.state, v2) + assert.deepEqual(r.state, v3) }) test('a NEWER schema is read best-effort and locked read-only', () => { const r = migrate({ version: 99, - profiles: { a: { provider: 'zai' } }, + providerAccounts: { a: { provider: 'zai' } }, + agentProfiles: { a: {} }, + profiles: { a: { agentProfile: 'a', accounts: ['a'] } }, defaultProfile: 'a', futureThing: true, }) assert.equal(r.readOnly, true) assert.equal(r.state.version, 99, 'the version must not be downgraded') - assert.equal(r.state.profiles.a!.provider, 'zai') + assert.equal(r.state.providerAccounts.a!.provider, 'zai') }) test('an unrecognizable object is treated as absent, not merged', () => { @@ -140,8 +185,8 @@ test('normalize resolves a dangling defaultProfile only when unambiguous', () => assert.equal(one.state.defaultProfile, 'solo') const many = normalize({ - version: 2, - profiles: { a: { provider: 'zai' }, b: { provider: 'openrouter' } }, + version: 2, agentProfiles: {}, + profiles: { a: makeProfile({ provider: 'zai' }), b: { provider: 'openrouter' } }, defaultProfile: 'gone', }) // Never guess alphabetically — that silently picks an account to bill. @@ -151,7 +196,7 @@ test('normalize resolves a dangling defaultProfile only when unambiguous', () => test('normalize drops non-absolute binding keys with a warning', () => { const { state, warnings } = normalize({ - version: 2, + version: 2, agentProfiles: {}, profiles: {}, bindings: { 'relative/path': 'a', '/abs/path': 'b' }, }) diff --git a/test/core/overrides.test.ts b/test/core/overrides.test.ts index 884f756..80a3773 100644 --- a/test/core/overrides.test.ts +++ b/test/core/overrides.test.ts @@ -79,7 +79,7 @@ test('the override path never writes to the config store', () => { { models: { opus: 'x' } }, { env: { A: '' } }, { baseUrl: 'https://y' }, - { provider: 'openrouter' }, + makeProfile({ provider: 'openrouter' }), ] for (const overrides of matrix) applyOverrides(base, overrides) assert.equal(saves, 0) @@ -87,13 +87,18 @@ test('the override path never writes to the config store', () => { }) test('retargeting keeps the key when the provider is unchanged', () => { - const r = retargetProvider(base, 'zai', makeState({ profiles: {} }), null, {}) + const r = retargetProvider(base, 'zai', makeState({ providerAccounts: {} }), null, {}) assert.ok(r.ok) assert.equal(r.profile.apiKey, 'zai-secret') }) -test('retargeting borrows the credential from a profile for that provider', () => { - const state = makeState({ profiles: { or: { provider: 'openrouter', apiKey: 'or-secret', baseUrl: 'https://or' } } }) +test('retargeting adopts the ACCOUNT for that provider', () => { + // Since v3 this is a LOOKUP, not a search. The v2 version rummaged through + // every other profile hoping to find one holding a key for the target; an + // account names exactly one provider, so the question is a filter. + const state = makeState({ + providerAccounts: { or: { provider: 'openrouter', apiKey: 'or-secret', baseUrl: 'https://or' } }, + }) const r = retargetProvider(base, 'openrouter', state, null, {}) assert.ok(r.ok) assert.equal(r.profile.apiKey, 'or-secret') @@ -103,7 +108,7 @@ test('retargeting borrows the credential from a profile for that provider', () = test('retargeting accepts a credential already in the ambient env', () => { const descriptor = makeDescriptor({ credentialEnv: 'ANTHROPIC_AUTH_TOKEN' }) - const r = retargetProvider(base, 'openrouter', makeState({ profiles: {} }), descriptor, { + const r = retargetProvider(base, 'openrouter', makeState({ providerAccounts: {} }), descriptor, { ANTHROPIC_AUTH_TOKEN: 'from-shell', }) assert.ok(r.ok) @@ -115,38 +120,46 @@ test('retargeting refuses rather than sending a key to another host', () => { // Falling through to "just send the key we have" would POST a z.ai token to // OpenRouter. const descriptor = makeDescriptor({ credentialEnv: 'ANTHROPIC_AUTH_TOKEN' }) - const r = retargetProvider(base, 'openrouter', makeState({ profiles: {} }), descriptor, {}) + const r = retargetProvider(base, 'openrouter', makeState({ providerAccounts: {} }), descriptor, {}) assert.equal(r.ok, false) - assert.match(r.reason, /no credential/) + assert.match(r.reason, /no account/) }) test('retargeting drops models chosen for the old provider', () => { // Same reasoning as the credential: `glm-5.2` was picked for z.ai, and // posting it to OpenRouter is a guaranteed 404 dressed as a working config. - const state = makeState({ profiles: { or: { provider: 'openrouter', apiKey: 'or-secret' } } }) + const state = makeState({ + providerAccounts: { or: { provider: 'openrouter', apiKey: 'or-secret' } }, + }) const r = retargetProvider(base, 'openrouter', state, null, {}) assert.ok(r.ok) assert.equal(r.profile.models, undefined, 'the provider defaults apply instead') }) -test('retargeting takes the models from the profile it borrowed from', () => { - // That profile is already configured FOR this provider, so its answer beats - // the descriptor default. +test('retargeting cannot inherit models, because accounts do not hold any', () => { + // BEHAVIOUR CHANGE, and a deliberate one. Under v2 this borrowed the models + // from whichever profile lent the credential, on the reasoning that it was + // "already configured FOR this provider". + // + // v3 removes both the ability and the need: models live on the agent profile, + // which does not change when the provider does, and an account carries no + // models to borrow. So the models chosen for the OLD provider are simply + // dropped and the target's own defaults apply — which is the safer of the two + // answers anyway, since the borrowed ids were never verified against this + // endpoint either. const state = makeState({ - profiles: { - or: { provider: 'openrouter', apiKey: 'or-secret', models: { opus: 'anthropic/claude-opus-4.8' } }, - }, + providerAccounts: { or: { provider: 'openrouter', apiKey: 'or-secret' } }, }) const r = retargetProvider(base, 'openrouter', state, null, {}) as Retargeted - assert.deepEqual(r.profile.models, { opus: 'anthropic/claude-opus-4.8' }) - // Deep-copied, not aliased: a per-run override must not reach stored state. - r.profile.models!.opus = 'mutated' - assert.equal(state.profiles.or!.models!.opus, 'anthropic/claude-opus-4.8') + assert.equal(r.profile.models, undefined) + assert.equal(r.borrowedFrom, 'or', 'the credential still came from that account') }) test('retargeting drops context windows keyed by the old catalog', () => { const withWindows = { ...base, contextWindows: { 'glm-5.2': 1000000 } } - const state = makeState({ profiles: { or: { provider: 'openrouter', apiKey: 'k' } } }) + const state = makeState({ + providerAccounts: { or: { provider: 'openrouter', apiKey: 'k' } }, + }) const retargeted = retargetProvider(withWindows, 'openrouter', state, null, {}) as Retargeted assert.equal(retargeted.profile.contextWindows, undefined) }) diff --git a/test/core/profile.test.ts b/test/core/profile.test.ts index 72ec4dd..2894ac0 100644 --- a/test/core/profile.test.ts +++ b/test/core/profile.test.ts @@ -14,9 +14,11 @@ import { makeState } from '../support/fixtures.ts' const state = { version: 2, + providerAccounts: { z: { provider: 'zai' }, or: { provider: 'openrouter' } }, + agentProfiles: { z: {}, or: {} }, profiles: { - z: { provider: 'zai' }, - or: { provider: 'openrouter' }, + z: { agentProfile: 'z', accounts: ['z'] }, + or: { agentProfile: 'or', accounts: ['or'] }, }, defaultProfile: 'z', bindings: { '/work/or-project': 'or' }, @@ -60,7 +62,7 @@ test('no profiles at all resolves to nothing, which means the wizard', () => { test('exactly one profile and no default is not ambiguous', () => { const sel = resolveProfile( - makeState({ profiles: { solo: { provider: 'zai' } }, defaultProfile: 'solo' }), + makeState({ providerAccounts: { solo: { provider: 'zai' } }, agentProfiles: { solo: {} }, profiles: { solo: { agentProfile: 'solo', accounts: ['solo'] } }, defaultProfile: 'solo' }), { cwd: '/x' }, ) assert.equal(sel.name, 'solo') @@ -157,6 +159,6 @@ test('tier 1 short-circuits the binding walk entirely', () => { test('a profile named after a subcommand is still selectable by flag', () => { // `config list` always wins positionally, but the profile is not unreachable. - const shadowed = makeState({ profiles: { list: { provider: 'zai' } }, defaultProfile: null }) + const shadowed = makeState({ providerAccounts: { list: { provider: 'zai' } }, agentProfiles: { list: {} }, profiles: { list: { agentProfile: 'list', accounts: ['list'] } }, defaultProfile: null }) assert.equal(resolveProfile(shadowed, { profileFlag: 'list' }).name, 'list') }) diff --git a/test/golden.test.ts b/test/golden.test.ts index 1ee8e7d..8d961a1 100644 --- a/test/golden.test.ts +++ b/test/golden.test.ts @@ -32,6 +32,7 @@ import test from 'node:test' import assert from 'node:assert/strict' import { buildEnvPlan } from '../src/adapters/agents/claude-code/env.ts' import { PROVIDERS, byId } from '../src/adapters/providers/registry.ts' +import { makeProfile } from './support/fixtures.ts' const AMBIENT = Object.freeze({ PATH: '/usr/bin', @@ -155,7 +156,7 @@ function planFor(id: string) { apiKey: 'KEY', ...(provider.askBaseUrl ? { baseUrl: 'https://custom.example' } : {}), } - return buildEnvPlan(profile, provider, AMBIENT) + return buildEnvPlan(makeProfile(profile), provider, AMBIENT) } for (const provider of PROVIDERS) { diff --git a/test/launch-overrides.test.ts b/test/launch-overrides.test.ts index 6049943..338767d 100644 --- a/test/launch-overrides.test.ts +++ b/test/launch-overrides.test.ts @@ -12,18 +12,24 @@ import { registry as agents } from '../src/adapters/agents/registry.ts' import type { LaunchPlan } from '../src/composition/launch-root.ts' import type { EnvMap } from '../src/ports/process.ts' import type { State } from '../src/ports/config-store.ts' +import { makeProfile } from './support/fixtures.ts' const STATE = (): State => ({ version: 2, + providerAccounts: { + z: makeProfile({ provider: 'zai', apiKey: 'zai-secret' }), + or: makeProfile({ provider: 'openrouter', apiKey: 'or-secret' }), + keyless: makeProfile({ provider: 'openrouter' }), + }, + agentProfiles: { + z: { models: { opus: 'glm-5.2', sonnet: 'glm-5.2', haiku: 'glm-5.2', fable: 'glm-5.2' }, skipPermissions: true }, + or: {}, + keyless: {}, + }, profiles: { - z: { - provider: 'zai', - apiKey: 'zai-secret', - models: { opus: 'glm-5.2', sonnet: 'glm-5.2', haiku: 'glm-5.2', fable: 'glm-5.2' }, - skipPermissions: true, - }, - or: { provider: 'openrouter', apiKey: 'or-secret' }, - keyless: { provider: 'openrouter' }, + z: { agentProfile: 'z', accounts: ['z'] }, + or: { agentProfile: 'or', accounts: ['or'] }, + keyless: { agentProfile: 'keyless', accounts: ['keyless'] }, }, defaultProfile: 'z', bindings: { '/work/or-project': 'or' }, @@ -212,18 +218,21 @@ test('--cc-model still wins over the retargeted defaults', () => { test('--cc-provider refuses rather than POSTing one host a key meant for another', () => { const state = STATE() - delete state.profiles.or - delete state.profiles.keyless + // Accounts, not profiles: a credential lives on the account now, so removing + // the profile would leave the key perfectly findable — which is exactly the + // improvement the split bought. + delete state.providerAccounts.or + delete state.providerAccounts.keyless assert.throws( () => plan(['--cc-provider', 'openrouter'], { state }), - (err) => err instanceof LaunchError && /no credential/.test(err.message), + (err) => err instanceof LaunchError && /no account/.test(err.message), ) }) test('--cc-provider accepts a credential already in the ambient environment', () => { const state = STATE() - delete state.profiles.or - delete state.profiles.keyless + delete state.providerAccounts.or + delete state.providerAccounts.keyless const r = plan(['--cc-provider', 'openrouter'], { state, env: { ANTHROPIC_AUTH_TOKEN: 'from-shell' }, diff --git a/test/picker.test.ts b/test/picker.test.ts index f968b62..5c7e27d 100644 --- a/test/picker.test.ts +++ b/test/picker.test.ts @@ -7,7 +7,7 @@ // nothing else. The second is what proves the abstraction generalizes — the // picker has to degrade to honest blanks rather than rendering "$0.00" over // data it does not have. -import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs' +import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import assert from 'node:assert/strict' @@ -186,10 +186,16 @@ await tick() // here stay exactly the ones that were here before. `assert.ok` still runs and // still fails first if the wizard produced nothing. assert.ok(result, 'wizard should have produced a profile') -assert.equal((result as Profile).provider, 'openrouter') -assert.equal((result as Profile).models!.opus, 'anthropic/claude-opus-4.8', 'picked model must persist') -assert.equal((result as Profile).models!.sonnet, 'openrouter/fusion', 'untouched tiers keep defaults') -assert.equal((result as Profile).models!.fable, 'openrouter/fusion', 'the fable tier must not be left unset') + +// The wizard returns the stored profile — references only since v3 — so what it +// configured is read back out of the file it wrote. +const written = JSON.parse(readFileSync(join(home, 'swisscode', 'config.json'), 'utf8')) +const account = written.providerAccounts[(result as Profile).accounts[0]!] +const agentProfile = written.agentProfiles[(result as Profile).agentProfile] +assert.equal(account.provider, 'openrouter') +assert.equal(agentProfile.models.opus, 'anthropic/claude-opus-4.8', 'picked model must persist') +assert.equal(agentProfile.models.sonnet, 'openrouter/fusion', 'untouched tiers keep defaults') +assert.equal(agentProfile.models.fable, 'openrouter/fusion', 'the fable tier must not be left unset') // ModelScope: no prices, no benchmarks diff --git a/test/profiles-ui.test.ts b/test/profiles-ui.test.ts index 63dcd3a..c6af728 100644 --- a/test/profiles-ui.test.ts +++ b/test/profiles-ui.test.ts @@ -11,6 +11,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import assert from 'node:assert/strict' import type { Profile, State } from '../src/ports/config-store.ts' +import { makeProfile } from './support/fixtures.ts' /** * dist/ui.js is BUILD OUTPUT — see src/cli.ts for the full reasoning. tsc must @@ -39,14 +40,17 @@ const CWD = '/work/proj' const baseState = (): State => ({ version: 2, + providerAccounts: { + work: { provider: 'zai', apiKey: SECRET }, + personal: makeProfile({ provider: 'openrouter', apiKey: 'or-secret' }), + }, + agentProfiles: { + work: { models: { opus: 'glm-5.2', sonnet: 'glm-5.2', haiku: 'glm-5.2', fable: 'glm-5.2' }, skipPermissions: true }, + personal: {}, + }, profiles: { - work: { - provider: 'zai', - apiKey: SECRET, - models: { opus: 'glm-5.2', sonnet: 'glm-5.2', haiku: 'glm-5.2', fable: 'glm-5.2' }, - skipPermissions: true, - }, - personal: { provider: 'openrouter', apiKey: 'or-secret' }, + work: { agentProfile: 'work', accounts: ['work'] }, + personal: { agentProfile: 'personal', accounts: ['personal'] }, }, defaultProfile: 'work', bindings: {}, @@ -154,7 +158,7 @@ function mount(props: Partial) { ['personal', 'work'], 'no profile may be touched by a default change', ) - assert.equal(store.saves[0]!.profiles.work!.apiKey, SECRET, 'other profiles survive intact') + assert.equal(store.saves[0]!.providerAccounts.work!.apiKey, SECRET, 'other profiles survive intact') ui.unmount() console.log('profiles ui: set-default writes only the default') } @@ -226,7 +230,7 @@ function mount(props: Partial) { assert.equal(store.saves.length, 1) const saved = store.saves[0]! assert.equal(saved.profiles.personal, undefined) - assert.equal(saved.profiles.work!.apiKey, SECRET, 'the other profile is untouched') + assert.equal(saved.providerAccounts.work!.apiKey, SECRET, 'the other profile is untouched') assert.deepEqual(Object.keys(saved.bindings), ['/other'], 'its bindings went with it') assert.equal(saved.defaultProfile, 'work', 'the survivor becomes the default') ui.unmount() @@ -309,7 +313,7 @@ function mount(props: Partial) { assert.equal(store.saves.length, 1) const saved = store.saves[0]! assert.ok(saved.profiles.personal, 'the edited profile keeps its name') - assert.equal(saved.profiles.personal!.apiKey, 'or-secret', 'the existing key survives an edit') + assert.equal(saved.providerAccounts.personal!.apiKey, 'or-secret', 'the existing key survives an edit') assert.ok(saved.profiles.work, 'the other profile survives the write') assert.equal(saved.defaultProfile, 'work', 'editing must not steal the default') ui.unmount() diff --git a/test/registry.test.ts b/test/registry.test.ts index fe148bd..3f48297 100644 --- a/test/registry.test.ts +++ b/test/registry.test.ts @@ -9,6 +9,7 @@ import { SUFFIX } from '../src/adapters/agents/claude-code/context.ts' import { buildEnvPlan, COMPAT_ENV } from '../src/adapters/agents/claude-code/env.ts' import { TIER_ENV_VARS } from '../src/adapters/agents/claude-code/tiers.ts' import { TIERS } from '../src/core/tiers.ts' +import { makeProfile } from './support/fixtures.ts' const CREDENTIAL_ENVS = ['ANTHROPIC_AUTH_TOKEN', 'ANTHROPIC_API_KEY'] @@ -84,7 +85,7 @@ for (const p of PROVIDERS) { test(`descriptor ${p.id}: a third-party endpoint always clears ANTHROPIC_API_KEY`, () => { if (p.baseUrl === null || p.credentialEnv === 'ANTHROPIC_API_KEY') return - const plan = buildEnvPlan({ provider: p.id, apiKey: 'k' }, p, { + const plan = buildEnvPlan(makeProfile({ provider: p.id, apiKey: 'k' }), p, { ANTHROPIC_API_KEY: 'sk-ant-STALE', }) assert.ok(plan.unset.includes('ANTHROPIC_API_KEY'), `${p.id} would bill the wrong account`) @@ -105,7 +106,7 @@ for (const p of PROVIDERS) { // tiers and not the fourth has a tier silently running at the standard // window. Nothing about a descriptor's shape prevents that by itself — // this does, for every provider added from here on. - const plan = buildEnvPlan({ provider: p.id, apiKey: 'k' }, p, {}) + const plan = buildEnvPlan(makeProfile({ provider: p.id, apiKey: 'k' }), p, {}) // filter(Boolean) does not narrow in TypeScript, so the element type stays // `string | undefined` after it. Asserted rather than rewritten to // `(v) => v !== undefined`, which WOULD narrow for free but would edit the @@ -125,7 +126,7 @@ for (const p of PROVIDERS) { // environment is the same bug wearing a fixed-looking descriptor. if (!p.extendedContext?.supported) return if (Object.keys(p.defaultModels).length === 0) return - const plan = buildEnvPlan({ provider: p.id, apiKey: 'k' }, p, {}) + const plan = buildEnvPlan(makeProfile({ provider: p.id, apiKey: 'k' }), p, {}) for (const v of TIER_ENV_VARS) { const value = plan.set[v] if (!value) continue diff --git a/test/support/fixtures.ts b/test/support/fixtures.ts index 24080d1..ffa22ef 100644 --- a/test/support/fixtures.ts +++ b/test/support/fixtures.ts @@ -31,15 +31,82 @@ // nothing — the object the test writes is the object the code under test // receives, with the same identity. All they do is carry a type claim across a // boundary the compiler cannot see across on its own. -import type { Profile, State } from '../../src/ports/config-store.ts' +import type { + AgentProfile, + Profile, + ProviderAccount, + ResolvedProfile, + State, +} from '../../src/ports/config-store.ts' import type { ProviderDescriptor } from '../../src/ports/provider.ts' import type { ProfileSelection } from '../../src/core/profile.ts' /** - * A Profile fixture that omits fields the code under test does not read. - * Everything present is checked; only absence is waived. + * A RESOLVED profile fixture — the flattened account + agent profile that + * everything downstream of resolution consumes. + * + * Named `makeProfile` still, and deliberately: it is used by roughly twenty + * suites that test `buildEnvPlan`, `buildIntent` and the agent adapters, none + * of which changed when the stored schema split in three. Renaming it would + * have implied those tests were testing something new. They are not — that is + * the whole point, and `test/golden.test.ts` passing unchanged is the proof. + * + * `accountName`/`agentProfileName` are defaulted rather than required because + * no consumer downstream of resolution reads them; they exist so a caller can + * REPORT which account paid. A fixture that had to invent both every time would + * add noise to twenty files for a field under test in none of them. + */ +export const makeProfile = (p: Partial): ResolvedProfile => + ({ accountName: 'acct', agentProfileName: 'agent', ...p }) as ResolvedProfile + +/** A stored `Profile` — references only. For tests about the pairing itself. */ +export const makeProfileRefs = (p: Partial): Profile => p as Profile + +/** A stored provider account. For tests about credentials and retargeting. */ +export const makeAccount = (a: Partial): ProviderAccount => a as ProviderAccount + +/** A stored agent profile. For tests about models, permissions and compat. */ +export const makeAgentProfile = (a: Partial): AgentProfile => a as AgentProfile + +/** + * A v3 state built from ONE profile's worth of flat v2-shaped fields. + * + * The migration produces exactly this 1:1:1 arrangement, and so does the + * wizard, so a test that just needs "a state with a working profile named N" + * can say so without spelling three objects. Tests that are ABOUT the split — + * multi-account profiles, shared agent profiles — build the maps explicitly. */ -export const makeProfile = (p: Partial): Profile => p as Profile +export const makeSimpleState = ( + name: string, + flat: Partial, + rest: Partial = {}, +): State => + ({ + version: 3, + providerAccounts: { + [name]: { + provider: flat.provider, + ...(flat.baseUrl !== undefined ? { baseUrl: flat.baseUrl } : {}), + ...(flat.apiKey !== undefined ? { apiKey: flat.apiKey } : {}), + ...(flat.apiKeyFromEnv !== undefined ? { apiKeyFromEnv: flat.apiKeyFromEnv } : {}), + }, + }, + agentProfiles: { + [name]: { + ...(flat.agent !== undefined ? { agent: flat.agent } : {}), + ...(flat.models !== undefined ? { models: flat.models } : {}), + ...(flat.skipPermissions !== undefined ? { skipPermissions: flat.skipPermissions } : {}), + ...(flat.env !== undefined ? { env: flat.env } : {}), + ...(flat.compat !== undefined ? { compat: flat.compat } : {}), + ...(flat.contextWindows !== undefined ? { contextWindows: flat.contextWindows } : {}), + }, + }, + profiles: { [name]: { agentProfile: name, accounts: [name], strategy: 'single' } }, + defaultProfile: name, + bindings: {}, + settings: {}, + ...rest, + }) as unknown as State /** * A State fixture that omits fields the code under test does not read. diff --git a/test/ui.test.ts b/test/ui.test.ts index 3ccaca7..5777a21 100644 --- a/test/ui.test.ts +++ b/test/ui.test.ts @@ -70,21 +70,25 @@ stdin.write(ENTER) // "yes" await tick() assert.ok(result, 'wizard should have produced a profile') -assert.equal(result.provider, 'zai') -assert.equal(result.apiKey, 'secret-token') -assert.equal(result.skipPermissions, true) +// The wizard returns the stored PROFILE — references only. What it configured +// lives in the account and the agent profile it wrote alongside it. +assert.equal(result.agentProfile, 'zai') +assert.deepEqual(result.accounts, ['zai']) + +const path = join(home, 'swisscode', 'config.json') +const saved = JSON.parse(readFileSync(path, 'utf8')) +assert.equal(saved.version, 3, 'wizard must write the v3 schema') + +// The settings themselves live on the agent profile the wizard minted. +assert.equal(saved.agentProfiles.zai.skipPermissions, true) // Four tiers, not three: [1m] is read per variable, so a tier the wizard never // writes is a tier that silently runs at the assumed window. -assert.deepEqual(result.models, { +assert.deepEqual(saved.agentProfiles.zai.models, { opus: 'glm-5.2', sonnet: 'glm-5.2', haiku: 'glm-5.2', fable: 'glm-5.2', }) - -const path = join(home, 'swisscode', 'config.json') -const saved = JSON.parse(readFileSync(path, 'utf8')) -assert.equal(saved.version, 2, 'wizard must write the v2 profile schema') assert.equal(saved.defaultProfile, 'zai') assert.deepEqual(saved.profiles.zai, result, 'the profile must persist verbatim') assert.equal(statSync(path).mode & 0o777, 0o600, 'the file holds an API key') From c7d9f43564b006c77b49f798972983c74705f882 Mon Sep 17 00:00:00 2001 From: jellologic <31935831+jellologic@users.noreply.github.com> Date: Wed, 22 Jul 2026 10:09:41 -0400 Subject: [PATCH 07/21] feat: rotation cursor, config accounts/agents, and resolution tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Completes the v3 core: round-robin now actually rotates, the two new concepts are addressable from the CLI, and the module that decides which account pays finally has tests. THE CURSOR LIVES OUTSIDE config.json, in the XDG state directory. The launch path writes no config — test/core/overrides.test.ts asserts zero store.save calls — and a rotation counter is not configuration: nobody edits it, nobody backs it up, and losing it costs one repeated account rather than a broken setup. Keeping it out also keeps config.json free of a field that would churn on every launch and appear in every diff a user pastes into a bug report. Every operation in that adapter is best effort, and the consequence is stated rather than hidden: an unwritable state directory stops rotation advancing, which the profile banner makes visible because it names the account. A torn write is not guarded against because the worst outcome is an unparseable file that reads as "no cursor" and is overwritten next launch — unlike the config store, where a torn write would destroy an API key and every write is therefore atomic. A hand-edited negative or fractional cursor restarts the rotation rather than indexing an account list out of range. `config accounts` and `config agents` exist mostly for their REVERSE INDEX. A profile lists its accounts; nothing else could say which profiles an account backs, and that is the question you have before deleting one or rotating a key. `config agents` marks a shared agent profile as shared, because sharing is the capability the split bought and a listing that hid it would leave two identical setups indistinguishable from one. test/core/resolve.test.ts covers the happy path and, at more length, every way resolution degrades: a missing agent profile, an empty account list, a dangling reference (skipped with a warning, not fatal — a profile with three accounts and one stale reference should still launch), rotation with no cursor store, and `usage` with no snapshot. Both degradations warn, because silently always-first looks identical to a working rotation right up until a bill arrives. Verified across four real launches: or-main, or-spare, or-main, or-spare, with the cursor in the state dir and config.json untouched. 625 tests pass; tarball 210.7 kB against the 260 kB ceiling. Signed-off-by: jellologic <31935831+jellologic@users.noreply.github.com> --- src/adapters/store/fs-cursor-store.ts | 100 +++++++++++++ src/composition/config-root.ts | 80 +++++++++++ src/composition/launch-root.ts | 2 + test/adapters/fs-cursor-store.test.ts | 101 ++++++++++++++ test/config-commands.test.ts | 49 +++++++ test/core/resolve.test.ts | 193 ++++++++++++++++++++++++++ 6 files changed, 525 insertions(+) create mode 100644 src/adapters/store/fs-cursor-store.ts create mode 100644 test/adapters/fs-cursor-store.test.ts create mode 100644 test/core/resolve.test.ts diff --git a/src/adapters/store/fs-cursor-store.ts b/src/adapters/store/fs-cursor-store.ts new file mode 100644 index 0000000..a2c04c8 --- /dev/null +++ b/src/adapters/store/fs-cursor-store.ts @@ -0,0 +1,100 @@ +// Where a round-robin cursor is remembered between launches. +// +// DELIBERATELY NOT config.json, and deliberately not the config store. The +// launch path writes no config — `test/core/overrides.test.ts` asserts zero +// `store.save` calls across the whole override matrix — and a rotation counter +// is not configuration: nobody edits it, nobody backs it up, and losing it +// costs one repeated account rather than a broken setup. +// +// So it lives in the STATE directory, which is what XDG has for exactly this: +// data a program regenerates without complaint. Keeping it out of the config +// directory also keeps `config.json` free of a field that would churn on every +// launch and show up in every diff a user pastes into a bug report. + +import { mkdirSync, readFileSync, writeFileSync } from 'node:fs' +import { homedir } from 'node:os' +import { join } from 'node:path' +import type { CursorPort } from '../../core/resolve.ts' + +type ReadableEnv = Record + +/** + * `$XDG_STATE_HOME/swisscode`, or `~/.local/state/swisscode`. + * + * The state spec's fallback is `~/.local/state` on every platform this ships + * for. macOS has no XDG convention of its own and the config store already + * resolves `~/.config` there rather than `~/Library`, so following the same + * rule keeps a user's two swisscode directories siblings instead of scattering + * them by platform. + */ +export function stateDir(env: ReadableEnv = process.env): string { + return join( + env.XDG_STATE_HOME || join(env.HOME || homedir(), '.local', 'state'), + 'swisscode', + ) +} + +export type FsCursorStoreOptions = { + env?: ReadableEnv + dir?: string | null +} + +/** + * Cursors for every profile, in one small JSON file. + * + * EVERY OPERATION IS BEST EFFORT. A read that fails yields null, which + * `selectAccount` treats as "start at the beginning" — a normal state, not an + * error. A write that fails is swallowed entirely: the launch has already been + * decided by the time it happens, and failing a launch because a counter could + * not be persisted would trade a working session for a tidy file. + * + * The consequence is stated rather than hidden: if the directory is unwritable, + * rotation silently stops advancing and every launch uses the same account. + * That is visible in the profile banner, which names the account. + */ +export function createFsCursorStore({ + env = process.env, + dir = null, +}: FsCursorStoreOptions = {}): CursorPort { + const DIR = dir ?? stateDir(env) + const PATH = join(DIR, 'cursors.json') + + function readAll(): Record { + try { + const parsed: unknown = JSON.parse(readFileSync(PATH, 'utf8')) + return parsed && typeof parsed === 'object' && !Array.isArray(parsed) + ? (parsed as Record) + : {} + } catch { + // Absent, unreadable, or not JSON. All three mean "no cursor yet". + return {} + } + } + + return { + read(profileName: string): number | null { + const value = readAll()[profileName] + // A hand-edited or corrupted entry must not index an array out of range, + // so anything that is not a non-negative integer restarts the rotation. + return typeof value === 'number' && Number.isInteger(value) && value >= 0 ? value : null + }, + + advance(profileName: string, next: number): void { + try { + // 0700: the file names PROFILES, which are user-chosen and can carry + // client names, exactly like the binding paths SECURITY.md already + // flags. It holds no credential, but it is nobody else's business. + mkdirSync(DIR, { recursive: true, mode: 0o700 }) + const all = readAll() + all[profileName] = next + // Not atomic, and it does not need to be: the worst outcome of a torn + // write is an unparseable file, which `readAll` treats as "no cursor" + // and the next launch overwrites. Compare the config store, where a + // torn write would destroy an API key and every write is atomic. + writeFileSync(PATH, `${JSON.stringify(all, null, 2)}\n`, { mode: 0o600 }) + } catch { + /* rotation stops advancing; the launch already succeeded */ + } + }, + } +} diff --git a/src/composition/config-root.ts b/src/composition/config-root.ts index 4b9b1a7..f699fd9 100644 --- a/src/composition/config-root.ts +++ b/src/composition/config-root.ts @@ -68,6 +68,8 @@ export type RunConfigCommandOptions = { const SUBCOMMANDS = Object.freeze([ 'list', 'default', 'agent', 'rm', 'use', 'bind', 'unbind', 'bindings', 'doctor', 'help', + 'accounts', + 'agents', ]) const USAGE = `swisscode config — manage profiles and directory bindings @@ -78,6 +80,9 @@ const USAGE = `swisscode config — manage profiles and directory bindings swisscode config default set the profile used when nothing else applies swisscode config rm delete a profile and any bindings to it + swisscode config accounts provider accounts, and which profiles use each + swisscode config agents agent profiles, and which profiles use each + swisscode config agent list agents and which profile uses each swisscode config agent show which coding CLI launches swisscode config agent set the coding CLI (claude-code|kilo|opencode) @@ -161,6 +166,10 @@ export async function runConfigCommand({ return doctorCommand({ deps, args: rest, out, err }) case 'web': return webCommand({ deps, args: rest, out, err }) + case 'accounts': + return listAccounts({ deps, out }) + case 'agents': + return listAgentProfiles({ deps, out }) default: break } @@ -780,3 +789,74 @@ async function webCommand({ return 2 } } + +/** + * `swisscode config accounts` — who pays, and who uses them. + * + * The reverse index is the point. A profile lists its accounts; nothing else + * says which profiles an account backs, and that is the question you have + * before deleting one or rotating a key. + */ +function listAccounts({ deps, out }: { deps: LaunchDeps; out: Emit }): number { + const { state } = deps.store.load() + const names = Object.keys(state.providerAccounts ?? {}).sort() + if (names.length === 0) { + out('No provider accounts yet. Run `swisscode config` to make one.') + return 0 + } + + for (const name of names) { + // `!` — read off Object.keys of this very object. + const a = state.providerAccounts[name]! + const provider = deps.registry.byId(a.provider) + const usedBy = Object.entries(state.profiles ?? {}) + .filter(([, p]) => (p.accounts ?? []).includes(name)) + .map(([n]) => n) + + out(` ${name}${a.label ? ` (${a.label})` : ''}`) + out(` provider ${a.provider}${provider ? '' : ' — not in this build'}`) + if (a.baseUrl) out(` baseUrl ${a.baseUrl}`) + // Presence and ORIGIN only, exactly as `config list` does: a masked key is + // still a fingerprint and this output gets pasted into bug reports. + out(` key ${credentialOrigin(a)}`) + out(` used by ${usedBy.length > 0 ? usedBy.join(', ') : '— nothing'}`) + } + return 0 +} + +/** + * `swisscode config agents` — what runs, and who uses it. + * + * Named for the concept rather than the CLI: `config agent ` + * already existed and still edits which coding CLI a profile launches. This + * lists the agent PROFILES, which is the thing that can now be shared. + */ +function listAgentProfiles({ deps, out }: { deps: LaunchDeps; out: Emit }): number { + const { state } = deps.store.load() + const names = Object.keys(state.agentProfiles ?? {}).sort() + if (names.length === 0) { + out('No agent profiles yet. Run `swisscode config` to make one.') + return 0 + } + + for (const name of names) { + const ap = state.agentProfiles[name]! + const usedBy = Object.entries(state.profiles ?? {}) + .filter(([, p]) => p.agentProfile === name) + .map(([n]) => n) + + out(` ${name}${ap.label ? ` (${ap.label})` : ''}`) + out(` agent ${ap.agent ?? DEFAULT_AGENT_ID}`) + const pinned = TIERS.filter((t) => ap.models?.[t]).map((t) => `${t}=${ap.models![t]}`) + out(` models ${pinned.length > 0 ? pinned.join(' ') : '— provider defaults'}`) + if (ap.skipPermissions) out(' perms --dangerously-skip-permissions by default') + const flags = Object.entries(ap.compat ?? {}).filter(([, on]) => on).map(([f]) => f) + if (flags.length > 0) out(` compat ${flags.join(', ')}`) + // Shared setups are the reason this concept exists, so say when one is. + out( + ` used by ${usedBy.length > 0 ? usedBy.join(', ') : '— nothing'}` + + (usedBy.length > 1 ? ' (shared)' : ''), + ) + } + return 0 +} diff --git a/src/composition/launch-root.ts b/src/composition/launch-root.ts index 1cc5c4d..ee59b03 100644 --- a/src/composition/launch-root.ts +++ b/src/composition/launch-root.ts @@ -12,6 +12,7 @@ import { applyOverrides, retargetProvider } from '../core/overrides.ts' import { resolveProfile } from '../core/profile.ts' import { resolveProfileRefs, type CursorPort } from '../core/resolve.ts' import { createFsConfigStore } from '../adapters/store/fs-config-store.ts' +import { createFsCursorStore } from '../adapters/store/fs-cursor-store.ts' import { createNodeProcess, detectRecursion } from '../adapters/process/node-process.ts' import { registry as providerRegistry } from '../adapters/providers/registry.ts' import { withCustomProviders } from '../adapters/providers/composite.ts' @@ -62,6 +63,7 @@ export function defaultDeps(): LaunchDeps { registry: providerRegistry, agents: agentRegistry, proc: createNodeProcess(), + cursor: createFsCursorStore(), } } diff --git a/test/adapters/fs-cursor-store.test.ts b/test/adapters/fs-cursor-store.test.ts new file mode 100644 index 0000000..6bee772 --- /dev/null +++ b/test/adapters/fs-cursor-store.test.ts @@ -0,0 +1,101 @@ +// The round-robin cursor store. +// +// Everything here is about DEGRADING WELL. The cursor is written after a launch +// has already been decided, so no failure in this adapter may ever propagate — +// the worst it is allowed to do is stop advancing, which the profile banner +// makes visible because it names the account. +import test from 'node:test' +import assert from 'node:assert/strict' +import { chmodSync, mkdtempSync, readFileSync, mkdirSync, statSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { createFsCursorStore, stateDir } from '../../src/adapters/store/fs-cursor-store.ts' + +const fresh = () => { + const dir = join(mkdtempSync(join(tmpdir(), 'swisscode-cursor-')), 'state') + return { dir, store: createFsCursorStore({ dir }) } +} + +test('an absent cursor reads as null, which starts the rotation', () => { + const { store } = fresh() + assert.equal(store.read('anything'), null) +}) + +test('a cursor round-trips', () => { + const { store } = fresh() + store.advance('work', 2) + assert.equal(store.read('work'), 2) +}) + +test('cursors for different profiles do not collide', () => { + const { store } = fresh() + store.advance('a', 1) + store.advance('b', 7) + assert.equal(store.read('a'), 1) + assert.equal(store.read('b'), 7) +}) + +test('it lives OUTSIDE the config directory', () => { + // The launch path writes no config, and a counter that churned on every + // launch would show up in every config diff pasted into a bug report. + const dir = stateDir({ HOME: '/home/u' }) + assert.match(dir, /\.local[/\\]state[/\\]swisscode$/) + assert.doesNotMatch(dir, /\.config/) + // XDG wins when set. + assert.equal(stateDir({ XDG_STATE_HOME: '/xdg/state' }), join('/xdg/state', 'swisscode')) +}) + +test('the file is 0600 in a 0700 directory', () => { + // It names PROFILES, which are user-chosen and can carry client names — the + // same leak SECURITY.md flags for binding paths. No credential, but nobody + // else's business. + const { dir, store } = fresh() + store.advance('work', 1) + assert.equal(statSync(dir).mode & 0o777, 0o700) + assert.equal(statSync(join(dir, 'cursors.json')).mode & 0o777, 0o600) +}) + +test('a corrupt file reads as no cursor rather than throwing', () => { + const { dir, store } = fresh() + mkdirSync(dir, { recursive: true }) + writeFileSync(join(dir, 'cursors.json'), 'not json at all') + assert.equal(store.read('work'), null) + // …and the next write repairs it, because a torn cursor file is worth + // nothing and overwriting it costs nothing. + store.advance('work', 3) + assert.equal(store.read('work'), 3) +}) + +test('a hand-edited nonsense value restarts the rotation', () => { + // It indexes an account array, so a negative or fractional value must never + // reach the caller. + const { dir, store } = fresh() + mkdirSync(dir, { recursive: true }) + for (const bad of ['-1', '1.5', '"two"', 'null', '{}']) { + writeFileSync(join(dir, 'cursors.json'), `{"work": ${bad}}`) + assert.equal(store.read('work'), null, `${bad} was accepted as a cursor`) + } +}) + +test('an unwritable directory stops rotation instead of failing the launch', () => { + // THE CONTRACT. By the time this runs the launch is already decided; throwing + // here would trade a working session for a tidy file. + const parent = mkdtempSync(join(tmpdir(), 'swisscode-cursor-ro-')) + const dir = join(parent, 'state') + mkdirSync(dir) + chmodSync(dir, 0o500) + const store = createFsCursorStore({ dir }) + try { + assert.doesNotThrow(() => store.advance('work', 1)) + assert.equal(store.read('work'), null, 'it degrades to no cursor, which means no rotation') + } finally { + chmodSync(dir, 0o700) + } +}) + +test('the written file is readable JSON, not an opaque blob', () => { + // Someone debugging a rotation should be able to look. + const { dir, store } = fresh() + store.advance('work', 4) + assert.deepEqual(JSON.parse(readFileSync(join(dir, 'cursors.json'), 'utf8')), { work: 4 }) +}) diff --git a/test/config-commands.test.ts b/test/config-commands.test.ts index 3270fcd..91575de 100644 --- a/test/config-commands.test.ts +++ b/test/config-commands.test.ts @@ -459,3 +459,52 @@ test('a profile on a genuinely unknown provider still says so', async () => { await h.run(['list']) assert.match(h.text(), /not in this build/) }) + +// the two concepts the v3 split made addressable + +test('config accounts lists who pays, and the reverse index of who uses them', async () => { + // The reverse index is the point: a profile lists its accounts, but nothing + // else says which profiles an account backs — and that is the question you + // have before deleting one or rotating a key. + const state = STATE() + state.providerAccounts.spare = { provider: 'openrouter', apiKey: 'sk-or-B' } + state.profiles.z!.accounts = ['z', 'spare'] + const h = harness({ state }) + assert.equal(await h.run(['accounts']), 0) + + const text = h.text() + assert.match(text, /spare/) + assert.match(text, /used by\s+z/) + // Presence and origin only — never any part of the value. + assert.match(text, /stored in config\.json/) + assert.ok(!text.includes('sk-or-B'), 'an account key reached the terminal') +}) + +test('config agents marks a shared agent profile as shared', async () => { + // Sharing is the capability the split bought; a listing that did not show it + // would leave the user unable to tell one setup from two identical ones. + const state = STATE() + state.profiles.or!.agentProfile = state.profiles.z!.agentProfile + const h = harness({ state }) + assert.equal(await h.run(['agents']), 0) + assert.match(h.text(), /used by\s+or, z\s+\(shared\)|used by\s+z, or\s+\(shared\)/) +}) + +test('both list commands cope with an empty config rather than printing nothing', async () => { + const empty = { + version: 3, + providerAccounts: {}, + agentProfiles: {}, + profiles: {}, + defaultProfile: null, + bindings: {}, + settings: {}, + } as unknown as State + const a = harness({ state: empty }) + await a.run(['accounts']) + assert.match(a.text(), /No provider accounts yet/) + + const b = harness({ state: empty }) + await b.run(['agents']) + assert.match(b.text(), /No agent profiles yet/) +}) diff --git a/test/core/resolve.test.ts b/test/core/resolve.test.ts new file mode 100644 index 0000000..a9c3857 --- /dev/null +++ b/test/core/resolve.test.ts @@ -0,0 +1,193 @@ +// Resolution: a profile -> the one account + agent profile a launch uses. +// +// This is the step that decides WHICH ACCOUNT PAYS, so it gets exhaustive +// coverage of both the happy path and every way it can degrade. Degrading is +// the interesting half: two of the three strategies can silently fall back, and +// silence about billing is the failure this codebase is arranged around. +import test from 'node:test' +import assert from 'node:assert/strict' +import { resolveProfileRefs, selectAccount } from '../../src/core/resolve.ts' +import type { CursorPort } from '../../src/core/resolve.ts' +import type { State } from '../../src/ports/config-store.ts' + +const state = (over: Partial = {}): State => + ({ + version: 3, + providerAccounts: { + work: { provider: 'openrouter', apiKey: 'or-key' }, + backup: { provider: 'zai', apiKey: 'zai-key' }, + third: { provider: 'anthropic' }, + }, + agentProfiles: { + main: { agent: 'kilo', models: { opus: 'm' }, skipPermissions: true }, + }, + profiles: { + p: { agentProfile: 'main', accounts: ['work'] }, + }, + defaultProfile: 'p', + bindings: {}, + settings: {}, + ...over, + }) as unknown as State + +/** An in-memory cursor, so rotation is observable without touching a disk. */ +function cursorStore(initial: Record = {}): CursorPort & { seen: number[] } { + const store = { ...initial } + const seen: number[] = [] + return { + seen, + read: (name) => (name in store ? store[name]! : null), + advance: (name, next) => { + store[name] = next + seen.push(next) + }, + } +} + +// the happy path + +test('resolution flattens the account and the agent profile together', () => { + const r = resolveProfileRefs(state(), 'p') + assert.ok(r.ok) + // From the account… + assert.equal(r.resolved.provider, 'openrouter') + assert.equal(r.resolved.apiKey, 'or-key') + // …and from the agent profile. + assert.equal(r.resolved.agent, 'kilo') + assert.equal(r.resolved.skipPermissions, true) + assert.deepEqual(r.resolved.models, { opus: 'm' }) + // Both names travel with it, so a caller can REPORT which account paid. + assert.equal(r.resolved.accountName, 'work') + assert.equal(r.resolved.agentProfileName, 'main') +}) + +test('one agent profile can back several profiles', () => { + // The point of the split: a shared setup is expressed by reference, not by + // duplicating models and permissions into every profile that wants them. + const s = state({ + profiles: { + a: { agentProfile: 'main', accounts: ['work'] }, + b: { agentProfile: 'main', accounts: ['backup'] }, + }, + } as Partial) + const a = resolveProfileRefs(s, 'a') + const b = resolveProfileRefs(s, 'b') + assert.ok(a.ok && b.ok) + assert.equal(a.resolved.provider, 'openrouter') + assert.equal(b.resolved.provider, 'zai') + assert.deepEqual(a.resolved.models, b.resolved.models, 'the shared setup really is shared') +}) + +// every reference failure names the fix + +test('a missing agent profile is refused with the repair named', () => { + const s = state({ profiles: { p: { agentProfile: 'gone', accounts: ['work'] } } } as Partial) + const r = resolveProfileRefs(s, 'p') + assert.equal(r.ok, false) + assert.match(r.reason, /agent profile "gone"/) + assert.match(r.reason, /swisscode config p/, 'the message must say what to run') +}) + +test('a profile with no accounts is refused rather than defaulted', () => { + // Picking "some other account" would be choosing who to bill. + const s = state({ profiles: { p: { agentProfile: 'main', accounts: [] } } } as Partial) + const r = resolveProfileRefs(s, 'p') + assert.equal(r.ok, false) + assert.match(r.reason, /no provider account/) +}) + +test('a dangling account is skipped with a warning, not fatal', () => { + // A profile with three accounts and one stale reference should still launch + // on the other two. + const s = state({ + profiles: { p: { agentProfile: 'main', accounts: ['gone', 'backup'] } }, + } as Partial) + const r = resolveProfileRefs(s, 'p') + assert.ok(r.ok) + assert.equal(r.resolved.accountName, 'backup') + assert.match(r.warnings.join(' '), /"gone".*no longer exists/) +}) + +test('when every account is dangling it refuses and says how many', () => { + const s = state({ + profiles: { p: { agentProfile: 'main', accounts: ['gone', 'also-gone'] } }, + } as Partial) + const r = resolveProfileRefs(s, 'p') + assert.equal(r.ok, false) + assert.match(r.reason, /2 provider account/) +}) + +// strategies + +test('single takes the first account and says nothing', () => { + const picked = selectAccount('p', ['work', 'backup'], 'single') + assert.equal(picked.name, 'work') + assert.deepEqual(picked.warnings, []) +}) + +test('round-robin advances once per launch and wraps', () => { + const cursor = cursorStore() + const accounts = ['a', 'b', 'c'] + const order = [0, 1, 2, 3, 4].map(() => selectAccount('p', accounts, 'round-robin', { cursor }).name) + assert.deepEqual(order, ['a', 'b', 'c', 'a', 'b'], 'rotation must be sequential and wrap') + assert.deepEqual(cursor.seen, [0, 1, 2, 0, 1]) +}) + +test('round-robin with no cursor store degrades LOUDLY', () => { + // Silently always-first would look identical to a working rotation from the + // outside, right up until a bill arrives. + const picked = selectAccount('p', ['a', 'b'], 'round-robin') + assert.equal(picked.name, 'a') + assert.match(picked.warnings.join(' '), /no cursor store/) + assert.match(picked.warnings.join(' '), /same account/) +}) + +test('a corrupt cursor restarts the rotation rather than indexing out of range', () => { + const cursor = cursorStore({ p: 99 }) + const picked = selectAccount('p', ['a', 'b'], 'round-robin', { cursor }) + assert.ok(['a', 'b'].includes(picked.name), 'must stay inside the account list') +}) + +test('usage picks the account with the most remaining, and reports the age', () => { + const picked = selectAccount('p', ['a', 'b', 'c'], 'usage', { + usage: { remaining: { a: 10, b: 900, c: 40 }, checkedAt: 0 }, + now: 600_000, + }) + assert.equal(picked.name, 'b') + assert.match(picked.warnings.join(' '), /10 minute/) + // The honesty that matters: it cannot know the CURRENT figure. + assert.match(picked.warnings.join(' '), /as fresh as the last check/) +}) + +test('usage ties keep the order the user wrote', () => { + const picked = selectAccount('p', ['a', 'b'], 'usage', { + usage: { remaining: { a: 100, b: 100 }, checkedAt: 0 }, + }) + assert.equal(picked.name, 'a', 'the listed order is the tiebreak, not object-key order') +}) + +test('usage with no snapshot falls back to single AND SAYS SO', () => { + // The launch path may not reach the network, so this is the ordinary state on + // a machine where the doctor has never run — it must not look like a choice. + const picked = selectAccount('p', ['a', 'b'], 'usage') + assert.equal(picked.name, 'a') + assert.match(picked.warnings.join(' '), /nothing has measured it yet/) + assert.match(picked.warnings.join(' '), /config doctor/) +}) + +test('usage ignores accounts nothing has measured', () => { + const picked = selectAccount('p', ['unmeasured', 'measured'], 'usage', { + usage: { remaining: { measured: 5 }, checkedAt: 0 }, + }) + assert.equal(picked.name, 'measured') +}) + +test('a single-account profile short-circuits every strategy', () => { + // No cursor is consulted and no warning is produced: with one account there + // is nothing to choose, whatever the strategy claims. + for (const strategy of ['single', 'round-robin', 'usage'] as const) { + const picked = selectAccount('p', ['only'], strategy) + assert.equal(picked.name, 'only') + assert.deepEqual(picked.warnings, [], `${strategy} warned about a choice it did not make`) + } +}) From f1e275457be1cc749b73f25cfb313b89be254888 Mon Sep 17 00:00:00 2001 From: jellologic <31935831+jellologic@users.noreply.github.com> Date: Wed, 22 Jul 2026 10:11:28 -0400 Subject: [PATCH 08/21] feat: provider usage capability (OpenRouter), verified against the live API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The "tools the AI provider gives us" half of the `usage` selection strategy. CONFIGURATION-TIME ONLY: the launch path may not reach the network, so nothing here is ever called during a launch — the doctor and the web UI call it, and core/resolve.ts reads what they cached. Verified against the live service before writing the adapter: OpenRouter serves GET {baseUrl}/v1/key with limit / limit_remaining / usage. The URL composes from the descriptor's own base URL so the two cannot drift. It is the ONLY usage adapter shipped, and that is deliberate rather than unfinished. No other preset was confirmed to publish anything equivalent, and a provider that reports nothing must report nothing rather than a plausible zero — the standard REJECTED_PROVIDERS and the Ollama work were held to. A test asserts exactly one provider declares `usageId`, so adding a speculative second one fails. The nullability is load-bearing. OpenRouter uses `null` for "no limit", so coercing it to 0 would make an UNCAPPED key rank LAST under a strategy that picks the account with the most remaining — exactly backwards. A payload with nothing usable returns null rather than a row of nulls, because "this provider does not publish usage" and "this account has no cap" are different answers and only one is worth rendering. test/architecture.test.ts now names adapters/usage alongside adapters/ui and adapters/catalog in the launch-path ban. An adapter that crept onto that path would quietly turn every launch into an HTTP request to a billing endpoint. 632 tests pass. Signed-off-by: jellologic <31935831+jellologic@users.noreply.github.com> --- src/adapters/providers/openrouter.ts | 3 + src/adapters/usage/openrouter.ts | 82 ++++++++++++++++++++++++++++ src/ports/provider-usage.ts | 73 +++++++++++++++++++++++++ src/ports/provider.ts | 9 +++ test/adapters/usage.test.ts | 68 +++++++++++++++++++++++ test/architecture.test.ts | 7 ++- 6 files changed, 241 insertions(+), 1 deletion(-) create mode 100644 src/adapters/usage/openrouter.ts create mode 100644 src/ports/provider-usage.ts create mode 100644 test/adapters/usage.test.ts diff --git a/src/adapters/providers/openrouter.ts b/src/adapters/providers/openrouter.ts index 57f56b5..8fab540 100644 --- a/src/adapters/providers/openrouter.ts +++ b/src/adapters/providers/openrouter.ts @@ -18,6 +18,9 @@ export const openrouter = { // Has a queryable catalog, so the wizard offers a browsable picker instead of // asking you to type model ids from memory. catalogId: 'openrouter', + // Verified against the live service: GET {baseUrl}/v1/key reports + // limit / limit_remaining / usage. See adapters/usage/openrouter.ts. + usageId: 'openrouter', // OpenRouter has no notion of the opus/sonnet/haiku tiers, so subagents need // to be pinned explicitly or they fall back to a model that 404s. subagentFollowsOpus: true, diff --git a/src/adapters/usage/openrouter.ts b/src/adapters/usage/openrouter.ts new file mode 100644 index 0000000..2c255be --- /dev/null +++ b/src/adapters/usage/openrouter.ts @@ -0,0 +1,82 @@ +// OpenRouter's key endpoint, as a usage source. +// +// VERIFIED against the live service before this was written: OpenRouter serves +// `GET {baseUrl}/v1/key` returning a `data` object with `limit`, +// `limit_remaining`, `usage`, and `usage_daily|weekly|monthly`. It composes +// from the descriptor's own base URL (`https://openrouter.ai/api`), so no +// second endpoint constant has to be kept in step with the first. +// +// It is the ONLY usage adapter shipped, and that is deliberate rather than +// unfinished: the other presets were not confirmed to publish anything +// equivalent, and a provider that reports nothing must report nothing rather +// than a plausible zero. That is the same standard REJECTED_PROVIDERS and the +// Ollama work were held to. + +import type { ProviderUsage, ProviderUsagePort } from '../../ports/provider-usage.ts' + +/** Composed from the provider's base URL, not hard-coded, so the two agree. */ +export function keyUrl(baseUrl: string): string { + return `${baseUrl.replace(/\/+$/, '')}/v1/key` +} + +function isObjectLike(v: unknown): v is Record { + return !!v && typeof v === 'object' && !Array.isArray(v) +} + +/** + * A finite number, or null. + * + * `null` is a REAL value in this payload — OpenRouter uses it for "no limit" — + * so it must survive as null rather than becoming 0. An uncapped key with + * `remaining: 0` would be ranked last by the `usage` strategy, which is exactly + * backwards. + */ +function num(v: unknown): number | null { + return typeof v === 'number' && Number.isFinite(v) ? v : null +} + +/** Extract usage from whatever the endpoint returned. Exported for testing. */ +export function parseKeyResponse(body: unknown, checkedAt: number): ProviderUsage | null { + const data = isObjectLike(body) && isObjectLike(body.data) ? body.data : null + if (!data) return null + + const usage: ProviderUsage = { + remaining: num(data.limit_remaining), + limit: num(data.limit), + used: num(data.usage), + // Credits, which OpenRouter prices in dollars — but the field is for + // display and nothing branches on it, so it is not asserted as 'usd'. + unit: 'credits', + checkedAt, + } + // Nothing usable at all is null, not a row of nulls: a caller should be able + // to tell "this provider does not publish usage" from "this account has no + // limit", and only one of those is worth showing. + if (usage.remaining === null && usage.limit === null && usage.used === null) return null + return usage +} + +export function createOpenRouterUsage(now: () => number = () => Date.now()): ProviderUsagePort { + return { + id: 'openrouter', + async fetch({ baseUrl, credential, timeoutMs = 4000 }) { + const controller = new AbortController() + const timer = setTimeout(() => controller.abort(), timeoutMs) + try { + const res = await globalThis.fetch(keyUrl(baseUrl), { + headers: { authorization: `Bearer ${credential}` }, + signal: controller.signal, + }) + if (!res.ok) return null + return parseKeyResponse(await res.json(), now()) + } catch { + // Down, rate-limited, offline, or the shape changed. All are findings + // for the caller to render, none is an exception worth unwinding a + // configuration screen with. + return null + } finally { + clearTimeout(timer) + } + }, + } +} diff --git a/src/ports/provider-usage.ts b/src/ports/provider-usage.ts new file mode 100644 index 0000000..57ac00f --- /dev/null +++ b/src/ports/provider-usage.ts @@ -0,0 +1,73 @@ +// Port: what a provider can tell you about your own remaining capacity. +// +// This is the "tools the AI provider gives us" half of the `usage` selection +// strategy, and it is CONFIGURATION-TIME ONLY. The launch path may not reach +// the network — test/architecture.test.ts bans fetch and node:http there by +// name — so nothing here is ever called during a launch. The doctor and the web +// UI call it, cache what it says, and `core/resolve.ts` reads that cache. +// +// Modelled on the Ollama introspection port in ports/doctor.ts: a small +// contract, an adapter only for providers whose endpoint has been VERIFIED +// against the live service, and `null` everywhere the fact is unknown. A +// provider that publishes nothing reports nothing rather than zero. +// +// Type-only, like every port: `export {}` at runtime. + +/** + * Remaining capacity for one account. + * + * EVERY FIELD IS NULLABLE and none is defaulted, because providers disagree + * about what they publish and a missing figure is not a zero. An account on a + * plan with no cap has a real `null` limit, which is a different fact from an + * endpoint that declines to say. + * + * `remaining` is what `usage` selection ranks on. When it is null the account + * is simply not a candidate — better to skip it than to invent a number and + * route real money by it. + */ +export type ProviderUsage = { + /** credits or budget left, in the provider's own unit; null = unknown or uncapped */ + remaining: number | null + /** the cap `remaining` counts down from; null = unknown or uncapped */ + limit: number | null + /** spent so far, if published */ + used: number | null + /** + * What the numbers are denominated in — 'usd', 'credits', 'tokens'. Free + * text because it is for DISPLAY only; nothing branches on it, and inventing + * an enum would force every future provider into one of today's guesses. + */ + unit: string | null + /** epoch ms when this was measured, so a consumer can say how stale it is */ + checkedAt: number +} + +/** + * A snapshot across accounts, which is what gets cached and what `resolve.ts` + * consumes. Keyed by ACCOUNT name, not provider: two accounts on the same + * provider have separate balances, and telling them apart is the entire reason + * this strategy exists. + */ +export type UsageSnapshot = { + remaining: Record + checkedAt: number +} + +/** + * One provider's usage endpoint. + * + * NEVER REJECTS, for the same reason the doctor's probe does not: a provider + * that is down, rate-limiting, or has changed its API is a finding to report, + * not an exception to unwind a configuration screen with. + */ +export type ProviderUsagePort = { + id: string + /** null when the endpoint answered but published nothing usable */ + fetch: (req: { + baseUrl: string + credential: string + timeoutMs?: number + }) => Promise +} + +export {} diff --git a/src/ports/provider.ts b/src/ports/provider.ts index 29fa383..65d4855 100644 --- a/src/ports/provider.ts +++ b/src/ports/provider.ts @@ -137,6 +137,15 @@ export type ProviderDescriptor = { extendedContext?: ExtendedContext /** id of a ModelCatalogPort, or null when this provider publishes no catalog */ catalogId?: string | null + /** + * id of a ProviderUsagePort, or absent when this provider publishes no + * remaining-capacity endpoint — which is most of them. + * + * Absent means UNKNOWN, never zero. Only wire this for a provider whose + * endpoint has been verified against the live service; a speculative entry + * would route real money by a number nobody checked. + */ + usageId?: string | null subagentFollowsOpus?: boolean hints?: ProviderHints } diff --git a/test/adapters/usage.test.ts b/test/adapters/usage.test.ts new file mode 100644 index 0000000..a43e211 --- /dev/null +++ b/test/adapters/usage.test.ts @@ -0,0 +1,68 @@ +// The provider usage capability. +// +// Every payload here matches OpenRouter's documented `GET /v1/key` shape. The +// tests are mostly about what happens when a figure is ABSENT, because a +// missing number and a zero are different facts and this one ranks accounts by +// remaining money. +import test from 'node:test' +import assert from 'node:assert/strict' +import { keyUrl, parseKeyResponse } from '../../src/adapters/usage/openrouter.ts' +import { registry } from '../../src/adapters/providers/registry.ts' + +test('the usage URL composes from the provider base URL', () => { + // Composed rather than hard-coded so the two cannot drift; the provider base + // URL is a bare host because Claude Code appends /v1/messages itself. + const openrouter = registry.byId('openrouter')! + assert.equal(keyUrl(openrouter.baseUrl!), 'https://openrouter.ai/api/v1/key') + assert.equal(keyUrl('https://x.example/api/'), 'https://x.example/api/v1/key') +}) + +test('a full payload becomes a usage record', () => { + const u = parseKeyResponse( + { data: { limit: 100, limit_remaining: 42.5, usage: 57.5, usage_daily: 3 } }, + 1000, + ) + assert.ok(u) + assert.equal(u.remaining, 42.5) + assert.equal(u.limit, 100) + assert.equal(u.used, 57.5) + assert.equal(u.checkedAt, 1000) +}) + +test('a null limit survives as null, never as zero', () => { + // OpenRouter uses null for "no limit". Coercing it to 0 would make an + // UNCAPPED key rank LAST under the usage strategy — exactly backwards. + const u = parseKeyResponse({ data: { limit: null, limit_remaining: null, usage: 12 } }, 1) + assert.ok(u) + assert.equal(u.limit, null) + assert.equal(u.remaining, null) + assert.equal(u.used, 12) +}) + +test('a payload with nothing usable is null, not a row of nulls', () => { + // "This provider does not publish usage" and "this account has no limit" are + // different answers, and only one of them is worth rendering. + assert.equal(parseKeyResponse({ data: {} }, 1), null) + assert.equal(parseKeyResponse({ data: { label: 'my key' } }, 1), null) +}) + +test('an unexpected shape is null rather than a throw', () => { + for (const body of [null, undefined, 'nope', [], { notData: 1 }, { data: 'string' }]) { + assert.equal(parseKeyResponse(body, 1), null) + } +}) + +test('non-finite numbers are refused', () => { + // NaN would sort unpredictably against real balances. + const u = parseKeyResponse({ data: { limit_remaining: Number.NaN, usage: Infinity } }, 1) + assert.equal(u, null) +}) + +test('only providers with a VERIFIED endpoint declare one', () => { + // The standard REJECTED_PROVIDERS and the Ollama work were held to: a + // speculative entry here would route real money by a number nobody checked. + const withUsage = registry.all().filter((p) => p.usageId) + assert.deepEqual(withUsage.map((p) => p.id), ['openrouter']) + // …and the id must name an adapter that exists. + assert.equal(withUsage[0]!.usageId, 'openrouter') +}) diff --git a/test/architecture.test.ts b/test/architecture.test.ts index bceb3d4..043af92 100644 --- a/test/architecture.test.ts +++ b/test/architecture.test.ts @@ -104,12 +104,17 @@ test('the published bin shim is dependency-free and runs compiled output', () => ) }) -test('the launch path never statically reaches adapters/ui or adapters/catalog', () => { +test('the launch path never statically reaches adapters/ui, catalog or usage', () => { const { files } = launchClosure() for (const f of files) { const rel = relative(ROOT, f) assert.ok(!rel.startsWith('src/adapters/ui'), `${rel} is a UI adapter`) assert.ok(!rel.startsWith('src/adapters/catalog'), `${rel} is a catalog adapter`) + // Usage endpoints are CONFIGURATION-TIME only. The `usage` selection + // strategy reads a cached snapshot precisely because the launch path may + // not reach the network, and an adapter that crept onto it would quietly + // turn every launch into an HTTP request to a billing endpoint. + assert.ok(!rel.startsWith('src/adapters/usage'), `${rel} is a usage adapter`) } }) From 8f1ccca9aff84014a2cb5d65c14ed4ce205f2a62 Mon Sep 17 00:00:00 2001 From: jellologic <31935831+jellologic@users.noreply.github.com> Date: Wed, 22 Jul 2026 10:16:56 -0400 Subject: [PATCH 09/21] feat(web): SPA screens for accounts and agent profiles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The browser UI now expresses all three concepts instead of the two-tab shape that predated the split. Tabs are ordered as the concepts compose: who pays, what runs, then the pairing. ACCOUNTS is the only screen that touches a credential, which is itself a benefit of the split — there is one type to get right rather than one field on a type carrying everything else. Write-only as before: the server sends `hasKey` and never the key, blank leaves the stored one alone, and clearing is a separate explicit action. AGENT PROFILES has no password field and no redaction to think about, because it holds no credential. It is the thing that can be SHARED, so the listing marks a shared setup and the editor warns before you change one that several profiles depend on. Its model picker borrows a provider from a profile that uses the setup — and when nothing does, it says there is no catalog to browse rather than offering a picker over a list it cannot obtain. PROFILES is now the pairing, and the only screen expressing MULTIPLE accounts. The strategy selector appears only once a second account is attached, and each option states its ceiling in the UI: round-robin advances per LAUNCH, not per request, because swisscode hands off and exits; `usage` reads the last measurement because it cannot check at launch. Better said in the place someone is choosing than in a doc they will not open. All three listings carry the REVERSE INDEX — which profiles use this account, which use this setup. A profile lists its references; only these views can answer the question you actually have before deleting one or rotating a key. Deleting reports what it breaks and repairs nothing: only the user knows where a profile should point next. Verified in a browser end to end: the three screens render, an account and an agent profile are created through the new routes, a profile naming a missing agent profile is refused with `no agent profile named "nope"`, a valid pairing is created, deleting an account reports the two profiles it affects, and a planted key canary appears in neither the payload nor the DOM. Also fixed while testing: `config list` counted a DANGLING account reference as "+1 more", promising a rotation partner that does not exist — confidently wrong about billing, which is the one thing that listing must not be. It now counts live accounts and prints the resolution warning, so a stale reference is visible instead of waiting to be found in the JSON. 632 tests pass; tarball 213.8 kB against the 260 kB ceiling. Signed-off-by: jellologic <31935831+jellologic@users.noreply.github.com> --- src/composition/config-root.ts | 11 +- web/src/App.tsx | 9 +- web/src/api.ts | 54 ++++- web/src/routes/Accounts.tsx | 236 ++++++++++++++++++++ web/src/routes/AgentProfiles.tsx | 299 ++++++++++++++++++++++++++ web/src/routes/Profiles.tsx | 356 ++++++++++++------------------- 6 files changed, 740 insertions(+), 225 deletions(-) create mode 100644 web/src/routes/Accounts.tsx create mode 100644 web/src/routes/AgentProfiles.tsx diff --git a/src/composition/config-root.ts b/src/composition/config-root.ts index f699fd9..133b8e4 100644 --- a/src/composition/config-root.ts +++ b/src/composition/config-root.ts @@ -374,12 +374,21 @@ function listProfiles({ deps, out }: { deps: LaunchDeps; out: Emit }): number { // The three-way structure, named. Which account pays is the most // consequential line here, so it is first and it is never elided. - const others = (p.accounts ?? []).filter((a) => a !== r.accountName) + // + // LIVE accounts only. Counting a dangling reference as "+1 more" would + // promise a rotation partner that does not exist — confidently wrong about + // billing, which is the one thing this listing must not be. + const others = (p.accounts ?? []).filter( + (a) => a !== r.accountName && state.providerAccounts?.[a], + ) out( ` account ${r.accountName} → ${r.provider}` + (provider ? '' : ' — not in this build') + (others.length ? ` (+${others.length} more, ${p.strategy ?? 'single'})` : ''), ) + // Resolution warnings say what was skipped and why; swallowing them here + // would leave a stale reference invisible until someone read the JSON. + for (const w of resolution.warnings) out(` ⚠ ${w}`) out(` agent ${r.agentProfileName} → ${r.agent ?? DEFAULT_AGENT_ID}`) if (r.baseUrl) out(` baseUrl ${r.baseUrl}`) // Presence and ORIGIN only. Never a prefix, never a suffix, never a length: diff --git a/web/src/App.tsx b/web/src/App.tsx index abf3e5d..b2e4d33 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -3,13 +3,18 @@ import { css } from '../styled-system/css' import { ApiError, api, type Bootstrap } from './api' import { Banner, Dot } from './ui' import { Profiles } from './routes/Profiles' +import { Accounts } from './routes/Accounts' +import { AgentProfiles } from './routes/AgentProfiles' import { Providers } from './routes/Providers' import { Settings } from './routes/Settings' import { Doctor } from './routes/Doctor' -type Tab = 'profiles' | 'providers' | 'doctor' | 'settings' +type Tab = 'profiles' | 'accounts' | 'agentProfiles' | 'providers' | 'doctor' | 'settings' const TABS: { id: Tab; label: string }[] = [ + // Ordered as the concepts compose: who pays, what runs, then the pairing. + { id: 'accounts', label: 'Accounts' }, + { id: 'agentProfiles', label: 'Agent profiles' }, { id: 'profiles', label: 'Profiles' }, { id: 'providers', label: 'Providers' }, { id: 'doctor', label: 'Doctor' }, @@ -136,6 +141,8 @@ export function App() { ))} + {tab === 'accounts' ? : null} + {tab === 'agentProfiles' ? : null} {tab === 'profiles' ? : null} {tab === 'providers' ? : null} {tab === 'doctor' ? : null} diff --git a/web/src/api.ts b/web/src/api.ts index 1fb28cf..eec7722 100644 --- a/web/src/api.ts +++ b/web/src/api.ts @@ -52,13 +52,25 @@ export type InstalledAgent = { error: string | null } -/** A profile as the browser is allowed to see it: `hasKey`, never the key. */ -export type Profile = { +/** + * WHO PAYS, as the browser is allowed to see it: `hasKey`, never the key. + * + * Only one of the three shapes is security-sensitive, and this is it — which is + * itself a benefit of the split, since there is one type to get right rather + * than one field on a type carrying everything else. + */ +export type ProviderAccount = { provider: string - agent?: string + label?: string baseUrl?: string hasKey: boolean apiKeyFromEnv?: string +} + +/** WHAT RUNS. Holds no credential, so it crosses whole. */ +export type AgentProfile = { + agent?: string + label?: string models?: Record compat?: Record env?: Record @@ -66,6 +78,16 @@ export type Profile = { skipPermissions?: boolean } +export type SelectionStrategy = 'single' | 'round-robin' | 'usage' + +/** THE PAIRING. References plus the rule for choosing among them. */ +export type Profile = { + label?: string + agentProfile: string + accounts: string[] + strategy?: SelectionStrategy +} + export type CustomProvider = { id: string label: string @@ -82,6 +104,8 @@ export type CustomProvider = { export type Bootstrap = { state: { + providerAccounts: Record + agentProfiles: Record profiles: Record defaultProfile: string | null bindings: Record @@ -173,6 +197,30 @@ export type CatalogResult = { export const api = { bootstrap: () => call('/api/bootstrap'), + saveAccount: (name: string, account: unknown, revision: string | null) => + call<{ revision: string }>(`/api/accounts/${encodeURIComponent(name)}`, { + method: 'PUT', + body: JSON.stringify({ revision, account }), + }), + + deleteAccount: (name: string, revision: string | null) => + call<{ revision: string; affectedProfiles: string[] }>( + `/api/accounts/${encodeURIComponent(name)}`, + { method: 'DELETE', body: JSON.stringify({ revision }) }, + ), + + saveAgentProfile: (name: string, agentProfile: unknown, revision: string | null) => + call<{ revision: string }>(`/api/agent-profiles/${encodeURIComponent(name)}`, { + method: 'PUT', + body: JSON.stringify({ revision, agentProfile }), + }), + + deleteAgentProfile: (name: string, revision: string | null) => + call<{ revision: string; affectedProfiles: string[] }>( + `/api/agent-profiles/${encodeURIComponent(name)}`, + { method: 'DELETE', body: JSON.stringify({ revision }) }, + ), + saveProfile: (name: string, profile: unknown, revision: string | null) => call<{ revision: string }>(`/api/profiles/${encodeURIComponent(name)}`, { method: 'PUT', diff --git a/web/src/routes/Accounts.tsx b/web/src/routes/Accounts.tsx new file mode 100644 index 0000000..ed01568 --- /dev/null +++ b/web/src/routes/Accounts.tsx @@ -0,0 +1,236 @@ +import { useState } from 'react' +import { css } from '../../styled-system/css' +import { ApiError, api, type Bootstrap } from '../api' +import { Banner, Button, Dot, Empty, Field, Panel, inputStyle, monoInput } from '../ui' + +/** + * Provider accounts — who pays. + * + * The credential is WRITE-ONLY, and this is the only screen that touches one. + * The server sends `hasKey` and never the key, so the field offers to replace + * what is stored: leaving it blank changes nothing, and clearing it is a + * separate, explicit action. "I did not touch this" and "delete my credential" + * must not be the same gesture. + * + * Deleting shows which profiles the account backs rather than repairing them. + * Only the user knows which account should pay instead. + */ +export function Accounts({ data, reload }: { data: Bootstrap; reload: () => Promise }) { + const accounts = Object.entries(data.state.providerAccounts ?? {}) + const [editing, setEditing] = useState(null) + const [draft, setDraft] = useState>({}) + const [error, setError] = useState(null) + const [warnings, setWarnings] = useState([]) + + const open = (name: string | null) => { + setError(null) + setWarnings([]) + setEditing(name ?? '') + setDraft( + name + ? { ...data.state.providerAccounts[name], apiKey: '' } + : { provider: data.providers[0]?.id ?? 'anthropic', apiKey: '' }, + ) + } + + const save = async (name: string) => { + setError(null) + try { + const body: Record = { ...draft } + // An empty string means "untouched", so it never leaves the browser. The + // server ignores it anyway; not sending it makes the intent explicit at + // the boundary that owns it. + if (!body.apiKey) delete body.apiKey + delete body.hasKey + await api.saveAccount(name, body, data.revision) + setEditing(null) + await reload() + } catch (err) { + setError(err instanceof ApiError ? err.message : String(err)) + } + } + + const remove = async (name: string) => { + setError(null) + try { + const res = await api.deleteAccount(name, data.revision) + if (res.affectedProfiles.length > 0) { + setWarnings([ + `These profiles still reference "${name}" and will not launch until you repoint ` + + `them: ${res.affectedProfiles.join(', ')}`, + ]) + } + await reload() + } catch (err) { + setError(err instanceof ApiError ? err.message : String(err)) + } + } + + const field = (k: string) => (draft[k] as string) ?? '' + const put = (k: string, v: unknown) => setDraft((d) => ({ ...d, [k]: v })) + + if (editing !== null) { + const isNew = !data.state.providerAccounts?.[editing] + const provider = data.providers.find((p) => p.id === draft.provider) + const stored = data.state.providerAccounts?.[editing] + return ( + <> +
+ +

+ {isNew ? 'New account' : `Account · ${editing}`} +

+
+ {error ? {error} : null} + + + {isNew ? ( + + setEditing(e.target.value)} + placeholder="work" + /> + + ) : null} + + put('label', e.target.value)} /> + + + + + {provider?.askBaseUrl || draft.baseUrl ? ( + + put('baseUrl', e.target.value)} + placeholder={provider?.baseUrl ?? 'https://…'} + /> + + ) : null} + + + + + put('apiKey', e.target.value)} + placeholder={stored?.hasKey ? '•••••••• stored' : 'paste key'} + /> + + + put('apiKeyFromEnv', e.target.value)} + placeholder="MY_TOKEN" + /> + + + +
+ + +
+ + ) + } + + return ( + <> +
+

Accounts

+ +
+ {error ? {error} : null} + {warnings.map((w) => ( + + {w} + + ))} + + + {accounts.length === 0 ? ( + No accounts yet. An account is a provider plus the credential that pays for it. + ) : ( + accounts.map(([name, a]) => { + // The reverse index: a profile lists its accounts, but only this + // view can say which profiles an account backs — the question you + // have before deleting one or rotating a key. + const usedBy = Object.entries(data.state.profiles ?? {}) + .filter(([, p]) => (p.accounts ?? []).includes(name)) + .map(([n]) => n) + return ( +
+ +
+
+ {name} + {a.label ? ( + + {a.label} + + ) : null} +
+
+ {a.provider} + {' · '} + {a.apiKeyFromEnv ? `key from $${a.apiKeyFromEnv}` : a.hasKey ? 'key stored' : 'no key'} + {' · '} + {usedBy.length > 0 ? `used by ${usedBy.join(', ')}` : 'unused'} +
+
+ + +
+ ) + }) + )} +
+ + ) +} diff --git a/web/src/routes/AgentProfiles.tsx b/web/src/routes/AgentProfiles.tsx new file mode 100644 index 0000000..54ff3d0 --- /dev/null +++ b/web/src/routes/AgentProfiles.tsx @@ -0,0 +1,299 @@ +import { useState } from 'react' +import { css } from '../../styled-system/css' +import { ApiError, api, type Bootstrap } from '../api' +import { Banner, Button, Dot, Empty, Field, Panel, inputStyle, monoInput } from '../ui' +import { ModelPicker } from './ModelPicker' + +/** + * Agent profiles — what runs. + * + * Holds no credential, which is why this screen has no password field and no + * redaction to think about. It is also the thing that can be SHARED: one setup + * ("Claude Code, yolo, glm on every tier") backing several profiles, each + * pointed at a different account. The listing says when one is shared, because + * editing a shared setup changes every profile that uses it. + * + * The model picker needs a provider to browse, and an agent profile has none — + * so it borrows one from a profile that uses this setup. When nothing does, + * there is no catalog to offer and the fields stay plain text, which is the + * honest answer rather than a picker over a list we cannot obtain. + */ +export function AgentProfiles({ data, reload }: { data: Bootstrap; reload: () => Promise }) { + const agentProfiles = Object.entries(data.state.agentProfiles ?? {}) + const [editing, setEditing] = useState(null) + const [draft, setDraft] = useState>({}) + const [error, setError] = useState(null) + const [warnings, setWarnings] = useState([]) + const [picking, setPicking] = useState(null) + + const open = (name: string | null) => { + setError(null) + setWarnings([]) + setEditing(name ?? '') + setDraft(name ? { ...data.state.agentProfiles[name] } : { models: {}, compat: {} }) + } + + const save = async (name: string) => { + setError(null) + try { + await api.saveAgentProfile(name, draft, data.revision) + setEditing(null) + await reload() + } catch (err) { + setError(err instanceof ApiError ? err.message : String(err)) + } + } + + const remove = async (name: string) => { + setError(null) + try { + const res = await api.deleteAgentProfile(name, data.revision) + if (res.affectedProfiles.length > 0) { + setWarnings([ + `These profiles still reference "${name}" and will not launch until you repoint ` + + `them: ${res.affectedProfiles.join(', ')}`, + ]) + } + await reload() + } catch (err) { + setError(err instanceof ApiError ? err.message : String(err)) + } + } + + const put = (k: string, v: unknown) => setDraft((d) => ({ ...d, [k]: v })) + const models = (draft.models as Record) ?? {} + const compat = (draft.compat as Record) ?? {} + + /** Which profiles use a given agent profile — the reverse index. */ + const usersOf = (name: string) => + Object.entries(data.state.profiles ?? {}) + .filter(([, p]) => p.agentProfile === name) + .map(([n]) => n) + + if (editing !== null) { + const isNew = !data.state.agentProfiles?.[editing] + const users = usersOf(editing) + // Borrow a provider from a profile that uses this setup, purely so the + // picker has a catalog to browse. + const viaProfile = users.map((n) => data.state.profiles[n]).find(Boolean) + const viaAccount = viaProfile?.accounts?.[0] + ? data.state.providerAccounts?.[viaProfile.accounts[0]] + : undefined + const provider = data.providers.find((p) => p.id === viaAccount?.provider) + + return ( + <> +
+ +

+ {isNew ? 'New agent profile' : `Agent profile · ${editing}`} +

+
+ {error ? {error} : null} + {users.length > 1 ? ( + + Shared by {users.length} profiles ({users.join(', ')}). Changes here affect all of them. + + ) : null} + + + {isNew ? ( + + setEditing(e.target.value)} + placeholder="main" + /> + + ) : null} + + put('label', e.target.value)} + /> + + + + + + + +

+ All four tiers, from one table. Claude Code reads the extended-context marker per + variable, so a tier left out is the bug where three run wide and the fourth silently + does not. Blank inherits the provider default. +

+ {data.tiers.map((tier) => ( + + + put('models', { ...models, [tier]: e.target.value })} + placeholder={provider?.defaultModels?.[tier] ?? '—'} + /> + {provider?.catalogId ? ( + + ) : null} + + + ))} + {!provider ? ( +

+ No profile uses this setup yet, so there is no provider to browse a catalog from. + Type ids by hand, or attach it to a profile first. +

+ ) : null} + + {picking && provider?.catalogId ? ( + setPicking(null)} + onPick={(model) => { + setDraft((d) => ({ + ...d, + models: { ...((d.models as Record) ?? {}), [picking]: model.id }, + // Capture the MEASURED window alongside the id — the only + // moment it is known, and what later lets swisscode set an + // auto-compact window without ever guessing one. + ...(model.context + ? { + contextWindows: { + ...((d.contextWindows as Record) ?? {}), + [model.id]: model.context, + }, + } + : {}), + })) + setPicking(null) + }} + /> + ) : null} +
+ + + + +
+ Gateway compatibility +
+ {data.compatFlags.map((flag) => ( + + ))} +
+ +
+ + +
+ + ) + } + + return ( + <> +
+

Agent profiles

+ +
+ {error ? {error} : null} + {warnings.map((w) => ( + + {w} + + ))} + + + {agentProfiles.length === 0 ? ( + None yet. An agent profile is a coding CLI plus how it should behave. + ) : ( + agentProfiles.map(([name, ap]) => { + const users = usersOf(name) + const pinned = data.tiers.filter((t) => ap.models?.[t]).length + return ( +
+ 0 ? 'ok' : 'faint'} /> +
+
+ {name} + {users.length > 1 ? ( + + shared + + ) : null} +
+
+ {ap.agent ?? 'claude-code'} + {' · '} + {pinned > 0 ? `${pinned}/${data.tiers.length} tiers pinned` : 'provider defaults'} + {' · '} + {users.length > 0 ? `used by ${users.join(', ')}` : 'unused'} +
+
+ + +
+ ) + }) + )} +
+ + ) +} diff --git a/web/src/routes/Profiles.tsx b/web/src/routes/Profiles.tsx index 99c5908..3356971 100644 --- a/web/src/routes/Profiles.tsx +++ b/web/src/routes/Profiles.tsx @@ -1,87 +1,86 @@ import { useState } from 'react' import { css } from '../../styled-system/css' -import { ApiError, api, type Bootstrap, type Profile } from '../api' -import { Banner, Button, Dot, Empty, Field, Panel, inputStyle, monoInput } from '../ui' -import { ModelPicker } from './ModelPicker' +import { ApiError, api, type Bootstrap, type SelectionStrategy } from '../api' +import { Banner, Button, Dot, Empty, Field, Panel, inputStyle } from '../ui' /** - * The profile editor exposes everything the CLI can express: provider, agent, - * all four tiers, permissions, compat flags, extra environment, and the - * measured context windows that drive auto-compaction. + * Profiles — the pairing, and the only screen that expresses MULTIPLE accounts. * - * The credential is WRITE-ONLY. The server sends `hasKey` and never the key, so - * the field shows whether one is stored and offers to replace it — leaving it - * blank changes nothing, which is why "I did not touch this" and "delete my - * credential" are different actions here. + * Holds no credential and no agent settings of its own: it names one agent + * profile, one or more accounts, and the rule for choosing among them. Editing + * what those references point AT happens on the other two screens, which is the + * whole reason the split exists. */ +const STRATEGIES: { id: SelectionStrategy; label: string; note: string }[] = [ + { id: 'single', label: 'Single', note: 'Always the first account. No state, no surprises.' }, + { + id: 'round-robin', + label: 'Round robin', + note: + 'Advances one account per LAUNCH, not per request — swisscode hands off and exits, so ' + + 'there is nothing left to rotate mid-session.', + }, + { + id: 'usage', + label: 'By remaining capacity', + note: + 'Picks the account with the most left, from the last measurement. swisscode cannot check ' + + 'this at launch, so it uses what the doctor or this UI cached — and falls back to the ' + + 'first account, saying so, when nothing has measured it yet.', + }, +] + export function Profiles({ data, reload }: { data: Bootstrap; reload: () => Promise }) { - const names = Object.keys(data.state.profiles) + const names = Object.keys(data.state.profiles ?? {}) + const accountNames = Object.keys(data.state.providerAccounts ?? {}) + const agentProfileNames = Object.keys(data.state.agentProfiles ?? {}) + const [editing, setEditing] = useState(null) const [draft, setDraft] = useState>({}) const [error, setError] = useState(null) - const [errors, setErrors] = useState([]) - const [picking, setPicking] = useState(null) const open = (name: string | null) => { setError(null) - setErrors([]) setEditing(name ?? '') - const existing = name ? data.state.profiles[name] : undefined setDraft( - existing - ? { ...existing, apiKey: '' } - : { provider: data.providers[0]?.id ?? 'anthropic', models: {}, compat: {}, apiKey: '' }, + name + ? { ...data.state.profiles[name] } + : { + agentProfile: agentProfileNames[0] ?? '', + accounts: accountNames[0] ? [accountNames[0]] : [], + strategy: 'single', + }, ) } const save = async (name: string) => { setError(null) - setErrors([]) try { - const body: Record = { ...draft } - // An empty string means "untouched", so it is removed rather than sent — - // the server would ignore it, but not sending it is what makes that - // intent explicit at the boundary that owns it. - if (!body.apiKey) delete body.apiKey - delete body.hasKey - await api.saveProfile(name, body, data.revision) + await api.saveProfile(name, draft, data.revision) setEditing(null) await reload() - } catch (err) { - if (err instanceof ApiError) { - setError(err.message) - setErrors(err.errors) - } else setError(String(err)) - } - } - - const remove = async (name: string) => { - setError(null) - try { - await api.deleteProfile(name, data.revision) - await reload() } catch (err) { setError(err instanceof ApiError ? err.message : String(err)) } } - const setDefault = async (name: string) => { + const act = async (fn: () => Promise) => { + setError(null) try { - await api.setDefault(name, data.revision) + await fn() await reload() } catch (err) { setError(err instanceof ApiError ? err.message : String(err)) } } - const field = (key: string) => (draft[key] as string) ?? '' - const put = (key: string, value: unknown) => setDraft((d) => ({ ...d, [key]: value })) - const models = (draft.models as Record) ?? {} - const compat = (draft.compat as Record) ?? {} + const put = (k: string, v: unknown) => setDraft((d) => ({ ...d, [k]: v })) + const accounts = (draft.accounts as string[]) ?? [] + const strategy = (draft.strategy as SelectionStrategy) ?? 'single' if (editing !== null) { - const isNew = !names.includes(editing) - const provider = data.providers.find((p) => p.id === draft.provider) + const isNew = !data.state.profiles?.[editing] + const canCreate = agentProfileNames.length > 0 && accountNames.length > 0 return ( <>
@@ -90,17 +89,11 @@ export function Profiles({ data, reload }: { data: Bootstrap; reload: () => Prom {isNew ? 'New profile' : `Profile · ${editing}`}
- - {error ? ( - - {error} - {errors.length > 1 ? ( -
    - {errors.slice(1).map((e) => ( -
  • {e}
  • - ))} -
- ) : null} + {error ? {error} : null} + {!canCreate ? ( + + A profile references an account and an agent profile, so at least one of each has to + exist first. ) : null} @@ -118,182 +111,88 @@ export function Profiles({ data, reload }: { data: Bootstrap; reload: () => Prom /> ) : null} - - - - - - + - - {provider?.askBaseUrl || draft.baseUrl ? ( - - put('baseUrl', e.target.value)} - placeholder={provider?.baseUrl ?? 'https://…'} - /> - - ) : null} -
- - - - put('apiKey', e.target.value)} - placeholder={ - (data.state.profiles[editing]?.hasKey ?? false) ? '•••••••• stored' : 'paste key' - } - /> - - - put('apiKeyFromEnv', e.target.value)} - placeholder="MY_TOKEN" - /> - - +

- All four tiers, from one table. Claude Code reads the extended-context marker per - variable, so a tier left out is the bug where three run wide and the fourth silently - does not. Blank inherits the provider default. + Who pays, in preference order. Attach more than one to rotate or to pick by remaining + capacity.

- {data.tiers.map((tier) => ( - - + {accountNames.map((n) => { + const on = accounts.includes(n) + const a = data.state.providerAccounts[n]! + return ( + - - ))} - - {picking && provider?.catalogId ? ( - setPicking(null)} - onPick={(model) => { - const next = { ...models, [picking]: model.id } - setDraft((d) => ({ - ...d, - models: next, - // Capture the MEASURED window alongside the id. This is the - // only moment it is known, and it is what lets swisscode set - // an auto-compact window later without ever guessing one. - ...(model.context - ? { - contextWindows: { - ...((d.contextWindows as Record) ?? {}), - [model.id]: model.context, - }, - } - : {}), - })) - setPicking(null) - }} - /> + + {n} + + {a.provider} + {a.hasKey || a.apiKeyFromEnv ? '' : ' · no key'} + + + + ) + })} + {accounts.length === 0 ? ( +

+ A profile with no account has nothing to authenticate with and will not launch. +

) : null}
- - - -
- Gateway compatibility -
- {data.compatFlags.map((flag) => ( - + ))} +
+ ) : null}
- @@ -310,16 +209,21 @@ export function Profiles({ data, reload }: { data: Bootstrap; reload: () => Prom New profile
- {error ? {error} : null} {names.length === 0 ? ( - No profiles yet. Create one to launch anything. + No profiles yet. A profile pairs an agent profile with one or more accounts. ) : ( names.map((name) => { - const p: Profile | undefined = data.state.profiles[name] + const p = data.state.profiles[name]! const isDefault = data.state.defaultProfile === name + // Report what it RESOLVES to, not what it references — a list of + // key names would make the reader do the dereference in their head. + const first = p.accounts?.[0] + const account = first ? data.state.providerAccounts?.[first] : undefined + const broken = + !data.state.agentProfiles?.[p.agentProfile] || (p.accounts ?? []).length === 0 || !account return (
Prom _last: { borderBottom: 'none' }, })} > - +
{name} @@ -344,12 +248,24 @@ export function Profiles({ data, reload }: { data: Bootstrap; reload: () => Prom ) : null}
- {p?.provider} · {p?.models?.opus || 'provider default'} + {broken + ? 'broken reference — open to repair' + : `${p.agentProfile} · ${first} → ${account!.provider}` + + ((p.accounts?.length ?? 0) > 1 + ? ` (+${p.accounts!.length - 1}, ${p.strategy ?? 'single'})` + : '')}
- {!isDefault ? : null} + {!isDefault ? ( + + ) : null} -
From 874eca701c9023973c3b9fd853627994f9a4b12b Mon Sep 17 00:00:00 2001 From: jellologic <31935831+jellologic@users.noreply.github.com> Date: Wed, 22 Jul 2026 10:46:50 -0400 Subject: [PATCH 10/21] =?UTF-8?q?feat(accounts):=20session=20mode=20?= =?UTF-8?q?=E2=80=94=20authenticate=20with=20a=20login=20the=20agent=20alr?= =?UTF-8?q?eady=20has?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An account has authenticated with a key up to now. A Claude Code subscription has no key to carry, only a directory where the official OAuth flow already ran, so `ProviderAccount` gains a third mode: `configDir`, mutually exclusive with `apiKey`/`apiKeyFromEnv` and refused rather than silently preferred when both are present. The neutral vocabulary is a SESSION DIRECTORY, not CLAUDE_CONFIG_DIR — core/ and ports/ may not name an agent's variables, and the v3 refactor learned that once already with `credentialEnvs`. `LaunchIntent.sessionDir` carries it; `adapters/agents/claude-code/env.ts` is the only place that lowers it. Kilo and OpenCode declare `sessionDir: false` and warn at `high` rather than launching unauthenticated — the capability-gap-never-silently-dropped rule that `collapsedTierWarning` already follows. A session launch clears BOTH ANTHROPIC_API_KEY and ANTHROPIC_AUTH_TOKEN. The key-mode path only ever cleared the first, so a stale ANTHROPIC_AUTH_TOKEN in the ambient environment survived into the child and could override the very subscription the account names — a launch that silently bills elsewhere. That earns a golden entry, since the golden maps exist for exactly this class of bug. No new I/O: nothing here reads a credential, a keychain, or the network. node bin/swisscode.js config accounts # two subscriptions, no keys launch 1 -> personal | CLAUDE_CONFIG_DIR=personal | API_KEY cleared: true launch 2 -> work | CLAUDE_CONFIG_DIR=work | API_KEY cleared: true 641 tests pass. Signed-off-by: jellologic <31935831+jellologic@users.noreply.github.com> --- src/adapters/agents/claude-code/env.ts | 27 +++++- src/adapters/agents/claude-code/index.ts | 3 + src/adapters/agents/kilo/index.ts | 6 ++ src/adapters/agents/opencode/index.ts | 6 ++ src/adapters/agents/shared.ts | 27 ++++++ src/adapters/web/api.ts | 10 ++ src/core/intent.ts | 3 + src/core/resolve.ts | 1 + src/ports/agent.ts | 22 +++++ src/ports/config-store.ts | 20 ++++ test/core/session-mode.test.ts | 116 +++++++++++++++++++++++ test/golden.test.ts | 23 +++++ 12 files changed, 262 insertions(+), 2 deletions(-) create mode 100644 test/core/session-mode.test.ts diff --git a/src/adapters/agents/claude-code/env.ts b/src/adapters/agents/claude-code/env.ts index 40c54c9..69326cc 100644 --- a/src/adapters/agents/claude-code/env.ts +++ b/src/adapters/agents/claude-code/env.ts @@ -145,14 +145,37 @@ export function buildEnvPlan( write('ANTHROPIC_API_KEY', '') } + // 4b. SESSION MODE. The account points at a directory holding a login Claude + // Code already performed, so the credential is not ours to supply — we + // just tell it where to look. + // + // BOTH credential variables are cleared, and that is the whole point. + // ANTHROPIC_API_KEY overrides an OAuth login outright, and a stale + // ANTHROPIC_AUTH_TOKEN left in a shell would be presented instead of the + // subscription this account names. Verified before this was written: the + // anthropic-direct path cleared only the first, so a stale auth token + // survived into the child — a silent wrong-account launch, which is the + // exact failure the golden maps exist to catch. + const sessionDir = profile?.configDir + if (sessionDir) { + write('CLAUDE_CONFIG_DIR', sessionDir) + write('ANTHROPIC_API_KEY', '') + write('ANTHROPIC_AUTH_TOKEN', '') + } + // 5. Credential, unconditionally — an empty one clears a stale variable. // `defaultCredential` covers the keyless endpoint: a local Ollama ignores // the token entirely (verified: no header, a wrong key and a bearer token // all behave identically), but Claude Code still wants the variable to // carry something, so the descriptor supplies the placeholder rather than // every user being told to invent one. - const credentialEnv = provider?.credentialEnv ?? 'ANTHROPIC_AUTH_TOKEN' - write(credentialEnv, resolveCredential(profile, ambientEnv) || (provider?.defaultCredential ?? '')) + // Skipped entirely in session mode: step 4b already cleared both + // variables, and writing one back — even an empty one — would re-open the + // question of which credential a subscription launch presented. + if (!sessionDir) { + const credentialEnv = provider?.credentialEnv ?? 'ANTHROPIC_AUTH_TOKEN' + write(credentialEnv, resolveCredential(profile, ambientEnv) || (provider?.defaultCredential ?? '')) + } // 6. All four tiers, from one table. const effectiveModels: Partial> = { diff --git a/src/adapters/agents/claude-code/index.ts b/src/adapters/agents/claude-code/index.ts index 00551b5..a454595 100644 --- a/src/adapters/agents/claude-code/index.ts +++ b/src/adapters/agents/claude-code/index.ts @@ -48,6 +48,9 @@ export const claudeCode = { skipPermissions: true, extendedContextSuffix: true, compatFlags: true, + // CLAUDE_CONFIG_DIR. The whole reason session mode exists: a subscription + // login lives in a directory rather than in a variable we could carry. + sessionDir: true, }, binary, translate(input: TranslateInput): Translation { diff --git a/src/adapters/agents/kilo/index.ts b/src/adapters/agents/kilo/index.ts index 7b13465..44c4960 100644 --- a/src/adapters/agents/kilo/index.ts +++ b/src/adapters/agents/kilo/index.ts @@ -19,6 +19,7 @@ import { ambientUnset, anthropicOptions, collapsedTierWarning, + sessionUnavailableWarning, extendedContextWarning, modelRef, modelsBlock, @@ -50,6 +51,9 @@ export const kilo = { skipPermissions: true, extendedContextSuffix: false, compatFlags: false, + // Kilo takes its credential inline in KILO_CONFIG_CONTENT and has no notion of + // an existing login directory, so a session-mode account gives it nothing. + sessionDir: false, }, binary, translate(input: TranslateInput): Translation { @@ -72,6 +76,8 @@ export const kilo = { const set: Record = { [KILO_CONFIG_ENV]: JSON.stringify(config) } const warnings: EnvWarning[] = [] + const noSession = sessionUnavailableWarning(intent, 'Kilo') + if (noSession) warnings.push(noSession) const collapse = collapsedTierWarning(intent, ['opus'], 'Kilo') if (collapse) warnings.push(collapse) const ext = extendedContextWarning(intent, primary, 'Kilo') diff --git a/src/adapters/agents/opencode/index.ts b/src/adapters/agents/opencode/index.ts index 1207fa3..17336f0 100644 --- a/src/adapters/agents/opencode/index.ts +++ b/src/adapters/agents/opencode/index.ts @@ -16,6 +16,7 @@ import { ambientUnset, anthropicOptions, collapsedTierWarning, + sessionUnavailableWarning, extendedContextWarning, modelRef, modelsBlock, @@ -49,6 +50,9 @@ export const opencode = { skipPermissions: true, extendedContextSuffix: false, compatFlags: false, + // OpenCode takes its credential inline in OPENCODE_CONFIG_CONTENT, so a + // session-mode account gives it nothing to authenticate with. + sessionDir: false, }, binary, translate(input: TranslateInput): Translation { @@ -77,6 +81,8 @@ export const opencode = { : [...passthrough] const warnings: EnvWarning[] = [] + const noSession = sessionUnavailableWarning(intent, 'OpenCode') + if (noSession) warnings.push(noSession) const collapse = collapsedTierWarning(intent, ['opus', 'haiku'], 'OpenCode') if (collapse) warnings.push(collapse) const ext = extendedContextWarning(intent, primary, 'OpenCode') diff --git a/src/adapters/agents/shared.ts b/src/adapters/agents/shared.ts index a4ec224..a41da85 100644 --- a/src/adapters/agents/shared.ts +++ b/src/adapters/agents/shared.ts @@ -125,3 +125,30 @@ export function extendedContextWarning( `Claude-Code-specific model suffix that ${agentLabel} does not send.`, } } + +/** + * Warn when an account authenticates by SESSION and this agent cannot use one. + * + * A session-mode account carries no credential — only a path to a directory + * where some agent already logged in. Claude Code can be pointed at it; + * Kilo and OpenCode take their credential inline and have no equivalent, so + * they would launch with nothing to authenticate with and fail at the first + * request with a message about the endpoint rather than about the account. + * + * `high`, unlike the tier-collapse warning: a collapsed tier still launches + * something useful, whereas this launch cannot work at all. + */ +export function sessionUnavailableWarning( + intent: LaunchIntent, + agentLabel: string, +): EnvWarning | null { + if (!intent.sessionDir) return null + return { + severity: 'high', + code: 'session-unsupported', + message: + `this account authenticates with an existing ${'`claude`'} login (a session directory), ` + + `and ${agentLabel} cannot use one — it needs a credential of its own. Give the ` + + `account an API key, or point this profile at an agent that supports sessions.`, + } +} diff --git a/src/adapters/web/api.ts b/src/adapters/web/api.ts index 11fde79..4e554b6 100644 --- a/src/adapters/web/api.ts +++ b/src/adapters/web/api.ts @@ -184,6 +184,10 @@ export function parseAccount( const account: ProviderAccount = { ...(existing ?? {}), provider } if (typeof input.label === 'string') account.label = input.label + if (typeof input.configDir === 'string') { + if (input.configDir) account.configDir = input.configDir + else delete account.configDir + } if (typeof input.baseUrl === 'string') account.baseUrl = input.baseUrl if (typeof input.apiKey === 'string' && input.apiKey.length > 0) account.apiKey = input.apiKey if (input.apiKey === null) delete account.apiKey @@ -191,6 +195,12 @@ export function parseAccount( if (input.apiKeyFromEnv) account.apiKeyFromEnv = input.apiKeyFromEnv else delete account.apiKeyFromEnv } + // The two modes are MUTUALLY EXCLUSIVE, and the conflict is refused rather + // than resolved by precedence: "which credential did this actually use" must + // never have a subtle answer. + if (account.configDir && (account.apiKey || account.apiKeyFromEnv)) { + return 'an account uses either a key or an existing login directory, never both' + } return account } diff --git a/src/core/intent.ts b/src/core/intent.ts index df00439..4beadc3 100644 --- a/src/core/intent.ts +++ b/src/core/intent.ts @@ -57,5 +57,8 @@ export function buildIntent( } // Under exactOptionalPropertyTypes, only present it when there is one. if (profile?.contextWindows) intent.contextWindows = profile.contextWindows + // Session mode. Neutral here — which variable expresses it is the adapter's + // business, and core/ may not name an agent's dialect in emitted code. + if (profile?.configDir) intent.sessionDir = profile.configDir return intent } diff --git a/src/core/resolve.ts b/src/core/resolve.ts index 60897d6..120b5db 100644 --- a/src/core/resolve.ts +++ b/src/core/resolve.ts @@ -88,6 +88,7 @@ function flatten( if (account.baseUrl !== undefined) resolved.baseUrl = account.baseUrl if (account.apiKey !== undefined) resolved.apiKey = account.apiKey if (account.apiKeyFromEnv !== undefined) resolved.apiKeyFromEnv = account.apiKeyFromEnv + if (account.configDir !== undefined) resolved.configDir = account.configDir if (agentProfile.agent !== undefined) resolved.agent = agentProfile.agent if (agentProfile.models !== undefined) resolved.models = agentProfile.models diff --git a/src/ports/agent.ts b/src/ports/agent.ts index 86839a2..f887e60 100644 --- a/src/ports/agent.ts +++ b/src/ports/agent.ts @@ -56,6 +56,19 @@ export type LaunchIntent = { skipPermissions: boolean extendedContext?: ExtendedContext | null contextWindows?: Record + /** + * A directory holding a login the agent already performed, or absent. + * + * The NEUTRAL half of session-mode authentication. An account can present a + * key, or it can point at somewhere the agent keeps its own credential — a + * Claude Code subscription being the shipped case. Which environment variable + * carries it is the adapter's business, exactly as it is for the credential. + * + * Spelled "session directory" rather than after any agent's variable because + * core/ builds this intent, and test/architecture.test.ts forbids core/ from + * naming an agent's dialect in emitted code. + */ + sessionDir?: string | null } /** @@ -74,6 +87,15 @@ export type AgentCapabilities = { skipPermissions: boolean extendedContextSuffix: boolean compatFlags: boolean + /** + * Can this CLI be pointed at an existing login directory? + * + * Claude Code can (CLAUDE_CONFIG_DIR). Kilo and OpenCode take a credential + * inline and have no equivalent, so an account in session mode gives them + * nothing to authenticate with — which they must WARN about rather than + * launch unauthenticated, the same rule tier-collapse already follows. + */ + sessionDir: boolean } /** diff --git a/src/ports/config-store.ts b/src/ports/config-store.ts index 39aa886..60f1807 100644 --- a/src/ports/config-store.ts +++ b/src/ports/config-store.ts @@ -45,6 +45,24 @@ export type ProviderAccount = { apiKey?: string /** read from the ambient env instead, so no secret sits in the file */ apiKeyFromEnv?: string + /** + * SESSION MODE: a directory holding a login the agent already performed. + * + * The third way to authenticate, and the odd one out — there is no secret + * here at all, only a path to somewhere the agent keeps its own credential. + * A Claude Code subscription works this way: you log in once with the + * official OAuth flow inside this directory, and every launch that names the + * account points the agent back at it. + * + * MUTUALLY EXCLUSIVE with `apiKey`/`apiKeyFromEnv`. An account carrying both + * is refused rather than resolved by precedence: "which credential did this + * actually use" is exactly the question that must never have a subtle answer. + * + * Neutral on purpose. Which environment variable expresses it belongs to the + * agent adapter — see `LaunchIntent.sessionDir` in ports/agent.ts — because + * core/ may not name an agent's dialect, and this port is read by core. + */ + configDir?: string } /** @@ -112,6 +130,8 @@ export type ResolvedProfile = { baseUrl?: string apiKey?: string apiKeyFromEnv?: string + /** session mode: a directory holding a login the agent already performed */ + configDir?: string // ── from the agent profile ── agent?: string models?: Partial> diff --git a/test/core/session-mode.test.ts b/test/core/session-mode.test.ts new file mode 100644 index 0000000..86fe371 --- /dev/null +++ b/test/core/session-mode.test.ts @@ -0,0 +1,116 @@ +// Session-mode accounts: authenticating with a login the agent already has. +// +// A subscription is not a key. There is no secret to carry — only a directory +// where the official OAuth flow already ran — so this mode is defined by what +// swisscode DOES NOT send: no credential of its own, and neither of the two +// variables that would override the login it just selected. +import test from 'node:test' +import assert from 'node:assert/strict' +import { buildIntent } from '../../src/core/intent.ts' +import { resolveProfileRefs } from '../../src/core/resolve.ts' +import { buildEnvPlan } from '../../src/adapters/agents/claude-code/env.ts' +import { claudeCode } from '../../src/adapters/agents/claude-code/index.ts' +import { kilo } from '../../src/adapters/agents/kilo/index.ts' +import { opencode } from '../../src/adapters/agents/opencode/index.ts' +import { anthropic } from '../../src/adapters/providers/anthropic.ts' +import { makeProfile } from '../support/fixtures.ts' +import type { State } from '../../src/ports/config-store.ts' + +const DIR = '/home/u/.config/swisscode/accounts/personal' + +const state = (over: Record = {}): State => + ({ + version: 3, + providerAccounts: { + personal: { provider: 'anthropic', configDir: DIR }, + keyed: { provider: 'anthropic', apiKey: 'sk-ant-real' }, + }, + agentProfiles: { main: {} }, + profiles: { p: { agentProfile: 'main', accounts: ['personal'] } }, + defaultProfile: 'p', + bindings: {}, + settings: {}, + ...over, + }) as unknown as State + +test('a session account resolves to a directory, not a credential', () => { + const r = resolveProfileRefs(state(), 'p') + assert.ok(r.ok) + assert.equal(r.resolved.configDir, DIR) + assert.equal(r.resolved.apiKey, undefined) + assert.equal(r.resolved.apiKeyFromEnv, undefined) +}) + +test('the neutral intent carries it WITHOUT naming any agent variable', () => { + // core/ may not name CLAUDE_CONFIG_DIR — test/architecture.test.ts forbids it + // in emitted code, and the v3 refactor already learned that once. The intent + // says "session directory"; the adapter decides what that means. + const intent = buildIntent(makeProfile({ provider: 'anthropic', configDir: DIR }), anthropic, {}) + assert.equal(intent.sessionDir, DIR) + assert.equal(intent.credential, '', 'a session account presents no credential of its own') +}) + +test('a key account carries no session directory', () => { + const intent = buildIntent(makeProfile({ provider: 'anthropic', apiKey: 'k' }), anthropic, {}) + assert.equal(intent.sessionDir, undefined) + assert.equal(intent.credential, 'k') +}) + +test('Claude Code lowers it, and clears both overriding variables', () => { + // ANTHROPIC_API_KEY overrides an OAuth login outright; a stale + // ANTHROPIC_AUTH_TOKEN would be presented in place of the subscription this + // account names. Either one turns "launch as personal" into "launch as + // whatever was in the shell" — silently, and billed elsewhere. + const plan = buildEnvPlan(makeProfile({ provider: 'anthropic', configDir: DIR }), anthropic, { + ANTHROPIC_API_KEY: 'sk-ant-STALE', + ANTHROPIC_AUTH_TOKEN: 'stale-oauth', + }) + assert.equal(plan.set.CLAUDE_CONFIG_DIR, DIR) + assert.ok(plan.unset.includes('ANTHROPIC_API_KEY')) + assert.ok(plan.unset.includes('ANTHROPIC_AUTH_TOKEN')) +}) + +test('a key account still sets its credential, and no config dir', () => { + // The regression guard for the change above: session mode must not have + // quietly disabled the ordinary path. + const plan = buildEnvPlan(makeProfile({ provider: 'anthropic', apiKey: 'sk-ant-real' }), anthropic, {}) + assert.equal(plan.set.ANTHROPIC_API_KEY, 'sk-ant-real') + assert.equal(plan.set.CLAUDE_CONFIG_DIR, undefined) +}) + +test('only Claude Code claims the capability', () => { + assert.equal(claudeCode.capabilities.sessionDir, true) + assert.equal(kilo.capabilities.sessionDir, false) + assert.equal(opencode.capabilities.sessionDir, false) +}) + +test('an agent that cannot use a session says so LOUDLY rather than launching', () => { + // `high`, unlike tier-collapse: a collapsed tier still launches something + // useful, whereas this launch cannot work at all — it would reach the + // endpoint with no credential and fail with a message about the endpoint + // rather than about the account. + for (const agent of [kilo, opencode]) { + const t = agent.translate({ + intent: buildIntent(makeProfile({ provider: 'anthropic', configDir: DIR }), anthropic, {}), + profile: makeProfile({ provider: 'anthropic', configDir: DIR }), + provider: anthropic, + passthrough: [], + ambient: {}, + }) + const w = t.warnings.find((x) => x.code === 'session-unsupported') + assert.ok(w, `${agent.label} launched a session account with no warning`) + assert.equal(w.severity, 'high') + assert.match(w.message, /cannot use one/) + } +}) + +test('Claude Code does NOT warn — it is the one that can', () => { + const t = claudeCode.translate({ + intent: buildIntent(makeProfile({ provider: 'anthropic', configDir: DIR }), anthropic, {}), + profile: makeProfile({ provider: 'anthropic', configDir: DIR }), + provider: anthropic, + passthrough: [], + ambient: {}, + }) + assert.equal(t.warnings.find((x) => x.code === 'session-unsupported'), undefined) +}) diff --git a/test/golden.test.ts b/test/golden.test.ts index 8d961a1..5b2aed9 100644 --- a/test/golden.test.ts +++ b/test/golden.test.ts @@ -112,6 +112,9 @@ const GOLDEN: Record = { }, unset: ['ANTHROPIC_API_KEY', ...TIER_VARS], }, + // Session mode is not a provider, so it cannot have its own GOLDEN entry + // keyed by provider id — it is asserted separately below, against the same + // polluted ambient env. ollama: { set: { // Bare host, http, loopback. The cleartext guard exempts loopback, so @@ -173,6 +176,26 @@ test('every shipped provider has a golden map', () => { assert.deepEqual(PROVIDERS.map((p) => p.id).sort(), Object.keys(GOLDEN).sort()) }) +test('a subscription launch clears BOTH credential variables', () => { + // THE SILENT-WRONG-ACCOUNT CASE. A session-mode account carries no credential + // of its own — Claude Code reads one from the directory we point it at. Any + // ANTHROPIC_API_KEY in the environment overrides an OAuth login outright, and + // a stale ANTHROPIC_AUTH_TOKEN would be presented instead of the subscription + // this account names. Before session mode existed the anthropic path cleared + // only the first, so the second survived into the child. + const plan = buildEnvPlan( + makeProfile({ provider: 'anthropic', configDir: '/home/u/.swisscode/personal' }), + byId('anthropic'), + AMBIENT, + ) + assert.equal(plan.set.CLAUDE_CONFIG_DIR, '/home/u/.swisscode/personal') + assert.ok(plan.unset.includes('ANTHROPIC_API_KEY'), 'a stale API key would override the login') + assert.ok(plan.unset.includes('ANTHROPIC_AUTH_TOKEN'), 'a stale auth token would be presented') + // And nothing invents a credential to put back. + assert.equal(plan.set.ANTHROPIC_API_KEY, undefined) + assert.equal(plan.set.ANTHROPIC_AUTH_TOKEN, undefined) +}) + test('no launch inherits a stale ANTHROPIC_API_KEY it did not ask for', () => { for (const provider of PROVIDERS) { const plan = planFor(provider.id) From ea19d7ce93159d58d29e9da1f17a944312a8a946 Mon Sep 17 00:00:00 2001 From: jellologic <31935831+jellologic@users.noreply.github.com> Date: Wed, 22 Jul 2026 10:55:09 -0400 Subject: [PATCH 11/21] feat(accounts): read who a session is logged in as, and adopt one with `login` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 2. Two surfaces, no credential access anywhere in them. `adapters/claude-session/identity.ts` reads `oauthAccount` out of `.claude.json` — the agent's own bookkeeping — so listing every account's identity costs one file read apiece and raises no Keychain prompt. Two things here were MEASURED against the real file rather than assumed, and both assumptions were wrong: - The plan tier is NOT `seatTier` or `userRateLimitTier`. Both are null on a live Max 20x account. It is `organizationRateLimitTier` ("default_claude_max_20x"). Trusting the plausible-sounding fields would have shown every user a blank plan. - The config-file path asymmetry is real, and confirmed by running the agent against a throwaway dir: CLAUDE_CONFIG_DIR unset puts it at ~/.claude.json, a SIBLING of ~/.claude; set, it goes INSIDE at /.claude.json. There is deliberately no fallback between them — reading the home file for a custom directory would report the default account's identity for a directory that is not it. `config accounts login ` creates a 0700 directory, records the account, and execve's the real binary at it. Swisscode never touches the OAuth flow: no browser, no code, no token — it makes an empty directory and hands over the terminal. It hands the agent a cleaned environment for the same reason a launch does, since either credential variable would authenticate the login flow as somebody else and make `/login` appear to do nothing. `--dir` adopts an existing directory, so the default ~/.claude becomes account #1 with no re-login. Mostly it refuses: path-escaping names, retargeting an account that already has a login, and giving a key-mode account a session as well. `test/architecture.test.ts` now holds adapters/claude-session off the launch path, beside the catalogs and usage adapters — a launch lowers a session directory to an env var without ever opening it. Verified live against the real account: config accounts login default --dir ~/.claude warning — /Users/…/.claude is readable by other users (mode 755) Account "default" already logged in as orepsdev@gmail.com · Max 20x config accounts default login orepsdev@gmail.com · Max 20x spare login not logged in → names its own fix api key stored in config.json (0600) ← key itself never printed 665 tests pass; launch path unchanged at 220.5 kB packed. Signed-off-by: jellologic <31935831+jellologic@users.noreply.github.com> --- src/adapters/claude-session/identity.ts | 197 ++++++++++++++++ src/adapters/claude-session/onboard.ts | 221 ++++++++++++++++++ src/composition/config-root.ts | 75 +++++- test/adapters/claude-session-identity.test.ts | 154 ++++++++++++ test/adapters/claude-session-onboard.test.ts | 192 +++++++++++++++ test/architecture.test.ts | 8 +- 6 files changed, 842 insertions(+), 5 deletions(-) create mode 100644 src/adapters/claude-session/identity.ts create mode 100644 src/adapters/claude-session/onboard.ts create mode 100644 test/adapters/claude-session-identity.test.ts create mode 100644 test/adapters/claude-session-onboard.test.ts diff --git a/src/adapters/claude-session/identity.ts b/src/adapters/claude-session/identity.ts new file mode 100644 index 0000000..2caf203 --- /dev/null +++ b/src/adapters/claude-session/identity.ts @@ -0,0 +1,197 @@ +// Who a Claude Code session directory is logged in as. +// +// READS NO CREDENTIAL. Everything here comes out of `.claude.json`, which the +// agent writes for its own bookkeeping, so listing every account's identity +// costs one file read apiece and prompts nothing — no Keychain, no network, no +// unlock dialog. That is the whole reason identity is a separate module from +// `credentials.ts`: `config accounts` runs constantly and must stay free. +// +// OFF THE LAUNCH PATH, like the doctor probes and the catalogs. +// `test/architecture.test.ts` holds that line: the launch path resolves a +// session directory and lowers it to an env var without ever opening it, since +// a launch that stats a 200 kB JSON file to print a nicer banner has spent the +// budget this tool exists to protect. + +import { readFileSync } from 'node:fs' +import { homedir } from 'node:os' +import { dirname, join, resolve } from 'node:path' + +type ReadableEnv = Record + +/** + * What `.claude.json` is willing to tell us about the logged-in account. + * + * Every field optional, and every field VERIFIED PRESENT on a real Max account + * rather than assumed from the shape of the type. The nulls below are why: + * this was written against the real file, and the obvious-looking fields turned + * out to be the empty ones. + */ +export type SessionIdentity = { + /** stable across email changes; the honest key for "same account?" */ + accountUuid?: string + email?: string + displayName?: string + organizationName?: string + organizationUuid?: string + /** + * A human-readable plan, best effort. + * + * MEASURED, NOT ASSUMED. `seatTier` and `userRateLimitTier` — the two fields + * that sound like the answer — are both `null` on a live Max 20x account. + * The field that actually carries it is `organizationRateLimitTier` + * ("default_claude_max_20x"), with `organizationType` ("claude_max") behind + * it. Preferring the plausible-sounding fields would have shown every user a + * blank plan, which is the class of bug this codebase measures to avoid. + */ + plan?: string + /** true when the account can spend beyond the subscription window */ + extraUsage?: boolean +} + +/** + * Where a session directory keeps its `.claude.json`. + * + * THE ASYMMETRY IS REAL AND IT IS THE POINT. Confirmed by running the agent + * against a throwaway dir: + * + * CLAUDE_CONFIG_DIR unset -> ~/.claude.json (a SIBLING of ~/.claude) + * CLAUDE_CONFIG_DIR= -> /.claude.json (INSIDE it) + * + * There is deliberately NO fallback between the two. If a custom directory has + * no `.claude.json`, the answer is "never logged in" — reading `~/.claude.json` + * instead would report the DEFAULT account's identity for a directory that is + * not it, which is precisely the silently-wrong-account failure this whole + * feature exists to end. + */ +export function configFilePath(configDir: string, env: ReadableEnv = process.env): string { + const home = env.HOME || homedir() + // `resolve` so that `~/.claude`, `~/.claude/`, and a relative path spelling of + // it all compare equal; a trailing slash must not turn the default directory + // into a custom one. + return resolve(configDir) === resolve(join(home, '.claude')) + ? join(home, '.claude.json') + : join(resolve(configDir), '.claude.json') +} + +/** The shape we pick out of the file. Everything else is the agent's business. */ +type OAuthAccount = { + accountUuid?: unknown + emailAddress?: unknown + displayName?: unknown + organizationName?: unknown + organizationUuid?: unknown + organizationType?: unknown + organizationRateLimitTier?: unknown + userRateLimitTier?: unknown + seatTier?: unknown + hasExtraUsageEnabled?: unknown +} + +const str = (v: unknown): string | undefined => + typeof v === 'string' && v.trim() !== '' ? v : undefined + +/** + * Turn a rate-limit tier id into something worth printing. + * + * Falls through to the raw id rather than to a blank: an unrecognised tier is + * far more useful shown verbatim than hidden, and a new plan name appearing in + * output is a smaller failure than a plan silently reading as "—". + */ +function readablePlan(a: OAuthAccount): string | undefined { + const raw = + str(a.organizationRateLimitTier) ?? + str(a.userRateLimitTier) ?? + str(a.seatTier) ?? + str(a.organizationType) + if (!raw) return undefined + const known: Record = { + default_claude_max_20x: 'Max 20x', + default_claude_max_5x: 'Max 5x', + default_claude_pro: 'Pro', + claude_max: 'Max', + claude_pro: 'Pro', + } + return known[raw] ?? raw +} + +export type ReadIdentityOptions = { + env?: ReadableEnv + /** injected in tests; defaults to node:fs */ + readFile?: (path: string) => string +} + +/** + * Read the identity of a session directory, or null. + * + * Null covers every "we cannot say" case — no directory, no file, unparseable + * file, or a file with no `oauthAccount` (a real state: a directory the agent + * has started in but nobody has run `/login` in yet). The caller distinguishes + * those with `existsSync` if it cares; for display purposes they are all + * "not logged in", and guessing between them would be inventing detail. + */ +export function readSessionIdentity( + configDir: string, + { env = process.env, readFile = (p) => readFileSync(p, 'utf8') }: ReadIdentityOptions = {}, +): SessionIdentity | null { + let parsed: unknown + try { + parsed = JSON.parse(readFile(configFilePath(configDir, env))) + } catch { + // Absent, unreadable, or not JSON. All three mean "we cannot say". + return null + } + if (!parsed || typeof parsed !== 'object') return null + const account = (parsed as { oauthAccount?: unknown }).oauthAccount + if (!account || typeof account !== 'object') return null + + const a = account as OAuthAccount + const identity: SessionIdentity = {} + // Conditional assignment throughout: `exactOptionalPropertyTypes` makes + // `email: undefined` a different type from an absent `email`, and callers + // branch on presence. + const accountUuid = str(a.accountUuid) + if (accountUuid) identity.accountUuid = accountUuid + const email = str(a.emailAddress) + if (email) identity.email = email + const displayName = str(a.displayName) + if (displayName) identity.displayName = displayName + const organizationName = str(a.organizationName) + if (organizationName) identity.organizationName = organizationName + const organizationUuid = str(a.organizationUuid) + if (organizationUuid) identity.organizationUuid = organizationUuid + const plan = readablePlan(a) + if (plan) identity.plan = plan + if (typeof a.hasExtraUsageEnabled === 'boolean') identity.extraUsage = a.hasExtraUsageEnabled + + // An `oauthAccount` with nothing recognisable in it is not an identity. + return Object.keys(identity).length > 0 ? identity : null +} + +/** + * One line naming the account, for a list. + * + * Prefers the email, because that is what the user typed at `/login` and what + * `/status` shows them. The org name is a poor substitute — on a personal Max + * account it is literally "'s Organization" — so it appears only when + * there is no email at all. + */ +export function describeIdentity(identity: SessionIdentity | null): string { + if (!identity) return 'not logged in' + const who = identity.email ?? identity.displayName ?? identity.organizationName ?? 'logged in' + return identity.plan ? `${who} · ${identity.plan}` : who +} + +/** + * Whether a directory looks like one the agent has ever run in. + * + * Distinguishes "never used" from "used but logged out", which is the + * difference between `config accounts login` being the fix and it being a + * puzzle. Deliberately a path check, not a login check. + */ +export function sessionDirLooksInitialised( + configDir: string, + { env = process.env, exists }: { env?: ReadableEnv; exists: (p: string) => boolean }, +): boolean { + const file = configFilePath(configDir, env) + return exists(file) || exists(dirname(file)) +} diff --git a/src/adapters/claude-session/onboard.ts b/src/adapters/claude-session/onboard.ts new file mode 100644 index 0000000..ae7a7fc --- /dev/null +++ b/src/adapters/claude-session/onboard.ts @@ -0,0 +1,221 @@ +// `swisscode config accounts login ` — adopt a subscription. +// +// The one-time step that turns "an account I pay for" into "an account +// swisscode can select". It creates a directory, records it, and then HANDS THE +// TERMINAL TO THE AGENT so the official `/login` runs, unmodified, in the +// official client. +// +// SWISSCODE NEVER TOUCHES THE OAUTH FLOW. It does not open a browser, does not +// hold a code, does not see a token. It creates an empty directory and execve's +// the real binary at it. Everything after that is between you and Anthropic — +// which is both the honest architecture and the reason this is a launcher +// rather than a credential manager. + +import { existsSync, mkdirSync, statSync } from 'node:fs' +import { homedir } from 'node:os' +import { isAbsolute, join, resolve } from 'node:path' +import { describeIdentity, readSessionIdentity } from './identity.ts' +import type { ConfigStorePort, ProviderAccount, State } from '../../ports/config-store.ts' +import type { AgentRegistryPort } from '../../ports/agent.ts' +import type { ProcessPort } from '../../ports/process.ts' + +type Emit = (line: string) => void + +export type LoginOptions = { + /** account name, as it will appear in config and in `swisscode config accounts` */ + name: string | undefined + /** `--dir `: adopt an existing directory instead of making one */ + dir?: string | undefined + /** `--provider `, defaulting to anthropic — the only one with this flow today */ + provider?: string | undefined + store: ConfigStorePort + agents: AgentRegistryPort + proc: ProcessPort + out: Emit + err: Emit +} + +/** + * Where swisscode keeps the session directories it makes. + * + * Beside `config.json`, under the config directory rather than the state + * directory: unlike a rotation cursor, a session directory is NOT regenerable. + * It holds a login. Losing it costs a `/login` per account, and it belongs + * wherever the user's backups already point. + */ +export function accountsDir(env: Record = process.env): string { + return join( + env.XDG_CONFIG_HOME || join(env.HOME || homedir(), '.config'), + 'swisscode', + 'accounts', + ) +} + +/** + * Names that may become a directory. + * + * Stricter than the profile-name grammar on purpose: this string is + * concatenated into a filesystem path, so `..`, separators and leading dots are + * refused outright rather than sanitised. A rejected name is a typo the user + * fixes in one second; a sanitised one is a directory somewhere they did not + * expect. + */ +export function validateAccountName(name: string): { ok: true } | { ok: false; reason: string } { + if (!name.trim()) return { ok: false, reason: 'an account needs a name.' } + if (!/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/.test(name) || name.includes('..')) { + return { + ok: false, + reason: + `"${name}" cannot be used as an account name. Use letters, digits, dot, dash or ` + + 'underscore, starting with a letter or digit — the name becomes a directory.', + } + } + return { ok: true } +} + +/** @returns the process exit code, or does not return at all (execve). */ +export function accountLogin({ + name, + dir, + provider = 'anthropic', + store, + agents, + proc, + out, + err, +}: LoginOptions): number { + if (name === undefined) { + err('swisscode: `config accounts login ` needs a name, e.g. `personal`.') + return 2 + } + const verdict = validateAccountName(name) + if (!verdict.ok) { + err(`swisscode: ${verdict.reason}`) + return 2 + } + + const loaded = store.load() + if (loaded.readOnly) { + err('swisscode: the config file is not writable, so a new account cannot be recorded.') + return 2 + } + const state = loaded.state + const existing = state.providerAccounts?.[name] + + // An adopted directory must be absolute: this process execve's away and the + // agent inherits the cwd, so a relative path would mean something different + // depending on where it is later launched from. + const target = dir + ? isAbsolute(dir) + ? resolve(dir) + : resolve(proc.cwd(), dir) + : join(accountsDir(proc.env()), name) + + // Re-login into an account that already exists is a legitimate thing to want + // (an expired refresh token, a wrong account picked the first time), so this + // is not an error — but silently retargeting an existing account at a + // DIFFERENT directory would abandon a login without saying so. + if (existing?.configDir && resolve(existing.configDir) !== target) { + err( + `swisscode: account "${name}" already uses ${existing.configDir}. Delete it first, or ` + + 'pass `--dir` with that same path to log in again.', + ) + return 2 + } + if (existing && !existing.configDir) { + err( + `swisscode: account "${name}" already authenticates with an API key. An account uses a ` + + 'key or a subscription login, never both — pick another name.', + ) + return 2 + } + + try { + // 0700 because this directory will hold a login. `recursive` also creates + // the parent `accounts/`, and mode applies to every level it creates. + mkdirSync(target, { recursive: true, mode: 0o700 }) + } catch (e) { + err(`swisscode: could not create ${target}: ${(e as { message?: string }).message ?? e}`) + return 2 + } + // An ADOPTED directory keeps whatever permissions it has — narrowing someone + // else's ~/.claude-work under their feet is not this command's business — but + // a permissive one earns a warning, since a login is about to live in it. + try { + const mode = statSync(target).mode & 0o777 + if (mode & 0o077) { + err( + `swisscode: warning — ${target} is readable by other users (mode ${mode.toString(8)}). ` + + 'It is about to hold a login. `chmod 700` it.', + ) + } + } catch { + /* stat failing here is not worth failing a login over */ + } + + const account: ProviderAccount = { provider, configDir: target } + const next: State = { + ...state, + providerAccounts: { ...(state.providerAccounts ?? {}), [name]: account }, + } + try { + store.save(next) + } catch (e) { + err(`swisscode: could not record the account: ${(e as { message?: string }).message ?? e}`) + return 2 + } + + const already = readSessionIdentity(target, { env: proc.env() }) + if (already) { + out(`Account "${name}" already logged in as ${describeIdentity(already)}.`) + out(` ${target}`) + out('Run `/login` inside the session that starts next to switch it to another account.') + } else { + out(`Account "${name}" recorded, using ${target}.`) + } + + // Claude Code is the only agent with this flow — the login being adopted IS a + // Claude subscription — so this does not consult the agent profile. Kilo and + // OpenCode declare `sessionDir: false` for exactly this reason. + const agent = agents.byId('claude-code') + if (!agent) { + err('swisscode: the Claude Code adapter is not in this build, so it cannot be launched.') + return 2 + } + + let bin: string + try { + bin = proc.resolveBinary(agent.binary) + } catch (e) { + // The account is already recorded, which is the useful half. Say so, rather + // than making the user wonder whether anything happened. + err(`swisscode: ${(e as { message?: string }).message ?? 'the Claude Code binary was not found'}`) + err(`swisscode: the account was still recorded. Install the CLI, then run \`/login\` with`) + err(`swisscode: CLAUDE_CONFIG_DIR=${target} claude`) + return 2 + } + + out('') + out('Starting Claude Code in that directory. Run `/login` inside it, then exit.') + out('') + + const env = proc.env() + env.CLAUDE_CONFIG_DIR = target + // The same both-variables rule the launch path enforces, for the same reason: + // either one present would authenticate the login flow as somebody else and + // the `/login` would appear to do nothing. + delete env.ANTHROPIC_API_KEY + delete env.ANTHROPIC_AUTH_TOKEN + + proc.replace(bin, [bin], env) + // Reached only on the spawn fallback (Node < 23.11), where `replace` relays + // the child's exit itself. + return 0 +} + +/** + * `config accounts login` needs to know whether a directory has ever been used, + * which is a filesystem question the listing also asks. Shared here so both + * surfaces answer it the same way. + */ +export const dirExists = (p: string): boolean => existsSync(p) diff --git a/src/composition/config-root.ts b/src/composition/config-root.ts index 133b8e4..d5b778e 100644 --- a/src/composition/config-root.ts +++ b/src/composition/config-root.ts @@ -31,6 +31,8 @@ import { resolveProfileRefs } from '../core/resolve.ts' import { TIERS } from '../core/tiers.ts' import { DEFAULT_AGENT_ID } from '../adapters/agents/registry.ts' import { withCustomProviders } from '../adapters/providers/composite.ts' +import { describeIdentity, readSessionIdentity } from '../adapters/claude-session/identity.ts' +import { accountLogin } from '../adapters/claude-session/onboard.ts' import type { LaunchDeps } from './launch-root.ts' import type { LoadResult, Profile, State } from '../ports/config-store.ts' import type { ProcessPort } from '../ports/process.ts' @@ -81,6 +83,8 @@ const USAGE = `swisscode config — manage profiles and directory bindings swisscode config rm delete a profile and any bindings to it swisscode config accounts provider accounts, and which profiles use each + swisscode config accounts login adopt a Claude subscription: makes a session + [--dir ] directory and runs the agent so you can /login swisscode config agents agent profiles, and which profiles use each swisscode config agent list agents and which profile uses each @@ -167,7 +171,7 @@ export async function runConfigCommand({ case 'web': return webCommand({ deps, args: rest, out, err }) case 'accounts': - return listAccounts({ deps, out }) + return accountsCommand({ deps, args: rest, out, err }) case 'agents': return listAgentProfiles({ deps, out }) default: @@ -799,6 +803,55 @@ async function webCommand({ } } +/** + * `swisscode config accounts [login …]`. + * + * Dispatch only. The login flow lives in `adapters/claude-session/onboard.ts` + * because it is Claude-Code-shaped — it knows about session directories and + * about `/login` — and this file is a composition root, not a home for + * agent-specific behaviour. + */ +function accountsCommand({ + deps, + args, + out, + err, +}: { + deps: LaunchDeps + args: string[] + out: Emit + err: Emit +}): number { + const [sub, ...rest] = args + if (sub === undefined) return listAccounts({ deps, out }) + if (sub !== 'login') { + err(`swisscode: unknown accounts subcommand "${sub}". Try \`swisscode config accounts\`.`) + return 2 + } + + const flag = (name: string): string | undefined => { + const i = rest.indexOf(name) + return i >= 0 ? rest[i + 1] : undefined + } + const positional = rest.filter((a, i) => !a.startsWith('--') && !rest[i - 1]?.startsWith('--')) + + const options = { + name: positional[0], + store: deps.store, + agents: deps.agents, + proc: deps.proc, + out, + err, + } + const dir = flag('--dir') + const provider = flag('--provider') + return accountLogin({ + ...options, + ...(dir !== undefined ? { dir } : {}), + ...(provider !== undefined ? { provider } : {}), + }) +} + /** * `swisscode config accounts` — who pays, and who uses them. * @@ -825,9 +878,23 @@ function listAccounts({ deps, out }: { deps: LaunchDeps; out: Emit }): number { out(` ${name}${a.label ? ` (${a.label})` : ''}`) out(` provider ${a.provider}${provider ? '' : ' — not in this build'}`) if (a.baseUrl) out(` baseUrl ${a.baseUrl}`) - // Presence and ORIGIN only, exactly as `config list` does: a masked key is - // still a fingerprint and this output gets pasted into bug reports. - out(` key ${credentialOrigin(a)}`) + if (a.configDir) { + // A session account has no key to describe, so describe the LOGIN + // instead. The email is the thing the user recognises — "which of my + // three accounts is this?" is the question, and a path does not answer it. + // Reads `.claude.json` only; no credential, no prompt, no network. + // Read ONCE — `.claude.json` is a 200 kB file on a well-used account. + const identity = readSessionIdentity(a.configDir) + out(` login ${describeIdentity(identity)}`) + out(` session ${a.configDir}`) + if (!identity) { + out(` run \`swisscode config accounts login ${name}\` and \`/login\` inside`) + } + } else { + // Presence and ORIGIN only, exactly as `config list` does: a masked key is + // still a fingerprint and this output gets pasted into bug reports. + out(` key ${credentialOrigin(a)}`) + } out(` used by ${usedBy.length > 0 ? usedBy.join(', ') : '— nothing'}`) } return 0 diff --git a/test/adapters/claude-session-identity.test.ts b/test/adapters/claude-session-identity.test.ts new file mode 100644 index 0000000..37224a5 --- /dev/null +++ b/test/adapters/claude-session-identity.test.ts @@ -0,0 +1,154 @@ +// Reading who a session directory is logged in as. +// +// Every payload below is shaped after the REAL `~/.claude.json` on a live Max +// 20x account, including the fields that turned out to be null. That is the +// point of the file: the obvious-looking fields are the empty ones. +import test from 'node:test' +import assert from 'node:assert/strict' +import { join } from 'node:path' +import { + configFilePath, + describeIdentity, + readSessionIdentity, +} from '../../src/adapters/claude-session/identity.ts' + +const HOME = '/home/u' +const env = { HOME } + +/** A reader over an in-memory filesystem, so no test touches a real dot-file. */ +const reader = (files: Record) => (p: string) => { + const content = files[p] + if (content === undefined) throw new Error(`ENOENT: ${p}`) + return content +} + +// Verbatim field names and values from the live account, minus the identifying +// parts. `seatTier` and `userRateLimitTier` really are null on a Max 20x plan. +const REAL_MAX_ACCOUNT = { + oauthAccount: { + accountUuid: 'c0bdf393-0000-0000-0000-000000000000', + emailAddress: 'someone@example.com', + organizationUuid: '023a272a-0000-0000-0000-000000000000', + hasExtraUsageEnabled: false, + billingType: 'stripe_subscription', + ccOnboardingFlags: {}, + claudeCodeTrialEndsAt: null, + seatTier: null, + displayName: 'Someone', + organizationRole: 'admin', + workspaceRole: null, + organizationName: "someone@example.com's Organization", + organizationType: 'claude_max', + organizationRateLimitTier: 'default_claude_max_20x', + userRateLimitTier: null, + }, +} + +test('the default directory keeps its config file OUTSIDE itself', () => { + // ~/.claude.json is a SIBLING of ~/.claude, not a child. Getting this + // backwards reads nothing and reports every account as logged out. + assert.equal(configFilePath(join(HOME, '.claude'), env), join(HOME, '.claude.json')) +}) + +test('a custom directory keeps it INSIDE itself', () => { + // Confirmed by running the agent against a throwaway dir: it created + // /.claude.json and left ~/.claude.json untouched. + assert.equal(configFilePath('/srv/work', env), '/srv/work/.claude.json') +}) + +test('a trailing slash does not turn the default directory into a custom one', () => { + assert.equal(configFilePath(`${HOME}/.claude/`, env), join(HOME, '.claude.json')) +}) + +test('a missing custom config file does NOT fall back to the home one', () => { + // The failure this prevents: reporting the DEFAULT account's identity for a + // directory that is not it — the silently-wrong-account bug, in the surface + // whose entire job is to tell the accounts apart. + const files = { [join(HOME, '.claude.json')]: JSON.stringify(REAL_MAX_ACCOUNT) } + assert.equal(readSessionIdentity('/srv/work', { env, readFile: reader(files) }), null) +}) + +test('the plan comes from organizationRateLimitTier, because the obvious fields are null', () => { + const files = { '/srv/work/.claude.json': JSON.stringify(REAL_MAX_ACCOUNT) } + const id = readSessionIdentity('/srv/work', { env, readFile: reader(files) }) + assert.ok(id) + assert.equal(id.plan, 'Max 20x') + assert.equal(id.email, 'someone@example.com') + assert.equal(id.extraUsage, false) +}) + +test('an unrecognised tier is shown verbatim rather than hidden', () => { + // A plan Anthropic ships next year should read as its raw id, not as blank. + const files = { + '/srv/work/.claude.json': JSON.stringify({ + oauthAccount: { emailAddress: 'a@b.c', organizationRateLimitTier: 'default_claude_ultra' }, + }), + } + const id = readSessionIdentity('/srv/work', { env, readFile: reader(files) }) + assert.equal(id?.plan, 'default_claude_ultra') +}) + +test('a fresh, never-logged-in directory reads as null', () => { + // The real shape: the agent creates .claude.json on first run with machineID, + // userID and projects — and no oauthAccount at all. + const files = { + '/srv/work/.claude.json': JSON.stringify({ + firstStartTime: '2026-01-01', + machineID: 'abc', + userID: 'def', + projects: {}, + }), + } + assert.equal(readSessionIdentity('/srv/work', { env, readFile: reader(files) }), null) +}) + +test('a corrupt or unreadable file reads as null rather than throwing', () => { + for (const content of ['', 'not json', 'null', '[]', '{"oauthAccount": 42}']) { + const files = { '/srv/work/.claude.json': content } + assert.equal( + readSessionIdentity('/srv/work', { env, readFile: reader(files) }), + null, + `${JSON.stringify(content)} should read as null`, + ) + } + assert.equal(readSessionIdentity('/srv/work', { env, readFile: reader({}) }), null) +}) + +test('an oauthAccount with nothing recognisable in it is not an identity', () => { + const files = { '/srv/work/.claude.json': JSON.stringify({ oauthAccount: { foo: 'bar' } }) } + assert.equal(readSessionIdentity('/srv/work', { env, readFile: reader(files) }), null) +}) + +test('the description names the account a user would recognise', () => { + assert.equal(describeIdentity(null), 'not logged in') + assert.equal(describeIdentity({ email: 'a@b.c', plan: 'Max 20x' }), 'a@b.c · Max 20x') + // No email: the org name is a poor substitute but better than nothing. On a + // personal plan it is literally "'s Organization", which is why it is + // last rather than first. + assert.equal(describeIdentity({ organizationName: 'Acme' }), 'Acme') + assert.equal(describeIdentity({}), 'logged in') +}) + +test('reading an identity never reveals a credential', () => { + // `.claude.json` holds far more than oauthAccount — project histories, and on + // some setups auth-adjacent bookkeeping. Only the identity fields come out. + const files = { + '/srv/work/.claude.json': JSON.stringify({ + ...REAL_MAX_ACCOUNT, + projects: { '/secret/client-work': { history: ['do not leak me'] } }, + claudeCodeFirstTokenDate: '2026-01-01', + }), + } + const id = readSessionIdentity('/srv/work', { env, readFile: reader(files) }) + const serialised = JSON.stringify(id) + assert.doesNotMatch(serialised, /secret|leak|history/) + assert.deepEqual(Object.keys(id ?? {}).sort(), [ + 'accountUuid', + 'displayName', + 'email', + 'extraUsage', + 'organizationName', + 'organizationUuid', + 'plan', + ]) +}) diff --git a/test/adapters/claude-session-onboard.test.ts b/test/adapters/claude-session-onboard.test.ts new file mode 100644 index 0000000..e5afcbd --- /dev/null +++ b/test/adapters/claude-session-onboard.test.ts @@ -0,0 +1,192 @@ +// `config accounts login` — adopting a subscription. +// +// The thing under test is mostly REFUSAL. Creating a directory is trivial; the +// value is in what it declines to do quietly — retarget an existing login, +// mix a key with a session, or hand the agent a polluted environment that would +// make `/login` appear to do nothing. +import test from 'node:test' +import assert from 'node:assert/strict' +import { mkdirSync, mkdtempSync, readdirSync, statSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { accountLogin, validateAccountName } from '../../src/adapters/claude-session/onboard.ts' +import { registry as agentRegistry } from '../../src/adapters/agents/registry.ts' +import type { State } from '../../src/ports/config-store.ts' + +function harness(state: Partial = {}, envOver: Record = {}) { + const root = mkdtempSync(join(tmpdir(), 'swisscode-onboard-')) + const out: string[] = [] + const err: string[] = [] + const saved: State[] = [] + const replaced: { bin: string; argv: string[]; env: Record }[] = [] + + const store = { + load: () => ({ + state: { version: 3, profiles: {}, bindings: {}, ...state } as State, + warnings: [], + readOnly: false, + }), + save: (s: State) => { + saved.push(s) + }, + } + const proc = { + env: () => ({ HOME: root, XDG_CONFIG_HOME: join(root, 'config'), ...envOver }), + cwd: () => root, + resolveBinary: () => '/usr/local/bin/claude', + replace: (bin: string, argv: string[], env: Record) => { + replaced.push({ bin, argv, env }) + }, + } + const run = (args: Parameters[0]) => accountLogin(args) + return { root, out, err, saved, replaced, store, proc, run } +} + +const base = (h: ReturnType) => ({ + store: h.store as never, + agents: agentRegistry, + proc: h.proc as never, + out: (l: string) => h.out.push(l), + err: (l: string) => h.err.push(l), +}) + +test('a name that would escape the accounts directory is refused, not sanitised', () => { + // A rejected name costs a second to fix. A sanitised one puts a login + // somewhere the user did not ask for and cannot find. + for (const bad of ['../evil', 'a/b', '.hidden', '', 'x/../../y']) { + assert.equal(validateAccountName(bad).ok, false, `${JSON.stringify(bad)} should be refused`) + } + for (const good of ['personal', 'work-2', 'a.b_c', 'x1']) { + assert.equal(validateAccountName(good).ok, true, `${good} should be allowed`) + } +}) + +test('it creates the session directory at 0700 and records the account', () => { + const h = harness() + const code = h.run({ ...base(h), name: 'personal' }) + assert.equal(code, 0) + + const dir = join(h.root, 'config', 'swisscode', 'accounts', 'personal') + assert.equal(statSync(dir).mode & 0o777, 0o700, 'a directory about to hold a login must be 0700') + assert.equal(h.saved.at(-1)?.providerAccounts?.personal?.configDir, dir) + assert.equal(h.saved.at(-1)?.providerAccounts?.personal?.provider, 'anthropic') + assert.equal(h.saved.at(-1)?.providerAccounts?.personal?.apiKey, undefined) +}) + +test('it hands the agent a CLEAN environment, or /login would appear to do nothing', () => { + // With either credential variable set, the agent authenticates as whoever + // that key belongs to and the login flow never runs — the user sees a + // working session and assumes it worked. + const h = harness({}, { ANTHROPIC_API_KEY: 'sk-ant-stale', ANTHROPIC_AUTH_TOKEN: 'stale' }) + h.run({ ...base(h), name: 'personal' }) + + const launched = h.replaced.at(-1) + assert.ok(launched) + assert.equal(launched.bin, '/usr/local/bin/claude') + assert.equal( + launched.env.CLAUDE_CONFIG_DIR, + join(h.root, 'config', 'swisscode', 'accounts', 'personal'), + ) + assert.equal(launched.env.ANTHROPIC_API_KEY, undefined) + assert.equal(launched.env.ANTHROPIC_AUTH_TOKEN, undefined) +}) + +test('it refuses to retarget an existing account at a different directory', () => { + // Silently repointing would abandon a working login with no way back to it. + const h = harness({ + providerAccounts: { personal: { provider: 'anthropic', configDir: '/existing/place' } }, + }) + const code = h.run({ ...base(h), name: 'personal' }) + assert.equal(code, 2) + assert.equal(h.saved.length, 0, 'nothing may be written on refusal') + assert.match(h.err.join('\n'), /already uses \/existing\/place/) +}) + +test('logging in again at the SAME directory is allowed — tokens do expire', () => { + const root = mkdtempSync(join(tmpdir(), 'swisscode-relogin-')) + const existing = join(root, 'place') + const h = harness({ + providerAccounts: { personal: { provider: 'anthropic', configDir: existing } }, + }) + const code = h.run({ ...base(h), name: 'personal', dir: existing }) + assert.equal(code, 0) + assert.equal(h.replaced.length, 1) +}) + +test('it refuses to give a key-mode account a session as well', () => { + const h = harness({ providerAccounts: { personal: { provider: 'anthropic', apiKey: 'sk-x' } } }) + const code = h.run({ ...base(h), name: 'personal' }) + assert.equal(code, 2) + assert.equal(h.saved.length, 0) + assert.match(h.err.join('\n'), /key or a subscription login, never both/) +}) + +test('a relative --dir is resolved against the cwd, since this process execves away', () => { + const h = harness() + h.run({ ...base(h), name: 'personal', dir: 'sub/dir' }) + assert.equal(h.saved.at(-1)?.providerAccounts?.personal?.configDir, join(h.root, 'sub', 'dir')) +}) + +test('a read-only config refuses before creating anything', () => { + const h = harness() + const store = { ...h.store, load: () => ({ ...h.store.load(), readOnly: true }) } + const code = h.run({ ...base(h), store: store as never, name: 'personal' }) + assert.equal(code, 2) + assert.equal(h.replaced.length, 0, 'nothing may be launched when the account cannot be recorded') +}) + +test('a missing binary still records the account, and says how to finish by hand', () => { + // The account is the durable half. Losing it because the CLI is not on PATH + // would make the user redo the part that succeeded. + const h = harness() + const proc = { + ...h.proc, + resolveBinary: () => { + throw new Error('claude was not found on PATH') + }, + } + const code = h.run({ ...base(h), proc: proc as never, name: 'personal' }) + assert.equal(code, 2) + assert.equal(h.saved.length, 1, 'the account is still recorded') + assert.match(h.err.join('\n'), /CLAUDE_CONFIG_DIR=/) +}) + +test('it reports when the adopted directory is already logged in', () => { + const h = harness() + const dir = join(h.root, 'adopted') + mkdirSync(dir, { recursive: true, mode: 0o700 }) + writeFileSync( + join(dir, '.claude.json'), + JSON.stringify({ + oauthAccount: { emailAddress: 'a@b.c', organizationRateLimitTier: 'default_claude_max_5x' }, + }), + ) + h.run({ ...base(h), name: 'adopted', dir }) + assert.match(h.out.join('\n'), /already logged in as a@b\.c {2}· {2}Max 5x/) +}) + +test('an adopted world-readable directory earns a warning rather than a silent chmod', () => { + const h = harness() + const dir = join(h.root, 'loose') + mkdirSync(dir, { recursive: true, mode: 0o755 }) + h.run({ ...base(h), name: 'loose', dir }) + assert.match(h.err.join('\n'), /readable by other users/) + // …and it still proceeds: narrowing someone else's directory under their feet + // is not this command's call to make. + assert.equal(h.replaced.length, 1) +}) + +test('the recorded config is exactly one account and nothing else', () => { + const h = harness({ profiles: { p: { agentProfile: 'a', accounts: [] } } } as Partial) + h.run({ ...base(h), name: 'personal' }) + const saved = h.saved.at(-1)! + assert.deepEqual(Object.keys(saved.providerAccounts ?? {}), ['personal']) + assert.deepEqual(Object.keys(saved.profiles ?? {}), ['p'], 'existing config is preserved') +}) + +test('the created directory is empty — swisscode writes no credential into it', () => { + const h = harness() + h.run({ ...base(h), name: 'personal' }) + const dir = join(h.root, 'config', 'swisscode', 'accounts', 'personal') + assert.deepEqual(readdirSync(dir), [], 'the agent creates its own files; swisscode creates none') +}) diff --git a/test/architecture.test.ts b/test/architecture.test.ts index 043af92..be63905 100644 --- a/test/architecture.test.ts +++ b/test/architecture.test.ts @@ -104,7 +104,7 @@ test('the published bin shim is dependency-free and runs compiled output', () => ) }) -test('the launch path never statically reaches adapters/ui, catalog or usage', () => { +test('the launch path never statically reaches adapters/ui, catalog, usage or claude-session', () => { const { files } = launchClosure() for (const f of files) { const rel = relative(ROOT, f) @@ -115,6 +115,12 @@ test('the launch path never statically reaches adapters/ui, catalog or usage', ( // not reach the network, and an adapter that crept onto it would quietly // turn every launch into an HTTP request to a billing endpoint. assert.ok(!rel.startsWith('src/adapters/usage'), `${rel} is a usage adapter`) + // Session inspection is configuration-time for the same reason, with a + // sharper edge: `.claude.json` is a ~200 kB file on a well-used account and + // reading a credential can raise a Keychain prompt. A launch resolves a + // session directory and lowers it to an env var WITHOUT OPENING IT — the + // agent is the one that reads it, milliseconds later, as it already does. + assert.ok(!rel.startsWith('src/adapters/claude-session'), `${rel} inspects a session`) } }) From 7bd18fa48fc7e9faedbdc706f00045a5255b7c5f Mon Sep 17 00:00:00 2001 From: jellologic <31935831+jellologic@users.noreply.github.com> Date: Wed, 22 Jul 2026 11:00:29 -0400 Subject: [PATCH 12/21] fix(session): naming the default directory must UNSET CLAUDE_CONFIG_DIR, not write it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The plan claimed the default ~/.claude "becomes account #1 with no re-login". That was false, and testing it rather than trusting it is the only reason this was caught before it shipped. Claude Code picks which Keychain item holds the credential from WHETHER CLAUDE_CONFIG_DIR IS SET — it hashes the path into the service name whenever it is — so the default directory has two different credentials depending on how you arrive at it. Measured on this machine: claude config ls -> logged in CLAUDE_CONFIG_DIR=$HOME/.claude claude config ls -> "Not logged in" Same directory, same file, different login. And the failure is quiet in the worst way: identity comes from `.claude.json`, which really IS shared, so adopting ~/.claude reported the correct email — "already logged in as orepsdev@gmail.com · Max 20x" — while producing a session that could not authenticate at all. So an account naming the default directory now lowers to CLEARING the variable (including a stale ambient one), which is what makes adopting an existing login actually work. `config accounts login --dir ~/.claude` returns immediately with nothing to do rather than sending the user through a second /login that would replace the login being adopted. `isDefaultConfigDir` lives in claude-code/env.ts rather than in a module of its own: it is an env-lowering question, and the launch path is held under 40 modules so it stays auditable in a sitting — a budget the first attempt at this tripped, which is the budget working. Also drops the permissions warning for the default directory. Claude Code creates ~/.claude at 0755 itself; warning about something swisscode neither made nor worsened, on a stock install, teaches people to skip warnings. `config doctor` is where a pre-existing permissions problem belongs. config accounts login default --dir ~/.claude adopted your existing login: orepsdev@gmail.com · Max 20x resulting launch: CLAUDE_CONFIG_DIR set to: (not set — correct) unset: CLAUDE_CONFIG_DIR, ANTHROPIC_API_KEY, ANTHROPIC_AUTH_TOKEN 670 tests pass. Signed-off-by: jellologic <31935831+jellologic@users.noreply.github.com> --- src/adapters/agents/claude-code/env.ts | 54 +++++++++++++++++++- src/adapters/claude-session/identity.ts | 12 ++--- src/adapters/claude-session/onboard.ts | 36 +++++++++++-- test/adapters/claude-session-onboard.test.ts | 34 ++++++++++++ test/core/session-mode.test.ts | 46 +++++++++++++++++ 5 files changed, 171 insertions(+), 11 deletions(-) diff --git a/src/adapters/agents/claude-code/env.ts b/src/adapters/agents/claude-code/env.ts index 69326cc..aff0db1 100644 --- a/src/adapters/agents/claude-code/env.ts +++ b/src/adapters/agents/claude-code/env.ts @@ -6,6 +6,8 @@ // variable this tool emits is chosen here. The generic accumulator it is built // on (makeEnvWriter, materializeEnv) is neutral and lives in core/env-plan.ts. +import { homedir } from 'node:os' +import { join, resolve } from 'node:path' import { TIERS } from '../../../core/tiers.ts' import { definedEntriesOf, makeEnvWriter, resolveCredential } from '../../../core/env-plan.ts' import { TIER_ENV } from './tiers.ts' @@ -17,6 +19,39 @@ import type { ProviderDescriptor, ResolvedModels, Tier } from '../../../ports/pr import type { EnvMap } from '../../../ports/process.ts' import type { EnvWarning } from '../../../ports/agent.ts' +/** + * Does this session directory mean "the default login"? + * + * Lives here, in the env lowering, because it is an ENV-LOWERING QUESTION: the + * answer decides whether CLAUDE_CONFIG_DIR is written or cleared. It earns no + * module of its own — the launch path is held under 40 modules so it stays + * auditable in a sitting, and that budget is a real constraint rather than a + * decoration. + * + * MEASURED, and the measurement is the whole reason it exists. Claude Code + * chooses which Keychain item holds the credential from WHETHER + * `CLAUDE_CONFIG_DIR` IS SET, hashing the path into the service name whenever + * it is — so the default directory has two distinct credentials depending on + * how you arrive at it. Verified on a real machine: + * + * claude config ls -> logged in + * CLAUDE_CONFIG_DIR=$HOME/.claude claude config ls -> "Not logged in" + * + * Same directory, same `.claude.json`, different login. Identity is shared — + * that file really is the same one — so a session lowered the wrong way reports + * the correct email while being unable to authenticate, which is about the most + * confusing failure on offer. + */ +export function isDefaultConfigDir( + dir: string, + env: Record = process.env, +): boolean { + // `resolve` so a trailing slash, a doubled separator, or a relative spelling + // of the same directory all answer alike. A near-miss does not fail loudly; + // it silently takes the hashed-credential branch. + return resolve(dir) === resolve(join(env.HOME || homedir(), '.claude')) +} + /** * CompatFlags -> env var. Descriptors never spell a variable name; they set a * boolean and this table decides what that means. Each entry below has a @@ -156,9 +191,26 @@ export function buildEnvPlan( // anthropic-direct path cleared only the first, so a stale auth token // survived into the child — a silent wrong-account launch, which is the // exact failure the golden maps exist to catch. + // + // SETTING THE VARIABLE TO THE DEFAULT PATH IS NOT THE SAME AS LEAVING IT + // UNSET, and this is not a subtlety we may round off. Claude Code decides + // which Keychain item holds the credential from whether CLAUDE_CONFIG_DIR + // IS SET — not from what it contains — hashing the path into the service + // name whenever it is. So the default directory has two different + // credentials depending on how you arrive at it. Verified on this machine: + // + // claude config ls -> logged in + // CLAUDE_CONFIG_DIR=$HOME/.claude ... ls -> "Not logged in" + // + // Same directory, same `.claude.json`, different login. An account that + // names the default directory therefore lowers to UNSETTING the variable, + // which is also what makes adopting an existing `~/.claude` work with no + // re-login. Writing the path instead would hand the user a session that + // reports the right email — identity comes from `.claude.json`, which IS + // shared — while being logged out. const sessionDir = profile?.configDir if (sessionDir) { - write('CLAUDE_CONFIG_DIR', sessionDir) + write('CLAUDE_CONFIG_DIR', isDefaultConfigDir(sessionDir, ambientEnv) ? '' : sessionDir) write('ANTHROPIC_API_KEY', '') write('ANTHROPIC_AUTH_TOKEN', '') } diff --git a/src/adapters/claude-session/identity.ts b/src/adapters/claude-session/identity.ts index 2caf203..8b3919d 100644 --- a/src/adapters/claude-session/identity.ts +++ b/src/adapters/claude-session/identity.ts @@ -15,6 +15,7 @@ import { readFileSync } from 'node:fs' import { homedir } from 'node:os' import { dirname, join, resolve } from 'node:path' +import { isDefaultConfigDir } from '../agents/claude-code/env.ts' type ReadableEnv = Record @@ -64,12 +65,11 @@ export type SessionIdentity = { * feature exists to end. */ export function configFilePath(configDir: string, env: ReadableEnv = process.env): string { - const home = env.HOME || homedir() - // `resolve` so that `~/.claude`, `~/.claude/`, and a relative path spelling of - // it all compare equal; a trailing slash must not turn the default directory - // into a custom one. - return resolve(configDir) === resolve(join(home, '.claude')) - ? join(home, '.claude.json') + // Shares `isDefaultConfigDir` with the launch path rather than re-deriving + // "is this the default directory?" — the two must agree, and the launch path + // owns that question because it is the one that has to unset the variable. + return isDefaultConfigDir(configDir, env) + ? join(env.HOME || homedir(), '.claude.json') : join(resolve(configDir), '.claude.json') } diff --git a/src/adapters/claude-session/onboard.ts b/src/adapters/claude-session/onboard.ts index ae7a7fc..2ab502e 100644 --- a/src/adapters/claude-session/onboard.ts +++ b/src/adapters/claude-session/onboard.ts @@ -15,6 +15,7 @@ import { existsSync, mkdirSync, statSync } from 'node:fs' import { homedir } from 'node:os' import { isAbsolute, join, resolve } from 'node:path' import { describeIdentity, readSessionIdentity } from './identity.ts' +import { isDefaultConfigDir } from '../agents/claude-code/env.ts' import type { ConfigStorePort, ProviderAccount, State } from '../../ports/config-store.ts' import type { AgentRegistryPort } from '../../ports/agent.ts' import type { ProcessPort } from '../../ports/process.ts' @@ -141,9 +142,15 @@ export function accountLogin({ // An ADOPTED directory keeps whatever permissions it has — narrowing someone // else's ~/.claude-work under their feet is not this command's business — but // a permissive one earns a warning, since a login is about to live in it. + // + // NOT for the default directory. Claude Code creates `~/.claude` at 0755 + // itself, adopting it changes nothing about its exposure, and a warning that + // fires on a stock install for something swisscode neither made nor worsened + // is noise that teaches people to skip warnings. `config doctor` is where a + // pre-existing permissions problem belongs. try { const mode = statSync(target).mode & 0o777 - if (mode & 0o077) { + if (mode & 0o077 && !isDefaultConfigDir(target, proc.env())) { err( `swisscode: warning — ${target} is readable by other users (mode ${mode.toString(8)}). ` + 'It is about to hold a login. `chmod 700` it.', @@ -165,11 +172,28 @@ export function accountLogin({ return 2 } - const already = readSessionIdentity(target, { env: proc.env() }) + const env = proc.env() + const isDefault = isDefaultConfigDir(target, env) + const already = readSessionIdentity(target, { env }) + + if (isDefault && already) { + // The common first step: adopt the login you already have. Nothing to do, + // and nothing to launch — telling someone to `/login` into the account they + // are already using would be busywork that risks replacing it. + out(`Account "${name}" adopted your existing login: ${describeIdentity(already)}.`) + out(` ${target} (Claude Code's default directory)`) + out('') + out('Nothing else to do — this account is ready to use. Add a second one with') + out(` swisscode config accounts login `) + return 0 + } if (already) { out(`Account "${name}" already logged in as ${describeIdentity(already)}.`) out(` ${target}`) out('Run `/login` inside the session that starts next to switch it to another account.') + } else if (isDefault) { + // Naming the default directory when nobody has ever logged in there. + out(`Account "${name}" recorded, using Claude Code's default directory.`) } else { out(`Account "${name}" recorded, using ${target}.`) } @@ -199,8 +223,12 @@ export function accountLogin({ out('Starting Claude Code in that directory. Run `/login` inside it, then exit.') out('') - const env = proc.env() - env.CLAUDE_CONFIG_DIR = target + // Setting the variable to the default path would send the agent to a + // DIFFERENT credential than the one it uses when the variable is unset, so a + // login performed here would not be the login a plain `claude` finds. Same + // rule as the launch path; see `isDefaultConfigDir`. + if (isDefault) delete env.CLAUDE_CONFIG_DIR + else env.CLAUDE_CONFIG_DIR = target // The same both-variables rule the launch path enforces, for the same reason: // either one present would authenticate the login flow as somebody else and // the `/login` would appear to do nothing. diff --git a/test/adapters/claude-session-onboard.test.ts b/test/adapters/claude-session-onboard.test.ts index e5afcbd..f4f820c 100644 --- a/test/adapters/claude-session-onboard.test.ts +++ b/test/adapters/claude-session-onboard.test.ts @@ -176,6 +176,40 @@ test('an adopted world-readable directory earns a warning rather than a silent c assert.equal(h.replaced.length, 1) }) +test('adopting the DEFAULT directory takes the existing login and launches nothing', () => { + // The trap this guards: Claude Code keys its credential on whether + // CLAUDE_CONFIG_DIR is SET, not on its value, so `--dir ~/.claude` must not + // send the user through a second /login — and must not launch at all, since + // logging in again would replace the login being adopted. + const root = mkdtempSync(join(tmpdir(), 'swisscode-default-')) + const home = join(root, 'home') + mkdirSync(join(home, '.claude'), { recursive: true }) + writeFileSync( + join(home, '.claude.json'), // a SIBLING of ~/.claude, not a child + JSON.stringify({ + oauthAccount: { emailAddress: 'me@example.com', organizationRateLimitTier: 'default_claude_max_20x' }, + }), + ) + const h = harness({}, { HOME: home }) + const code = h.run({ ...base(h), name: 'default', dir: join(home, '.claude') }) + + assert.equal(code, 0) + assert.equal(h.replaced.length, 0, 'adopting an existing login must not start a login flow') + assert.match(h.out.join('\n'), /adopted your existing login: me@example\.com {2}· {2}Max 20x/) + assert.equal(h.saved.at(-1)?.providerAccounts?.default?.configDir, join(home, '.claude')) +}) + +test('the default directory earns no permissions warning — swisscode did not create it', () => { + // Claude Code makes ~/.claude at 0755. A warning that fires on a stock + // install, for something swisscode neither made nor worsened, is noise. + const root = mkdtempSync(join(tmpdir(), 'swisscode-defperm-')) + const home = join(root, 'home') + mkdirSync(join(home, '.claude'), { recursive: true, mode: 0o755 }) + const h = harness({}, { HOME: home }) + h.run({ ...base(h), name: 'default', dir: join(home, '.claude') }) + assert.doesNotMatch(h.err.join('\n'), /readable by other users/) +}) + test('the recorded config is exactly one account and nothing else', () => { const h = harness({ profiles: { p: { agentProfile: 'a', accounts: [] } } } as Partial) h.run({ ...base(h), name: 'personal' }) diff --git a/test/core/session-mode.test.ts b/test/core/session-mode.test.ts index 86fe371..34f922c 100644 --- a/test/core/session-mode.test.ts +++ b/test/core/session-mode.test.ts @@ -70,6 +70,52 @@ test('Claude Code lowers it, and clears both overriding variables', () => { assert.ok(plan.unset.includes('ANTHROPIC_AUTH_TOKEN')) }) +test('naming the DEFAULT directory unsets the variable rather than writing the path', () => { + // The trap, measured on a real machine: + // + // claude config ls -> logged in + // CLAUDE_CONFIG_DIR=$HOME/.claude claude config ls -> "Not logged in" + // + // Claude Code picks the Keychain item from whether the variable IS SET, not + // from what it holds, so writing the default path here hands the user a + // session that reports the right email — identity comes from the shared + // .claude.json — while being unable to authenticate. + const home = '/home/u' + const plan = buildEnvPlan( + makeProfile({ provider: 'anthropic', configDir: `${home}/.claude` }), + anthropic, + { HOME: home, CLAUDE_CONFIG_DIR: '/somewhere/stale' }, + ) + assert.equal(plan.set.CLAUDE_CONFIG_DIR, undefined, 'the path must not be written') + assert.ok( + plan.unset.includes('CLAUDE_CONFIG_DIR'), + 'a stale ambient CLAUDE_CONFIG_DIR must be actively cleared, not merely left alone', + ) + // …and it is still a session launch, so both credential vars still go. + assert.ok(plan.unset.includes('ANTHROPIC_API_KEY')) + assert.ok(plan.unset.includes('ANTHROPIC_AUTH_TOKEN')) +}) + +test('a trailing slash on the default directory is still the default directory', () => { + // A near-miss does not fail loudly — it silently takes the hashed-credential + // branch — so the comparison normalises before it compares. + const plan = buildEnvPlan( + makeProfile({ provider: 'anthropic', configDir: '/home/u/.claude/' }), + anthropic, + { HOME: '/home/u' }, + ) + assert.equal(plan.set.CLAUDE_CONFIG_DIR, undefined) +}) + +test('a NON-default directory does write the path', () => { + const plan = buildEnvPlan( + makeProfile({ provider: 'anthropic', configDir: '/home/u/.claude-work' }), + anthropic, + { HOME: '/home/u' }, + ) + assert.equal(plan.set.CLAUDE_CONFIG_DIR, '/home/u/.claude-work') +}) + test('a key account still sets its credential, and no config dir', () => { // The regression guard for the change above: session mode must not have // quietly disabled the ordinary path. From c2153d7b18d310ebeb897eda3e6544e2ede4d239 Mon Sep 17 00:00:00 2001 From: jellologic <31935831+jellologic@users.noreply.github.com> Date: Wed, 22 Jul 2026 11:08:55 -0400 Subject: [PATCH 13/21] feat(usage): read a subscription's real remaining capacity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 3. Three modules, each verified against the live service rather than against the plan's description of it. `claude-session/credentials.ts` reads the OAuth token for a session directory and does nothing else with it: never refreshes (that needs Anthropic's own client id — the impersonation line this design stays behind), never writes, and never carries the refresh token at all, so nothing downstream is even tempted. `/usr/bin/security` by absolute path, since PATH is attacker-influenced on a shared machine and this command's whole job is handling a secret. A dismissed Keychain prompt reports as `denied`, distinct from `absent` — they send the user to fix completely different things. Verified live: the derived service name matched the real item exactly, and read back subscriptionType 'max'. `usage/anthropic-subscription.ts` asks GET /api/oauth/usage. Ranks on the WORSE of the two windows, never the average: an account 10% into its 5-hour window and 95% into its weekly one has almost nothing left, and averaging to 52% would send work straight at it. Verified live — 37% / 73% used, ranked as 27% remaining. Two things the real payload corrected: - It is `extra_usage.is_enabled`, not `.enabled`. The wrong name fails SILENTLY and permanently: it yields null, indistinguishable from a field the endpoint did not publish, so the feature would simply never have worked. - There are per-model weekly windows (`seven_day_opus`, `seven_day_sonnet`), both null on this account. They are reported but NOT ranked on — an exhausted Opus window should not disqualify an account for a profile that runs Sonnet, and ranking that properly needs a fact this adapter does not have. `store/fs-usage-store.ts` CLOSES A LOOP THAT WAS OPEN: `core/resolve.ts` has read a UsageSnapshot since the v3 split, but nothing ever wrote one, so the `usage` strategy always took its documented fallback. This fixes OpenRouter's `usage` selection too — the gap was never provider-specific. 12-hour TTL, because a figure older than that describes a 5-hour window that has since rolled over; an expired snapshot reads as ABSENT rather than as zero. Writing the port's own contract down in a test caught the adapter violating it: "published nothing usable" must return null, not an all-null object that every caller's null check would read as a successful measurement. 707 tests pass; launch path unchanged at 228.2 kB packed, well inside budget. Signed-off-by: jellologic <31935831+jellologic@users.noreply.github.com> --- src/adapters/claude-session/credentials.ts | 225 ++++++++++++++++++ src/adapters/store/fs-usage-store.ts | 96 ++++++++ src/adapters/usage/anthropic-subscription.ts | 156 ++++++++++++ src/ports/provider-usage.ts | 28 +++ .../anthropic-subscription-usage.test.ts | 201 ++++++++++++++++ .../claude-session-credentials.test.ts | 196 +++++++++++++++ test/adapters/fs-usage-store.test.ts | 101 ++++++++ 7 files changed, 1003 insertions(+) create mode 100644 src/adapters/claude-session/credentials.ts create mode 100644 src/adapters/store/fs-usage-store.ts create mode 100644 src/adapters/usage/anthropic-subscription.ts create mode 100644 test/adapters/anthropic-subscription-usage.test.ts create mode 100644 test/adapters/claude-session-credentials.test.ts create mode 100644 test/adapters/fs-usage-store.test.ts diff --git a/src/adapters/claude-session/credentials.ts b/src/adapters/claude-session/credentials.ts new file mode 100644 index 0000000..2670223 --- /dev/null +++ b/src/adapters/claude-session/credentials.ts @@ -0,0 +1,225 @@ +// Reading the OAuth token a session directory owns. +// +// THE ONE MODULE IN THIS TOOL THAT TOUCHES A CREDENTIAL IT DID NOT PUT THERE, +// and it is deliberately the smallest thing that can work: read, report, never +// refresh and never write. It exists so `usage` selection can ask Anthropic +// how much of each subscription is left — nothing else. +// +// WHAT THIS DOES NOT DO, on purpose: +// +// - It never REFRESHES an expired token. Refreshing needs Anthropic's own +// OAuth client id, which is the impersonation line this design stays behind. +// An expired token is reported as expired; the agent refreshes it itself the +// next time you run it, which is both correct and free. +// - It never WRITES. Writing is Phase 4's targeted swap, and keeping the read +// path write-free means nothing here can damage a login. +// - It never LOGS the token, and no caller is given a shape that tempts it to. +// +// OFF THE LAUNCH PATH — `test/architecture.test.ts` enforces it. A launch hands +// the directory to the agent and lets the agent read its own credential, which +// is why an ordinary `swisscode` never raises a Keychain prompt. + +import { createHash } from 'node:crypto' +import { execFileSync } from 'node:child_process' +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { isDefaultConfigDir } from '../agents/claude-code/env.ts' + +type ReadableEnv = Record + +/** + * An OAuth credential, as Claude Code stores it. + * + * `accessToken` is the only field anything here uses. `refreshToken` is + * deliberately NOT in this type: nothing in swisscode may refresh, so carrying + * one around would only create the opportunity. + */ +export type SessionCredential = { + accessToken: string + /** epoch ms, when published */ + expiresAt: number | null + /** e.g. 'max' — Anthropic's own word for the plan behind this token */ + subscriptionType: string | null + /** where it was found, for diagnosis */ + source: 'keychain' | 'file' +} + +export type CredentialResult = + | { ok: true; credential: SessionCredential; expired: boolean } + /** + * `reason` names the fix, and `kind` lets a caller distinguish "log in" from + * "we could not look" — a Keychain prompt the user dismissed is not the same + * finding as an account that was never logged into. + */ + | { ok: false; kind: 'absent' | 'denied' | 'unreadable'; reason: string } + +/** + * The Keychain service name holding a session directory's credential. + * + * DERIVED FROM CLAUDE CODE'S OWN RULE, whose shape is: + * + * isDefaultDir = !process.env.CLAUDE_CONFIG_DIR + * dirHash = isDefaultDir ? '' : `-${sha256(configDir).slice(0, 8)}` + * service = `Claude Code-credentials${dirHash}` + * + * Two things follow, and both matter more than they look: + * + * 1. The branch is on whether the VARIABLE IS SET, not on the path's value — + * the same asymmetry `isDefaultConfigDir` exists for. A session lowered as + * "unset the variable" must be looked up under the UNHASHED name. + * 2. The hash is over the string that would be WRITTEN to CLAUDE_CONFIG_DIR. + * We control that string — the env lowering writes `account.configDir` + * verbatim — so this hashes exactly the same string rather than a + * re-normalised spelling of it. Normalising here and not there would produce + * a name that is right in every test and wrong on every machine. + * + * The unhashed branch is VERIFIED live: the real item on this machine is + * `Claude Code-credentials`, matching exactly. The hashed branch follows the + * rule above but cannot be confirmed without performing a real `/login` into a + * custom directory, so `config doctor` reports what it finds rather than + * asserting the name is right. + */ +export function keychainService(configDir: string, env: ReadableEnv = process.env): string { + if (isDefaultConfigDir(configDir, env)) return 'Claude Code-credentials' + const hash = createHash('sha256').update(configDir).digest('hex').slice(0, 8) + return `Claude Code-credentials-${hash}` +} + +/** + * Where the credential lives when it is a file rather than a Keychain item. + * + * Unlike `.claude.json`, this one has NO asymmetry — it is inside the config + * directory either way. Worth stating because the neighbouring file does not + * behave like this, and assuming they match is an easy way to read the wrong + * account's token. + */ +export function credentialFilePath(configDir: string): string { + return join(configDir, '.credentials.json') +} + +/** Pull the fields we use out of whichever envelope the token arrived in. */ +function parseCredential(raw: string, source: 'keychain' | 'file'): SessionCredential | null { + let parsed: unknown + try { + parsed = JSON.parse(raw) + } catch { + return null + } + if (!parsed || typeof parsed !== 'object') return null + // Both stores wrap the token in `claudeAiOauth`; tolerate a bare object too, + // since the wrapper is not ours and tolerating it costs one line. + const o = parsed as Record + const oauth = (o.claudeAiOauth ?? o) as Record + const accessToken = oauth.accessToken + if (typeof accessToken !== 'string' || accessToken === '') return null + return { + accessToken, + expiresAt: typeof oauth.expiresAt === 'number' ? oauth.expiresAt : null, + subscriptionType: + typeof oauth.subscriptionType === 'string' ? oauth.subscriptionType : null, + source, + } +} + +export type ReadCredentialOptions = { + env?: ReadableEnv + /** injected in tests; defaults to /usr/bin/security */ + keychain?: (service: string) => string + /** injected in tests; defaults to node:fs */ + readFile?: (path: string) => string + platform?: NodeJS.Platform + /** injected so expiry is decidable without a clock */ + now?: number +} + +/** + * `/usr/bin/security find-generic-password -s -w`. + * + * ABSOLUTE PATH, so a `security` earlier on PATH cannot impersonate it — this + * command's whole job is handling a secret, and PATH is attacker-influenced on a + * shared machine. + * + * The service name goes in argv, which is fine: it is not a secret and it is + * already visible in the Keychain. THE TOKEN COMES BACK ON STDOUT and is never + * placed in argv by anything here. + */ +function readKeychain(service: string): string { + return execFileSync('/usr/bin/security', ['find-generic-password', '-s', service, '-w'], { + encoding: 'utf8', + // A Keychain prompt the user never answers must not hang a configuration + // screen forever. 20s is generous for a click and short enough to recover. + timeout: 20_000, + // The token is on stdout; keep the CLI's own chatter off our stderr. + stdio: ['ignore', 'pipe', 'ignore'], + }) +} + +/** + * Read the credential for a session directory. + * + * Never throws and never refreshes. On macOS it tries the Keychain, then the + * file — Claude Code writes the file on platforms without a Keychain, and some + * macOS setups end up with one too, so trying both is what actually works + * rather than what the platform table says should. + */ +export function readSessionCredential( + configDir: string, + { + env = process.env, + keychain = readKeychain, + readFile = (p) => readFileSync(p, 'utf8'), + platform = process.platform, + now = Date.now(), + }: ReadCredentialOptions = {}, +): CredentialResult { + let denied = false + + if (platform === 'darwin') { + try { + const credential = parseCredential(keychain(keychainService(configDir, env)), 'keychain') + if (credential) { + return { ok: true, credential, expired: isExpired(credential, now) } + } + } catch (e) { + // Exit 44 is "item not found", which is a normal state for a directory + // nobody has logged into. Anything else — a dismissed prompt, a locked + // keychain — is a DIFFERENT finding and must not be reported as + // "not logged in", which would send the user to fix the wrong thing. + const status = (e as { status?: number }).status + denied = status !== 44 && status !== undefined + } + } + + try { + const credential = parseCredential(readFile(credentialFilePath(configDir)), 'file') + if (credential) return { ok: true, credential, expired: isExpired(credential, now) } + } catch { + /* fall through to the verdict below */ + } + + if (denied) { + return { + ok: false, + kind: 'denied', + reason: + 'the keychain refused to hand over this account\'s token — the prompt was dismissed, ' + + 'or the keychain is locked. Unlock it and try again; nothing is wrong with the account.', + } + } + return { + ok: false, + kind: 'absent', + reason: `no login found for ${configDir}. Run \`swisscode config accounts login\` for it.`, + } +} + +/** + * Expiry is REPORTED, never acted on. + * + * A token past `expiresAt` still identifies the account; it just will not + * authenticate. Callers say so and move on — the agent refreshes it on its next + * run, which is the only party entitled to. + */ +function isExpired(credential: SessionCredential, now: number): boolean { + return credential.expiresAt !== null && credential.expiresAt <= now +} diff --git a/src/adapters/store/fs-usage-store.ts b/src/adapters/store/fs-usage-store.ts new file mode 100644 index 0000000..12675c5 --- /dev/null +++ b/src/adapters/store/fs-usage-store.ts @@ -0,0 +1,96 @@ +// The measured-usage snapshot, between runs. +// +// THIS CLOSES A LOOP THAT WAS OPEN. `core/resolve.ts` has read a `UsageSnapshot` +// since the v3 split, but nothing ever wrote one — so the `usage` strategy +// always took its documented fallback and always warned. That was honest, and +// useless. This is the writer, and it fixes OpenRouter's `usage` selection at +// the same time as enabling the subscription one, because the gap was never +// provider-specific. +// +// In the STATE directory beside the rotation cursor, and for the same reason: a +// measurement is regenerable. Losing it costs one refresh, not a broken setup. +// It is also stale by nature — nothing here pretends otherwise, and `checkedAt` +// travels with the numbers so every consumer can say how old they are. +// +// WRITTEN AT CONFIGURATION TIME ONLY — by the doctor and the web UI. The launch +// path reads it and never refreshes it, because refreshing means the network. + +import { mkdirSync, readFileSync, writeFileSync } from 'node:fs' +import { join } from 'node:path' +import { stateDir } from './fs-cursor-store.ts' +import type { UsageSnapshot } from '../../core/resolve.ts' + +type ReadableEnv = Record + +export type UsageStorePort = { + /** null when nothing has been measured, or the file is unusable */ + read: () => UsageSnapshot | null + /** best effort; a failed write must never fail the command that triggered it */ + write: (snapshot: UsageSnapshot) => void +} + +export type FsUsageStoreOptions = { + env?: ReadableEnv + dir?: string | null +} + +/** + * How long a snapshot is worth reading. + * + * Twelve hours. A 5-hour subscription window means a figure older than that + * describes a window that has since rolled over — routing on it would be + * routing on a number that is not merely stale but about a different period. + * Expired snapshots read as ABSENT rather than as zero, so selection falls back + * to the first account and says so, which is the behaviour that was already + * there and already tested. + */ +export const SNAPSHOT_TTL_MS = 12 * 60 * 60 * 1000 + +export function createFsUsageStore({ + env = process.env, + dir = null, +}: FsUsageStoreOptions = {}): UsageStorePort { + const DIR = dir ?? stateDir(env) + const PATH = join(DIR, 'usage.json') + + return { + read(): UsageSnapshot | null { + let parsed: unknown + try { + parsed = JSON.parse(readFileSync(PATH, 'utf8')) + } catch { + return null + } + if (!parsed || typeof parsed !== 'object') return null + const o = parsed as { remaining?: unknown; checkedAt?: unknown } + if (typeof o.checkedAt !== 'number' || !Number.isFinite(o.checkedAt)) return null + if (Date.now() - o.checkedAt > SNAPSHOT_TTL_MS) return null + if (!o.remaining || typeof o.remaining !== 'object' || Array.isArray(o.remaining)) return null + + // Filter rather than trust: a hand-edited or half-written file must not + // put a NaN or a string into the comparison that decides which account + // pays. Anything that is not a finite number is simply not a candidate. + const remaining: Record = {} + for (const [name, value] of Object.entries(o.remaining as Record)) { + if (typeof value === 'number' && Number.isFinite(value)) remaining[name] = value + } + return Object.keys(remaining).length > 0 + ? { remaining, checkedAt: o.checkedAt } + : null + }, + + write(snapshot: UsageSnapshot): void { + try { + // 0700/0600: the file names ACCOUNTS and how much of each is left. No + // credential, but it is a map of what you pay for and it is nobody + // else's business — the same reasoning the cursor file carries. + mkdirSync(DIR, { recursive: true, mode: 0o700 }) + writeFileSync(PATH, `${JSON.stringify(snapshot, null, 2)}\n`, { mode: 0o600 }) + } catch { + // The measurement already happened and was already displayed. Failing + // the doctor because a cache could not be written would trade a working + // diagnosis for a tidy filesystem. + } + }, + } +} diff --git a/src/adapters/usage/anthropic-subscription.ts b/src/adapters/usage/anthropic-subscription.ts new file mode 100644 index 0000000..f71f623 --- /dev/null +++ b/src/adapters/usage/anthropic-subscription.ts @@ -0,0 +1,156 @@ +// How much of a Claude subscription is left. +// +// This is the adapter that makes `/usage` unnecessary: the same figure, for +// EVERY account at once, without switching into each one to look. +// +// CONFIGURATION-TIME ONLY, like every usage adapter — `test/architecture.test.ts` +// keeps `adapters/usage` off the launch path by name. `usage` selection reads +// the cached snapshot this writes; it never reaches the network itself. + +import { readSessionCredential } from '../claude-session/credentials.ts' +import type { ProviderUsage, ProviderUsagePort, SubscriptionWindow } from '../../ports/provider-usage.ts' + +/** + * The endpoint, and the header that makes it answer. + * + * UNDOCUMENTED. It is what the official client calls, and the beta header is + * not optional — without it the request is refused. Stated plainly here rather + * than buried, because an undocumented endpoint can change and the failure + * should read as "Anthropic changed something", not as "your account is + * broken". Every parse below degrades to null for exactly that reason. + */ +const USAGE_PATH = '/api/oauth/usage' +const OAUTH_BETA = 'oauth-2025-04-20' + +/** The windows a subscription is actually limited by. */ +export type SubscriptionUsage = ProviderUsage & { + fiveHour: SubscriptionWindow + sevenDay: SubscriptionWindow + /** + * Per-model weekly windows, REPORTED BUT NOT RANKED ON. + * + * Both were null on the live Max 20x account this was written against, so + * they are not universally populated — which is itself the reason they are + * kept: a null window must stay null rather than being read as "unlimited" + * or as zero. + * + * They stay out of `remaining` because an exhausted Opus window should not + * disqualify an account for a profile that runs Sonnet. Ranking that + * accurately means knowing which model the launch will use, which is a + * profile-level fact this adapter does not have and should not guess at. + */ + sevenDayOpus: SubscriptionWindow + sevenDaySonnet: SubscriptionWindow + /** true when the account may spend past the window rather than being cut off */ + extraUsage: boolean | null +} + +const num = (v: unknown): number | null => + typeof v === 'number' && Number.isFinite(v) ? v : null +const text = (v: unknown): string | null => (typeof v === 'string' && v !== '' ? v : null) + +/** One `{utilization, resets_at}` block, tolerant of an absent one. */ +function window(raw: unknown): SubscriptionWindow { + if (!raw || typeof raw !== 'object') return { utilization: null, resetsAt: null } + const o = raw as Record + return { utilization: num(o.utilization), resetsAt: text(o.resets_at) } +} + +/** + * Rank on the window that is FURTHEST ALONG, not on an average of the two. + * + * The limits bite independently: an account at 10% of its 5-hour window and 95% + * of its weekly one has almost nothing left, and averaging to 52% would send + * work straight at it. Taking the worse window is the only reading that matches + * how it actually fails. + */ +export function remainingFrom(fiveHour: SubscriptionWindow, sevenDay: SubscriptionWindow): number | null { + const worst = Math.max(fiveHour.utilization ?? -1, sevenDay.utilization ?? -1) + // Neither window published a figure: unknown, NOT full. An account that + // reports nothing must not out-rank one that honestly reported 5% used. + if (worst < 0) return null + return Math.max(0, 100 - worst) +} + +export const anthropicSubscriptionUsage: ProviderUsagePort = { + id: 'anthropic-subscription', + + async fetch({ baseUrl, credential, sessionDir = null, timeoutMs = 8000 }) { + // A session account's token is read HERE, at the moment it is needed, and + // never held anywhere else. A key-mode Anthropic account has no + // subscription window at all — it bills per token — so it is not this + // adapter's business. + let token = '' + if (sessionDir) { + const found = readSessionCredential(sessionDir) + if (!found.ok) return null + // An EXPIRED token is reported as unknown rather than refreshed. Asking + // with it would return 401, which is indistinguishable from a revoked + // account; the honest answer is "we cannot say right now". + if (found.expired) return null + token = found.credential.accessToken + } else { + token = credential + } + if (!token) return null + + const controller = new AbortController() + const timer = setTimeout(() => controller.abort(), timeoutMs) + try { + const response = await fetch(`${baseUrl.replace(/\/+$/, '')}${USAGE_PATH}`, { + headers: { + authorization: `Bearer ${token}`, + 'anthropic-beta': OAUTH_BETA, + accept: 'application/json', + }, + signal: controller.signal, + }) + if (!response.ok) return null + const body: unknown = await response.json() + if (!body || typeof body !== 'object') return null + + const o = body as Record + const fiveHour = window(o.five_hour) + const sevenDay = window(o.seven_day) + const remaining = remainingFrom(fiveHour, sevenDay) + // The port's contract: null when the endpoint ANSWERED but published + // nothing usable. Returning a fully-null object instead would look like a + // successful measurement to every caller that checks for null — and would + // get written into the snapshot as a real reading of "unknown", which is + // not something a cache should carry. + if (remaining === null) return null + + const usage: SubscriptionUsage = { + remaining, + // A subscription window has no credit balance. `limit` is the + // percentage scale the figure lives on, and `used` its complement — + // stated rather than left null so the generic display has something + // true to show. + // Unreachable as null now — the early return above guarantees a figure. + limit: 100, + used: 100 - remaining, + unit: 'percent of window remaining', + checkedAt: Date.now(), + fiveHour, + sevenDay, + sevenDayOpus: window(o.seven_day_opus), + sevenDaySonnet: window(o.seven_day_sonnet), + // `is_enabled`, MEASURED — not `enabled`, which is what the field + // looked like it should be called and what the first draft read. The + // difference is silent: the wrong name yields `null` forever, so the + // feature would simply never have reported extra usage for anyone. + extraUsage: + typeof (o.extra_usage as Record | undefined)?.is_enabled === 'boolean' + ? ((o.extra_usage as Record).is_enabled as boolean) + : null, + } + return usage + } catch { + // Down, rate-limited, timed out, or changed shape. A finding to report, + // never an exception to unwind a configuration screen with. + return null + } finally { + clearTimeout(timer) + } + }, +} diff --git a/src/ports/provider-usage.ts b/src/ports/provider-usage.ts index 57ac00f..6b963ac 100644 --- a/src/ports/provider-usage.ts +++ b/src/ports/provider-usage.ts @@ -65,9 +65,37 @@ export type ProviderUsagePort = { /** null when the endpoint answered but published nothing usable */ fetch: (req: { baseUrl: string + /** + * The key, for a key-mode account. EMPTY for a session account — the token + * is not swisscode's to hold, so an adapter that needs one reads it from + * `sessionDir` itself, at the moment it asks and no earlier. + */ credential: string + /** + * A session directory, for a subscription account. Present because the two + * account modes get their credential from genuinely different places, and + * collapsing that into one string would mean reading a token out of the + * Keychain on every code path that merely wants to name an account. + */ + sessionDir?: string | null timeoutMs?: number }) => Promise } +/** + * What a subscription account has left, beyond the generic shape above. + * + * Subscriptions are not denominated in credits: the thing that bites is a + * PERCENTAGE OF A ROLLING WINDOW, and there are two windows that bite + * independently. A single `remaining` number cannot express "fine for the next + * five hours, nearly out for the week", which is exactly the state a user wants + * to route around. + */ +export type SubscriptionWindow = { + /** 0–100; how much of this window is spent */ + utilization: number | null + /** ISO 8601, when the window rolls over */ + resetsAt: string | null +} + export {} diff --git a/test/adapters/anthropic-subscription-usage.test.ts b/test/adapters/anthropic-subscription-usage.test.ts new file mode 100644 index 0000000..4e6c0ba --- /dev/null +++ b/test/adapters/anthropic-subscription-usage.test.ts @@ -0,0 +1,201 @@ +// Subscription usage: parsing, ranking, and refusing to guess. +// +// The payload below is the REAL one, captured from +// GET https://api.anthropic.com/api/oauth/usage on a live Max 20x account, with +// only the figures adjusted per test. Every field name here was measured — one +// of them (`is_enabled`) was wrong in the first draft, and a wrong field name +// fails silently forever, which is why the fixture is real rather than invented. +import test from 'node:test' +import assert from 'node:assert/strict' +import { + anthropicSubscriptionUsage, + remainingFrom, + type SubscriptionUsage, +} from '../../src/adapters/usage/anthropic-subscription.ts' + +const REAL_PAYLOAD = { + five_hour: { + utilization: 37, + resets_at: '2026-07-22T15:10:00.342470+00:00', + limit_dollars: null, + used_dollars: null, + remaining_dollars: null, + }, + seven_day: { + utilization: 73, + resets_at: '2026-07-25T17:00:00.425040+00:00', + limit_dollars: null, + used_dollars: null, + remaining_dollars: null, + }, + // Both null on the live account — a real state, not an oversight. + seven_day_opus: null, + seven_day_sonnet: null, + extra_usage: { + is_enabled: false, + monthly_limit: null, + used_credits: null, + utilization: null, + currency: null, + decimal_places: null, + disabled_reason: null, + }, + limits: [ + { kind: 'session', group: 'session', percent: 37, severity: 'normal', is_active: false }, + ], +} + +/** Stand in for the network without touching it. */ +function withFetch(handler: (url: string, init: RequestInit) => Response, run: () => T): T { + const original = globalThis.fetch + globalThis.fetch = ((url: string, init: RequestInit) => + Promise.resolve(handler(url, init))) as typeof fetch + try { + return run() + } finally { + globalThis.fetch = original + } +} + +const json = (body: unknown, status = 200) => + new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } }) + +const fetchUsage = (body: unknown, status = 200) => + withFetch( + () => json(body, status), + () => + anthropicSubscriptionUsage.fetch({ + baseUrl: 'https://api.anthropic.com', + credential: 'tok', + }), + ) + +test('ranking takes the WORSE window, never the average', () => { + // The failure this prevents: an account 10% into its 5-hour window and 95% + // into its weekly one averages to 52% and looks healthy, when it has almost + // nothing left. The limits bite independently. + assert.equal(remainingFrom({ utilization: 10, resetsAt: null }, { utilization: 95, resetsAt: null }), 5) + assert.equal(remainingFrom({ utilization: 37, resetsAt: null }, { utilization: 73, resetsAt: null }), 27) +}) + +test('an account that publishes nothing is UNKNOWN, not full', () => { + // Returning 100 here would make a silent account out-rank one that honestly + // reported 5% used — routing real money by the absence of information. + assert.equal(remainingFrom({ utilization: null, resetsAt: null }, { utilization: null, resetsAt: null }), null) +}) + +test('utilisation over 100 floors at zero rather than going negative', () => { + assert.equal(remainingFrom({ utilization: 130, resetsAt: null }, { utilization: 0, resetsAt: null }), 0) +}) + +test('the real payload parses, with both windows and the reset times', async () => { + const u = (await fetchUsage(REAL_PAYLOAD)) as SubscriptionUsage | null + assert.ok(u) + assert.equal(u.remaining, 27) // 100 - max(37, 73) + assert.equal(u.limit, 100) + assert.equal(u.used, 73) + assert.equal(u.fiveHour.utilization, 37) + assert.equal(u.sevenDay.utilization, 73) + assert.equal(u.sevenDay.resetsAt, '2026-07-25T17:00:00.425040+00:00') +}) + +test('extra usage reads `is_enabled` — the name that is actually in the payload', () => { + // A wrong field name here fails SILENTLY and forever: it yields null, which + // is indistinguishable from an endpoint that did not publish the field. + assert.equal(Object.keys(REAL_PAYLOAD.extra_usage).includes('is_enabled'), true) + assert.equal(Object.keys(REAL_PAYLOAD.extra_usage).includes('enabled'), false) +}) + +test('extra usage parses to a real boolean, not null', async () => { + const off = (await fetchUsage(REAL_PAYLOAD)) as SubscriptionUsage + assert.equal(off.extraUsage, false) + const on = (await fetchUsage({ + ...REAL_PAYLOAD, + extra_usage: { ...REAL_PAYLOAD.extra_usage, is_enabled: true }, + })) as SubscriptionUsage + assert.equal(on.extraUsage, true) +}) + +test('null per-model windows stay null — never zero, never unlimited', async () => { + const u = (await fetchUsage(REAL_PAYLOAD)) as SubscriptionUsage + assert.equal(u.sevenDayOpus.utilization, null) + assert.equal(u.sevenDaySonnet.utilization, null) +}) + +test('a per-model window does NOT drag the ranking down', async () => { + // An exhausted Opus window must not disqualify an account for a profile that + // runs Sonnet. It is reported; it is not ranked on. + const u = (await fetchUsage({ + ...REAL_PAYLOAD, + seven_day_opus: { utilization: 99, resets_at: null }, + })) as SubscriptionUsage + assert.equal(u.remaining, 27, 'ranking still comes from the two universal windows') + assert.equal(u.sevenDayOpus.utilization, 99, 'but it is still reported') +}) + +test('an HTTP error is a null finding, never a throw', async () => { + for (const status of [401, 403, 429, 500]) { + assert.equal(await fetchUsage(REAL_PAYLOAD, status), null, `HTTP ${status} should read as null`) + } +}) + +test('a changed payload shape degrades to null instead of inventing a figure', async () => { + assert.equal(await fetchUsage({}), null) + assert.equal(await fetchUsage({ five_hour: 'nonsense', seven_day: null }), null) + assert.equal(await fetchUsage(null), null) +}) + +test('a network failure is a null finding', async () => { + const result = await withFetch( + () => { + throw new Error('ECONNREFUSED') + }, + () => + anthropicSubscriptionUsage.fetch({ baseUrl: 'https://api.anthropic.com', credential: 'tok' }), + ) + assert.equal(result, null) +}) + +test('it sends the OAuth beta header, without which the endpoint refuses', async () => { + let seen: RequestInit | undefined + await withFetch( + (_url, init) => { + seen = init + return json(REAL_PAYLOAD) + }, + () => + anthropicSubscriptionUsage.fetch({ baseUrl: 'https://api.anthropic.com', credential: 'tok' }), + ) + const headers = seen?.headers as Record + assert.equal(headers['anthropic-beta'], 'oauth-2025-04-20') + assert.equal(headers.authorization, 'Bearer tok') +}) + +test('it hits the right path, tolerating a trailing slash on the base url', async () => { + let url = '' + await withFetch( + (u) => { + url = u + return json(REAL_PAYLOAD) + }, + () => + anthropicSubscriptionUsage.fetch({ + baseUrl: 'https://api.anthropic.com/', + credential: 'tok', + }), + ) + assert.equal(url, 'https://api.anthropic.com/api/oauth/usage') +}) + +test('with no credential and no session it asks nothing at all', async () => { + let called = false + const result = await withFetch( + () => { + called = true + return json(REAL_PAYLOAD) + }, + () => anthropicSubscriptionUsage.fetch({ baseUrl: 'https://api.anthropic.com', credential: '' }), + ) + assert.equal(result, null) + assert.equal(called, false, 'no token means no request, not an anonymous one') +}) diff --git a/test/adapters/claude-session-credentials.test.ts b/test/adapters/claude-session-credentials.test.ts new file mode 100644 index 0000000..c0e5abf --- /dev/null +++ b/test/adapters/claude-session-credentials.test.ts @@ -0,0 +1,196 @@ +// Reading a session's OAuth token. +// +// The unhashed branch is verified against the REAL Keychain item on a live +// machine: `Claude Code-credentials`, read successfully, subscriptionType +// 'max'. These tests pin the derivation and — more importantly — the refusals. +import test from 'node:test' +import assert from 'node:assert/strict' +import { + credentialFilePath, + keychainService, + readSessionCredential, +} from '../../src/adapters/claude-session/credentials.ts' + +const HOME = '/home/u' +const env = { HOME } +const DEFAULT_DIR = `${HOME}/.claude` +const CUSTOM_DIR = `${HOME}/.claude-work` + +/** The envelope Claude Code actually stores. */ +const envelope = (over: Record = {}) => + JSON.stringify({ + claudeAiOauth: { + accessToken: 'sk-ant-oat-TOKEN', + refreshToken: 'sk-ant-ort-REFRESH', + expiresAt: 4_000_000_000_000, + scopes: ['user:inference'], + subscriptionType: 'max', + ...over, + }, + }) + +const fail = (status: number) => () => { + const e = new Error('security failed') as Error & { status: number } + e.status = status + throw e +} + +test('the DEFAULT directory uses the unhashed service name', () => { + // Verified against the real Keychain: this exact string is the live item. + assert.equal(keychainService(DEFAULT_DIR, env), 'Claude Code-credentials') +}) + +test('a custom directory hashes the path into the name, and does so stably', () => { + const name = keychainService(CUSTOM_DIR, env) + assert.match(name, /^Claude Code-credentials-[0-9a-f]{8}$/) + assert.equal(name, keychainService(CUSTOM_DIR, env), 'derivation must be deterministic') + assert.notEqual(name, keychainService(`${HOME}/.claude-other`, env)) +}) + +test('the hash covers the string that would be WRITTEN to the variable', () => { + // Not a re-normalised spelling of it. Normalising here but not in the env + // lowering would produce a name that is right in every test and wrong on + // every machine, because the agent hashes what it was actually given. + assert.notEqual( + keychainService(CUSTOM_DIR, env), + keychainService(`${CUSTOM_DIR}/`, env), + 'a different string is a different keychain item, and pretending otherwise reads the wrong account', + ) +}) + +test('the credential file has NO home/dir asymmetry, unlike .claude.json', () => { + assert.equal(credentialFilePath(DEFAULT_DIR), `${DEFAULT_DIR}/.credentials.json`) + assert.equal(credentialFilePath(CUSTOM_DIR), `${CUSTOM_DIR}/.credentials.json`) +}) + +test('a keychain hit is parsed, and the refresh token is NOT carried', () => { + const r = readSessionCredential(DEFAULT_DIR, { + env, + platform: 'darwin', + keychain: () => envelope(), + now: 1_000, + }) + assert.ok(r.ok) + assert.equal(r.credential.accessToken, 'sk-ant-oat-TOKEN') + assert.equal(r.credential.subscriptionType, 'max') + assert.equal(r.credential.source, 'keychain') + assert.equal(r.expired, false) + // Nothing in swisscode may refresh, so nothing carries a refresh token — + // it is not in the type and must not be in the value. + assert.equal('refreshToken' in r.credential, false) + assert.doesNotMatch(JSON.stringify(r.credential), /REFRESH/) +}) + +test('an expired token is REPORTED, never refreshed', () => { + // Refreshing needs Anthropic's own OAuth client id — the impersonation line + // this design stays behind. The agent refreshes it itself on next use. + const r = readSessionCredential(DEFAULT_DIR, { + env, + platform: 'darwin', + keychain: () => envelope({ expiresAt: 500 }), + now: 1_000, + }) + assert.ok(r.ok) + assert.equal(r.expired, true) + assert.equal(r.credential.accessToken, 'sk-ant-oat-TOKEN', 'still identifies the account') +}) + +test('a dismissed prompt is DENIED, not "never logged in"', () => { + // These send the user to fix completely different things, so conflating them + // would send them to fix the wrong one. + const r = readSessionCredential(DEFAULT_DIR, { + env, + platform: 'darwin', + keychain: fail(1), + readFile: () => { + throw new Error('ENOENT') + }, + }) + assert.equal(r.ok, false) + assert.equal(r.kind, 'denied') + assert.match(r.reason, /nothing is wrong with the account/) +}) + +test('keychain exit 44 means the item is simply absent', () => { + const r = readSessionCredential(CUSTOM_DIR, { + env, + platform: 'darwin', + keychain: fail(44), + readFile: () => { + throw new Error('ENOENT') + }, + }) + assert.equal(r.ok, false) + assert.equal(r.kind, 'absent') + assert.match(r.reason, /accounts login/) +}) + +test('off macOS it reads the file, and says so', () => { + const r = readSessionCredential(CUSTOM_DIR, { + env, + platform: 'linux', + keychain: () => assert.fail('the keychain must not be consulted off macOS'), + readFile: (p) => { + assert.equal(p, `${CUSTOM_DIR}/.credentials.json`) + return envelope() + }, + now: 1_000, + }) + assert.ok(r.ok) + assert.equal(r.credential.source, 'file') +}) + +test('on macOS a missing keychain item still falls through to the file', () => { + // Some macOS setups end up with a file too. Trying both is what works, rather + // than what the platform table says should. + const r = readSessionCredential(CUSTOM_DIR, { + env, + platform: 'darwin', + keychain: fail(44), + readFile: () => envelope(), + now: 1_000, + }) + assert.ok(r.ok) + assert.equal(r.credential.source, 'file') +}) + +test('a bare (unwrapped) envelope is tolerated', () => { + const r = readSessionCredential(CUSTOM_DIR, { + env, + platform: 'linux', + readFile: () => JSON.stringify({ accessToken: 'sk-bare', expiresAt: null }), + now: 1_000, + }) + assert.ok(r.ok) + assert.equal(r.credential.accessToken, 'sk-bare') + assert.equal(r.credential.expiresAt, null) +}) + +test('garbage never becomes a credential', () => { + for (const body of ['', 'not json', '{}', '{"claudeAiOauth":{}}', '{"claudeAiOauth":{"accessToken":""}}', 'null']) { + const r = readSessionCredential(CUSTOM_DIR, { + env, + platform: 'linux', + readFile: () => body, + }) + assert.equal(r.ok, false, `${JSON.stringify(body)} must not parse as a credential`) + } +}) + +test('a failure reason never contains a token', () => { + const r = readSessionCredential(CUSTOM_DIR, { + env, + platform: 'darwin', + keychain: () => envelope(), + now: 1_000, + }) + assert.ok(r.ok) + // …and the negative case: nothing on the failure branch echoes input. + const bad = readSessionCredential(CUSTOM_DIR, { + env, + platform: 'linux', + readFile: () => envelope({ accessToken: '' }), + }) + assert.equal(bad.ok, false) + assert.doesNotMatch(bad.reason, /sk-ant|TOKEN|REFRESH/) +}) diff --git a/test/adapters/fs-usage-store.test.ts b/test/adapters/fs-usage-store.test.ts new file mode 100644 index 0000000..8cee4e8 --- /dev/null +++ b/test/adapters/fs-usage-store.test.ts @@ -0,0 +1,101 @@ +// The measured-usage snapshot, between runs. +// +// Mirrors test/adapters/fs-cursor-store.test.ts, because the discipline is the +// same: best-effort writes, a corrupt file reads as absent, and nothing here may +// fail the command that triggered it. +import test from 'node:test' +import assert from 'node:assert/strict' +import { mkdtempSync, readFileSync, statSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { SNAPSHOT_TTL_MS, createFsUsageStore } from '../../src/adapters/store/fs-usage-store.ts' + +const fresh = () => mkdtempSync(join(tmpdir(), 'swisscode-usage-')) + +test('nothing measured yet reads as null, not as empty', () => { + const store = createFsUsageStore({ dir: join(fresh(), 'never-created') }) + assert.equal(store.read(), null) +}) + +test('a snapshot round-trips', () => { + const dir = fresh() + const store = createFsUsageStore({ dir }) + const snapshot = { remaining: { personal: 27, work: 88 }, checkedAt: Date.now() } + store.write(snapshot) + assert.deepEqual(store.read(), snapshot) +}) + +test('the file is 0600 and the directory 0700 — it maps what you pay for', () => { + const dir = join(fresh(), 'state') + const store = createFsUsageStore({ dir }) + store.write({ remaining: { personal: 27 }, checkedAt: Date.now() }) + assert.equal(statSync(dir).mode & 0o777, 0o700) + assert.equal(statSync(join(dir, 'usage.json')).mode & 0o777, 0o600) +}) + +test('a snapshot older than the TTL reads as ABSENT, not as zero', () => { + // A 5-hour window means a figure older than the TTL describes a window that + // has since rolled over. Selection falls back to the first account and says + // so, which is better than routing on a number about a different period. + const dir = fresh() + const store = createFsUsageStore({ dir }) + store.write({ remaining: { personal: 27 }, checkedAt: Date.now() - SNAPSHOT_TTL_MS - 1 }) + assert.equal(store.read(), null) +}) + +test('a snapshot just inside the TTL still reads', () => { + const dir = fresh() + const store = createFsUsageStore({ dir }) + const checkedAt = Date.now() - SNAPSHOT_TTL_MS + 60_000 + store.write({ remaining: { personal: 27 }, checkedAt }) + assert.equal(store.read()?.checkedAt, checkedAt) +}) + +test('a corrupt file reads as absent rather than throwing', () => { + const dir = fresh() + const store = createFsUsageStore({ dir }) + store.write({ remaining: { personal: 27 }, checkedAt: Date.now() }) + for (const body of ['', 'not json', '[]', 'null', '{"remaining":{}}', '{"remaining":null,"checkedAt":1}']) { + writeFileSync(join(dir, 'usage.json'), body) + assert.equal(store.read(), null, `${JSON.stringify(body)} should read as absent`) + } +}) + +test('non-numeric entries are DROPPED, never compared against', () => { + // This map decides which account pays. A hand-edited NaN or string must not + // enter that comparison. + const dir = fresh() + const store = createFsUsageStore({ dir }) + writeFileSync( + join(dir, 'usage.json'), + JSON.stringify({ + remaining: { good: 42, nan: Number.NaN, str: '99', nul: null, obj: {} }, + checkedAt: Date.now(), + }), + ) + assert.deepEqual(store.read()?.remaining, { good: 42 }) +}) + +test('a snapshot with nothing usable left in it reads as absent', () => { + const dir = fresh() + const store = createFsUsageStore({ dir }) + writeFileSync( + join(dir, 'usage.json'), + JSON.stringify({ remaining: { bad: 'x' }, checkedAt: Date.now() }), + ) + assert.equal(store.read(), null) +}) + +test('an unwritable directory does not throw — the measurement already happened', () => { + const store = createFsUsageStore({ dir: '/proc/nonexistent/swisscode' }) + assert.doesNotThrow(() => store.write({ remaining: { a: 1 }, checkedAt: Date.now() })) + assert.equal(store.read(), null) +}) + +test('the file holds no credential — only account names and figures', () => { + const dir = fresh() + const store = createFsUsageStore({ dir }) + store.write({ remaining: { personal: 27 }, checkedAt: Date.now() }) + const raw = readFileSync(join(dir, 'usage.json'), 'utf8') + assert.deepEqual(Object.keys(JSON.parse(raw)).sort(), ['checkedAt', 'remaining']) +}) From e5a616425d7fc5d6407e43226cb8247eae3bee55 Mon Sep 17 00:00:00 2001 From: jellologic <31935831+jellologic@users.noreply.github.com> Date: Wed, 22 Jul 2026 11:12:21 -0400 Subject: [PATCH 14/21] =?UTF-8?q?feat(usage):=20wire=20measurement=20to=20?= =?UTF-8?q?selection=20=E2=80=94=20`config=20accounts=20usage`?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 3's modules existed but nothing called them, so the `usage` strategy still could not work. This connects the two ends. `swisscode config accounts usage` measures every account once and caches the result — the command that replaces switching into each account to run `/usage`. Reads are SEQUENTIAL, not parallel: each subscription read can raise a Keychain prompt, and three dialogs at once is worse than waiting. A key-mode account is reported as having no subscription window rather than being silently skipped, because "nothing shown" and "nothing to show" look identical otherwise. `planLaunch` now reads the snapshot. Reads only — refreshing means the network, which the launch path is banned from reaching, so a stale or missing snapshot degrades to the first account and says so. The module-count ceiling went 40 -> 42 for the usage store. Stated deliberately in the test: it is the `usage` strategy's exact counterpart to the rotation cursor already on that path, and selection cannot honour a strategy whose input it cannot see. The budget is doing its job either way — it turned away a module earlier this phase, which is how `isDefaultConfigDir` ended up in the env lowering that uses it instead of getting a file of its own. Verified live, end to end: config accounts usage default (orepsdev@gmail.com · Max 20x) remaining 27% (the tighter of the two windows) 5-hour 0% used, resets 7/22 4:10 PM 7-day 73% used, resets 7/25 1:00 PM spare (not logged in) — could not be measured key — key account, no subscription window planLaunch with strategy: usage selected account: default session dir : (unset — the default login) [medium] selected "default" by remaining capacity, measured 1 minute(s) ago 707 tests pass. Signed-off-by: jellologic <31935831+jellologic@users.noreply.github.com> --- src/adapters/usage/anthropic-subscription.ts | 174 +++++++++++-------- src/composition/config-root.ts | 80 ++++++++- src/composition/launch-root.ts | 16 ++ test/architecture.test.ts | 11 +- 4 files changed, 202 insertions(+), 79 deletions(-) diff --git a/src/adapters/usage/anthropic-subscription.ts b/src/adapters/usage/anthropic-subscription.ts index f71f623..a5ce617 100644 --- a/src/adapters/usage/anthropic-subscription.ts +++ b/src/adapters/usage/anthropic-subscription.ts @@ -72,85 +72,105 @@ export function remainingFrom(fiveHour: SubscriptionWindow, sevenDay: Subscripti return Math.max(0, 100 - worst) } -export const anthropicSubscriptionUsage: ProviderUsagePort = { - id: 'anthropic-subscription', - - async fetch({ baseUrl, credential, sessionDir = null, timeoutMs = 8000 }) { - // A session account's token is read HERE, at the moment it is needed, and - // never held anywhere else. A key-mode Anthropic account has no - // subscription window at all — it bills per token — so it is not this - // adapter's business. - let token = '' - if (sessionDir) { - const found = readSessionCredential(sessionDir) - if (!found.ok) return null - // An EXPIRED token is reported as unknown rather than refreshed. Asking - // with it would return 401, which is indistinguishable from a revoked - // account; the honest answer is "we cannot say right now". - if (found.expired) return null - token = found.credential.accessToken - } else { - token = credential - } - if (!token) return null +/** + * The subscription-shaped read, with the two windows still attached. + * + * Exported separately from the port implementation below because + * `ProviderUsagePort.fetch` is deliberately narrow — it returns the generic + * `ProviderUsage` that ranking needs — and a caller that wants to SHOW someone + * their 5-hour and 7-day windows would otherwise have to cast the result back + * to a type it already knows it has. + */ +export async function fetchSubscriptionUsage({ + baseUrl, + credential, + sessionDir = null, + timeoutMs = 8000, +}: { + baseUrl: string + credential: string + sessionDir?: string | null + timeoutMs?: number +}): Promise { +// A session account's token is read HERE, at the moment it is needed, and + // never held anywhere else. A key-mode Anthropic account has no + // subscription window at all — it bills per token — so it is not this + // adapter's business. + let token = '' + if (sessionDir) { + const found = readSessionCredential(sessionDir) + if (!found.ok) return null + // An EXPIRED token is reported as unknown rather than refreshed. Asking + // with it would return 401, which is indistinguishable from a revoked + // account; the honest answer is "we cannot say right now". + if (found.expired) return null + token = found.credential.accessToken + } else { + token = credential + } + if (!token) return null - const controller = new AbortController() - const timer = setTimeout(() => controller.abort(), timeoutMs) - try { - const response = await fetch(`${baseUrl.replace(/\/+$/, '')}${USAGE_PATH}`, { - headers: { - authorization: `Bearer ${token}`, - 'anthropic-beta': OAUTH_BETA, - accept: 'application/json', - }, - signal: controller.signal, - }) - if (!response.ok) return null - const body: unknown = await response.json() - if (!body || typeof body !== 'object') return null + const controller = new AbortController() + const timer = setTimeout(() => controller.abort(), timeoutMs) + try { + const response = await fetch(`${baseUrl.replace(/\/+$/, '')}${USAGE_PATH}`, { + headers: { + authorization: `Bearer ${token}`, + 'anthropic-beta': OAUTH_BETA, + accept: 'application/json', + }, + signal: controller.signal, + }) + if (!response.ok) return null + const body: unknown = await response.json() + if (!body || typeof body !== 'object') return null - const o = body as Record - const fiveHour = window(o.five_hour) - const sevenDay = window(o.seven_day) - const remaining = remainingFrom(fiveHour, sevenDay) - // The port's contract: null when the endpoint ANSWERED but published - // nothing usable. Returning a fully-null object instead would look like a - // successful measurement to every caller that checks for null — and would - // get written into the snapshot as a real reading of "unknown", which is - // not something a cache should carry. - if (remaining === null) return null + const o = body as Record + const fiveHour = window(o.five_hour) + const sevenDay = window(o.seven_day) + const remaining = remainingFrom(fiveHour, sevenDay) + // The port's contract: null when the endpoint ANSWERED but published + // nothing usable. Returning a fully-null object instead would look like a + // successful measurement to every caller that checks for null — and would + // get written into the snapshot as a real reading of "unknown", which is + // not something a cache should carry. + if (remaining === null) return null - const usage: SubscriptionUsage = { - remaining, - // A subscription window has no credit balance. `limit` is the - // percentage scale the figure lives on, and `used` its complement — - // stated rather than left null so the generic display has something - // true to show. - // Unreachable as null now — the early return above guarantees a figure. - limit: 100, - used: 100 - remaining, - unit: 'percent of window remaining', - checkedAt: Date.now(), - fiveHour, - sevenDay, - sevenDayOpus: window(o.seven_day_opus), - sevenDaySonnet: window(o.seven_day_sonnet), - // `is_enabled`, MEASURED — not `enabled`, which is what the field - // looked like it should be called and what the first draft read. The - // difference is silent: the wrong name yields `null` forever, so the - // feature would simply never have reported extra usage for anyone. - extraUsage: - typeof (o.extra_usage as Record | undefined)?.is_enabled === 'boolean' - ? ((o.extra_usage as Record).is_enabled as boolean) - : null, - } - return usage - } catch { - // Down, rate-limited, timed out, or changed shape. A finding to report, - // never an exception to unwind a configuration screen with. - return null - } finally { - clearTimeout(timer) + const usage: SubscriptionUsage = { + remaining, + // A subscription window has no credit balance. `limit` is the + // percentage scale the figure lives on, and `used` its complement — + // stated rather than left null so the generic display has something + // true to show. Never null here: the early return above guarantees a + // figure by this point. + limit: 100, + used: 100 - remaining, + unit: 'percent of window remaining', + checkedAt: Date.now(), + fiveHour, + sevenDay, + sevenDayOpus: window(o.seven_day_opus), + sevenDaySonnet: window(o.seven_day_sonnet), + // `is_enabled`, MEASURED — not `enabled`, which is what the field + // looked like it should be called and what the first draft read. The + // difference is silent: the wrong name yields `null` forever, so the + // feature would simply never have reported extra usage for anyone. + extraUsage: + typeof (o.extra_usage as Record | undefined)?.is_enabled === 'boolean' + ? ((o.extra_usage as Record).is_enabled as boolean) + : null, } - }, + return usage + } catch { + // Down, rate-limited, timed out, or changed shape. A finding to report, + // never an exception to unwind a configuration screen with. + return null + } finally { + clearTimeout(timer) + } +} + +export const anthropicSubscriptionUsage: ProviderUsagePort = { + id: 'anthropic-subscription', + fetch: fetchSubscriptionUsage, } diff --git a/src/composition/config-root.ts b/src/composition/config-root.ts index d5b778e..134347c 100644 --- a/src/composition/config-root.ts +++ b/src/composition/config-root.ts @@ -33,6 +33,7 @@ import { DEFAULT_AGENT_ID } from '../adapters/agents/registry.ts' import { withCustomProviders } from '../adapters/providers/composite.ts' import { describeIdentity, readSessionIdentity } from '../adapters/claude-session/identity.ts' import { accountLogin } from '../adapters/claude-session/onboard.ts' +import { fetchSubscriptionUsage } from '../adapters/usage/anthropic-subscription.ts' import type { LaunchDeps } from './launch-root.ts' import type { LoadResult, Profile, State } from '../ports/config-store.ts' import type { ProcessPort } from '../ports/process.ts' @@ -85,6 +86,7 @@ const USAGE = `swisscode config — manage profiles and directory bindings swisscode config accounts provider accounts, and which profiles use each swisscode config accounts login adopt a Claude subscription: makes a session [--dir ] directory and runs the agent so you can /login + swisscode config accounts usage how much of each subscription is left, and cache it swisscode config agents agent profiles, and which profiles use each swisscode config agent list agents and which profile uses each @@ -811,6 +813,81 @@ async function webCommand({ * about `/login` — and this file is a composition root, not a home for * agent-specific behaviour. */ +/** + * `swisscode config accounts usage` — measure every account, once. + * + * The command that replaces switching into each account to run `/usage`. It is + * also the ONLY thing that writes the snapshot the `usage` selection strategy + * reads: measuring is configuration-time by construction, because the launch + * path may not reach the network. + */ +async function refreshUsage({ + deps, + out, + err, +}: { + deps: LaunchDeps + out: Emit + err: Emit +}): Promise { + const { state } = deps.store.load() + const accounts = Object.entries(state.providerAccounts ?? {}) + if (accounts.length === 0) { + out('No provider accounts yet. Run `swisscode config accounts login `.') + return 0 + } + + const remaining: Record = {} + // Sequential, not parallel. Each subscription read can raise a Keychain + // prompt, and three simultaneous dialogs is a worse experience than waiting. + for (const [name, account] of accounts) { + if (!account.configDir) { + // A key-mode account bills per token; it has no window to be out of. + out(` ${name} — key account, no subscription window`) + continue + } + const identity = readSessionIdentity(account.configDir) + const usage = await fetchSubscriptionUsage({ + baseUrl: 'https://api.anthropic.com', + credential: '', + sessionDir: account.configDir, + }) + const who = describeIdentity(identity) + if (!usage || usage.remaining === null) { + out(` ${name} (${who})`) + out(' usage — could not be measured (not logged in, expired, or endpoint refused)') + continue + } + remaining[name] = usage.remaining + out(` ${name} (${who})`) + out(` remaining ${usage.remaining}% (the tighter of the two windows)`) + out(` 5-hour ${pctUsed(usage.fiveHour)}`) + out(` 7-day ${pctUsed(usage.sevenDay)}`) + } + + if (Object.keys(remaining).length === 0) { + err('swisscode: nothing could be measured, so the cached snapshot was left alone.') + return 1 + } + // Best effort, like every write in the state directory: the figures above are + // already on screen, and failing the command because a cache could not be + // written would trade a working answer for a tidy filesystem. + deps.usage?.write({ remaining, checkedAt: Date.now() }) + out('') + out('Cached. Profiles using the `usage` strategy will select on these figures.') + return 0 +} + +/** "37% used, resets 15:10 today" — a window, phrased for a person. */ +function pctUsed(w: { utilization: number | null; resetsAt: string | null }): string { + if (w.utilization === null) return '— not published' + const resets = w.resetsAt ? new Date(w.resetsAt) : null + return ( + `${w.utilization}% used` + + (resets && !Number.isNaN(resets.getTime()) ? `, resets ${resets.toLocaleString()}` : '') + ) +} + function accountsCommand({ deps, args, @@ -821,9 +898,10 @@ function accountsCommand({ args: string[] out: Emit err: Emit -}): number { +}): number | Promise { const [sub, ...rest] = args if (sub === undefined) return listAccounts({ deps, out }) + if (sub === 'usage') return refreshUsage({ deps, out, err }) if (sub !== 'login') { err(`swisscode: unknown accounts subcommand "${sub}". Try \`swisscode config accounts\`.`) return 2 diff --git a/src/composition/launch-root.ts b/src/composition/launch-root.ts index ee59b03..6ee253c 100644 --- a/src/composition/launch-root.ts +++ b/src/composition/launch-root.ts @@ -13,6 +13,7 @@ import { resolveProfile } from '../core/profile.ts' import { resolveProfileRefs, type CursorPort } from '../core/resolve.ts' import { createFsConfigStore } from '../adapters/store/fs-config-store.ts' import { createFsCursorStore } from '../adapters/store/fs-cursor-store.ts' +import { createFsUsageStore, type UsageStorePort } from '../adapters/store/fs-usage-store.ts' import { createNodeProcess, detectRecursion } from '../adapters/process/node-process.ts' import { registry as providerRegistry } from '../adapters/providers/registry.ts' import { withCustomProviders } from '../adapters/providers/composite.ts' @@ -55,6 +56,14 @@ export type LaunchDeps = { * path writes no config, and a rotation counter is not configuration. */ cursor?: CursorPort + /** + * The last measured usage snapshot. READ-ONLY here, and optional for the same + * reason the cursor is: only the `usage` strategy consults it, and a caller + * without one degrades to the first account WITH A WARNING rather than + * failing. Nothing on the launch path may refresh it — that means the + * network, which this path is banned from reaching. + */ + usage?: UsageStorePort } export function defaultDeps(): LaunchDeps { @@ -64,6 +73,7 @@ export function defaultDeps(): LaunchDeps { agents: agentRegistry, proc: createNodeProcess(), cursor: createFsCursorStore(), + usage: createFsUsageStore(), } } @@ -163,6 +173,7 @@ export function planLaunch({ agents, proc, cursor, + usage, passthrough = [], skipOverride = null, positional = null, @@ -212,6 +223,11 @@ export function planLaunch({ // downstream of here changed when the schema split. const resolution = resolveProfileRefs(loaded.state, sel.name ?? '', { cursor: cursor ?? null, + // READ, never refreshed. Measuring here would mean an HTTP request to a + // billing endpoint on every launch, which is the thing the whole + // snapshot-and-cache arrangement exists to avoid. A stale or missing + // snapshot degrades to the first account, loudly — see `selectAccount`. + usage: usage?.read() ?? null, now: Date.now(), }) if (!resolution.ok) throw new LaunchError(resolution.reason) diff --git a/test/architecture.test.ts b/test/architecture.test.ts index be63905..390ebc2 100644 --- a/test/architecture.test.ts +++ b/test/architecture.test.ts @@ -139,8 +139,17 @@ test('the launch path is small enough to keep auditing by hand', () => { // Bumped from 30 when issue #19 put the agent-CLI seam on the launch path: the // agent registry, three adapters (claude-code/kilo/opencode) and the Claude // Code lowering that moved out of core/. Still small enough to read in a sitting. + // + // Bumped again from 40 for `adapters/store/fs-usage-store.ts` — the `usage` + // strategy's exact counterpart to the rotation cursor that was already here. + // Both are one small best-effort read from the state directory, and selection + // cannot honour a strategy whose input it cannot see. This ceiling is a real + // constraint, not a decoration: it has already turned away one module this + // phase (`isDefaultConfigDir` went into the env lowering that uses it rather + // than getting a file of its own). Raise it only for a capability the launch + // genuinely needs, and say which one. const { files } = launchClosure() - assert.ok(files.length < 40, `launch path has grown to ${files.length} modules`) + assert.ok(files.length <= 42, `launch path has grown to ${files.length} modules`) }) function walk(dir: string): string[] { From cae77fc25d7dd759feb8c2cdecfd26646bc835c4 Mon Sep 17 00:00:00 2001 From: jellologic <31935831+jellologic@users.noreply.github.com> Date: Wed, 22 Jul 2026 11:41:26 -0400 Subject: [PATCH 15/21] feat(doctor): diagnose a session login, and actually refresh the usage snapshot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `core/resolve.ts` has been telling users "Run `swisscode config doctor` to refresh usage" since the usage strategy shipped. The doctor had no idea what a snapshot was, so that advice sent them in a circle. It now measures and writes one. Two checks: - `session login` — who a config dir is logged in as, or a warning that distinguishes "never used" from "used but logged out". Costs a file read, so it runs under --offline. This is the one failure the launch path cannot catch by design: it never opens the directory it points at, so a dir nobody has logged into surfaces as the agent's own login prompt AFTER execve, with swisscode gone and unable to explain. - `usage snapshot` — measures the accounts a `usage`-strategy profile names, writes the snapshot, and names any account it could not measure. Scoped to those accounts rather than every account on the machine because each measurement can raise a Keychain prompt, and prompting for figures nothing reads is a cost with no answer attached. Nothing measurable leaves the cached snapshot alone rather than clearing it. The measurement loop moves to adapters/usage/measure.ts, shared with `config accounts usage`. It looks trivial enough to copy, which is why it is not: sequential-not-parallel, key accounts reported rather than failed, and identity read once are all invisible in the shape of the code and would drift apart between two copies. `runDoctor` gains `usageFetch`, injected in tests. Unlike `probe` and `ollama` this is not a convenience — the refresh reaches api.anthropic.com by default, so without it a doctor test would make a real request against whatever credential the machine holds. Verified live against a temp config: session login read as " · Max 20x", the measurable account written to the snapshot at 26%, the nonexistent one named as unmeasured, and the endpoint probe skipped — so no inference was billed. 722 tests, 232.0 kB packed. Signed-off-by: jellologic <31935831+jellologic@users.noreply.github.com> --- src/adapters/usage/measure.ts | 88 +++++++ src/composition/config-root.ts | 39 ++- src/composition/doctor-root.ts | 138 +++++++++++ test/adapters/doctor-session.test.ts | 347 +++++++++++++++++++++++++++ 4 files changed, 590 insertions(+), 22 deletions(-) create mode 100644 src/adapters/usage/measure.ts create mode 100644 test/adapters/doctor-session.test.ts diff --git a/src/adapters/usage/measure.ts b/src/adapters/usage/measure.ts new file mode 100644 index 0000000..c6c7dc1 --- /dev/null +++ b/src/adapters/usage/measure.ts @@ -0,0 +1,88 @@ +// Measuring every account once, in the one order that respects the Keychain. +// +// Shared by `config accounts usage` and `config doctor`. The loop below looks +// trivial enough to copy into both, which is exactly why it is not: the +// decisions in it — sequential rather than parallel, key accounts reported +// rather than failed, identity read once — are invisible in the shape of the +// code and would drift apart silently between two copies. +// +// CONFIGURATION-TIME ONLY, like everything under adapters/usage. +// `test/architecture.test.ts` keeps this directory off the launch path by name. + +import { readSessionIdentity } from '../claude-session/identity.ts' +import type { SessionIdentity } from '../claude-session/identity.ts' +import { fetchSubscriptionUsage } from './anthropic-subscription.ts' +import type { SubscriptionUsage } from './anthropic-subscription.ts' + +/** Where subscription usage lives. Not a provider baseUrl — this endpoint is + * Anthropic's own, and a profile pointed at a proxy still has its subscription + * window measured here rather than wherever it sends inference. */ +export const ANTHROPIC_BASE_URL = 'https://api.anthropic.com' + +/** The little a measurement needs to know about an account. */ +export type MeasurableAccount = { name: string; configDir?: string } + +export type AccountMeasurement = { + name: string + /** null for a key-mode account: it bills per token and has no window at all */ + configDir: string | null + identity: SessionIdentity | null + usage: SubscriptionUsage | null +} + +export type MeasureOptions = { + baseUrl?: string + /** injected in tests, so measuring costs no network and no Keychain */ + fetchUsage?: typeof fetchSubscriptionUsage + readIdentity?: (dir: string) => SessionIdentity | null +} + +/** + * Measure each account's remaining subscription capacity. + * + * Returns one entry per account IN THE ORDER GIVEN, including the ones that + * could not be measured — a caller rendering a list needs to say "this one + * failed" as much as it needs the figures, and dropping the failures here would + * make that impossible to distinguish from an account that no longer exists. + */ +export async function measureAccounts( + accounts: readonly MeasurableAccount[], + { + baseUrl = ANTHROPIC_BASE_URL, + fetchUsage = fetchSubscriptionUsage, + readIdentity = (dir) => readSessionIdentity(dir), + }: MeasureOptions = {}, +): Promise { + const results: AccountMeasurement[] = [] + // SEQUENTIAL, and not an oversight. Each subscription read can raise a + // Keychain prompt, and three stacked unlock dialogs is a worse experience + // than waiting for three round trips. + for (const account of accounts) { + if (!account.configDir) { + results.push({ name: account.name, configDir: null, identity: null, usage: null }) + continue + } + const identity = readIdentity(account.configDir) + const usage = await fetchUsage({ baseUrl, credential: '', sessionDir: account.configDir }) + results.push({ name: account.name, configDir: account.configDir, identity, usage }) + } + return results +} + +/** + * The account→remaining map the `usage` strategy selects on. + * + * An account that could not be measured is ABSENT rather than zero. Selection + * reads a missing entry as "unknown" and falls back saying so, where a zero + * would read as "exhausted" and route work away from an account that may be + * completely free — the wrong answer, arrived at confidently. + */ +export function remainingMap( + measurements: readonly AccountMeasurement[], +): Record { + const remaining: Record = {} + for (const m of measurements) { + if (m.usage && m.usage.remaining !== null) remaining[m.name] = m.usage.remaining + } + return remaining +} diff --git a/src/composition/config-root.ts b/src/composition/config-root.ts index 134347c..52b5555 100644 --- a/src/composition/config-root.ts +++ b/src/composition/config-root.ts @@ -33,7 +33,7 @@ import { DEFAULT_AGENT_ID } from '../adapters/agents/registry.ts' import { withCustomProviders } from '../adapters/providers/composite.ts' import { describeIdentity, readSessionIdentity } from '../adapters/claude-session/identity.ts' import { accountLogin } from '../adapters/claude-session/onboard.ts' -import { fetchSubscriptionUsage } from '../adapters/usage/anthropic-subscription.ts' +import { measureAccounts, remainingMap } from '../adapters/usage/measure.ts' import type { LaunchDeps } from './launch-root.ts' import type { LoadResult, Profile, State } from '../ports/config-store.ts' import type { ProcessPort } from '../ports/process.ts' @@ -831,40 +831,35 @@ async function refreshUsage({ err: Emit }): Promise { const { state } = deps.store.load() - const accounts = Object.entries(state.providerAccounts ?? {}) + const accounts = Object.entries(state.providerAccounts ?? {}).map(([name, account]) => ({ + name, + ...(account.configDir ? { configDir: account.configDir } : {}), + })) if (accounts.length === 0) { out('No provider accounts yet. Run `swisscode config accounts login `.') return 0 } - const remaining: Record = {} - // Sequential, not parallel. Each subscription read can raise a Keychain - // prompt, and three simultaneous dialogs is a worse experience than waiting. - for (const [name, account] of accounts) { - if (!account.configDir) { + // The loop itself lives in adapters/usage/measure.ts, shared with the doctor. + // This function's job is the rendering below. + const measurements = await measureAccounts(accounts) + for (const m of measurements) { + if (!m.configDir) { // A key-mode account bills per token; it has no window to be out of. - out(` ${name} — key account, no subscription window`) + out(` ${m.name} — key account, no subscription window`) continue } - const identity = readSessionIdentity(account.configDir) - const usage = await fetchSubscriptionUsage({ - baseUrl: 'https://api.anthropic.com', - credential: '', - sessionDir: account.configDir, - }) - const who = describeIdentity(identity) - if (!usage || usage.remaining === null) { - out(` ${name} (${who})`) + out(` ${m.name} (${describeIdentity(m.identity)})`) + if (!m.usage || m.usage.remaining === null) { out(' usage — could not be measured (not logged in, expired, or endpoint refused)') continue } - remaining[name] = usage.remaining - out(` ${name} (${who})`) - out(` remaining ${usage.remaining}% (the tighter of the two windows)`) - out(` 5-hour ${pctUsed(usage.fiveHour)}`) - out(` 7-day ${pctUsed(usage.sevenDay)}`) + out(` remaining ${m.usage.remaining}% (the tighter of the two windows)`) + out(` 5-hour ${pctUsed(m.usage.fiveHour)}`) + out(` 7-day ${pctUsed(m.usage.sevenDay)}`) } + const remaining = remainingMap(measurements) if (Object.keys(remaining).length === 0) { err('swisscode: nothing could be measured, so the cached snapshot was left alone.') return 1 diff --git a/src/composition/doctor-root.ts b/src/composition/doctor-root.ts index bda249b..9b116a4 100644 --- a/src/composition/doctor-root.ts +++ b/src/composition/doctor-root.ts @@ -35,6 +35,13 @@ import { import { bindingEntries, pruneBindings } from '../core/binding.ts' import { withCustomProviders } from '../adapters/providers/composite.ts' import { createProbe } from '../adapters/doctor/probe.ts' +import { + describeIdentity, + readSessionIdentity, + sessionDirLooksInitialised, +} from '../adapters/claude-session/identity.ts' +import { measureAccounts, remainingMap } from '../adapters/usage/measure.ts' +import type { MeasureOptions } from '../adapters/usage/measure.ts' import type { LaunchDeps } from './launch-root.ts' import { createOllamaIntrospect, interpretOllamaContext } from '../adapters/doctor/ollama.ts' import type { @@ -61,6 +68,16 @@ export type RunDoctorOptions = { probe?: AnthropicMessagesProbePort | null /** Injected in tests, like `probe`. Only consulted for an Ollama profile. */ ollama?: OllamaIntrospectPort | null + /** + * Injected in tests, and REQUIRED for the suite to stay offline. + * + * Unlike `probe` and `ollama`, which the tests inject to observe what was + * asked, this one exists because the usage refresh below reaches + * api.anthropic.com by default. A doctor test with a `usage`-strategy profile + * and no `--offline` would otherwise make a real request against whatever + * credential the machine happens to hold. + */ + usageFetch?: MeasureOptions['fetchUsage'] | null } /** @@ -78,6 +95,7 @@ export async function runDoctor({ now = () => Date.now(), probe = null, ollama = null, + usageFetch = null, }: RunDoctorOptions): Promise { const { store, registry: baseRegistry, agents, proc } = deps const ambient = proc.env() @@ -167,6 +185,45 @@ export async function runDoctor({ ) } + // A session directory nobody has logged into. + // + // THE FAILURE THIS CATCHES IS EXPENSIVE BECAUSE IT IS LATE. Resolution + // succeeds, the env plan is correct, `execve` replaces the process — and the + // first sign of trouble is the agent's own login prompt, by which point + // swisscode no longer exists to explain itself. Nothing earlier in the launch + // can catch it, because the launch path deliberately never opens the + // directory it points at. So it belongs here, in the one command whose job is + // to look. + // + // Costs a file read, no credential and no network, so it runs under + // `--offline` like the rest of the static checks. + if (profile?.configDir) { + const dir = profile.configDir + const identity = readSessionIdentity(dir) + if (identity) { + checks.push( + makeCheck('session', 'session login', OK, `${describeIdentity(identity)} — ${dir}`), + ) + } else { + // "Never used" and "used but logged out" get different sentences because + // they are different problems: one is onboarding you have not done, the + // other is a login that lapsed. Both take the same fix, but a user who is + // told the right one stops guessing. + const used = sessionDirLooksInitialised(dir, { exists: existsSync }) + checks.push( + makeCheck( + 'session', + 'session login', + WARN, + used + ? `${dir} has been used but carries no login — nobody has completed \`/login\` there` + : `${dir} has never been used by the agent`, + { fix: `swisscode config accounts login ${profile.accountName}` }, + ), + ) + } + } + // live probes const spec = plan ? probeSpec(profile, provider, plan) : null // Redaction also covers a credential carried as https://user:pass@host userinfo, @@ -317,6 +374,87 @@ export async function runDoctor({ } } + // Refresh the usage snapshot. + // + // The doctor is where `core/resolve.ts` ALREADY SENDS PEOPLE: when a `usage` + // profile has no snapshot, `selectAccount` falls back to the first account and + // prints "Run `swisscode config doctor` to refresh usage." Until this block + // existed that sentence was a lie — the doctor had no idea what a snapshot + // was, so the advice sent users in a circle. + // + // Scoped to the accounts a `usage` profile actually names, rather than every + // account on the machine. Each measurement can raise a Keychain prompt, and + // prompting for figures that nothing will ever read is a cost with no answer + // attached. + const usageAccountNames = [ + ...new Set( + Object.values(loaded.state.profiles ?? {}) + .filter((p) => p.strategy === 'usage') + .flatMap((p) => p.accounts ?? []), + ), + ] + if (usageAccountNames.length === 0) { + checks.push( + makeCheck( + 'usage-snapshot', + 'usage snapshot', + SKIP, + 'skipped: no profile selects its account by remaining usage', + ), + ) + } else if (offline) { + checks.push(makeCheck('usage-snapshot', 'usage snapshot', SKIP, 'skipped (--offline)')) + } else { + const measurements = await measureAccounts( + usageAccountNames.map((name) => { + const account = loaded.state.providerAccounts?.[name] + return { name, ...(account?.configDir ? { configDir: account.configDir } : {}) } + }), + usageFetch ? { fetchUsage: usageFetch } : {}, + ) + const remaining = remainingMap(measurements) + const measured = Object.keys(remaining) + if (measured.length === 0) { + checks.push( + makeCheck( + 'usage-snapshot', + 'usage snapshot', + WARN, + 'nothing could be measured, so the cached snapshot was left alone', + { fix: 'swisscode config accounts usage' }, + ), + ) + } else { + // Best effort, like every write to the state directory. The figures are + // already in the report; failing a diagnostic because its cache could not + // be written would trade a working answer for a tidy filesystem. + deps.usage?.write({ remaining, checkedAt: now() }) + checks.push( + makeCheck( + 'usage-snapshot', + 'usage snapshot', + OK, + measured.map((n) => `${n} ${remaining[n]}% left`).join(', '), + ), + ) + // Name what was NOT measured. A partial snapshot still selects, and it + // selects among the accounts that answered — so an account missing from + // it silently stops being a candidate, which is worth one line. + const missed = usageAccountNames.filter((n) => !(n in remaining)) + if (missed.length > 0) { + checks.push( + makeCheck( + 'usage-unmeasured', + 'usage snapshot', + WARN, + `not measured: ${missed.join(', ')} — selection will pass over them until they answer`, + { fix: 'swisscode config accounts usage' }, + ), + ) + } + } + } + // repairs const repairs = [] if (fix && !loaded.readOnly) { diff --git a/test/adapters/doctor-session.test.ts b/test/adapters/doctor-session.test.ts new file mode 100644 index 0000000..98cc1fa --- /dev/null +++ b/test/adapters/doctor-session.test.ts @@ -0,0 +1,347 @@ +// The doctor's two session-mode jobs: say who a session directory is logged in +// as, and refresh the snapshot that `usage` selection reads. +// +// The second one closes a loop that was open in the shipped code: +// `core/resolve.ts` tells users "Run `swisscode config doctor` to refresh +// usage" when a `usage` profile has no snapshot, and until now the doctor had +// no idea what a snapshot was. +import test from 'node:test' +import assert from 'node:assert/strict' +import { mkdtempSync, mkdirSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { runDoctor } from '../../src/composition/doctor-root.ts' +import { measureAccounts, remainingMap } from '../../src/adapters/usage/measure.ts' +import { registry } from '../../src/adapters/providers/registry.ts' +import { registry as agents } from '../../src/adapters/agents/registry.ts' +import type { State } from '../../src/ports/config-store.ts' +import type { DoctorCheck } from '../../src/ports/doctor.ts' +import type { UsageSnapshot } from '../../src/core/resolve.ts' +import type { SubscriptionUsage } from '../../src/adapters/usage/anthropic-subscription.ts' + +const fresh = () => mkdtempSync(join(tmpdir(), 'swisscode-doctor-')) + +/** A measured window, shaped like the real payload but without the network. */ +const usageOf = (remaining: number): SubscriptionUsage => ({ + remaining, + limit: 100, + used: 100 - remaining, + unit: 'percent of window remaining', + checkedAt: 0, + fiveHour: { utilization: 100 - remaining, resetsAt: null }, + sevenDay: { utilization: 0, resetsAt: null }, + sevenDayOpus: { utilization: null, resetsAt: null }, + sevenDaySonnet: { utilization: null, resetsAt: null }, + extraUsage: null, +}) + +/** Write a `.claude.json` carrying a login, the way `/login` leaves it. */ +function loginAt(dir: string, email: string): string { + mkdirSync(dir, { recursive: true }) + writeFileSync( + join(dir, '.claude.json'), + JSON.stringify({ + oauthAccount: { + emailAddress: email, + organizationRateLimitTier: 'default_claude_max_20x', + }, + }), + ) + return dir +} + +type DepsOver = { + state?: State + usageStore?: { read: () => UsageSnapshot | null; write: (s: UsageSnapshot) => void } +} + +function deps(over: DepsOver = {}) { + const state = + over.state ?? + ({ + version: 3, + providerAccounts: { z: { provider: 'zai', apiKey: 'k' } }, + agentProfiles: { z: { models: { opus: 'glm-5.2' } } }, + profiles: { z: { agentProfile: 'z', accounts: ['z'] } }, + defaultProfile: 'z', + bindings: {}, + settings: {}, + } as unknown as State) + return { + store: { + load: () => ({ state, corrupt: false, readOnly: false, migrated: false, warnings: [] }), + save: () => '/tmp/config.json', + path: () => '/tmp/config.json', + modes: () => ({ dir: 0o700, file: 0o600 }), + }, + registry, + agents, + proc: { + env: () => ({}), + cwd: () => '/work', + resolveBinary: () => '/usr/local/bin/claude', + replace: () => { + throw new Error('doctor must never launch anything') + }, + }, + ...(over.usageStore ? { usage: over.usageStore } : {}), + } as unknown as Parameters[0]['deps'] +} + +/** A state whose default profile is a session-mode Anthropic account. */ +const sessionState = (configDir: string, over: { strategy?: string } = {}): State => + ({ + version: 3, + providerAccounts: { personal: { provider: 'anthropic', configDir } }, + agentProfiles: { a: { models: { opus: 'claude-opus-4-8' } } }, + profiles: { + a: { + agentProfile: 'a', + accounts: ['personal'], + ...(over.strategy ? { strategy: over.strategy } : {}), + }, + }, + defaultProfile: 'a', + bindings: {}, + settings: {}, + }) as unknown as State + +const byId = (checks: readonly DoctorCheck[], id: string) => checks.find((c) => c.id === id) + +// ── the session login check ── + +test('a logged-in session directory reports WHO, not just that it exists', async () => { + const dir = loginAt(join(fresh(), 'personal'), 'a@b.c') + const { report } = await runDoctor({ deps: deps({ state: sessionState(dir) }), offline: true }) + const check = byId(report.checks, 'session') + assert.equal(check?.status, 'ok') + // The email is the thing a user recognises — "which of my accounts is this?" + assert.match(check!.detail, /a@b\.c/) + assert.match(check!.detail, /Max 20x/) +}) + +test('a session directory that has never been used warns, and says how to fix it', async () => { + // The failure this catches is late and expensive: the launch succeeds, the + // process is replaced, and the first sign of trouble is the agent's own login + // prompt — with swisscode gone and unable to explain. + const dir = join(fresh(), 'never-created') + const { report } = await runDoctor({ deps: deps({ state: sessionState(dir) }), offline: true }) + const check = byId(report.checks, 'session') + assert.equal(check?.status, 'warn') + assert.match(check!.detail, /never been used/) + assert.equal(check!.fix, 'swisscode config accounts login personal') +}) + +test('used-but-logged-out is a DIFFERENT sentence from never-used', async () => { + // Same fix, different problem: one is onboarding you have not done, the other + // is a login that lapsed. A user told the right one stops guessing. + const dir = join(fresh(), 'used') + mkdirSync(dir, { recursive: true }) + writeFileSync(join(dir, '.claude.json'), JSON.stringify({ someOtherKey: true })) + const { report } = await runDoctor({ deps: deps({ state: sessionState(dir) }), offline: true }) + const check = byId(report.checks, 'session') + assert.equal(check?.status, 'warn') + assert.match(check!.detail, /has been used but carries no login/) + assert.doesNotMatch(check!.detail, /never been used/) +}) + +test('a key-mode profile gets no session check at all', async () => { + // Not an OK check saying "n/a" — an absent one. A key account has no session + // directory to be right or wrong about. + const { report } = await runDoctor({ deps: deps(), offline: true }) + assert.equal(byId(report.checks, 'session'), undefined) +}) + +test('the session check runs under --offline, because it costs no network', async () => { + const dir = loginAt(join(fresh(), 'personal'), 'a@b.c') + const { report } = await runDoctor({ deps: deps({ state: sessionState(dir) }), offline: true }) + assert.equal(byId(report.checks, 'session')?.status, 'ok') +}) + +// ── the usage snapshot ── + +test('no profile selects by usage, so nothing is measured and nothing prompts', async () => { + const dir = loginAt(join(fresh(), 'personal'), 'a@b.c') + let asked = 0 + const { report } = await runDoctor({ + deps: deps({ state: sessionState(dir) }), + usageFetch: async () => { + asked++ + return usageOf(50) + }, + }) + assert.equal(asked, 0, 'measuring for figures nothing reads costs a Keychain prompt for nothing') + assert.equal(byId(report.checks, 'usage-snapshot')?.status, 'skip') + assert.match(byId(report.checks, 'usage-snapshot')!.detail, /no profile selects/) +}) + +test('--offline skips the refresh rather than measuring', async () => { + const dir = loginAt(join(fresh(), 'personal'), 'a@b.c') + let asked = 0 + const { report } = await runDoctor({ + deps: deps({ state: sessionState(dir, { strategy: 'usage' }) }), + offline: true, + usageFetch: async () => { + asked++ + return usageOf(50) + }, + }) + assert.equal(asked, 0) + assert.match(byId(report.checks, 'usage-snapshot')!.detail, /--offline/) +}) + +test('a usage profile gets measured and the snapshot WRITTEN', async () => { + const dir = loginAt(join(fresh(), 'personal'), 'a@b.c') + const written: UsageSnapshot[] = [] + const { report } = await runDoctor({ + deps: deps({ + state: sessionState(dir, { strategy: 'usage' }), + usageStore: { read: () => null, write: (s) => void written.push(s) }, + }), + now: () => 1234, + usageFetch: async () => usageOf(27), + }) + assert.deepEqual(written, [{ remaining: { personal: 27 }, checkedAt: 1234 }]) + const check = byId(report.checks, 'usage-snapshot') + assert.equal(check?.status, 'ok') + assert.match(check!.detail, /personal 27% left/) +}) + +test('only the accounts a usage profile NAMES are measured', async () => { + // Each measurement can raise a Keychain prompt. Measuring the whole machine + // to answer a question about one profile is a cost with no answer attached. + const dir = loginAt(join(fresh(), 'personal'), 'a@b.c') + const other = loginAt(join(fresh(), 'other'), 'x@y.z') + const state = { + version: 3, + providerAccounts: { + personal: { provider: 'anthropic', configDir: dir }, + unused: { provider: 'anthropic', configDir: other }, + }, + agentProfiles: { a: { models: { opus: 'claude-opus-4-8' } } }, + profiles: { + a: { agentProfile: 'a', accounts: ['personal'], strategy: 'usage' }, + b: { agentProfile: 'a', accounts: ['unused'] }, + }, + defaultProfile: 'a', + bindings: {}, + settings: {}, + } as unknown as State + + const asked: (string | null | undefined)[] = [] + await runDoctor({ + deps: deps({ state, usageStore: { read: () => null, write: () => {} } }), + usageFetch: async (req) => { + asked.push(req.sessionDir) + return usageOf(40) + }, + }) + assert.deepEqual(asked, [dir], 'the non-usage profile\'s account must not be touched') +}) + +test('nothing measurable leaves the cached snapshot ALONE rather than clearing it', async () => { + // A stale figure that selection will age out beats no figure at all: the + // alternative is falling back to "first account" the moment the endpoint + // hiccups. + const dir = loginAt(join(fresh(), 'personal'), 'a@b.c') + const written: UsageSnapshot[] = [] + const { report } = await runDoctor({ + deps: deps({ + state: sessionState(dir, { strategy: 'usage' }), + usageStore: { read: () => null, write: (s) => void written.push(s) }, + }), + usageFetch: async () => null, + }) + assert.deepEqual(written, [], 'the snapshot was overwritten with nothing') + const check = byId(report.checks, 'usage-snapshot') + assert.equal(check?.status, 'warn') + assert.match(check!.detail, /left alone/) +}) + +test('a partly-measured set writes what it has and NAMES what it missed', async () => { + // A partial snapshot still selects, and it selects among the accounts that + // answered — so an account missing from it silently stops being a candidate. + const a = loginAt(join(fresh(), 'a'), 'a@b.c') + const b = loginAt(join(fresh(), 'b'), 'x@y.z') + const state = { + version: 3, + providerAccounts: { + one: { provider: 'anthropic', configDir: a }, + two: { provider: 'anthropic', configDir: b }, + }, + agentProfiles: { p: { models: { opus: 'claude-opus-4-8' } } }, + profiles: { p: { agentProfile: 'p', accounts: ['one', 'two'], strategy: 'usage' } }, + defaultProfile: 'p', + bindings: {}, + settings: {}, + } as unknown as State + + const written: UsageSnapshot[] = [] + const { report } = await runDoctor({ + deps: deps({ state, usageStore: { read: () => null, write: (s) => void written.push(s) } }), + now: () => 99, + usageFetch: async (req) => (req.sessionDir === a ? usageOf(80) : null), + }) + assert.deepEqual(written, [{ remaining: { one: 80 }, checkedAt: 99 }]) + assert.equal(byId(report.checks, 'usage-snapshot')?.status, 'ok') + const missed = byId(report.checks, 'usage-unmeasured') + assert.equal(missed?.status, 'warn') + assert.match(missed!.detail, /two/) +}) + +// ── the shared measurement loop ── + +test('measureAccounts returns an entry per account, failures included', async () => { + // Dropping the failures would make "could not be measured" indistinguishable + // from "no longer exists" for anything rendering a list. + const result = await measureAccounts( + [{ name: 'a', configDir: '/a' }, { name: 'b', configDir: '/b' }], + { fetchUsage: async (req) => (req.sessionDir === '/a' ? usageOf(10) : null), readIdentity: () => null }, + ) + assert.deepEqual(result.map((r) => r.name), ['a', 'b']) + assert.equal(result[0]!.usage?.remaining, 10) + assert.equal(result[1]!.usage, null) +}) + +test('a key-mode account is never asked — it has no window to be out of', async () => { + let asked = 0 + const result = await measureAccounts([{ name: 'k' }], { + fetchUsage: async () => { + asked++ + return usageOf(1) + }, + readIdentity: () => null, + }) + assert.equal(asked, 0) + assert.deepEqual(result, [{ name: 'k', configDir: null, identity: null, usage: null }]) +}) + +test('accounts are measured SEQUENTIALLY, so Keychain dialogs cannot stack', async () => { + let inFlight = 0 + let maxInFlight = 0 + await measureAccounts( + [{ name: 'a', configDir: '/a' }, { name: 'b', configDir: '/b' }, { name: 'c', configDir: '/c' }], + { + readIdentity: () => null, + fetchUsage: async () => { + inFlight++ + maxInFlight = Math.max(maxInFlight, inFlight) + await new Promise((r) => setTimeout(r, 1)) + inFlight-- + return usageOf(5) + }, + }, + ) + assert.equal(maxInFlight, 1, 'three unlock dialogs at once is worse than waiting') +}) + +test('an unmeasured account is ABSENT from the map, never zero', () => { + // Zero would read as "exhausted" and route work away from an account that may + // be entirely free. Absent reads as "unknown", which is the truth. + const map = remainingMap([ + { name: 'good', configDir: '/a', identity: null, usage: usageOf(42) }, + { name: 'failed', configDir: '/b', identity: null, usage: null }, + { name: 'key', configDir: null, identity: null, usage: null }, + ]) + assert.deepEqual(map, { good: 42 }) +}) From 9017eb7c741c7f9a65d41ca5d41016fe41c9ab8f Mon Sep 17 00:00:00 2001 From: jellologic <31935831+jellologic@users.noreply.github.com> Date: Wed, 22 Jul 2026 11:47:10 -0400 Subject: [PATCH 16/21] feat(web): session accounts and measured usage in the browser MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Accounts screen could only express an API key, so a session-mode account created from the CLI rendered as "no key" — a working account that looked broken. - Bootstrap carries `logins`: who each session account is logged in as, read from `.claude.json`. A file read apiece, no credential and no Keychain, so it is safe on every cold start. Null when unwired rather than an empty map, which would be indistinguishable from "every account is logged out" and would send a user to re-run `/login` on accounts that are fine. - `POST /api/usage` measures every account and caches the snapshot. POST rather than GET because each measurement can raise a Keychain unlock dialog on macOS, and a GET is something a browser may prefetch, retry or replay on its own initiative. Measuring is a button for the same reason — a screen that measured on load would pop dialogs for opening it. - The credential panel becomes a two-mode switch. Switching to session mode sends an explicit `apiKey: null`, because the server refuses an account holding both — correctly — so clearing the old key is what makes the switch expressible at all. - The screen says the one thing it cannot do: `/login` is an interactive flow in the agent's own TUI and a browser tab cannot drive it. Pointing an account at an empty directory is legal here and silently useless, so the terminal command is named rather than implied. The status dot for a session account tracks the LOGIN, not the path. A directory nobody has logged into is indistinguishable in config.json from one that works, and fails only after execve. Verified live via Playwright against a temp config with a planted `sk-ant-secret-canary` key: absent from the bootstrap payload and the DOM, still present in config.json. Measured default at 26% (5h 6%, 7d 74%), "spare" reported as unmeasurable rather than zero, the key account reported as having no window, and the snapshot written to the state dir. 727 tests. Signed-off-by: jellologic <31935831+jellologic@users.noreply.github.com> --- src/adapters/web/api-async.ts | 39 ++++++ src/adapters/web/api.ts | 18 +++ src/composition/web-root.ts | 41 ++++++ test/adapters/web-async.test.ts | 69 ++++++++++ test/adapters/web.test.ts | 18 +++ web/src/api.ts | 44 +++++++ web/src/routes/Accounts.tsx | 216 +++++++++++++++++++++++++++----- 7 files changed, 411 insertions(+), 34 deletions(-) diff --git a/src/adapters/web/api-async.ts b/src/adapters/web/api-async.ts index 113b262..1dffcab 100644 --- a/src/adapters/web/api-async.ts +++ b/src/adapters/web/api-async.ts @@ -23,6 +23,32 @@ export type AsyncApiDeps = { doctor?: (opts: { offline: boolean }) => Promise /** Built lazily by web-root; absent when no catalog adapters are wired. */ catalogs?: CatalogRegistryPort + /** + * Measures every account's remaining subscription capacity and caches it. + * + * Injected like `doctor`, and a POST for the same reason: on macOS each + * measurement can raise a Keychain unlock dialog. A GET that a browser might + * prefetch, retry or replay must never be able to do that. + */ + measureUsage?: () => Promise +} + +/** One account, measured. Carries no credential — only what it bought you. */ +export type MeasuredAccount = { + name: string + /** 'key' accounts bill per token and have no window; they are listed, not measured */ + mode: 'session' | 'key' + /** "a@b.c · Max 20x", or null when nobody has logged in there */ + login: string | null + remaining: number | null + fiveHour: { utilization: number | null; resetsAt: string | null } | null + sevenDay: { utilization: number | null; resetsAt: string | null } | null +} + +export type UsageReport = { + accounts: MeasuredAccount[] + /** epoch ms, or null when nothing could be measured and no snapshot was written */ + checkedAt: number | null } const json = (status: number, body: unknown): ApiResponse => ({ status, body }) @@ -57,6 +83,19 @@ export async function handleAsyncApi( } } + if (resource === 'usage' && req.method === 'POST') { + if (!deps.measureUsage) { + return json(501, { error: 'usage measurement is not available in this context' }) + } + try { + return json(200, await deps.measureUsage()) + } catch (err) { + // Same discipline as every other measurement in this codebase: a finding + // to report, not an exception that takes a configuration screen down. + return json(500, { error: (err as { message?: string }).message ?? 'could not measure usage' }) + } + } + if (resource === 'catalog' && req.method === 'GET') { const id = rest[0] ? decodeURIComponent(rest[0]) : null if (!id) return json(400, { error: 'catalog id is required' }) diff --git a/src/adapters/web/api.ts b/src/adapters/web/api.ts index 4e554b6..f4e98a0 100644 --- a/src/adapters/web/api.ts +++ b/src/adapters/web/api.ts @@ -51,6 +51,20 @@ export type ApiDeps = { * be a claim nobody checked. */ installed?: () => InstalledAgent[] + /** + * Who each session-mode account is logged in as, keyed by account name. + * + * A thunk for the same reason as `installed`: it reads a `.claude.json` per + * session account, and only `bootstrap` wants it. Cheap enough to run on every + * cold start — a file read, no credential, no Keychain prompt, no network — + * which is precisely why identity is separate from usage. Measuring a window + * costs a prompt and is a button; saying who an account IS costs nothing and + * should already be on screen. + * + * Optional, and absent rather than empty when unwired: `{}` would be + * indistinguishable from "every account is logged out". + */ + identities?: () => Record } /** One agent CLI, as found (or not) on this machine. */ @@ -330,6 +344,10 @@ export function handleApi(req: ApiRequest, deps: ApiDeps): ApiResponse { // Which of these are actually on this machine. Absent when the caller // wired no process port; never faked. installedAgents: deps.installed ? deps.installed() : null, + // Who each session account is logged in as. Same "never faked" rule as + // `installedAgents`: null when unwired, never an empty map that would read + // as "all logged out". + logins: deps.identities ? deps.identities() : null, // Custom providers are returned SEPARATELY from `providers` even though // the registry already merges them: the UI has to know which ones it may // edit, and a merged list cannot say. diff --git a/src/composition/web-root.ts b/src/composition/web-root.ts index 7436518..3d93f7a 100644 --- a/src/composition/web-root.ts +++ b/src/composition/web-root.ts @@ -19,6 +19,7 @@ import { createFsCacheStore } from '../adapters/store/fs-cache-store.ts' import { fetchNet } from '../adapters/net/fetch-net.ts' import { systemClock } from '../adapters/clock/system-clock.ts' import { configDir } from '../adapters/store/fs-config-store.ts' +import { describeIdentity, readSessionIdentity } from '../adapters/claude-session/identity.ts' import type { LaunchDeps } from './launch-root.ts' export type RunWebOptions = { @@ -93,6 +94,46 @@ export async function runWeb({ const run = await runDoctor({ deps, offline }) return run.report }, + // Who each session account is logged in as. A file read apiece, so it is + // safe on every cold start — see the note on ApiDeps.identities. + identities: () => { + const { state } = deps.store.load() + const logins: Record = {} + for (const [name, account] of Object.entries(state.providerAccounts ?? {})) { + if (!account.configDir) continue + logins[name] = describeIdentity(readSessionIdentity(account.configDir)) + } + return logins + }, + // Lazy for the same reason as the doctor, and one step further: this one + // can raise a Keychain prompt, so it loads only when someone asks for it. + measureUsage: async () => { + const { measureAccounts, remainingMap } = await import('../adapters/usage/measure.ts') + const { state } = deps.store.load() + const measurements = await measureAccounts( + Object.entries(state.providerAccounts ?? {}).map(([name, account]) => ({ + name, + ...(account.configDir ? { configDir: account.configDir } : {}), + })), + ) + const remaining = remainingMap(measurements) + // Write only when something answered. Clearing a usable snapshot + // because the endpoint hiccupped would drop every `usage` profile back + // to "first account" for no reason. + const checkedAt = Object.keys(remaining).length > 0 ? Date.now() : null + if (checkedAt !== null) deps.usage?.write({ remaining, checkedAt }) + return { + checkedAt, + accounts: measurements.map((m) => ({ + name: m.name, + mode: m.configDir ? ('session' as const) : ('key' as const), + login: m.configDir ? describeIdentity(m.identity) : null, + remaining: m.usage?.remaining ?? null, + fiveHour: m.usage?.fiveHour ?? null, + sevenDay: m.usage?.sevenDay ?? null, + })), + } + }, port, assetDir: assetDir(), }) diff --git a/test/adapters/web-async.test.ts b/test/adapters/web-async.test.ts index 8ac117c..2f58a42 100644 --- a/test/adapters/web-async.test.ts +++ b/test/adapters/web-async.test.ts @@ -135,3 +135,72 @@ test('a provider with no catalog is a 404, which is a normal state', async () => ) assert.equal(res?.status, 404) }) + +// ── usage measurement ── + +const usageReport = () => ({ + checkedAt: 1234, + accounts: [ + { + name: 'personal', + mode: 'session' as const, + login: 'a@b.c · Max 20x', + remaining: 27, + fiveHour: { utilization: 0, resetsAt: null }, + sevenDay: { utilization: 73, resetsAt: null }, + }, + ], +}) + +test('measuring usage is POST-only, so nothing can prefetch a Keychain prompt', async () => { + // A GET is something a browser may prefetch, retry or replay on its own + // initiative. On macOS each measurement can raise an unlock dialog, and + // nothing that pops a system dialog should be reachable that way. + let called = 0 + const deps = { + measureUsage: async () => { + called++ + return usageReport() + }, + } + assert.equal(await handleAsyncApi({ method: 'GET', path: '/api/usage', body: null }, deps), null) + assert.equal(called, 0) + + const res = await handleAsyncApi({ method: 'POST', path: '/api/usage', body: {} }, deps) + assert.equal(res?.status, 200) + assert.equal(called, 1) +}) + +test('measured usage crosses with the windows attached and no credential', async () => { + const res = await handleAsyncApi( + { method: 'POST', path: '/api/usage', body: {} }, + { measureUsage: async () => usageReport() }, + ) + const body = res?.body as ReturnType + assert.equal(body.accounts[0]!.remaining, 27) + assert.equal(body.accounts[0]!.sevenDay!.utilization, 73) + // The identity is the point of the payload; a token never is. + const raw = JSON.stringify(body) + assert.match(raw, /a@b\.c/) + assert.doesNotMatch(raw, /sk-ant|accessToken|Bearer/) +}) + +test('an unwired measurer answers 501 rather than pretending everything is fine', async () => { + // The same rule as the doctor: a context that cannot measure says so, instead + // of returning an empty list that reads as "you have no accounts". + const res = await handleAsyncApi({ method: 'POST', path: '/api/usage', body: {} }, {}) + assert.equal(res?.status, 501) +}) + +test('a thrown measurement becomes a 500 body, never an unhandled rejection', async () => { + const res = await handleAsyncApi( + { method: 'POST', path: '/api/usage', body: {} }, + { + measureUsage: async () => { + throw new Error('keychain denied') + }, + }, + ) + assert.equal(res?.status, 500) + assert.match(String((res?.body as { error: string }).error), /keychain denied/) +}) diff --git a/test/adapters/web.test.ts b/test/adapters/web.test.ts index caddf4c..66aec4a 100644 --- a/test/adapters/web.test.ts +++ b/test/adapters/web.test.ts @@ -405,6 +405,24 @@ test('installed agents are reported as unknown rather than faked when unavailabl assert.equal(agentsFound.length, 2) }) +test('session logins are reported as unknown rather than faked when unwired', () => { + // Same rule as installedAgents, and it matters more here: an empty map would + // be indistinguishable from "every account is logged out", which would send a + // user to re-run `/login` on accounts that are fine. + const s = store(baseState()) + const without = handleApi({ method: 'GET', path: '/api/bootstrap', body: null }, deps(s)) + assert.equal((without.body as Record).logins, null) + + const withIdentities = handleApi({ method: 'GET', path: '/api/bootstrap', body: null }, { + ...deps(s), + identities: () => ({ personal: 'a@b.c · Max 20x', spare: null }), + }) + assert.deepEqual((withIdentities.body as Record).logins, { + personal: 'a@b.c · Max 20x', + spare: null, + }) +}) + test('a custom provider round-trips through the API and becomes launchable', () => { const s = store(baseState()) const created = handleApi( diff --git a/web/src/api.ts b/web/src/api.ts index eec7722..3497ef9 100644 --- a/web/src/api.ts +++ b/web/src/api.ts @@ -65,6 +65,15 @@ export type ProviderAccount = { baseUrl?: string hasKey: boolean apiKeyFromEnv?: string + /** + * Session mode: a directory holding a login the agent already performed. + * + * Crosses to the browser in full, unlike `apiKey`, because it is a PATH and + * not a secret — the credential it points at stays in the Keychain, which is + * the entire point of this mode. Mutually exclusive with the key fields; the + * server refuses the combination rather than picking one. + */ + configDir?: string } /** WHAT RUNS. Holds no credential, so it crosses whole. */ @@ -122,10 +131,35 @@ export type Bootstrap = { compatFlags: CompatFlag[] credentialEnvs: string[] installedAgents: InstalledAgent[] | null + /** + * Who each session account is logged in as, keyed by account name. + * + * Null when the server wired no identity reader — NOT an empty map, which + * would be indistinguishable from "every account is logged out". Key-mode + * accounts are simply absent from it. + */ + logins: Record | null customProviders: Record reservedProviderIds: string[] } +/** One window of a subscription, as the endpoint publishes it. */ +export type UsageWindow = { utilization: number | null; resetsAt: string | null } + +export type MeasuredAccount = { + name: string + mode: 'session' | 'key' + login: string | null + remaining: number | null + fiveHour: UsageWindow | null + sevenDay: UsageWindow | null +} + +export type UsageReport = { + accounts: MeasuredAccount[] + checkedAt: number | null +} + /** A refusal the UI must render rather than swallow. */ export class ApiError extends Error { status: number @@ -263,6 +297,16 @@ export const api = { catalog: (id: string) => call(`/api/catalog/${encodeURIComponent(id)}`), + /** + * Measure every account's remaining subscription window, and cache it. + * + * A POST, not a GET, because on macOS each measurement can raise a Keychain + * unlock dialog — and a GET is something a browser may prefetch, retry or + * replay on its own initiative. Nothing that can pop a system dialog should + * be reachable that way. + */ + usage: () => call('/api/usage', { method: 'POST', body: JSON.stringify({}) }), + saveSettings: (settings: unknown, revision: string | null) => call<{ revision: string }>('/api/settings', { method: 'PUT', diff --git a/web/src/routes/Accounts.tsx b/web/src/routes/Accounts.tsx index ed01568..068c171 100644 --- a/web/src/routes/Accounts.tsx +++ b/web/src/routes/Accounts.tsx @@ -1,26 +1,57 @@ import { useState } from 'react' import { css } from '../../styled-system/css' -import { ApiError, api, type Bootstrap } from '../api' +import { ApiError, api, type Bootstrap, type UsageReport, type UsageWindow } from '../api' import { Banner, Button, Dot, Empty, Field, Panel, inputStyle, monoInput } from '../ui' /** * Provider accounts — who pays. * - * The credential is WRITE-ONLY, and this is the only screen that touches one. - * The server sends `hasKey` and never the key, so the field offers to replace - * what is stored: leaving it blank changes nothing, and clearing it is a - * separate, explicit action. "I did not touch this" and "delete my credential" - * must not be the same gesture. + * An account authenticates one of two ways, and they are mutually exclusive: + * + * key an API key, WRITE-ONLY. The server sends `hasKey` and never the + * key, so the field offers to REPLACE what is stored: leaving it + * blank changes nothing, and clearing it is a separate, explicit + * action. "I did not touch this" and "delete my credential" must not + * be the same gesture. + * session a directory holding a login the agent already performed. The path + * crosses to this page in full because it is not a secret — the + * credential stays in the OS keychain, which is the whole point. * * Deleting shows which profiles the account backs rather than repairing them. * Only the user knows which account should pay instead. */ +/** + * One window, in the smallest space that stays honest. + * + * An unpublished window renders as “—”, never as 0%. The endpoint genuinely + * omits these — both per-model weekly windows were null on the account this was + * built against — and showing an omission as zero would read as "completely + * unused", which is the opposite of "we do not know". + */ +function pct(w: UsageWindow | null): string { + return w?.utilization === null || w === null ? '—' : `${w.utilization}%` +} + export function Accounts({ data, reload }: { data: Bootstrap; reload: () => Promise }) { const accounts = Object.entries(data.state.providerAccounts ?? {}) const [editing, setEditing] = useState(null) const [draft, setDraft] = useState>({}) const [error, setError] = useState(null) const [warnings, setWarnings] = useState([]) + const [usage, setUsage] = useState(null) + const [measuring, setMeasuring] = useState(false) + + const measure = async () => { + setError(null) + setMeasuring(true) + try { + setUsage(await api.usage()) + } catch (err) { + setError(err instanceof ApiError ? err.message : String(err)) + } finally { + setMeasuring(false) + } + } const open = (name: string | null) => { setError(null) @@ -42,6 +73,15 @@ export function Accounts({ data, reload }: { data: Bootstrap; reload: () => Prom // the boundary that owns it. if (!body.apiKey) delete body.apiKey delete body.hasKey + // Switching an account to session mode must CLEAR the key it used to + // carry, not leave it stored behind the new one. The server refuses an + // account holding both — correctly, since "which credential did this + // actually use" must never have a subtle answer — so the explicit null + // is what makes the switch expressible at all. + if (body.configDir) { + body.apiKey = null + body.apiKeyFromEnv = '' + } await api.saveAccount(name, body, data.revision) setEditing(null) await reload() @@ -128,31 +168,81 @@ export function Accounts({ data, reload }: { data: Bootstrap; reload: () => Prom - put('apiKey', e.target.value)} - placeholder={stored?.hasKey ? '•••••••• stored' : 'paste key'} - /> - - - put('apiKeyFromEnv', e.target.value)} - placeholder="MY_TOKEN" - /> + + + {draft.configDir ? ( + <> + + put('configDir', e.target.value)} + placeholder="~/.claude" + /> + + {/* + THE ONE THING THIS PAGE CANNOT DO. `/login` is an interactive + OAuth flow inside the agent's own TUI; a browser tab cannot + drive it. Pointing an account at an empty directory here is + legal and silently useless until someone logs in there, so the + terminal command that does it is named rather than implied. + */} + + Logging in happens in a terminal, not here — run{' '} + swisscode config accounts login {editing || ''} and complete{' '} + /login inside the agent. This page only points the account at a + directory. + + {!isNew && data.logins ? ( +
+ currently: {data.logins[editing] ?? 'not logged in'} +
+ ) : null} + + ) : ( + <> + + put('apiKey', e.target.value)} + placeholder={stored?.hasKey ? '•••••••• stored' : 'paste key'} + /> + + + put('apiKeyFromEnv', e.target.value)} + placeholder="MY_TOKEN" + /> + + + )}
@@ -169,9 +259,20 @@ export function Accounts({ data, reload }: { data: Bootstrap; reload: () => Prom <>

Accounts

- +
+ {/* + Measuring is a BUTTON rather than something the page does on load. + Each session account costs a keychain read — which on macOS can pop + an unlock dialog — plus a request to Anthropic. A screen that did + that every time you opened it would be indefensible. + */} + + +
{error ? {error} : null} {warnings.map((w) => ( @@ -179,6 +280,20 @@ export function Accounts({ data, reload }: { data: Bootstrap; reload: () => Prom {w} ))} + {usage?.checkedAt === null ? ( + + Nothing could be measured, so the cached figures were left alone. An account must be logged + in, and its token unexpired, to report a window. + + ) : null} + {usage?.checkedAt ? ( +
+ Measured {new Date(usage.checkedAt).toLocaleTimeString()}. “% left” is the tighter of the two + windows, never their average — an account at 5% of its 5-hour window and 95% of its weekly + one has almost nothing left. Profiles using the usage strategy now select on + these figures. +
+ ) : null} {accounts.length === 0 ? ( @@ -191,6 +306,8 @@ export function Accounts({ data, reload }: { data: Bootstrap; reload: () => Prom const usedBy = Object.entries(data.state.profiles ?? {}) .filter(([, p]) => (p.accounts ?? []).includes(name)) .map(([n]) => n) + const login = a.configDir ? (data.logins?.[name] ?? null) : null + const measured = usage?.accounts.find((m) => m.name === name) return (
Prom _last: { borderBottom: 'none' }, })} > - + {/* + A session account is "ready" when someone has LOGGED IN there, + not when a path is set. A directory nobody has logged into + looks identical in config.json and fails only after execve, so + the dot tracks the login rather than the field. + */} +
{name} @@ -217,10 +350,25 @@ export function Accounts({ data, reload }: { data: Bootstrap; reload: () => Prom
{a.provider} {' · '} - {a.apiKeyFromEnv ? `key from $${a.apiKeyFromEnv}` : a.hasKey ? 'key stored' : 'no key'} + {a.configDir + ? (login ?? 'not logged in') + : a.apiKeyFromEnv + ? `key from $${a.apiKeyFromEnv}` + : a.hasKey + ? 'key stored' + : 'no key'} {' · '} {usedBy.length > 0 ? `used by ${usedBy.join(', ')}` : 'unused'}
+ {measured ? ( +
+ {measured.mode === 'key' + ? 'key account — no subscription window' + : measured.remaining === null + ? 'could not be measured' + : `${measured.remaining}% left · 5h ${pct(measured.fiveHour)} · 7d ${pct(measured.sevenDay)}`} +
+ ) : null}