diff --git a/.github/workflows/npm-publish.yml b/.github/workflows/npm-publish.yml index a547e07ba1..78e1641fb1 100644 --- a/.github/workflows/npm-publish.yml +++ b/.github/workflows/npm-publish.yml @@ -49,6 +49,7 @@ on: branches: [ main ] paths: - 'cli/**' + - 'packages/seats/**' # bundled into the cli tarball (bundleDependencies) - 'commonly-mcp/**' - 'docs/agents/skills/commonly/SKILL.md' # cli prepublishOnly copies it in workflow_dispatch: @@ -112,6 +113,14 @@ jobs: with: node-version: '22' + # Before the compare, not just before publish: the cli bundles + # packages/seats, and `npm pack` on a tree with no node_modules exits 0 + # with the bundle silently missing. The compare would then see every + # tree as different from what npm serves. cli's prepack now refuses + # that case, so a missing install fails here, by name. + - name: Install + run: npm ci --ignore-scripts + - name: Decide whether this tree holds anything unpublished id: cmp shell: bash @@ -237,10 +246,6 @@ jobs: done true - - name: Install - if: steps.cmp.outputs.action == 'publish' - run: npm ci --ignore-scripts - - name: Publish if: steps.cmp.outputs.action == 'publish' # No NODE_AUTH_TOKEN: npm exchanges this job's OIDC id-token with the diff --git a/cli/__tests__/seats-package.test.mjs b/cli/__tests__/seats-package.test.mjs new file mode 100644 index 0000000000..ef8bb984f8 --- /dev/null +++ b/cli/__tests__/seats-package.test.mjs @@ -0,0 +1,80 @@ +/** + * packages/seats is the runtime the CLI bundles (D62, extract-in-place). These + * tests hold the two properties the move depends on: the package imports + * nothing from Commonly, and the CLI tarball carries it. + */ +import { execFileSync } from 'child_process'; +import { readdirSync, readFileSync, statSync } from 'fs'; +import { builtinModules } from 'module'; +import { dirname, join, relative, resolve, sep } from 'path'; +import { fileURLToPath } from 'url'; +import { createHermesAdapter, createRegistry, stub } from '@commonlyai/seats'; +import cliHermes from '../src/lib/adapters/hermes.js'; +import { getAdapter } from '../src/lib/adapters/index.js'; + +const CLI = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const SEATS = resolve(CLI, '..', 'packages', 'seats'); + +const sourceFiles = (dir) => readdirSync(dir).flatMap((name) => { + const full = join(dir, name); + return statSync(full).isDirectory() ? sourceFiles(full) : (full.endsWith('.js') ? [full] : []); +}); + +const specifiers = (code) => [ + ...code.matchAll(/(?:^|\n)\s*(?:import|export)\b[^'"`;]*?from\s*['"]([^'"]+)['"]/g), + ...code.matchAll(/(?:^|\n)\s*import\s*['"]([^'"]+)['"]/g), + ...code.matchAll(/\bimport\(\s*['"]([^'"]+)['"]\s*\)/g), +].map((match) => match[1]); + +describe('packages/seats', () => { + test('imports only node built-ins and its own modules', () => { + const builtins = new Set(builtinModules); + const files = sourceFiles(join(SEATS, 'src')); + expect(files.length).toBeGreaterThan(5); + const outside = []; + for (const file of files) { + for (const spec of specifiers(readFileSync(file, 'utf8'))) { + if (spec.startsWith('node:') || builtins.has(spec)) continue; + const target = spec.startsWith('.') ? resolve(dirname(file), spec) : null; + if (target && (target + sep).startsWith(join(SEATS, 'src') + sep)) continue; + outside.push(`${relative(SEATS, file)} → ${spec}`); + } + } + expect(outside).toEqual([]); + }); + + test('the boundary scan sees a bad import', () => { + expect(specifiers("import x from '../../cli/src/lib/environment.js';\nexport { y } from 'commander';")) + .toEqual(['../../cli/src/lib/environment.js', 'commander']); + }); + + test('the CLI registry serves the bound adapters', () => { + expect(getAdapter('stub')).toBe(stub); + expect(getAdapter('hermes')).toBe(cliHermes); + expect(Object.keys(cliHermes).sort()).toEqual( + Object.keys(createHermesAdapter()).sort(), + ); + }); + + test('a host-less Hermes adapter takes the neutral defaults', async () => { + const hermes = createHermesAdapter(); + expect(hermes.requiresModel).toBe(false); + await expect(hermes.validateEnvironment({ sandbox: { trust: 'public' } })) + .rejects.toThrow('no enforced confinement adapter exists'); + }); + + test('a registry refuses duplicate names', () => { + expect(() => createRegistry([stub, stub])).toThrow('duplicate adapter name: stub'); + expect(createRegistry([stub]).get('stub')).toBe(stub); + }); + + test('the CLI tarball bundles every package source file', () => { + const [pack] = JSON.parse(execFileSync('npm', ['pack', '--dry-run', '--json', '--ignore-scripts'], { + cwd: CLI, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], + })); + const packed = new Set(pack.files.map((file) => file.path)); + const expected = sourceFiles(join(SEATS, 'src')) + .map((file) => `node_modules/@commonlyai/seats/${relative(SEATS, file).split(sep).join('/')}`); + expect(expected.filter((path) => !packed.has(path))).toEqual([]); + }, 60000); +}); diff --git a/cli/package-lock.json b/cli/package-lock.json index 7ed20a78f4..f7e349ba90 100644 --- a/cli/package-lock.json +++ b/cli/package-lock.json @@ -7,8 +7,12 @@ "": { "name": "@commonlyai/cli", "version": "0.1.155", + "bundleDependencies": [ + "@commonlyai/seats" + ], "license": "Apache-2.0", "dependencies": { + "@commonlyai/seats": "file:../packages/seats", "commander": "^12.0.0" }, "bin": { @@ -22,6 +26,14 @@ "node": ">=20.0.0" } }, + "../packages/seats": { + "name": "@commonlyai/seats", + "version": "0.0.0", + "license": "Apache-2.0", + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/@babel/code-frame": { "version": "7.29.0", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", @@ -518,6 +530,10 @@ "dev": true, "license": "MIT" }, + "node_modules/@commonlyai/seats": { + "resolved": "../packages/seats", + "link": true + }, "node_modules/@eslint-community/eslint-utils": { "version": "4.10.1", "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.10.1.tgz", diff --git a/cli/package.json b/cli/package.json index 2145947183..883171eb3a 100644 --- a/cli/package.json +++ b/cli/package.json @@ -2,7 +2,7 @@ "name": "@commonlyai/cli", "version": "0.1.155", "license": "Apache-2.0", - "description": "The Commonly CLI — connect agents, manage pods, iterate fast", + "description": "The Commonly CLI \u2014 connect agents, manage pods, iterate fast", "type": "module", "main": "./src/index.js", "bin": { @@ -32,11 +32,16 @@ "lint:fix": "eslint src --ext .js --fix", "test": "node --experimental-vm-modules node_modules/.bin/jest", "test:temp-cleanup": "node scripts/verify-temp-cleanup.mjs", + "prepack": "node scripts/require-bundled-seats.mjs", "prepublishOnly": "node -e \"require('fs').copyFileSync('../docs/agents/skills/commonly/SKILL.md','skills/commonly/SKILL.md')\"" }, "dependencies": { + "@commonlyai/seats": "file:../packages/seats", "commander": "^12.0.0" }, + "bundleDependencies": [ + "@commonlyai/seats" + ], "devDependencies": { "eslint": "^8.56.0", "jest": "^29.7.0" diff --git a/cli/scripts/require-bundled-seats.mjs b/cli/scripts/require-bundled-seats.mjs new file mode 100644 index 0000000000..63aaaf4f76 --- /dev/null +++ b/cli/scripts/require-bundled-seats.mjs @@ -0,0 +1,11 @@ +// prepack: refuse to pack the CLI without its bundled runtime package. +// `npm pack` on a tree where npm ci has not run exits 0 and silently leaves +// node_modules/@commonlyai/seats out of the tarball, which would publish a CLI +// whose adapters cannot load. +import { existsSync } from 'fs'; + +const manifest = new URL('../node_modules/@commonlyai/seats/package.json', import.meta.url); +if (!existsSync(manifest)) { + console.error('@commonlyai/seats is not installed in cli/node_modules; run `npm ci` in cli/ before packing or publishing'); + process.exit(1); +} diff --git a/cli/src/lib/adapters/hermes.js b/cli/src/lib/adapters/hermes.js index 2c6906c620..8181bbe1f2 100644 --- a/cli/src/lib/adapters/hermes.js +++ b/cli/src/lib/adapters/hermes.js @@ -1,199 +1,18 @@ -import { observeSpawn, controlsFromArguments } from '../runtime-observation.js'; -/** Hermes 0.22.2 local CLI wrapper. Hermes owns provider I/O; this file only spawns it. */ -import { requireSeatModel, seatModelRequired } from './seat-model.js'; -import { spawn as childSpawn, spawnSync } from 'child_process'; -import { createHash } from 'crypto'; -import { mkdir } from 'fs/promises'; import { homedir } from 'os'; import { join } from 'path'; -import { StringDecoder } from 'string_decoder'; +import { createHermesAdapter } from '@commonlyai/seats/adapters/hermes'; import { buildMemoryPreamble } from '../memory-bridge.js'; import { effectiveSandboxTrust } from '../environment.js'; -import { adapterFailure, spawnCredentials } from '../upstream-refusal.js'; - -export const TESTED_HERMES_VERSION = '0.22.2'; -const DEFAULT_TIMEOUT_MS = 15 * 60 * 1000; -const VERSION_TIMEOUT_MS = 15000; -const MAX_STDERR_CHARS = 4096; -const MAX_LINE_CHARS = 1024 * 1024; - -const readVersion = (env, spawnSyncImpl = spawnSync) => { - let probe; - for (let attempt = 0; attempt < 2; attempt += 1) { - probe = spawnSyncImpl('hermes', ['--version'], { - encoding: 'utf8', env, timeout: VERSION_TIMEOUT_MS, - }); - if (probe.error?.code === 'ENOENT') return null; - if (probe.error || probe.status !== 0) continue; - const version = String(probe.stdout || '').match(/Hermes Agent v([^\s]+)/i)?.[1]; - if (version !== TESTED_HERMES_VERSION) { - throw new Error(`Hermes ${version || 'unknown'} is unsupported; this adapter requires ${TESTED_HERMES_VERSION}`); - } - return version; - } - const status = probe.status ?? 'none'; - const signal = probe.signal ?? 'none'; - const code = probe.error?.code ?? 'none'; - throw new Error(`Hermes version probe failed after 2 attempts (status=${status}, signal=${signal}, code=${code}, timeout=${VERSION_TIMEOUT_MS}ms); check the Hermes installation or startup log`); -}; - -export const readHermesJsonl = (line, state) => { - let event; - try { event = JSON.parse(line); } catch { throw new Error('Hermes emitted invalid JSONL'); } - if (state.result) throw new Error('Hermes emitted data after its terminal result'); - if (event?.type === 'system' && event.subtype === 'init') { - state.started = true; - } else if (event?.type === 'result') { - state.result = event; - } else if (!state.started) { - throw new Error('Hermes emitted an event before init'); - } -}; - -const runHermes = ({ args, prompt, cwd, env, timeoutMs, spawnImpl, credentials, onSpawn, controls = {} }) => new Promise((resolve, reject) => { - const child = (spawnImpl || childSpawn)('hermes', args, { - cwd, env, stdio: ['pipe', 'pipe', 'pipe'], - }); - observeSpawn(child, onSpawn, controls); - const state = { started: false, result: null }; - const decoder = new StringDecoder('utf8'); - let buffer = ''; - let stderr = ''; - let timedOut = false; - let settled = false; - let closed = false; - let stopping = false; - let killTimer; - const finish = (error, result) => { - if (settled) return; - settled = true; - clearTimeout(timer); - if (error) reject(error); else resolve(result); - }; - const stop = () => { - if (stopping || closed) return; - stopping = true; - child.kill('SIGTERM'); - killTimer = setTimeout(() => { - if (!closed) child.kill('SIGKILL'); - }, 5000); - }; - const failAndStop = (error) => { - stop(); - finish(error); - }; - const timer = setTimeout(() => { - timedOut = true; - stop(); - }, timeoutMs); - child.on('error', (error) => failAndStop(adapterFailure('Hermes', error?.message || 'spawn failed', { credentials }))); - child.stdout.on('data', (chunk) => { - if (settled) return; - buffer += decoder.write(chunk); - let newline = buffer.indexOf('\n'); - while (newline !== -1) { - if (newline > MAX_LINE_CHARS) { - failAndStop(new Error('Hermes JSONL line exceeded 1 MiB')); - return; - } - const line = buffer.slice(0, newline).trim(); - buffer = buffer.slice(newline + 1); - if (line) { - try { readHermesJsonl(line, state); } catch (error) { - failAndStop(error); - return; - } - } - newline = buffer.indexOf('\n'); - } - if (buffer.length > MAX_LINE_CHARS) { - failAndStop(new Error('Hermes JSONL line exceeded 1 MiB')); - } - }); - child.stdout.on('error', (error) => failAndStop(adapterFailure('Hermes stdout', error?.message || 'read failed', { credentials }))); - child.stderr.on('data', (chunk) => { - stderr = `${stderr}${chunk.toString()}`.slice(-MAX_STDERR_CHARS); - }); - child.on('close', (code) => { - closed = true; - clearTimeout(killTimer); - if (settled) return; - if (timedOut) return finish(new Error(`Hermes timed out after ${timeoutMs}ms`)); - buffer += decoder.end(); - if (buffer.trim()) { - try { readHermesJsonl(buffer.trim(), state); } catch (error) { return finish(error); } - } - const result = state.result; - if (!result || !state.started) return finish(new Error('Hermes exited without a complete JSONL result')); - if (code !== 0 || result.exit_code !== 0) { - return finish(adapterFailure('Hermes', result.error || stderr || `exit ${code}`, { - credentials, exitCode: code, - })); - } - if (typeof result.text !== 'string' || !result.session_id) { - return finish(new Error('Hermes result omitted text or session_id')); - } - return finish(null, { text: result.text, newSessionId: result.session_id }); - }); - child.stdin.on('error', (error) => failAndStop(adapterFailure('Hermes stdin', error?.message || 'write failed', { credentials }))); - child.stdin.end(prompt); +import { seatModelRequired } from './seat-model.js'; + +export { TESTED_HERMES_VERSION, readHermesJsonl } from '@commonlyai/seats/adapters/hermes'; + +// Commonly's policy for the generic Hermes adapter: its memory preamble, its +// legacy sandbox-trust names, the fleet seat-model switch, and the profile +// directory seats have always used. +export default createHermesAdapter({ + memoryPreamble: buildMemoryPreamble, + effectiveTrust: effectiveSandboxTrust, + seatModelRequired, + profileRoot: join(homedir(), '.commonly', 'hermes', 'profiles'), }); - -export default { - get requiresModel() { return seatModelRequired(); }, - name: 'hermes', - runtimeType: 'hermes', - supportsProvider: false, - providerKeyEnv: 'OPENROUTER_API_KEY', - - async validateEnvironment(environment) { - if (environment?.provider !== undefined) { - throw new Error('Hermes adapter does not consume environment.provider; supply OPENROUTER_API_KEY in the host environment'); - } - if (environment?.mcp?.length) { - throw new Error('Hermes adapter does not yet consume environment.mcp declarations'); - } - if (effectiveSandboxTrust(environment?.sandbox?.trust) === 'public' - || (environment?.sandbox?.mode && environment.sandbox.mode !== 'none')) { - throw new Error('Hermes public or sandbox-declared seats are unsupported: no enforced confinement adapter exists'); - } - }, - - async detect() { - const version = readVersion(process.env); - return version ? { path: 'hermes', version } : null; - }, - - async spawn(prompt, ctx = {}) { - const seatModel = requireSeatModel(ctx.environment, 'hermes'); - await this.validateEnvironment(ctx.environment); - // Keep Hermes away from the operator's default profile, including any - // Nous Portal/Cloud credentials there. The wrapper provides the sole key. - const seat = `${ctx.agentName || ''}\0${ctx.cwd || ''}`; - const home = ctx._hermesHome || join(homedir(), '.commonly', 'hermes', 'profiles', - createHash('sha256').update(seat).digest('hex').slice(0, 24)); - await mkdir(home, { recursive: true, mode: 0o700 }); - const env = { ...(ctx.env || process.env), HERMES_HOME: home }; - if (!readVersion(env, ctx._spawnSyncImpl)) { - throw new Error('Hermes Agent 0.22.2 is not on PATH'); - } - if (!env.OPENROUTER_API_KEY) { - throw new Error('Supply the operator OPENROUTER_API_KEY through Secret Manager before running Hermes'); - } - const args = ['chat', '--oneshot', '--query-file', '-', '--format', 'stream-json', - '--provider', 'openrouter', '--source', 'tool', '--no-restore-cwd']; - if (seatModel) args.push('--model', seatModel); - if (ctx.sessionId) args.push('--resume', ctx.sessionId); - const fullPrompt = buildMemoryPreamble(prompt, ctx.memoryLongTerm, { - freshSession: !ctx.sessionId, - }); - const requestedTimeout = Number(ctx.timeoutMs); - const timeoutMs = Number.isFinite(requestedTimeout) && requestedTimeout > 0 - ? Math.min(requestedTimeout, DEFAULT_TIMEOUT_MS) : DEFAULT_TIMEOUT_MS; - return runHermes({ - args, prompt: fullPrompt, cwd: ctx.cwd, env, timeoutMs, - spawnImpl: ctx._spawnImpl, onSpawn: ctx.onSpawn, controls: controlsFromArguments(args, { modelFlag: '--model', valueFlags: ['--query-file', '--format', '--provider', '--source', '--resume'] }), - credentials: spawnCredentials(ctx, ['OPENROUTER_API_KEY']), - }); - }, -}; diff --git a/cli/src/lib/adapters/seat-model.js b/cli/src/lib/adapters/seat-model.js index fd9a0a7dd9..75c226d4a4 100644 --- a/cli/src/lib/adapters/seat-model.js +++ b/cli/src/lib/adapters/seat-model.js @@ -1,15 +1,8 @@ +import { requireSeatModel as requireDeclaredModel } from '@commonlyai/seats/seat-model'; + /** Fleet runners opt in; BYO users retain their own CLI's model default. */ export const seatModelRequired = () => process.env.COMMONLY_REQUIRE_SEAT_MODEL === '1'; -export const requireSeatModel = (environment, adapterName, required = seatModelRequired()) => { - const model = environment?.model; - if (typeof model !== 'string' || !model.trim()) { - if (!required) return undefined; - const error = new Error( - `${adapterName} requires environment.model; set the seat model with commonly agent config --model before running`, - ); - error.code = 'seat_model_missing'; - throw error; - } - return model.trim(); -}; +export const requireSeatModel = (environment, adapterName, required = seatModelRequired()) => ( + requireDeclaredModel(environment, adapterName, required) +); diff --git a/cli/src/lib/adapters/stub.js b/cli/src/lib/adapters/stub.js index 9cae043466..5e756a1b38 100644 --- a/cli/src/lib/adapters/stub.js +++ b/cli/src/lib/adapters/stub.js @@ -1,24 +1 @@ -/** - * Stub adapter — the no-op reference implementation of the ADR-005 adapter - * contract. Used by `commonly agent attach stub` and the Phase-1a test - * harness so the run loop can be reviewed end-to-end without a real CLI - * on PATH. - * - * Contract (ADR-005 §Adapter pattern): - * detect(): Promise<{ path, version } | null> - * spawn(prompt, ctx): Promise<{ text, newSessionId?, memorySummary? }> - * - * The adapter MUST be pure: input (argv + env + prompt) → output (text + optional - * next session-id + optional memory summary). No direct network, no direct API - * calls, no hidden state. The run loop handles all Commonly-facing I/O. - */ - -export default { - name: 'stub', - async detect() { - return { path: '(builtin)', version: '0.0.0' }; - }, - async spawn(prompt, _ctx) { - return { text: `(stub) received: ${String(prompt ?? '').slice(0, 200)}` }; - }, -}; +export { default } from '@commonlyai/seats/adapters/stub'; diff --git a/cli/src/lib/runtime-observation.js b/cli/src/lib/runtime-observation.js index 2dc35fbfcb..9fd70c5530 100644 --- a/cli/src/lib/runtime-observation.js +++ b/cli/src/lib/runtime-observation.js @@ -1,30 +1,6 @@ -// Read only adapter-owned control arguments. Callers exclude prompt/stdin and -// provide every value-taking option, so a flag-shaped model/session value is -// never reinterpreted as a second option. Raw arguments never leave the runner. -export const controlsFromArguments = (args, { modelFlag, effortFlag, valueFlags = [], codex = false }) => { - const controls = { model: null, effort: null }; - const flags = new Set([modelFlag, effortFlag, ...valueFlags].filter(Boolean)); - for (let index = 0; index < args.length; index += 1) { - const flag = args[index]; - if (!flags.has(flag)) continue; - const value = args[++index]; - if (flag === modelFlag) controls.model = value || null; - if (flag === effortFlag) controls.effort = value || null; - if (codex && flag === '-c' && value?.startsWith('model_reasoning_effort=')) { - try { controls.effort = JSON.parse(value.slice('model_reasoning_effort='.length)); } catch { /* unknown encoding */ } - } - } - return controls; -}; - -// Called at the child's successful OS spawn event, not when a requested -// configuration is saved or when a turn is merely queued. Never send argv. -export const observeSpawn = (child, onSpawn, controls) => { - if (typeof onSpawn !== 'function') return; - child.once('spawn', () => { - try { onSpawn({ model: controls.model || null, effort: controls.effort || null, at: new Date().toISOString() }); } catch { /* diagnostic only */ } - }); -}; +// controlsFromArguments and observeSpawn live in the seats runtime package; +// the reporter below posts to a Commonly instance, so it stays in the CLI. +export { controlsFromArguments, observeSpawn } from '@commonlyai/seats/runtime-observation'; export const createRuntimeReporter = ({ client, harness, detect }) => { let queue = Promise.resolve(); diff --git a/cli/src/lib/upstream-refusal.js b/cli/src/lib/upstream-refusal.js index 598edb8a51..f70cef6312 100644 --- a/cli/src/lib/upstream-refusal.js +++ b/cli/src/lib/upstream-refusal.js @@ -1,354 +1 @@ -/** - * TASK-096 — name a refused model route, instead of reporting the silence it - * causes. - * - * Measured 2026-09-22 against pi 0.84.1 with a route answering 429 (the shape - * that made a seat look broken for two and a half days): pi runs its own retry - * ladder, reports the refusal on `message_end`/`auto_retry_end` JSON events, and - * EXITS 0 with no assistant text. `extractReply` in the pi adapter collected only - * `type: "error"` events — of which this produces none — so the wrapper logged - * `no wrapper-post (empty output)` and the cause sat unread in the stream. - * - * Two rules govern the text that reaches a seat log: - * - * 1. KEEP-LIST, not strip-list (Vera 70876, Wren 70877). A strip-list removes - * only the shapes we thought of, and a 401 can echo the key back. So a body - * contributes one NAMED field or nothing at all. - * 2. Exact-match refusal for credentials. If the kept text contains a value - * this spawn was handed, the text is dropped — a known value, not a guessed - * shape (Wren 70877). - * - * Two limits follow from rule 2. They are the accepted cost of refusing guessed - * shapes, named here so they are not rediscovered as defects later (Vera 71082): - * - * a. The match is exact and against THIS spawn's values only, so a credential - * that arrives transformed — base64 inside a proxy error, percent-encoded in - * a URL echo — does not match and the text is kept. Matching transformed - * shapes instead would reintroduce the class rule 1 exists to avoid. - * b. The keep-list can still surface someone ELSE's secret. A third party's key - * relayed in an upstream error is not detectable here, because the one - * inventory this module has is the set of values it was itself handed. - * - * Scope: this reader was wired into the pi adapter only. TASK-103 extended it - * to codex and claude, which are NOT silent the way pi was: the claude adapter - * rejects on a non-zero exit and deliberately reports stdout with it, because a - * `-p` run writes quota conditions there; the codex adapter rejects on - * `turnFailedMessage` even at exit 0. Their gap was the opposite one — the text - * they raised was the raw tail, unscrubbed by the rules above, and carried no - * HTTP status for the run loop to classify on. `scrubAdapterFailure` below is - * the shared reader they now both go through, so the two rules cannot drift - * apart between three adapters. - */ - -/** The kept detail is truncated, ellipsis included, to this many characters. */ -export const MAX_REFUSAL_DETAIL = 256; - -/** - * The status an adapter prefixes to its error text: `"429: {…}"` -> `429`. - * Null for anything that is not a 4xx/5xx, so a message that merely starts with - * a number ("4040 tokens over budget") is not read as a status. - * - * The separator pi uses is NOT fixed — measured 2026-09-22, the same pi 0.84.1 - * emits `"429: {"message":…}"` for a JSON body and `"502 …"` for an - * HTML one. Both forms are matched here; a parser that required the colon would - * have kept `502 …` as the whole "body" and then dropped it as non-JSON, - * reporting the status alone with no sign that the body was never read. - */ -const STATUS_PREFIX = /^\s*(\d{3})(?!\d)\s*:?\s*/; - -export const statusFromError = (value) => { - const match = STATUS_PREFIX.exec(String(value ?? '')); - if (!match) return null; - const status = Number(match[1]); - return status >= 400 && status <= 599 ? status : null; -}; - -/** - * The body the adapter carried after its status prefix, or the whole text when - * there is no status to strip. `"429: {"message":"a: b"}"` -> `{"message":"a: b"}`; - * `"502 "` -> ``. - */ -export const bodyFromError = (value) => { - const text = String(value ?? ''); - if (statusFromError(text) === null) return text.trim(); - return text.replace(STATUS_PREFIX, '').trim(); -}; - -/** - * Replace credential values this spawn was handed with a marker. - * - * This is the exact-match half, for text that is kept for its whole shape rather - * than filtered to one field — an adapter's stderr tail, say, whose diagnostic - * value is in the lines around the secret. Values shorter than eight characters - * are ignored: redacting a one-character "credential" would corrupt ordinary - * prose, and no issued credential is that short. - */ -export const redactKnownCredentials = (text, credentials = []) => { - let out = String(text ?? ''); - for (const value of credentials) { - if (typeof value !== 'string' || value.length < 8) continue; - out = out.split(value).join('[redacted]'); - } - return out; -}; - -/** - * The keep-list. Given the raw body an adapter captured, keep at most one named - * field of it and only when that field is a string: - * - * `error.message` first (the OpenAI-shaped error), then a top-level `message` - * (which is the shape LiteLLM's budget refusal actually uses — the motivating - * case; a keep-list that kept only `error.message` would name the status and - * still hide the budget line that started this row). - * - * Everything else — HTML, a non-JSON body, a JSON body with neither field, a - * body echoing a known credential — contributes nothing. Returns null rather - * than a partial guess. - */ -export const keptRefusalDetail = (raw, { credentials = [] } = {}) => { - let parsed; - try { - parsed = JSON.parse(String(raw ?? '')); - } catch { - return null; - } - if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return null; - - const candidate = parsed?.error?.message ?? parsed?.message; - if (typeof candidate !== 'string') return null; - - const text = candidate.replace(/\s+/g, ' ').trim(); - if (!text) return null; - - const redacted = redactKnownCredentials(text, credentials); - // `[redacted]` appearing is the signal that the body echoed something this - // spawn was handed. The status is still worth keeping; the text is not. - if (redacted !== text) return null; - - return text.length > MAX_REFUSAL_DETAIL - ? `${text.slice(0, MAX_REFUSAL_DETAIL - 1)}…` - : text; -}; - -/** - * Read a pi JSON stream and return the refusal it ended on, or null. - * - * `auto_retry_end` is the terminal word — it names the status and the body after - * the ladder has finished — so it wins over the per-attempt `message_end` - * events that precede it. A run that refused on its first attempt and never - * retried still reports through `message_end`. - */ -export const readUpstreamRefusal = (stdout, { credentials = [] } = {}) => { - let status = null; - let raw = null; - - for (const line of String(stdout ?? '').split('\n')) { - if (!line.trim()) continue; - let event; - try { - event = JSON.parse(line); - } catch { - continue; - } - - if (event?.type === 'auto_retry_end' && event.success === false) { - const candidate = event.finalError ?? event.errorMessage; - const attemptStatus = statusFromError(candidate); - if (attemptStatus) { - status = attemptStatus; - raw = bodyFromError(candidate); - } - continue; - } - - const message = event?.message; - if ( - event?.type === 'message_end' - && message?.role === 'assistant' - && message.stopReason === 'error' - && status === null - ) { - const attemptStatus = statusFromError(message.errorMessage); - if (attemptStatus) { - status = attemptStatus; - raw = bodyFromError(message.errorMessage); - } - } - } - - if (status === null) return null; - return { status, detail: keptRefusalDetail(raw, { credentials }) }; -}; - -/** - * Is this text STRUCTURED upstream data — a JSON object or an array? The gate for - * the whole-tail fallback below, deliberately NOT `kept === null`, which is true - * for five different reasons (Vera 71350). - * - * An array counts, and that is a correction (Vera 71360, measured): `[{...}]` is - * structured data with fields this code does not know, which is exactly what rule - * 1 exists for, and treating it as prose shipped the whole body under exact-match - * alone. A JSON *string* is the opposite case and does NOT count — `"Too many - * requests"` is a whole-shape text with no fields to reduce, so it keeps its - * wording like any other prose. - */ -const isStructuredBody = (raw) => { - try { - const parsed = JSON.parse(String(raw ?? '')); - return parsed !== null && typeof parsed === 'object'; - } catch { - return false; - } -}; - -/** - * A 3-digit number is read as a status only when the tail NAMES it as one: - * `status 429`, `HTTP 429`, `code: "429"` — or when it opens the tail or a ` | ` - * segment followed by a colon, which is the shape claude's `stderr | stdout` - * join produces for a body that leads with its status. - * - * A bare `\b(\d{3})\b` scan is not enough, and the false positives are not - * hypothetical: `500 tokens`, `"max_tokens": 500` and `retry in 500 seconds` - * all read as 500, and `classifySpawnFailure` tests the status FIELD before the - * message — so a count misread as a status stands the fleet down on a refusal - * that never happened, which is worse than no status at all (Vera 71352). - */ -const STATUS_NAMED = /\b(?:status(?:_?code)?|code|http)\b\W{0,4}(\d{3})(?!\d)/gi; -// `HTTP/1.1 429 Too Many Requests` is the most canonical refusal shape there is, -// and the `\W{0,4}` gap above handles `HTTP 429` but cannot reach a VERSIONED -// line: the `1` in `/1.1` is a -// WORD character, so `HTTP` and `429` are not within four non-word characters of -// each other. It read as `status: null` — an unclassified runtime failure on the -// single most recognisable shape, which is the misclassification this row exists -// to remove (Vera 71359). Its own pattern, not a looser gap: widening the gap -// would start reading `/v1/models 500` as a status. -const STATUS_LINE = /\bHTTP\/\d(?:\.\d)?\s+(\d{3})(?!\d)/gi; -const STATUS_COLON = /(?:^|\|\s*)(\d{3})(?!\d)\s*:/g; - -/** The first 4xx/5xx one of those shapes names, or null. */ -const scanStatuses = (text, pattern) => { - for (const match of String(text ?? '').matchAll(pattern)) { - const value = Number(match[1]); - if (value >= 400 && value <= 599) return value; - } - return null; -}; - -/** - * Find the status AND the body boundary, because they are the same question. - * - * Anchoring the status to a ` | ` segment start is not enough on its own: the - * body has to be taken from just past that status too, or a mid-tail read - * isolates the status and then hands the WHOLE joined tail to the body rules, - * which no longer look like a JSON body and so fall through to "keep it all". - * That is the difference between dropping a credential-echoing body and shipping - * the part of it that exact-match cannot see. - * - * `STATUS_COLON` is the only shape that moves the boundary, because it is the - * same shape as the anchored prefix (`429: {body}`), just found at a segment - * boundary — claude's `stderr | stdout` join. A status that appears inside a - * body (`{"error":{"code":"429"}}`) or as prose (`HTTP 503`) names the status - * (`HTTP/1.1 429`) but marks no boundary, so the whole text stays the body. - */ -const readStatusAndBody = (text) => { - const raw = String(text ?? ''); - const prefixed = statusFromError(raw); - if (prefixed !== null) return { status: prefixed, body: bodyFromError(raw) }; - // Only the colon shape moves the boundary — see the note above. A status LINE - // names the status but its reason phrase is prose, so the body stays the whole - // text and the exact-match rule protects it. - for (const match of raw.matchAll(STATUS_COLON)) { - const value = Number(match[1]); - if (value >= 400 && value <= 599) { - return { status: value, body: raw.slice(match.index + match[0].length).replace(/^\s*:?\s*/, '') }; - } - } - const named = scanStatuses(raw, STATUS_NAMED); - return { status: named ?? scanStatuses(raw, STATUS_LINE), body: raw }; -}; - -/** - * The shared reader for an adapter's own failure tail (TASK-103). - * - * The two rules above are about a body an adapter captured, and they apply here - * too — but the tail is a different shape from pi's refusal, and the difference - * decides how each rule lands: - * - * - The tail is often NOT structured. Claude's `-p` mode writes its terminal - * condition as prose (`Claude usage limit reached. Your limit will reset at - * 11:40pm.`), and that line is the whole diagnostic value of reporting - * stdout at all — 361 consecutive failures on 2026-08-03 carried no reason - * because of it. So the keep-list applies only when the tail parses as a - * body; when it does not, the text is KEPT as a whole shape and the - * exact-match rule is what protects it (`redactKnownCredentials`, which - * substitutes rather than drops, precisely because a whole-shape text has no - * second field to fall back to). - * - * - A body that IS structured (a JSON object or array) is reduced to one named - * field, and a body whose field echoed a value this spawn was handed is - * DROPPED WHOLE — the same trade rule 2 makes everywhere. The fallback to the - * whole tail is gated on the SHAPE (is it structured at all), never on - * "the keep-list returned null": that null has - * four other causes, and one of them is the credential echo this line exists - * to drop. Gating on `kept === null` would emit the whole body for that case, - * which is precisely what rule 2 forbids — and exact-match cannot see a - * transformed shape (limit (a) in the header), so a key that arrived - * base64'd or percent-encoded would ship (Vera 71350/71351). - * - * Returns `{ status, detail }`: `status` is the 4xx/5xx the tail named, or null. - * The caller decides where the status goes; `classifySpawnFailure` reads it off - * `error.status` or out of the message text, so both a leading `429: …` prefix - * and an attached field classify the same way. `detail` is `''` when nothing of - * the body may be shown — the status is then the whole diagnostic. - */ -export const scrubAdapterFailure = (text, { credentials = [], limit = MAX_REFUSAL_DETAIL } = {}) => { - const raw = String(text ?? '').trim(); - const { status, body } = readStatusAndBody(raw); - const kept = keptRefusalDetail(body, { credentials }); - const detail = kept ?? (isStructuredBody(body) ? '' : redactKnownCredentials(body, credentials)); - return { status, detail: detail.slice(0, limit) }; -}; - -/** - * The Error an adapter raises, built from its own failure tail. - * - * `status` is attached as a field, not only written into the text: the run - * loop's classifier tests `error.status === 429` before it looks at the - * message, and a refusal that is only readable by regex is one wording away - * from the misclassification that cost an hour on 2026-08-18 (see - * spawn-retry.js). The message still names the status so a human log says the - * same thing as the classifier's decision. - */ -export const adapterFailure = (label, text, { credentials = [], exitCode = null, limit } = {}) => { - const { status, detail } = scrubAdapterFailure(text, { credentials, limit }); - const code = exitCode === null ? '' : ` exited with code ${exitCode}`; - const named = status === null ? '' : ` (upstream ${status})`; - const error = new Error(`${label}${code}${named}${detail ? `: ${detail}` : ''}`); - if (status !== null) error.status = status; - return error; -}; - -/** - * The seat-log clause. The status is always present; the detail only when the - * keep-list kept one. `grep 'upstream refused'` finds every refusal, which is - * the property the old `no wrapper-post (empty output)` line did not have. - */ -export const describeUpstreamRefusal = (refusal) => { - if (!refusal || typeof refusal.status !== 'number') return null; - return refusal.detail - ? `upstream refused ${refusal.status}: ${refusal.detail}` - : `upstream refused ${refusal.status}`; -}; - -/** - * The credential values this spawn was handed, for the exact-match check in - * `redactKnownCredentials`. `envNames` are the adapter's own provider keys; the - * caller supplies them because only the adapter knows which key its route uses. - */ -export const spawnCredentials = (ctx = {}, envNames = []) => { - const env = ctx.env || process.env || {}; - return [ - ctx.runtimeToken, - ...envNames.map((name) => env[name]), - ].filter((value) => typeof value === 'string' && value.length >= 8); -}; +export * from '@commonlyai/seats/upstream-refusal'; diff --git a/packages/seats/README.md b/packages/seats/README.md new file mode 100644 index 0000000000..04e57fd9af --- /dev/null +++ b/packages/seats/README.md @@ -0,0 +1,9 @@ +# packages/seats + +The adapters that run an agent CLI as a supervised seat: one prompt in, one CLI run, one reply out. The package holds the contract, the registry, the failure naming, the spawn observation, and the first adapters (`stub`, `hermes`). + +It is private and is not published on its own. `@commonlyai/cli` depends on it as `file:../packages/seats` and lists it in `bundleDependencies`, so it ships inside the CLI tarball. + +**Rule:** nothing here imports Commonly code. An adapter that needs Commonly policy (the memory preamble, the legacy sandbox-trust names, the fleet seat-model switch) takes it as a host object through its factory, for example `createHermesAdapter(host)`. The CLI binds that host in `cli/src/lib/adapters/.js`. `cli/__tests__/seats-package.test.mjs` enforces the import rule and checks that the tarball carries every source file. + +The extraction plan is D62, extract-in-place. diff --git a/packages/seats/package.json b/packages/seats/package.json new file mode 100644 index 0000000000..f42ec21ff4 --- /dev/null +++ b/packages/seats/package.json @@ -0,0 +1,19 @@ +{ + "name": "@commonlyai/seats", + "version": "0.0.0", + "private": true, + "license": "Apache-2.0", + "description": "Adapters that run agent CLIs as supervised seats. Bundled inside @commonlyai/cli; not published on its own.", + "type": "module", + "exports": { + ".": "./src/index.js", + "./*": "./src/*.js" + }, + "files": [ + "src", + "README.md" + ], + "engines": { + "node": ">=20.0.0" + } +} diff --git a/packages/seats/src/adapters/hermes.js b/packages/seats/src/adapters/hermes.js new file mode 100644 index 0000000000..e54973f88f --- /dev/null +++ b/packages/seats/src/adapters/hermes.js @@ -0,0 +1,206 @@ +/** Hermes 0.22.2 local CLI wrapper. Hermes owns provider I/O; this file only spawns it. */ +import { spawn as childSpawn, spawnSync } from 'child_process'; +import { createHash } from 'crypto'; +import { mkdir } from 'fs/promises'; +import { homedir } from 'os'; +import { join } from 'path'; +import { StringDecoder } from 'string_decoder'; +import { observeSpawn, controlsFromArguments } from '../runtime-observation.js'; +import { requireSeatModel } from '../seat-model.js'; +import { adapterFailure, spawnCredentials } from '../upstream-refusal.js'; + +export const TESTED_HERMES_VERSION = '0.22.2'; +const DEFAULT_TIMEOUT_MS = 15 * 60 * 1000; +const VERSION_TIMEOUT_MS = 15000; +const MAX_STDERR_CHARS = 4096; +const MAX_LINE_CHARS = 1024 * 1024; + +const readVersion = (env, spawnSyncImpl = spawnSync) => { + let probe; + for (let attempt = 0; attempt < 2; attempt += 1) { + probe = spawnSyncImpl('hermes', ['--version'], { + encoding: 'utf8', env, timeout: VERSION_TIMEOUT_MS, + }); + if (probe.error?.code === 'ENOENT') return null; + if (probe.error || probe.status !== 0) continue; + const version = String(probe.stdout || '').match(/Hermes Agent v([^\s]+)/i)?.[1]; + if (version !== TESTED_HERMES_VERSION) { + throw new Error(`Hermes ${version || 'unknown'} is unsupported; this adapter requires ${TESTED_HERMES_VERSION}`); + } + return version; + } + const status = probe.status ?? 'none'; + const signal = probe.signal ?? 'none'; + const code = probe.error?.code ?? 'none'; + throw new Error(`Hermes version probe failed after 2 attempts (status=${status}, signal=${signal}, code=${code}, timeout=${VERSION_TIMEOUT_MS}ms); check the Hermes installation or startup log`); +}; + +export const readHermesJsonl = (line, state) => { + let event; + try { event = JSON.parse(line); } catch { throw new Error('Hermes emitted invalid JSONL'); } + if (state.result) throw new Error('Hermes emitted data after its terminal result'); + if (event?.type === 'system' && event.subtype === 'init') { + state.started = true; + } else if (event?.type === 'result') { + state.result = event; + } else if (!state.started) { + throw new Error('Hermes emitted an event before init'); + } +}; + +const runHermes = ({ args, prompt, cwd, env, timeoutMs, spawnImpl, credentials, onSpawn, controls = {} }) => new Promise((resolve, reject) => { + const child = (spawnImpl || childSpawn)('hermes', args, { + cwd, env, stdio: ['pipe', 'pipe', 'pipe'], + }); + observeSpawn(child, onSpawn, controls); + const state = { started: false, result: null }; + const decoder = new StringDecoder('utf8'); + let buffer = ''; + let stderr = ''; + let timedOut = false; + let settled = false; + let closed = false; + let stopping = false; + let killTimer; + const finish = (error, result) => { + if (settled) return; + settled = true; + clearTimeout(timer); + if (error) reject(error); else resolve(result); + }; + const stop = () => { + if (stopping || closed) return; + stopping = true; + child.kill('SIGTERM'); + killTimer = setTimeout(() => { + if (!closed) child.kill('SIGKILL'); + }, 5000); + }; + const failAndStop = (error) => { + stop(); + finish(error); + }; + const timer = setTimeout(() => { + timedOut = true; + stop(); + }, timeoutMs); + child.on('error', (error) => failAndStop(adapterFailure('Hermes', error?.message || 'spawn failed', { credentials }))); + child.stdout.on('data', (chunk) => { + if (settled) return; + buffer += decoder.write(chunk); + let newline = buffer.indexOf('\n'); + while (newline !== -1) { + if (newline > MAX_LINE_CHARS) { + failAndStop(new Error('Hermes JSONL line exceeded 1 MiB')); + return; + } + const line = buffer.slice(0, newline).trim(); + buffer = buffer.slice(newline + 1); + if (line) { + try { readHermesJsonl(line, state); } catch (error) { + failAndStop(error); + return; + } + } + newline = buffer.indexOf('\n'); + } + if (buffer.length > MAX_LINE_CHARS) { + failAndStop(new Error('Hermes JSONL line exceeded 1 MiB')); + } + }); + child.stdout.on('error', (error) => failAndStop(adapterFailure('Hermes stdout', error?.message || 'read failed', { credentials }))); + child.stderr.on('data', (chunk) => { + stderr = `${stderr}${chunk.toString()}`.slice(-MAX_STDERR_CHARS); + }); + child.on('close', (code) => { + closed = true; + clearTimeout(killTimer); + if (settled) return; + if (timedOut) return finish(new Error(`Hermes timed out after ${timeoutMs}ms`)); + buffer += decoder.end(); + if (buffer.trim()) { + try { readHermesJsonl(buffer.trim(), state); } catch (error) { return finish(error); } + } + const result = state.result; + if (!result || !state.started) return finish(new Error('Hermes exited without a complete JSONL result')); + if (code !== 0 || result.exit_code !== 0) { + return finish(adapterFailure('Hermes', result.error || stderr || `exit ${code}`, { + credentials, exitCode: code, + })); + } + if (typeof result.text !== 'string' || !result.session_id) { + return finish(new Error('Hermes result omitted text or session_id')); + } + return finish(null, { text: result.text, newSessionId: result.session_id }); + }); + child.stdin.on('error', (error) => failAndStop(adapterFailure('Hermes stdin', error?.message || 'write failed', { credentials }))); + child.stdin.end(prompt); +}); + +/** + * @param {import('../contract.js').SeatHost & { profileRoot?: string }} [host] + * profileRoot is where each seat's isolated HERMES_HOME is created. + */ +export const createHermesAdapter = ({ + memoryPreamble = (prompt) => prompt, + effectiveTrust = (trust) => trust, + seatModelRequired = () => false, + profileRoot = join(homedir(), '.seats', 'hermes', 'profiles'), +} = {}) => ({ + get requiresModel() { return seatModelRequired(); }, + name: 'hermes', + runtimeType: 'hermes', + supportsProvider: false, + providerKeyEnv: 'OPENROUTER_API_KEY', + + async validateEnvironment(environment) { + if (environment?.provider !== undefined) { + throw new Error('Hermes adapter does not consume environment.provider; supply OPENROUTER_API_KEY in the host environment'); + } + if (environment?.mcp?.length) { + throw new Error('Hermes adapter does not yet consume environment.mcp declarations'); + } + if (effectiveTrust(environment?.sandbox?.trust) === 'public' + || (environment?.sandbox?.mode && environment.sandbox.mode !== 'none')) { + throw new Error('Hermes public or sandbox-declared seats are unsupported: no enforced confinement adapter exists'); + } + }, + + async detect() { + const version = readVersion(process.env); + return version ? { path: 'hermes', version } : null; + }, + + async spawn(prompt, ctx = {}) { + const seatModel = requireSeatModel(ctx.environment, 'hermes', seatModelRequired()); + await this.validateEnvironment(ctx.environment); + // Keep Hermes away from the operator's default profile, including any + // Nous Portal/Cloud credentials there. The wrapper provides the sole key. + const seat = `${ctx.agentName || ''}\0${ctx.cwd || ''}`; + const home = ctx._hermesHome || join(profileRoot, + createHash('sha256').update(seat).digest('hex').slice(0, 24)); + await mkdir(home, { recursive: true, mode: 0o700 }); + const env = { ...(ctx.env || process.env), HERMES_HOME: home }; + if (!readVersion(env, ctx._spawnSyncImpl)) { + throw new Error('Hermes Agent 0.22.2 is not on PATH'); + } + if (!env.OPENROUTER_API_KEY) { + throw new Error('Supply the operator OPENROUTER_API_KEY through Secret Manager before running Hermes'); + } + const args = ['chat', '--oneshot', '--query-file', '-', '--format', 'stream-json', + '--provider', 'openrouter', '--source', 'tool', '--no-restore-cwd']; + if (seatModel) args.push('--model', seatModel); + if (ctx.sessionId) args.push('--resume', ctx.sessionId); + const fullPrompt = memoryPreamble(prompt, ctx.memoryLongTerm, { + freshSession: !ctx.sessionId, + }); + const requestedTimeout = Number(ctx.timeoutMs); + const timeoutMs = Number.isFinite(requestedTimeout) && requestedTimeout > 0 + ? Math.min(requestedTimeout, DEFAULT_TIMEOUT_MS) : DEFAULT_TIMEOUT_MS; + return runHermes({ + args, prompt: fullPrompt, cwd: ctx.cwd, env, timeoutMs, + spawnImpl: ctx._spawnImpl, onSpawn: ctx.onSpawn, controls: controlsFromArguments(args, { modelFlag: '--model', valueFlags: ['--query-file', '--format', '--provider', '--source', '--resume'] }), + credentials: spawnCredentials(ctx, ['OPENROUTER_API_KEY']), + }); + }, +}); diff --git a/packages/seats/src/adapters/stub.js b/packages/seats/src/adapters/stub.js new file mode 100644 index 0000000000..9cae043466 --- /dev/null +++ b/packages/seats/src/adapters/stub.js @@ -0,0 +1,24 @@ +/** + * Stub adapter — the no-op reference implementation of the ADR-005 adapter + * contract. Used by `commonly agent attach stub` and the Phase-1a test + * harness so the run loop can be reviewed end-to-end without a real CLI + * on PATH. + * + * Contract (ADR-005 §Adapter pattern): + * detect(): Promise<{ path, version } | null> + * spawn(prompt, ctx): Promise<{ text, newSessionId?, memorySummary? }> + * + * The adapter MUST be pure: input (argv + env + prompt) → output (text + optional + * next session-id + optional memory summary). No direct network, no direct API + * calls, no hidden state. The run loop handles all Commonly-facing I/O. + */ + +export default { + name: 'stub', + async detect() { + return { path: '(builtin)', version: '0.0.0' }; + }, + async spawn(prompt, _ctx) { + return { text: `(stub) received: ${String(prompt ?? '').slice(0, 200)}` }; + }, +}; diff --git a/packages/seats/src/contract.js b/packages/seats/src/contract.js new file mode 100644 index 0000000000..3980951746 --- /dev/null +++ b/packages/seats/src/contract.js @@ -0,0 +1,48 @@ +/** + * The adapter contract (ADR-005 §Adapter pattern). An adapter turns one prompt + * into one CLI run and one reply. It owns argv, env and output parsing for its + * CLI; the caller owns everything that talks to a Commonly instance. + * + * @typedef {object} Adapter + * @property {string} name + * @property {string} [runtimeType] + * @property {string} [providerKeyEnv] host env var the adapter's provider key arrives in + * @property {() => Promise<{ path: string, version: string } | null>} detect + * @property {(environment: object) => Promise} [validateEnvironment] refuse before spawn + * @property {(prompt: string, ctx: object) => Promise} spawn + * + * @typedef {object} SpawnResult + * @property {string} text + * @property {string} [newSessionId] + * @property {number} [contextTokens] + * @property {string} [memorySummary] + * + * Adapters that need host policy are built by a factory that takes a SeatHost. + * Every field is optional; an absent field takes the neutral default named. + * + * @typedef {object} SeatHost + * @property {(prompt: string, longTerm: string | null | undefined, opts: { freshSession: boolean }) => string} [memoryPreamble] default: the prompt unchanged + * @property {(trust: string | undefined) => string | undefined} [effectiveTrust] default: the declared trust unchanged + * @property {() => boolean} [seatModelRequired] default: false + */ + +/** + * @param {Adapter[]} adapters + */ +export const createRegistry = (adapters) => { + const byName = {}; + for (const adapter of adapters) { + if (!adapter?.name) throw new Error('every adapter needs a name'); + if (byName[adapter.name]) throw new Error(`duplicate adapter name: ${adapter.name}`); + byName[adapter.name] = adapter; + } + return { + adapters: byName, + names: () => Object.keys(byName), + get: (name) => byName[name] || null, + }; +}; + +export const providerKeyEnvNames = (adapters) => [...new Set( + Object.values(adapters).map((adapter) => adapter.providerKeyEnv).filter(Boolean), +)]; diff --git a/packages/seats/src/index.js b/packages/seats/src/index.js new file mode 100644 index 0000000000..7667aefdb9 --- /dev/null +++ b/packages/seats/src/index.js @@ -0,0 +1,6 @@ +export { createRegistry, providerKeyEnvNames } from './contract.js'; +export { requireSeatModel } from './seat-model.js'; +export { controlsFromArguments, observeSpawn } from './runtime-observation.js'; +export { adapterFailure, spawnCredentials } from './upstream-refusal.js'; +export { default as stub } from './adapters/stub.js'; +export { createHermesAdapter } from './adapters/hermes.js'; diff --git a/packages/seats/src/runtime-observation.js b/packages/seats/src/runtime-observation.js new file mode 100644 index 0000000000..59eb63ee8c --- /dev/null +++ b/packages/seats/src/runtime-observation.js @@ -0,0 +1,27 @@ +// Read only adapter-owned control arguments. Callers exclude prompt/stdin and +// provide every value-taking option, so a flag-shaped model/session value is +// never reinterpreted as a second option. Raw arguments never leave the runner. +export const controlsFromArguments = (args, { modelFlag, effortFlag, valueFlags = [], codex = false }) => { + const controls = { model: null, effort: null }; + const flags = new Set([modelFlag, effortFlag, ...valueFlags].filter(Boolean)); + for (let index = 0; index < args.length; index += 1) { + const flag = args[index]; + if (!flags.has(flag)) continue; + const value = args[++index]; + if (flag === modelFlag) controls.model = value || null; + if (flag === effortFlag) controls.effort = value || null; + if (codex && flag === '-c' && value?.startsWith('model_reasoning_effort=')) { + try { controls.effort = JSON.parse(value.slice('model_reasoning_effort='.length)); } catch { /* unknown encoding */ } + } + } + return controls; +}; + +// Called at the child's successful OS spawn event, not when a requested +// configuration is saved or when a turn is merely queued. Never send argv. +export const observeSpawn = (child, onSpawn, controls) => { + if (typeof onSpawn !== 'function') return; + child.once('spawn', () => { + try { onSpawn({ model: controls.model || null, effort: controls.effort || null, at: new Date().toISOString() }); } catch { /* diagnostic only */ } + }); +}; diff --git a/packages/seats/src/seat-model.js b/packages/seats/src/seat-model.js new file mode 100644 index 0000000000..b672e394d2 --- /dev/null +++ b/packages/seats/src/seat-model.js @@ -0,0 +1,17 @@ +/** + * The model a seat runs on is a declared field (`environment.model`), never a + * CLI default the operator did not choose. Whether a missing model is an error + * is the host's policy, passed as `required`. + */ +export const requireSeatModel = (environment, adapterName, required = false) => { + const model = environment?.model; + if (typeof model !== 'string' || !model.trim()) { + if (!required) return undefined; + const error = new Error( + `${adapterName} requires environment.model; set the seat model with commonly agent config --model before running`, + ); + error.code = 'seat_model_missing'; + throw error; + } + return model.trim(); +}; diff --git a/packages/seats/src/upstream-refusal.js b/packages/seats/src/upstream-refusal.js new file mode 100644 index 0000000000..598edb8a51 --- /dev/null +++ b/packages/seats/src/upstream-refusal.js @@ -0,0 +1,354 @@ +/** + * TASK-096 — name a refused model route, instead of reporting the silence it + * causes. + * + * Measured 2026-09-22 against pi 0.84.1 with a route answering 429 (the shape + * that made a seat look broken for two and a half days): pi runs its own retry + * ladder, reports the refusal on `message_end`/`auto_retry_end` JSON events, and + * EXITS 0 with no assistant text. `extractReply` in the pi adapter collected only + * `type: "error"` events — of which this produces none — so the wrapper logged + * `no wrapper-post (empty output)` and the cause sat unread in the stream. + * + * Two rules govern the text that reaches a seat log: + * + * 1. KEEP-LIST, not strip-list (Vera 70876, Wren 70877). A strip-list removes + * only the shapes we thought of, and a 401 can echo the key back. So a body + * contributes one NAMED field or nothing at all. + * 2. Exact-match refusal for credentials. If the kept text contains a value + * this spawn was handed, the text is dropped — a known value, not a guessed + * shape (Wren 70877). + * + * Two limits follow from rule 2. They are the accepted cost of refusing guessed + * shapes, named here so they are not rediscovered as defects later (Vera 71082): + * + * a. The match is exact and against THIS spawn's values only, so a credential + * that arrives transformed — base64 inside a proxy error, percent-encoded in + * a URL echo — does not match and the text is kept. Matching transformed + * shapes instead would reintroduce the class rule 1 exists to avoid. + * b. The keep-list can still surface someone ELSE's secret. A third party's key + * relayed in an upstream error is not detectable here, because the one + * inventory this module has is the set of values it was itself handed. + * + * Scope: this reader was wired into the pi adapter only. TASK-103 extended it + * to codex and claude, which are NOT silent the way pi was: the claude adapter + * rejects on a non-zero exit and deliberately reports stdout with it, because a + * `-p` run writes quota conditions there; the codex adapter rejects on + * `turnFailedMessage` even at exit 0. Their gap was the opposite one — the text + * they raised was the raw tail, unscrubbed by the rules above, and carried no + * HTTP status for the run loop to classify on. `scrubAdapterFailure` below is + * the shared reader they now both go through, so the two rules cannot drift + * apart between three adapters. + */ + +/** The kept detail is truncated, ellipsis included, to this many characters. */ +export const MAX_REFUSAL_DETAIL = 256; + +/** + * The status an adapter prefixes to its error text: `"429: {…}"` -> `429`. + * Null for anything that is not a 4xx/5xx, so a message that merely starts with + * a number ("4040 tokens over budget") is not read as a status. + * + * The separator pi uses is NOT fixed — measured 2026-09-22, the same pi 0.84.1 + * emits `"429: {"message":…}"` for a JSON body and `"502 …"` for an + * HTML one. Both forms are matched here; a parser that required the colon would + * have kept `502 …` as the whole "body" and then dropped it as non-JSON, + * reporting the status alone with no sign that the body was never read. + */ +const STATUS_PREFIX = /^\s*(\d{3})(?!\d)\s*:?\s*/; + +export const statusFromError = (value) => { + const match = STATUS_PREFIX.exec(String(value ?? '')); + if (!match) return null; + const status = Number(match[1]); + return status >= 400 && status <= 599 ? status : null; +}; + +/** + * The body the adapter carried after its status prefix, or the whole text when + * there is no status to strip. `"429: {"message":"a: b"}"` -> `{"message":"a: b"}`; + * `"502 "` -> ``. + */ +export const bodyFromError = (value) => { + const text = String(value ?? ''); + if (statusFromError(text) === null) return text.trim(); + return text.replace(STATUS_PREFIX, '').trim(); +}; + +/** + * Replace credential values this spawn was handed with a marker. + * + * This is the exact-match half, for text that is kept for its whole shape rather + * than filtered to one field — an adapter's stderr tail, say, whose diagnostic + * value is in the lines around the secret. Values shorter than eight characters + * are ignored: redacting a one-character "credential" would corrupt ordinary + * prose, and no issued credential is that short. + */ +export const redactKnownCredentials = (text, credentials = []) => { + let out = String(text ?? ''); + for (const value of credentials) { + if (typeof value !== 'string' || value.length < 8) continue; + out = out.split(value).join('[redacted]'); + } + return out; +}; + +/** + * The keep-list. Given the raw body an adapter captured, keep at most one named + * field of it and only when that field is a string: + * + * `error.message` first (the OpenAI-shaped error), then a top-level `message` + * (which is the shape LiteLLM's budget refusal actually uses — the motivating + * case; a keep-list that kept only `error.message` would name the status and + * still hide the budget line that started this row). + * + * Everything else — HTML, a non-JSON body, a JSON body with neither field, a + * body echoing a known credential — contributes nothing. Returns null rather + * than a partial guess. + */ +export const keptRefusalDetail = (raw, { credentials = [] } = {}) => { + let parsed; + try { + parsed = JSON.parse(String(raw ?? '')); + } catch { + return null; + } + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return null; + + const candidate = parsed?.error?.message ?? parsed?.message; + if (typeof candidate !== 'string') return null; + + const text = candidate.replace(/\s+/g, ' ').trim(); + if (!text) return null; + + const redacted = redactKnownCredentials(text, credentials); + // `[redacted]` appearing is the signal that the body echoed something this + // spawn was handed. The status is still worth keeping; the text is not. + if (redacted !== text) return null; + + return text.length > MAX_REFUSAL_DETAIL + ? `${text.slice(0, MAX_REFUSAL_DETAIL - 1)}…` + : text; +}; + +/** + * Read a pi JSON stream and return the refusal it ended on, or null. + * + * `auto_retry_end` is the terminal word — it names the status and the body after + * the ladder has finished — so it wins over the per-attempt `message_end` + * events that precede it. A run that refused on its first attempt and never + * retried still reports through `message_end`. + */ +export const readUpstreamRefusal = (stdout, { credentials = [] } = {}) => { + let status = null; + let raw = null; + + for (const line of String(stdout ?? '').split('\n')) { + if (!line.trim()) continue; + let event; + try { + event = JSON.parse(line); + } catch { + continue; + } + + if (event?.type === 'auto_retry_end' && event.success === false) { + const candidate = event.finalError ?? event.errorMessage; + const attemptStatus = statusFromError(candidate); + if (attemptStatus) { + status = attemptStatus; + raw = bodyFromError(candidate); + } + continue; + } + + const message = event?.message; + if ( + event?.type === 'message_end' + && message?.role === 'assistant' + && message.stopReason === 'error' + && status === null + ) { + const attemptStatus = statusFromError(message.errorMessage); + if (attemptStatus) { + status = attemptStatus; + raw = bodyFromError(message.errorMessage); + } + } + } + + if (status === null) return null; + return { status, detail: keptRefusalDetail(raw, { credentials }) }; +}; + +/** + * Is this text STRUCTURED upstream data — a JSON object or an array? The gate for + * the whole-tail fallback below, deliberately NOT `kept === null`, which is true + * for five different reasons (Vera 71350). + * + * An array counts, and that is a correction (Vera 71360, measured): `[{...}]` is + * structured data with fields this code does not know, which is exactly what rule + * 1 exists for, and treating it as prose shipped the whole body under exact-match + * alone. A JSON *string* is the opposite case and does NOT count — `"Too many + * requests"` is a whole-shape text with no fields to reduce, so it keeps its + * wording like any other prose. + */ +const isStructuredBody = (raw) => { + try { + const parsed = JSON.parse(String(raw ?? '')); + return parsed !== null && typeof parsed === 'object'; + } catch { + return false; + } +}; + +/** + * A 3-digit number is read as a status only when the tail NAMES it as one: + * `status 429`, `HTTP 429`, `code: "429"` — or when it opens the tail or a ` | ` + * segment followed by a colon, which is the shape claude's `stderr | stdout` + * join produces for a body that leads with its status. + * + * A bare `\b(\d{3})\b` scan is not enough, and the false positives are not + * hypothetical: `500 tokens`, `"max_tokens": 500` and `retry in 500 seconds` + * all read as 500, and `classifySpawnFailure` tests the status FIELD before the + * message — so a count misread as a status stands the fleet down on a refusal + * that never happened, which is worse than no status at all (Vera 71352). + */ +const STATUS_NAMED = /\b(?:status(?:_?code)?|code|http)\b\W{0,4}(\d{3})(?!\d)/gi; +// `HTTP/1.1 429 Too Many Requests` is the most canonical refusal shape there is, +// and the `\W{0,4}` gap above handles `HTTP 429` but cannot reach a VERSIONED +// line: the `1` in `/1.1` is a +// WORD character, so `HTTP` and `429` are not within four non-word characters of +// each other. It read as `status: null` — an unclassified runtime failure on the +// single most recognisable shape, which is the misclassification this row exists +// to remove (Vera 71359). Its own pattern, not a looser gap: widening the gap +// would start reading `/v1/models 500` as a status. +const STATUS_LINE = /\bHTTP\/\d(?:\.\d)?\s+(\d{3})(?!\d)/gi; +const STATUS_COLON = /(?:^|\|\s*)(\d{3})(?!\d)\s*:/g; + +/** The first 4xx/5xx one of those shapes names, or null. */ +const scanStatuses = (text, pattern) => { + for (const match of String(text ?? '').matchAll(pattern)) { + const value = Number(match[1]); + if (value >= 400 && value <= 599) return value; + } + return null; +}; + +/** + * Find the status AND the body boundary, because they are the same question. + * + * Anchoring the status to a ` | ` segment start is not enough on its own: the + * body has to be taken from just past that status too, or a mid-tail read + * isolates the status and then hands the WHOLE joined tail to the body rules, + * which no longer look like a JSON body and so fall through to "keep it all". + * That is the difference between dropping a credential-echoing body and shipping + * the part of it that exact-match cannot see. + * + * `STATUS_COLON` is the only shape that moves the boundary, because it is the + * same shape as the anchored prefix (`429: {body}`), just found at a segment + * boundary — claude's `stderr | stdout` join. A status that appears inside a + * body (`{"error":{"code":"429"}}`) or as prose (`HTTP 503`) names the status + * (`HTTP/1.1 429`) but marks no boundary, so the whole text stays the body. + */ +const readStatusAndBody = (text) => { + const raw = String(text ?? ''); + const prefixed = statusFromError(raw); + if (prefixed !== null) return { status: prefixed, body: bodyFromError(raw) }; + // Only the colon shape moves the boundary — see the note above. A status LINE + // names the status but its reason phrase is prose, so the body stays the whole + // text and the exact-match rule protects it. + for (const match of raw.matchAll(STATUS_COLON)) { + const value = Number(match[1]); + if (value >= 400 && value <= 599) { + return { status: value, body: raw.slice(match.index + match[0].length).replace(/^\s*:?\s*/, '') }; + } + } + const named = scanStatuses(raw, STATUS_NAMED); + return { status: named ?? scanStatuses(raw, STATUS_LINE), body: raw }; +}; + +/** + * The shared reader for an adapter's own failure tail (TASK-103). + * + * The two rules above are about a body an adapter captured, and they apply here + * too — but the tail is a different shape from pi's refusal, and the difference + * decides how each rule lands: + * + * - The tail is often NOT structured. Claude's `-p` mode writes its terminal + * condition as prose (`Claude usage limit reached. Your limit will reset at + * 11:40pm.`), and that line is the whole diagnostic value of reporting + * stdout at all — 361 consecutive failures on 2026-08-03 carried no reason + * because of it. So the keep-list applies only when the tail parses as a + * body; when it does not, the text is KEPT as a whole shape and the + * exact-match rule is what protects it (`redactKnownCredentials`, which + * substitutes rather than drops, precisely because a whole-shape text has no + * second field to fall back to). + * + * - A body that IS structured (a JSON object or array) is reduced to one named + * field, and a body whose field echoed a value this spawn was handed is + * DROPPED WHOLE — the same trade rule 2 makes everywhere. The fallback to the + * whole tail is gated on the SHAPE (is it structured at all), never on + * "the keep-list returned null": that null has + * four other causes, and one of them is the credential echo this line exists + * to drop. Gating on `kept === null` would emit the whole body for that case, + * which is precisely what rule 2 forbids — and exact-match cannot see a + * transformed shape (limit (a) in the header), so a key that arrived + * base64'd or percent-encoded would ship (Vera 71350/71351). + * + * Returns `{ status, detail }`: `status` is the 4xx/5xx the tail named, or null. + * The caller decides where the status goes; `classifySpawnFailure` reads it off + * `error.status` or out of the message text, so both a leading `429: …` prefix + * and an attached field classify the same way. `detail` is `''` when nothing of + * the body may be shown — the status is then the whole diagnostic. + */ +export const scrubAdapterFailure = (text, { credentials = [], limit = MAX_REFUSAL_DETAIL } = {}) => { + const raw = String(text ?? '').trim(); + const { status, body } = readStatusAndBody(raw); + const kept = keptRefusalDetail(body, { credentials }); + const detail = kept ?? (isStructuredBody(body) ? '' : redactKnownCredentials(body, credentials)); + return { status, detail: detail.slice(0, limit) }; +}; + +/** + * The Error an adapter raises, built from its own failure tail. + * + * `status` is attached as a field, not only written into the text: the run + * loop's classifier tests `error.status === 429` before it looks at the + * message, and a refusal that is only readable by regex is one wording away + * from the misclassification that cost an hour on 2026-08-18 (see + * spawn-retry.js). The message still names the status so a human log says the + * same thing as the classifier's decision. + */ +export const adapterFailure = (label, text, { credentials = [], exitCode = null, limit } = {}) => { + const { status, detail } = scrubAdapterFailure(text, { credentials, limit }); + const code = exitCode === null ? '' : ` exited with code ${exitCode}`; + const named = status === null ? '' : ` (upstream ${status})`; + const error = new Error(`${label}${code}${named}${detail ? `: ${detail}` : ''}`); + if (status !== null) error.status = status; + return error; +}; + +/** + * The seat-log clause. The status is always present; the detail only when the + * keep-list kept one. `grep 'upstream refused'` finds every refusal, which is + * the property the old `no wrapper-post (empty output)` line did not have. + */ +export const describeUpstreamRefusal = (refusal) => { + if (!refusal || typeof refusal.status !== 'number') return null; + return refusal.detail + ? `upstream refused ${refusal.status}: ${refusal.detail}` + : `upstream refused ${refusal.status}`; +}; + +/** + * The credential values this spawn was handed, for the exact-match check in + * `redactKnownCredentials`. `envNames` are the adapter's own provider keys; the + * caller supplies them because only the adapter knows which key its route uses. + */ +export const spawnCredentials = (ctx = {}, envNames = []) => { + const env = ctx.env || process.env || {}; + return [ + ctx.runtimeToken, + ...envNames.map((name) => env[name]), + ].filter((value) => typeof value === 'string' && value.length >= 8); +};