diff --git a/cli/__tests__/seats-package.test.mjs b/cli/__tests__/seats-package.test.mjs index 1a52ccd104..756143ecd1 100644 --- a/cli/__tests__/seats-package.test.mjs +++ b/cli/__tests__/seats-package.test.mjs @@ -66,7 +66,7 @@ describe('packages/seats', () => { }); test('the CLI provider proxy is the package module', () => { - expect(Object.keys(cliProxy).sort()).toEqual(['redactProviderMaterial', 'resolveProviderKeyFile', 'startProviderProxy']); + expect(Object.keys(cliProxy).sort()).toEqual(['PROVIDER_WIRES', 'redactProviderMaterial', 'resolveProviderKeyFile', 'startProviderProxy']); for (const name of Object.keys(cliProxy)) expect(cliProxy[name]).toBe(seatsProxy[name]); }); diff --git a/packages/seats/docs/record-v1.md b/packages/seats/docs/record-v1.md index bc1a392764..e8fee8e582 100644 --- a/packages/seats/docs/record-v1.md +++ b/packages/seats/docs/record-v1.md @@ -338,8 +338,8 @@ built in Go runs its tests with `-race` in CI. | kind | data | default actor | |---|---|---| -| `credential.granted` | `{names: [NAME]}` | `system` | -| `credential.denied` | `{name, reason}`, where `reason` is `not_granted`, `unresolved` or `too_short` (a value under 8 characters) | `system` | +| `credential.granted` | `{names: [NAME], injected?: [NAME]}`. `injected` names the credentials a loopback provider proxy holds and adds outbound; the run's env has only that proxy's token under those names. The other names are held by the run | `system` | +| `credential.denied` | `{name, reason}`, where `reason` is `not_granted`, `unresolved`, `too_short` (a value under 8 characters) or `no_upstream` (marked `inject` with no provider named for it) | `system` | These cover the environment credentials a run is started with. Values never appear here: `NAME` is an environment variable name matching diff --git a/packages/seats/src/record/format.js b/packages/seats/src/record/format.js index 2612d8a91c..1c08288289 100644 --- a/packages/seats/src/record/format.js +++ b/packages/seats/src/record/format.js @@ -371,7 +371,13 @@ const CHECKS = { optionalArgsDigest(data, 'tool.call'); return data; }, - 'credential.granted': (data) => ({ ...data, names: envNames(data.names ?? [], 'credential.granted.names') }), + 'credential.granted': (data) => { + const names = envNames(data.names ?? [], 'credential.granted.names'); + if (data.injected === undefined) return { ...data, names }; + const injected = envNames(data.injected, 'credential.granted.injected'); + if (!injected.every((name) => names.includes(name))) fail('credential.granted.injected must be a subset of names'); + return { ...data, names, injected }; + }, 'credential.denied': (data) => { envNames([data.name], 'credential.denied.name'); return data; diff --git a/packages/seats/src/trace/events.js b/packages/seats/src/trace/events.js index 229a7ca7f9..e7cbef601e 100644 --- a/packages/seats/src/trace/events.js +++ b/packages/seats/src/trace/events.js @@ -23,8 +23,8 @@ export const EVENT_TYPES = Object.freeze([ 'usage', // { inputTokens?, outputTokens?, totalTokens?, cost? } null-not-zero when unknown 'liveness.stalled', // { idleMs, windowMs } 'liveness.recovery', // { action: 'cancel'|'kill'|'retry', attempt } - 'credential.granted', // { names: [...] } names only, never values - 'credential.denied', // { name, reason?: 'not_granted'|'unresolved'|'too_short' } + 'credential.granted', // { names: [...], injected?: [...] } names only, never values + 'credential.denied', // { name, reason?: 'not_granted'|'unresolved'|'too_short'|'no_upstream' } 'process.exited', // { code, signal } // Record kinds with no earlier trace name (docs/record-v1.md has the full data). 'task.claimed', // { leaseUntil? } mirrors the board's claim; the board stays the source of truth diff --git a/packages/seats/src/valet/env.js b/packages/seats/src/valet/env.js index 507bcaa186..39ef6bd784 100644 --- a/packages/seats/src/valet/env.js +++ b/packages/seats/src/valet/env.js @@ -19,6 +19,7 @@ import { traceOf } from '../trace/events.js'; import { runFailure } from '../failures.js'; import { MIN_CREDENTIAL_LENGTH } from '../upstream-refusal.js'; import { validateGrant } from './grant.js'; +import { startProviderProxy } from '../wire/provider-proxy.js'; // Same spirit as the goose adapter's SAFE_ENV: locale, terminal and TLS trust // only. USER/LOGNAME are left out on purpose; they name the account whose @@ -104,8 +105,8 @@ const grantIssued = (meta) => { }; /** - * prepareRunEnv({ grant, baseEnv, resolveSecret, root?, require?, trace?, ctx?, grantMeta? }) - * → { env, home, cleanup, granted: [names], grantId? } + * prepareRunEnv({ grant, baseEnv, resolveSecret, root?, require?, trace?, ctx?, grantMeta?, upstreams? }) + * → { env, home, cleanup, granted: [names], injected: { NAME: { baseURL, wire } }, grantId? } * * `require` lists credential names the adapter cannot run without; one the * grant does not name fails the run as credential_denied before any spawn. @@ -114,14 +115,25 @@ const grantIssued = (meta) => { * silently lacks its key fails later and less legibly, and a value that short * would slip past the refusal-text reader's redaction. * + * A credential the grant marks `inject: true` never reaches the env. The host + * names its provider in `upstreams[NAME]` ({ baseURL, wire }, wire defaulting + * to openai-chat); the run gets a loopback provider proxy holding the value in + * memory, env[NAME] becomes that proxy's token, and `injected[NAME].baseURL` + * is where the host points the CLI. cleanup() closes the proxies. One with no + * upstream fails as credential_denied (no_upstream) before it is resolved. + * The proxy listens on 127.0.0.1, so a run without the host's network + * (bwrap --unshare-net) cannot reach it yet. + * * Events go to `trace`, else ctx.trace: grant.issued (only with grantMeta, - * first), then credential.granted {names} or credential.denied {name, reason}. + * first), then credential.granted {names, injected?} or credential.denied + * {name, reason}. `injected` lists the names held by a proxy; the rest of + * `names` are held by the run. * Values never enter an event or a message, and a hook that offers - * redact(values) is handed the resolved values so the record can scrub them - * out of agent text too. + * redact(values) is handed the resolved values and each proxy token, so the + * record can scrub them out of agent text too. */ export const prepareRunEnv = async ({ - grant, baseEnv = {}, resolveSecret, root, require: required = [], trace, ctx, grantMeta, + grant, baseEnv = {}, resolveSecret, root, require: required = [], trace, ctx, grantMeta, upstreams = {}, } = {}) => { const valid = validateGrant(grant); const hook = trace && typeof trace.emit === 'function' ? trace : traceOf(ctx); @@ -132,6 +144,7 @@ export const prepareRunEnv = async ({ not_granted: 'is not in the run grant', unresolved: 'could not be resolved', too_short: `resolved to a value shorter than ${MIN_CREDENTIAL_LENGTH} characters`, + no_upstream: 'is marked inject, but the host named no upstream for it', }; const deny = (name, reason, values) => { hook.emit('credential.denied', { name, reason }); @@ -143,11 +156,18 @@ export const prepareRunEnv = async ({ if (missing) throw deny(missing, 'not_granted', []); const { home, dirs } = await makeHome(root); - const cleanup = () => rm(home, { recursive: true, force: true }); + const proxies = []; + const cleanup = async () => { + await Promise.all(proxies.splice(0).map((proxy) => proxy.close())); + await rm(home, { recursive: true, force: true }); + }; const env = { ...pickBaseEnv(baseEnv, realHomes), HOME: home, ...dirs }; const values = []; + const injected = {}; try { for (const spec of valid.credentials) { + const upstream = spec.inject ? upstreams[spec.name] : undefined; + if (spec.inject && !upstream) throw deny(spec.name, 'no_upstream', values); let value; // A resolver's error text may quote the secret it failed on; drop it. try { value = await resolveOne(spec, { baseEnv, resolveSecret }); } catch { value = undefined; } @@ -156,14 +176,26 @@ export const prepareRunEnv = async ({ // refusal-text reader would not redact it. Refuse it here, before spawn. if (value.length < MIN_CREDENTIAL_LENGTH) throw deny(spec.name, 'too_short', values); values.push(value); - env[spec.name] = value; + if (upstream) { + const wire = upstream.wire ?? 'openai-chat'; + const proxy = await startProviderProxy({ + provider: { baseURL: upstream.baseURL }, keyMaterial: value, wire, adapterLabel: 'Valet', + }); + proxies.push(proxy); + env[spec.name] = proxy.token; + injected[spec.name] = { baseURL: proxy.baseURL, wire }; + } else { + env[spec.name] = value; + } } } catch (error) { await cleanup(); throw error; } - if (typeof hook.redact === 'function') hook.redact(values); + // A proxy token authorizes provider calls until cleanup, so the record scrubs it too. + if (typeof hook.redact === 'function') hook.redact([...values, ...proxies.map((proxy) => proxy.token)]); const names = valid.credentials.map((c) => c.name); - hook.emit('credential.granted', { names }); - return { env, home, cleanup, granted: names, ...(issued ? { grantId: issued.grantId } : {}) }; + const proxied = Object.keys(injected); + hook.emit('credential.granted', proxied.length ? { names, injected: proxied } : { names }); + return { env, home, cleanup, granted: names, injected, ...(issued ? { grantId: issued.grantId } : {}) }; }; diff --git a/packages/seats/src/valet/grant.js b/packages/seats/src/valet/grant.js index b2109bfd39..8d8ca36d79 100644 --- a/packages/seats/src/valet/grant.js +++ b/packages/seats/src/valet/grant.js @@ -3,7 +3,7 @@ * and which tool calls it may make without asking, only after asking, or never. * * { - * credentials: [{ name: 'OPENAI_API_KEY', from: 'env'|'file'|'resolver', ref? }], + * credentials: [{ name: 'OPENAI_API_KEY', from: 'env'|'file'|'resolver', ref?, inject? }], * tools: { allow: [pattern], ask: [pattern], deny: [pattern] }, * } * @@ -11,6 +11,10 @@ * path in `ref`; `from: 'resolver'` asks the host's resolveSecret(name, spec), * with `ref` as an opaque hint for it. * + * `inject: true` keeps the value out of the run: its env gets a per-run proxy + * token under the name instead, and a loopback provider proxy adds the real + * key to outbound requests. Default false: the run holds the value. + * * A tool pattern is `` or `:`, where kind is one of * ACP's ToolKind values or `*`. `execute` matches every execute call; * `execute:git status*` matches execute calls whose title starts that way; @@ -61,8 +65,8 @@ const parsePattern = (pattern, where) => { const validateCredential = (credential, index, seen) => { const where = `credentials[${index}]`; if (!isPlainObject(credential)) fail(`${where} must be an object`); - refuseUnknownKeys(credential, ['name', 'from', 'ref'], `${where}.`); - const { name, from, ref } = credential; + refuseUnknownKeys(credential, ['name', 'from', 'ref', 'inject'], `${where}.`); + const { name, from, ref, inject } = credential; if (typeof name !== 'string' || !ENV_NAME.test(name)) fail(`${where}.name must be an ENV_NAME (A-Z, 0-9, _)`); if (RESERVED_NAME.test(name)) fail(`${where}.name ${name} is reserved for the run env`); if (seen.has(name)) fail(`${where}.name ${name} is granted twice`); @@ -71,7 +75,8 @@ const validateCredential = (credential, index, seen) => { if (ref !== undefined && (typeof ref !== 'string' || !ref)) fail(`${where}.ref must be a non-empty string`); if (from === 'env' && ref !== undefined && !ENV_NAME.test(ref)) fail(`${where}.ref must be an ENV_NAME for from: env`); if (from === 'file' && (ref === undefined || !ref.startsWith('/'))) fail(`${where}.ref must be an absolute path for from: file`); - return Object.freeze(ref === undefined ? { name, from } : { name, from, ref }); + if (inject !== undefined && typeof inject !== 'boolean') fail(`${where}.inject must be true or false`); + return Object.freeze({ name, from, ...(ref === undefined ? {} : { ref }), ...(inject ? { inject } : {}) }); }; /** Validate and freeze a grant. Throws on the first problem; never repairs one. */ diff --git a/packages/seats/src/wire/provider-proxy.js b/packages/seats/src/wire/provider-proxy.js index 0b4c09260d..626e53a27f 100644 --- a/packages/seats/src/wire/provider-proxy.js +++ b/packages/seats/src/wire/provider-proxy.js @@ -1,6 +1,6 @@ import { Transform } from 'node:stream'; import { StringDecoder } from 'node:string_decoder'; -/** The per-spawn loopback proxy shared by OpenAI-compatible CLI adapters. */ +/** The per-spawn loopback proxy that holds a provider key for a CLI (OpenAI chat, Anthropic Messages, OpenAI Responses). */ import { randomBytes, timingSafeEqual } from 'crypto'; import { constants as fsConstants, realpathSync } from 'fs'; import { createServer, request as httpRequest } from 'http'; @@ -167,11 +167,30 @@ const readProviderKey = async (keyFile, adapterLabel) => { } }; +// The routes a CLI on each wire may call, relative to the configured base +// path, and the header the real key goes out in. Anything else is refused. +const WIRES = Object.freeze({ + 'openai-chat': { routes: [['POST', 'chat/completions'], ['GET', 'models']], auth: 'bearer' }, + 'anthropic-messages': { + routes: [['POST', 'v1/messages'], ['POST', 'v1/messages/count_tokens'], ['GET', 'v1/models']], + auth: 'x-api-key', + }, + 'openai-responses': { routes: [['POST', 'responses'], ['GET', 'models']], auth: 'bearer' }, +}); +export const PROVIDER_WIRES = Object.freeze(Object.keys(WIRES)); + +const presents = (value, expected) => { + const supplied = Buffer.from(typeof value === 'string' ? value : ''); + return supplied.length === expected.length && timingSafeEqual(supplied, expected); +}; + export const startProviderProxy = async ({ - provider, keyFile, keyMaterial, tokenFile, socketPath, isolatedPort, adapterLabel = 'OpenCode', + provider, keyFile, keyMaterial, tokenFile, socketPath, isolatedPort, adapterLabel = 'OpenCode', wire = 'openai-chat', // When set, every completion must request this model and be served by it. pinnedModel, }) => { + const wireSpec = WIRES[wire]; + if (!wireSpec) throw new Error(`${adapterLabel} provider wire must be one of ${PROVIDER_WIRES.join(', ')}`); if (pinnedModel !== undefined && (typeof pinnedModel !== 'string' || !pinnedModel.trim())) { throw new Error(`${adapterLabel} pinnedModel must be a non-empty string`); } @@ -189,8 +208,13 @@ export const startProviderProxy = async ({ const upstreamKey = keyMaterial === undefined ? await readProviderKey(keyFile, adapterLabel) : keyMaterial; if (typeof upstreamKey !== 'string' || !upstreamKey || /[\r\n]/.test(upstreamKey)) throw new Error('Invalid provider key'); const proxyToken = randomBytes(32).toString('base64url'); - await writeFile(tokenFile, proxyToken, { encoding: 'utf8', mode: 0o600 }); - await chmod(tokenFile, 0o600); + // A caller that hands the token to its child in memory needs no file. + if (tokenFile) { + await writeFile(tokenFile, proxyToken, { encoding: 'utf8', mode: 0o600 }); + await chmod(tokenFile, 0o600); + } + const bearerToken = Buffer.from(`Bearer ${proxyToken}`); + const bareToken = Buffer.from(proxyToken); const logRefusedRequest = (method, requestPath) => { let normalizedPath = '/'; @@ -204,29 +228,34 @@ export const startProviderProxy = async ({ process.stderr.write(`[${adapterLabel.toLowerCase()}] provider proxy refused ${safeMethod} ${normalizedPath}\n`); }; + // A refusal answers before the request body has been read. A keep-alive + // client that reuses that socket can have its next request reset, so every + // refusal on every wire also closes the connection. + const refuse = (incoming, outgoing, status, headers = {}, body = undefined) => { + incoming.resume(); + outgoing.writeHead(status, { ...headers, connection: 'close' }); + outgoing.end(body); + }; + const server = createServer((incoming, outgoing) => { - const supplied = Buffer.from(incoming.headers.authorization || ''); - const expected = Buffer.from(`Bearer ${proxyToken}`); - if (supplied.length !== expected.length || !timingSafeEqual(supplied, expected)) { - incoming.resume(); - outgoing.writeHead(401).end(); + // Anthropic clients send their key as x-api-key, the others as a Bearer. + if (!presents(incoming.headers.authorization, bearerToken) + && !presents(incoming.headers['x-api-key'], bareToken)) { + refuse(incoming, outgoing, 401); return; } if (!['GET', 'POST'].includes(incoming.method || '')) { - incoming.resume(); - outgoing.writeHead(405).end(); + refuse(incoming, outgoing, 405); return; } const rejectRoute = (path) => { logRefusedRequest(incoming.method, path); - incoming.resume(); - outgoing.writeHead(403, { 'content-type': 'text/plain; charset=utf-8' }); - outgoing.end(`Unsupported provider request: ${incoming.method || 'UNKNOWN'} ${path}`); + refuse(incoming, outgoing, 403, { 'content-type': 'text/plain; charset=utf-8' }, + `Unsupported provider request: ${incoming.method || 'UNKNOWN'} ${path}`); }; const rejectBadRequest = () => { logRefusedRequest(incoming.method, incoming.url); - incoming.resume(); - outgoing.writeHead(400).end(); + refuse(incoming, outgoing, 400); }; let target; @@ -241,8 +270,8 @@ export const startProviderProxy = async ({ return; } // The CLI owns this local request path, so never use it as an outbound - // URL. The OpenAI-compatible chat provider needs only these fixed routes; - // parse the incoming URL against a loopback sentinel, then select a + // URL. Each wire needs only the fixed routes in WIRES; parse the + // incoming URL against a loopback sentinel, then select a // constant endpoint after matching the configured base path exactly. // This keeps a CLI request from turning the proxy into an arbitrary // path/URL fetcher (including paths on a local provider host). @@ -252,17 +281,13 @@ export const startProviderProxy = async ({ return; } const configuredPath = basePath === '/' ? '' : basePath; - let endpoint; - if (incoming.method === 'POST' - && requested.pathname === `${configuredPath}/chat/completions`) { - endpoint = 'chat/completions'; - } else if (incoming.method === 'GET' - && requested.pathname === `${configuredPath}/models`) { - endpoint = 'models'; - } else { + const route = wireSpec.routes.find(([method, path]) => incoming.method === method + && requested.pathname === `${configuredPath}/${path}`); + if (!route) { rejectRoute(requested.pathname); return; } + const endpoint = route[1]; target = { path: `${configuredPath}/${endpoint}`, protocol: upstream.protocol, @@ -281,7 +306,8 @@ export const startProviderProxy = async ({ 'proxy-authorization', 'te', 'trailer', 'transfer-encoding', 'upgrade', 'x-api-key', ].includes(name.toLowerCase()))); - headers.authorization = `Bearer ${upstreamKey}`; + if (wireSpec.auth === 'x-api-key') headers['x-api-key'] = upstreamKey; + else headers.authorization = `Bearer ${upstreamKey}`; headers.host = target.host; headers['accept-encoding'] = 'identity'; const pinned = pinnedModel !== undefined && target.path.endsWith('/chat/completions'); @@ -378,6 +404,12 @@ export const startProviderProxy = async ({ }); }); + // A WebSocket client (codex on the Responses wire) falls back to HTTP SSE on + // 426. Nothing is relayed over an upgrade, so no token check is needed first. + server.on('upgrade', (_incoming, socket) => { + socket.end('HTTP/1.1 426 Upgrade Required\r\nconnection: close\r\ncontent-length: 0\r\n\r\n'); + }); + await new Promise((resolve, reject) => { server.once('error', reject); const started = () => { @@ -399,6 +431,7 @@ export const startProviderProxy = async ({ const proxyBaseURL = new URL(upstream.pathname, `http://127.0.0.1:${port}`).toString(); return { baseURL: proxyBaseURL, + token: proxyToken, tokenFile, socketPath, port, diff --git a/packages/seats/test/provider-proxy-wires.test.js b/packages/seats/test/provider-proxy-wires.test.js new file mode 100644 index 0000000000..a77bde0610 --- /dev/null +++ b/packages/seats/test/provider-proxy-wires.test.js @@ -0,0 +1,163 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { Agent, createServer, request as httpRequest } from 'node:http'; +import { connect } from 'node:net'; +import { existsSync, mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { PROVIDER_WIRES, startProviderProxy } from '../src/wire/provider-proxy.js'; + +const KEY = 'sk-ant-real-0123456789abcdefghij'; + +// A fake provider that records each request and answers 200 with what it saw. +const startUpstream = async () => { + const seen = []; + const server = createServer((req, res) => { + let body = ''; + req.on('data', (chunk) => { body += chunk; }); + req.on('end', () => { + seen.push({ method: req.method, url: req.url, headers: req.headers, body }); + res.writeHead(200, { 'content-type': 'application/json' }); + res.end(JSON.stringify({ ok: true, path: req.url })); + }); + }); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + return { seen, url: `http://127.0.0.1:${server.address().port}`, close: () => new Promise((resolve) => server.close(resolve)) }; +}; + +const call = (baseURL, method, path, headers = {}, body, agent = undefined) => new Promise((resolve, reject) => { + const url = new URL(path, baseURL); + const req = httpRequest({ hostname: url.hostname, port: url.port, path: url.pathname, method, headers, agent }, (res) => { + let text = ''; + res.on('data', (chunk) => { text += chunk; }); + res.on('end', () => resolve({ status: res.statusCode, headers: res.headers, text })); + }); + req.on('error', reject); + req.end(body); +}); + +const withProxy = async (wire, basePath, fn) => { + const upstream = await startUpstream(); + const proxy = await startProviderProxy({ provider: { baseURL: `${upstream.url}${basePath}` }, keyMaterial: KEY, wire, adapterLabel: 'Test' }); + try { return await fn({ upstream, proxy }); } finally { + await proxy.close(); + await upstream.close(); + } +}; + +test('the wires are the three the route table names', () => { + assert.deepEqual([...PROVIDER_WIRES], ['openai-chat', 'anthropic-messages', 'openai-responses']); +}); + +test('an unknown wire is refused before anything listens', async () => { + await assert.rejects(startProviderProxy({ provider: { baseURL: 'http://127.0.0.1:9' }, keyMaterial: KEY, wire: 'gemini' }), /wire must be one of/); +}); + +test('anthropic-messages: the token arrives as x-api-key and the real key leaves as x-api-key', () => withProxy('anthropic-messages', '', async ({ upstream, proxy }) => { + const res = await call(proxy.baseURL, 'POST', '/v1/messages', { + 'x-api-key': proxy.token, 'anthropic-version': '2023-06-01', 'content-type': 'application/json', + }, '{"model":"m","max_tokens":1,"messages":[]}'); + assert.equal(res.status, 200); + assert.equal(upstream.seen.length, 1); + const { headers, url, body } = upstream.seen[0]; + assert.equal(url, '/v1/messages'); + assert.equal(headers['x-api-key'], KEY); + assert.equal(headers.authorization, undefined, 'no Bearer goes out on the Anthropic wire'); + assert.equal(headers['anthropic-version'], '2023-06-01'); + assert.equal(body, '{"model":"m","max_tokens":1,"messages":[]}'); +})); + +test('anthropic-messages: a Bearer token is accepted too, and count_tokens and models are routed', () => withProxy('anthropic-messages', '', async ({ upstream, proxy }) => { + const bearer = { authorization: `Bearer ${proxy.token}` }; + assert.equal((await call(proxy.baseURL, 'POST', '/v1/messages/count_tokens', bearer, '{}')).status, 200); + assert.equal((await call(proxy.baseURL, 'GET', '/v1/models', bearer)).status, 200); + assert.deepEqual(upstream.seen.map((r) => [r.method, r.url, r.headers['x-api-key']]), [ + ['POST', '/v1/messages/count_tokens', KEY], ['GET', '/v1/models', KEY], + ]); +})); + +test('a missing or wrong token is 401 in either header, and nothing reaches the provider', () => withProxy('anthropic-messages', '', async ({ upstream, proxy }) => { + for (const headers of [{}, { 'x-api-key': `${proxy.token}x` }, { 'x-api-key': KEY }, { authorization: proxy.token }, { authorization: `Bearer ${KEY}` }]) { + assert.equal((await call(proxy.baseURL, 'POST', '/v1/messages', headers, '{}')).status, 401, JSON.stringify(Object.keys(headers))); + } + assert.equal(upstream.seen.length, 0); +})); + +test('each wire refuses the routes it does not list', () => withProxy('anthropic-messages', '', async ({ upstream, proxy }) => { + const auth = { 'x-api-key': proxy.token }; + for (const [method, path] of [['POST', '/v1/messages/batches'], ['POST', '/v1/chat/completions'], ['GET', '/v1/messages'], ['POST', '/v1/complete']]) { + assert.equal((await call(proxy.baseURL, method, path, auth, '{}')).status, 403, `${method} ${path}`); + } + assert.equal(upstream.seen.length, 0); +})); + +// Rhea on #2766: a refusal answered before the body was read left the socket +// reusable, and the next request on it was reset (ECONNRESET in CI). One +// keep-alive socket, a body larger than one read, every wire. +test('a keep-alive client gets a 403 on each refused request, never a reset, on every wire', async () => { + const body = JSON.stringify({ pad: 'x'.repeat(256 * 1024) }); + for (const wire of PROVIDER_WIRES) { + // eslint-disable-next-line no-await-in-loop + await withProxy(wire, '/v1', async ({ upstream, proxy }) => { + const agent = new Agent({ keepAlive: true, maxSockets: 1 }); + const auth = { authorization: `Bearer ${proxy.token}`, 'content-type': 'application/json' }; + try { + for (let i = 0; i < 6; i += 1) { + // eslint-disable-next-line no-await-in-loop + const refused = await call(proxy.baseURL, 'POST', '/v1/messages/batches', auth, body, agent); + assert.equal(refused.status, 403, `${wire} request ${i}`); + assert.equal(refused.headers.connection, 'close', `${wire} request ${i}`); + } + } finally { + agent.destroy(); + } + assert.equal(upstream.seen.length, 0); + }); + } +}); + +test('openai-responses: POST responses and GET models under the base path, Bearer out; chat/completions refused', () => withProxy('openai-responses', '/v1', async ({ upstream, proxy }) => { + const auth = { authorization: `Bearer ${proxy.token}` }; + assert.equal((await call(proxy.baseURL, 'POST', '/v1/responses', auth, '{}')).status, 200); + assert.equal((await call(proxy.baseURL, 'GET', '/v1/models', auth)).status, 200); + assert.equal((await call(proxy.baseURL, 'POST', '/v1/chat/completions', auth, '{}')).status, 403); + assert.deepEqual(upstream.seen.map((r) => [r.method, r.url, r.headers.authorization, r.headers['x-api-key']]), [ + ['POST', '/v1/responses', `Bearer ${KEY}`, undefined], ['GET', '/v1/models', `Bearer ${KEY}`, undefined], + ]); +})); + +test('openai-chat stays the default: chat/completions and models only, Bearer out', () => withProxy(undefined, '/v1', async ({ upstream, proxy }) => { + const auth = { authorization: `Bearer ${proxy.token}` }; + assert.equal((await call(proxy.baseURL, 'POST', '/v1/chat/completions', auth, '{}')).status, 200); + assert.equal((await call(proxy.baseURL, 'POST', '/v1/responses', auth, '{}')).status, 403); + assert.equal(upstream.seen[0].headers.authorization, `Bearer ${KEY}`); + assert.equal(upstream.seen.length, 1); +})); + +test('a WebSocket upgrade gets 426, so the client falls back to HTTP, and nothing reaches the provider', () => withProxy('openai-responses', '/v1', async ({ upstream, proxy }) => { + const port = new URL(proxy.baseURL).port; + const reply = await new Promise((resolve, reject) => { + const socket = connect(Number(port), '127.0.0.1', () => { + socket.write(`GET /v1/responses HTTP/1.1\r\nhost: 127.0.0.1\r\nauthorization: Bearer ${proxy.token}\r\nconnection: Upgrade\r\nupgrade: websocket\r\nsec-websocket-version: 13\r\nsec-websocket-key: dGhlIHNhbXBsZSBub25jZQ==\r\n\r\n`); + }); + let text = ''; + socket.on('data', (chunk) => { text += chunk; }); + socket.on('end', () => resolve(text)); + socket.on('error', reject); + }); + assert.match(reply, /^HTTP\/1\.1 426 /); + assert.equal(upstream.seen.length, 0); +})); + +test('without a tokenFile the proxy writes no file and hands the token back in memory', async () => { + const dir = mkdtempSync(join(tmpdir(), 'seats-proxy-')); + try { + const proxy = await startProviderProxy({ provider: { baseURL: 'http://127.0.0.1:9/v1' }, keyMaterial: KEY }); + try { + assert.match(proxy.token, /^[A-Za-z0-9_-]{43}$/); + assert.equal(proxy.tokenFile, undefined); + } finally { await proxy.close(); } + const withFile = await startProviderProxy({ provider: { baseURL: 'http://127.0.0.1:9/v1' }, keyMaterial: KEY, tokenFile: join(dir, 'token') }); + try { assert.ok(existsSync(join(dir, 'token'))); } finally { await withFile.close(); } + } finally { rmSync(dir, { recursive: true, force: true }); } +}); diff --git a/packages/seats/test/valet-inject.test.js b/packages/seats/test/valet-inject.test.js new file mode 100644 index 0000000000..748579819a --- /dev/null +++ b/packages/seats/test/valet-inject.test.js @@ -0,0 +1,194 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { execFile, spawnSync } from 'node:child_process'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { createServer } from 'node:http'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { prepareRunEnv } from '../src/valet/env.js'; +import { validateGrant } from '../src/valet/grant.js'; +import { appendEntry } from '../src/record/format.js'; + +const REAL = 'sk-ant-real-key-0123456789abcdefghijklmnop'; +// A held value with no key shape: the secret scan reads every branch commit. +const HELD = 'held-held-held-held'; +const recorder = () => { const events = []; return { events, emit: (type, data) => events.push({ type, data }) }; }; +const withScratch = async (fn) => { + const dir = mkdtempSync(join(tmpdir(), 'seats-inject-')); + try { return await fn(dir); } finally { rmSync(dir, { recursive: true, force: true }); } +}; + +// Stack's counterexample: an allowed upstream that echoes the auth it received, +// in a response header and in the streamed body, split into 7-byte chunks. +const startEchoUpstream = async () => { + const seen = []; + const server = createServer((req, res) => { + req.resume(); + req.on('end', () => { + const got = req.headers['x-api-key'] || ''; + seen.push({ url: req.url, key: got, authorization: req.headers.authorization }); + res.writeHead(401, { 'content-type': 'text/event-stream', 'x-echo': `received ${got}` }); + const body = `data: {"error":"invalid x-api-key: ${got}"}\n\n`; + let i = 0; + const tick = () => { + if (i >= body.length) { res.end(); return; } + res.write(body.slice(i, i + 7)); i += 7; setImmediate(tick); + }; + tick(); + }); + }); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + return { seen, url: `http://127.0.0.1:${server.address().port}`, close: () => new Promise((resolve) => server.close(resolve)) }; +}; + +// The child does what Claude Code does: send its env key as x-api-key to the base URL. +const CHILD = ` +fetch(process.env.ANTHROPIC_BASE_URL + '/v1/messages', { + method: 'POST', headers: { 'x-api-key': process.env.ANTHROPIC_API_KEY, 'content-type': 'application/json' }, body: '{}', +}).then(async (res) => { + process.stdout.write(JSON.stringify({ status: res.status, echo: res.headers.get('x-echo'), body: await res.text(), env: process.env })); +}).catch((err) => { process.stdout.write(JSON.stringify({ error: String(err) })); });`; +// Async on purpose: the proxy and the upstream live on this process's event +// loop, so a spawnSync child would wait on a server that can never answer. +const runChild = (env) => new Promise((resolve, reject) => { + execFile(process.execPath, ['-e', CHILD], { env, encoding: 'utf8', timeout: 15000 }, (error, stdout, stderr) => { + if (error) { reject(new Error(`child failed: ${error.message}\n${stderr}`)); return; } + resolve({ raw: stdout, ...JSON.parse(stdout) }); + }); +}); + +test('echo canary: an injected key reaches the provider, never the child, even when the provider echoes it', () => withScratch(async (root) => { + const upstream = await startEchoUpstream(); + const trace = recorder(); + const run = await prepareRunEnv({ + grant: { credentials: [{ name: 'ANTHROPIC_API_KEY', from: 'env', inject: true }] }, + baseEnv: { ANTHROPIC_API_KEY: REAL, PATH: process.env.PATH }, + upstreams: { ANTHROPIC_API_KEY: { baseURL: upstream.url, wire: 'anthropic-messages' } }, + root, + trace, + }); + try { + assert.notEqual(run.env.ANTHROPIC_API_KEY, REAL); + assert.ok(!JSON.stringify(run.env).includes(REAL), 'the run env never holds the real key'); + assert.equal(run.injected.ANTHROPIC_API_KEY.wire, 'anthropic-messages'); + assert.match(run.injected.ANTHROPIC_API_KEY.baseURL, /^http:\/\/127\.0\.0\.1:\d+\/$/); + const seen = await runChild({ ...run.env, ANTHROPIC_BASE_URL: run.injected.ANTHROPIC_API_KEY.baseURL.replace(/\/$/, '') }); + // The provider got the real key, in the header its wire uses. + assert.deepEqual(upstream.seen.map((r) => [r.url, r.key, r.authorization]), [['/v1/messages', REAL, undefined]]); + // The echo came back redacted in the header and in the chunked body; the child saw nothing of the key. + assert.equal(seen.status, 401); + assert.equal(seen.echo, 'received [redacted]'); + assert.equal(seen.body, 'data: {"error":"invalid x-api-key: [redacted]"}\n\n'); + assert.ok(!seen.raw.includes(REAL), 'the child output never carries the real key'); + assert.equal(seen.env.ANTHROPIC_API_KEY, run.env.ANTHROPIC_API_KEY, 'the child holds only the proxy token'); + assert.deepEqual(trace.events, [{ + type: 'credential.granted', data: { names: ['ANTHROPIC_API_KEY'], injected: ['ANTHROPIC_API_KEY'] }, + }]); + } finally { + await run.cleanup(); + await upstream.close(); + } + // cleanup() closed the proxy: the run's token now reaches nothing. + const after = await runChild({ PATH: process.env.PATH, ANTHROPIC_API_KEY: 'x', ANTHROPIC_BASE_URL: run.injected.ANTHROPIC_API_KEY.baseURL.replace(/\/$/, '') }); + assert.match(after.error, /fetch failed/); +})); + +test('the record is told to scrub the proxy token as well as the real key', () => withScratch(async (root) => { + const upstream = await startEchoUpstream(); + const registered = []; + const trace = { ...recorder(), redact: (values) => registered.push(...values) }; + const run = await prepareRunEnv({ + grant: { credentials: [{ name: 'GH_TOKEN', from: 'env' }, { name: 'OPENAI_API_KEY', from: 'env', inject: true }] }, + baseEnv: { GH_TOKEN: HELD, OPENAI_API_KEY: REAL }, + upstreams: { OPENAI_API_KEY: { baseURL: `${upstream.url}/v1` } }, + root, + trace, + }); + try { + assert.notEqual(run.env.OPENAI_API_KEY, REAL); + assert.deepEqual(registered, [HELD, REAL, run.env.OPENAI_API_KEY]); + } finally { + await run.cleanup(); + await upstream.close(); + } +})); + +test('a held credential and an injected one in the same run: the record says which is which', () => withScratch(async (root) => { + const upstream = await startEchoUpstream(); + const trace = recorder(); + const run = await prepareRunEnv({ + grant: { credentials: [{ name: 'GH_TOKEN', from: 'env' }, { name: 'OPENAI_API_KEY', from: 'env', inject: true }] }, + baseEnv: { GH_TOKEN: HELD, OPENAI_API_KEY: REAL }, + upstreams: { OPENAI_API_KEY: { baseURL: `${upstream.url}/v1` } }, + root, + trace, + }); + try { + assert.equal(run.env.GH_TOKEN, HELD); + assert.notEqual(run.env.OPENAI_API_KEY, REAL); + assert.equal(run.injected.OPENAI_API_KEY.wire, 'openai-chat', 'the wire defaults to openai-chat'); + assert.deepEqual(Object.keys(run.injected), ['OPENAI_API_KEY']); + assert.deepEqual(trace.events[0].data, { names: ['GH_TOKEN', 'OPENAI_API_KEY'], injected: ['OPENAI_API_KEY'] }); + } finally { + await run.cleanup(); + await upstream.close(); + } +})); + +test('a run with nothing injected keeps the old record shape and an empty injected map', () => withScratch(async (root) => { + const trace = recorder(); + const run = await prepareRunEnv({ grant: { credentials: [{ name: 'OPENAI_API_KEY', from: 'env' }] }, baseEnv: { OPENAI_API_KEY: REAL }, root, trace }); + try { + assert.equal(run.env.OPENAI_API_KEY, REAL); + assert.deepEqual(run.injected, {}); + assert.deepEqual(trace.events, [{ type: 'credential.granted', data: { names: ['OPENAI_API_KEY'] } }]); + } finally { await run.cleanup(); } +})); + +test('inject with no upstream is denied before the value is resolved, and leaves no HOME', () => withScratch(async (root) => { + const trace = recorder(); + let resolved = 0; + await assert.rejects(prepareRunEnv({ + grant: { credentials: [{ name: 'VAULT_KEY', from: 'resolver', inject: true }] }, + resolveSecret: async () => { resolved += 1; return REAL; }, + root, + trace, + }), (error) => error.kind === 'credential_denied' && /no upstream/.test(error.message) && !error.message.includes(REAL)); + assert.equal(resolved, 0); + assert.deepEqual(trace.events, [{ type: 'credential.denied', data: { name: 'VAULT_KEY', reason: 'no_upstream' } }]); + assert.equal(spawnSync('ls', ['-A', root], { encoding: 'utf8' }).stdout, ''); +})); + +test('a later credential failing closes the proxies already started', () => withScratch(async (root) => { + const openServers = () => process.getActiveResourcesInfo().filter((name) => name === 'TCPServerWrap').length; + const before = openServers(); + const upstream = await startEchoUpstream(); + const trace = { emit: () => {} }; + await assert.rejects(prepareRunEnv({ + grant: { credentials: [{ name: 'OPENAI_API_KEY', from: 'env', inject: true }, { name: 'GONE', from: 'env' }] }, + baseEnv: { OPENAI_API_KEY: REAL }, + upstreams: { OPENAI_API_KEY: { baseURL: `${upstream.url}/v1` } }, + root, + trace, + }), /credential_denied/); + await upstream.close(); + // The proxy's port was never handed out, so count listening servers instead. + // A closed server's handle leaves the list a few ticks after close() resolves; + // a leaked one never does. + for (let tick = 0; tick < 50 && openServers() > before; tick += 1) await new Promise((resolve) => setImmediate(resolve)); + assert.equal(openServers(), before, 'no proxy server is left open'); +})); + +test('grant: inject must be a boolean; false and absent both mean held', () => { + assert.throws(() => validateGrant({ credentials: [{ name: 'K', from: 'env', inject: 'yes' }] }), /inject must be true or false/); + assert.deepEqual({ ...validateGrant({ credentials: [{ name: 'K', from: 'env', inject: false }] }).credentials[0] }, { name: 'K', from: 'env' }); + assert.deepEqual({ ...validateGrant({ credentials: [{ name: 'K', from: 'env', inject: true }] }).credentials[0] }, { name: 'K', from: 'env', inject: true }); +}); + +test('record: credential.granted.injected holds names only, and only names that were granted', () => { + const ENV = { runId: 'run-inject-0001', taskId: 'TASK-7', podId: 'pod-a' }; + const granted = (data) => appendEntry(null, { ...ENV, ts: '2026-10-26T03:00:00.000Z', kind: 'credential.granted', data }); + assert.match(granted({ names: ['A_KEY', 'B_KEY'], injected: ['B_KEY'] }), /"injected":\["B_KEY"\]/); + assert.throws(() => granted({ names: ['A_KEY'], injected: ['B_KEY'] }), /subset of names/); + assert.throws(() => granted({ names: ['A_KEY'], injected: ['sk-live-0123456789'] }), /NAMES/); +});