diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6e224e376..42bc28623 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -4,8 +4,8 @@ # Turborepo cache, plus the formatting check and the no-vendor-type-across-the-seam fence. # # Branch protection (set in the GitHub repo settings, not here): `ci`, `coverage` and `windows-concurrency` -# are the REQUIRED status checks to merge into `main` (the Windows job since 2026-11-04: ADR-0106's -# acceptance names required CI for the platform-scoped claims it carries). Wire, usage, filesystem, secrets, +# are the REQUIRED status checks to merge into `main` (the Windows job since the maintainer's 2026-11-04 +# ruling: it is the CI lane that validates ADR-0106's Windows-scoped claims). Wire, usage, filesystem, secrets, # packed CLI and product map control jobs feed `ci` through an explicit success-only aggregation; they cannot # silently skip or fail while `ci` stays green. The other jobs self-label `(advisory)` — `floor-check` and # `peer-dep-gate`. @@ -226,7 +226,7 @@ jobs: # RUN the artifact this job just built, through the SAME `pnpm smoke:cli` script the root `ci` script # calls — a check that exists in only one of the two is exactly the #312 divergence this change closes. # Until now nothing in the required gate executed - # `apps/cli/dist/index.js` — only the advisory Windows leg and the tag-gated release smoke did (#294) — + # `apps/cli/dist/index.js` — only the then-advisory Windows leg and the tag-gated release smoke did (#294) — # so a bundle that builds but cannot boot merged green. The `run --json` leg additionally proves the # migrations resolve beside the bundle, which is the failure `apps/cli/drizzle/**` becoming a declared # turbo output (#315) exists to prevent: a cache-hit replay used to leave `dist/` fresh next to a @@ -424,7 +424,8 @@ jobs: # Node-22 prebuild (ABI 127 — one binary for ALL of 22.x, so 22.13.0 loads the same artifact 22.0.0 would; # no node-gyp C++ source build). What it does NOT prove is that no RUNTIME dep requires >22.0.0 — that is # a property of the prod closure, verified at the ADR (see ADR-0067's amendment note) rather than here. - # A SEPARATE job (the required check stays the ubuntu `ci` job on Node 24); promote once confirmed stable. + # A SEPARATE, advisory job (the required checks are `ci`, `coverage` and `windows-concurrency`; see the header); + # promote once confirmed stable. floor-check: name: node 22-line floor (22.13.0) · typecheck · test · build (advisory) runs-on: ubuntu-latest @@ -537,8 +538,8 @@ jobs: # Runs the @relavium/db concurrency suite (the real Windows native-addon + WAL exercise) + the CLI # concurrency/perf harness there, the native Step 2 filesystem boundaries (ADR-0105), plus a headless # no-TTY smoke. Native links/containment complement portable Windows lexical vectors; these results are - # evidence only after this job actually runs. A SEPARATE, advisory job (the required check - # stays the ubuntu `ci` job) — promote to required in branch protection once confirmed stable. POSIX 0600/0700 + # evidence only after this job actually runs. A SEPARATE job and, since 2026-11-04, a REQUIRED check in + # branch protection beside `ci` and `coverage` (see the header). POSIX 0600/0700 # perm assertions are NOT exercised here (a documented Windows no-op — ADR-0050); nor is ink's raw-mode code, # which is TTY-gated (the driver-selection gate picks the plain driver without a TTY — see the smoke below). windows-concurrency: @@ -603,8 +604,10 @@ jobs: # `chat` is TTY-gated on STDOUT (selectChatDriver / io.stdoutIsTty): with stdout captured (non-TTY) # here, the driver-selection gate picks the PLAIN driver (ink/setRawMode is never entered), and the # plain driver must exit cleanly on EOF — never crash. A regression routing to ink without a TTY would - # throw "Raw mode is not supported…", which this catches. - if out="$(printf '' | node apps/cli/dist/index.js chat 2>&1)"; then + # throw "Raw mode is not supported…", which this catches. The runner stores no provider key, and since + # MVP-A4 a keyless `chat` refuses with exit 2 before choosing a driver, so the chat gets a fake env key: + # an empty stdin sends nothing, so the key is never sent. + if out="$(printf '' | RELAVIUM_ANTHROPIC_API_KEY=relavium-headless-smoke-not-a-key node apps/cli/dist/index.js chat 2>&1)"; then chat_status=0 else chat_status=$? diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0e1d17a93..8857ff2c9 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -14,7 +14,8 @@ # release ADR that publishes the smoke-verified package-test tarball, with a sunset at 1.0 or when Steps # 9c/9d land. That ADR must explicitly override ADR-0111's "missing custody cannot downgrade to test mode". # Until it is Accepted and its guard change passes a security sitting, the guard below stays as it is. -# `publish` runs in the `release` deployment environment, so the npm token can be scoped to it. +# `publish` names the `release` deployment environment, so the npm token can be scoped to it. The environment and +# that scoping are MVP-A23's prerequisite (not done on 2026-11-04: the token is still a repository secret). # # Flow: `pack` builds the engine-inlined bundle and `pnpm pack`s it (resolving catalog:/workspace: to # concrete versions) into a tarball artifact → `smoke` installs THAT SAME tarball outside the workspace on ubuntu / @@ -30,7 +31,9 @@ # The `allow_off_main` dispatch input is the maintainer escape hatch for building and smoking an unmerged # BRANCH. It cannot reach a publish: `workflow_dispatch` accepts a tag ref as readily as a branch, so the # ancestry assertion ignores the override whenever the ref is a `v*` tag, and `publish` additionally requires -# `github.event_name == 'push'` — a dispatched run never publishes, whatever ref or input it was given. +# `github.event_name == 'push'` — a dispatched run of this workflow never publishes, whatever ref or input it +# was given. A dispatch runs the workflow file found at its ref, so an edited copy on another ref is bounded only by +# where `NPM_TOKEN` lives (MVP-A23). # # Third-party actions are pinned to a commit SHA (the `# vX.Y.Z` comment tracks the release), matching ci.yml. name: Release CLI @@ -227,8 +230,9 @@ jobs: # manually dispatched run publish. Every manual run now stops after the cross-OS smoke. if: ${{ github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') }} runs-on: ubuntu-latest - # The deployment environment that holds `NPM_TOKEN` and its required reviewer (repo settings); a - # repository-level secret is readable by any workflow a writer pushes. It sets no job environment. + # The deployment environment meant to hold `NPM_TOKEN` and its required reviewer (repo settings, MVP-A23); a + # repository-level secret is readable by any workflow a writer pushes. It does not exist yet: GitHub creates it + # unprotected on the first run that names it, so it gates nothing until MVP-A23 sets it up. environment: release timeout-minutes: 10 permissions: diff --git a/AGENTS.md b/AGENTS.md index 8ecf7addc..590862f3a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -24,7 +24,8 @@ gateway; engine stays local, Phase 2) — split across build phase 5 (managed in phase 6 (cloud execution + portal); the engine is identical across all three (ADR-0012..0015). **Status (2026-11-04): the CLI MVP comes first.** Phases 1, 2 and 2.5 are complete (`relavium@0.1.1` is published); Phase 2.6.5 is complete with approved deferrals (49/51, PR #92). Phase 2.5.5's Waves 0–2 and Wave 3 -Steps 1–8 are merged (PR #94, PR #95), and Steps 9a.1–9b.2 and NI are accepted on `development`. On 2026-10-26 +Steps 1–8 are merged (PR #94, PR #95), and Steps 9a.1–9b.2 and NI are accepted and on `main` since the +`development`→`main` checkpoint (PR #96, 2026-11-04). On 2026-10-26 the maintainer paused Wave 3 (Steps 9c–19 pending, not declined) and put the CLI MVP first: `relavium` v0.2.0 (milestone M3.5), ordered as MVP-1…MVP-10 under decisions D58–D72 in [current.md](docs/roadmap/current.md#next-work--the-cli-mvp). Phase 2.6 is in progress (2.6.C and 2.6.F done, diff --git a/CLAUDE.md b/CLAUDE.md index de059aaa5..1adcd4e25 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -44,13 +44,13 @@ and [docs/architecture/managed-inference.md](docs/architecture/managed-inference complete (M2.5-4, PR #69, 2026-07-08); Phase 2.6.5 (Core reliability) is complete with approved deferrals (49/51, PR #92, 2026-10-11). Phase 2.5.5 (Hardening and Remediation): Waves 0–2 are merged (Wave 2: PR #94), Wave 3 Steps 1–8 are merged (PR #95, 2026-10-18), and PR95, 9a.1, 9a.2a, 9a.2b, NI, 9b.1 and 9b.2 are -accepted on `development`. **On 2026-10-26 the maintainer paused Wave 3 — Steps 9c–19 are pending, not +accepted and on `main` since the `development`→`main` checkpoint (PR #96, merged 2026-11-04). **On 2026-10-26 the maintainer paused Wave 3 — Steps 9c–19 are pending, not declined — and put the CLI MVP first:** `relavium` v0.2.0 on npm (milestone M3.5), ordered as MVP-1…MVP-10 in [current.md](docs/roadmap/current.md#next-work--the-cli-mvp) under decisions D58–D72 (ruled 2026-11-04). Phase 2.6 (Conversational Authoring and the First-Class CLI) is in progress: 2.6.C and 2.6.F are done (2.6.F's v0.2.0 release clause closes with the MVP) and 2.6.Q P1–P5 landed; the MVP takes slices of the rest. -Two rules shape every MVP increment. Step 9a.1's interim release guard on `development` refuses any npm +Two rules shape every MVP increment. Step 9a.1's interim release guard (on `main` since PR #96) refuses any npm publication until the superseding pre-1.0 release ADR (D59) is Accepted. The acceptance bar is tiered (D64): an increment touching an approval or consent path, egress, keys or secrets, filesystem writes, a new terminal sink for untrusted content, the lease or resume paths, or release keeps a rigor pass, one NEW complete diff --git a/README.md b/README.md index 771233280..bf569d89b 100644 --- a/README.md +++ b/README.md @@ -158,11 +158,12 @@ Relavium is under active development. The **CLI is published as v0.1.1**; the pu agent-session entry point, workflow runtime, inbound MCP client, and CLI management surface are implemented. **Phase 2.6.5 — Core Reliability Remediation is complete with approved deferrals**, with W8 merged in PR #92. The joint Phase 2.5.5/2.6 security remediation -Wave 2 merged to `main` on 2026-10-15 as PR #94, and the first eight steps of Wave 3 -(“Clear the ground”) followed on 2026-10-18 as PR #95. Since 2026-10-26 the **CLI MVP -comes first**: Wave 3 is paused, not dropped, while the work a CLI user feels — visible -run results, a chat agent that can edit and run commands, readable replies, conversational -authoring, web, the Home hub and MCP management — leads to `relavium` v0.2.0. +Wave 2 merged to `main` on 2026-10-15 as PR #94. The first eight steps of Wave 3 +(“Clear the ground”) followed on 2026-10-18 as PR #95, and the hardened release tooling and +the MVP plan on 2026-11-04 as PR #96. Since 2026-10-26 the **CLI MVP comes first**: Wave 3 +is paused, not dropped, while the work a CLI user feels — visible run results, a chat agent +that can edit and run commands, readable replies, conversational authoring, web, the Home +hub and MCP management — leads to `relavium` v0.2.0. Status changes quickly, so this README intentionally stays high-level. The canonical source for the exact active wave, completed work, and open reliability obligations is @@ -178,8 +179,8 @@ for the exact active wave, completed work, and open reliability obligations is | [`packages/db`](packages/db/) | Local SQLite persistence with a Postgres-compatible schema and migrations | | [`packages/mcp`](packages/mcp/) | SDK-confined inbound MCP client and schema validation | | [`apps/cli`](apps/cli/) | Published terminal product and integration harness | -| [`apps/desktop`](apps/desktop/) | Tauri desktop surface under development | -| [`apps/vscode-extension`](apps/vscode-extension/) | VS Code surface under development | +| [`apps/desktop`](apps/desktop/) | Tauri desktop surface — a placeholder, built in Phase 3 | +| [`apps/vscode-extension`](apps/vscode-extension/) | VS Code surface — a placeholder, built in Phase 4 | The full dependency graph and ownership rules live in [docs/project-structure.md](docs/project-structure.md). diff --git a/apps/cli/src/authoring/create-prompter.ts b/apps/cli/src/authoring/create-prompter.ts index 4467adb08..42a588d5e 100644 --- a/apps/cli/src/authoring/create-prompter.ts +++ b/apps/cli/src/authoring/create-prompter.ts @@ -126,7 +126,8 @@ export function createClackPrompter(deps: ClackCreateDeps = defaultDeps): Create const toolsRaw = await deps.text({ message: 'Tools (comma-separated tool ids, optional)', - placeholder: 'read_file, web_search', + // Tools that also work on `relavium run`, which wires no egress or os arm (2.6.A). + placeholder: 'read_file, list_directory', }); if (deps.isCancel(toolsRaw)) return null; const tools = toolsRaw diff --git a/apps/cli/src/chat/agent-source.test.ts b/apps/cli/src/chat/agent-source.test.ts index b19a89ddf..debdc869f 100644 --- a/apps/cli/src/chat/agent-source.test.ts +++ b/apps/cli/src/chat/agent-source.test.ts @@ -5,6 +5,7 @@ import { join } from 'node:path'; import { AgentParseError, MAX_SOURCE_CHARS } from '@relavium/core'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { KNOWN_PROVIDERS } from '../engine/providers.js'; import { isCliError } from '../process/errors.js'; import { DEFAULT_CHAT_AGENT_ID, DEFAULT_CHAT_MODEL } from './default-agent.js'; import { resolveChatAgent } from './agent-source.js'; @@ -72,6 +73,52 @@ describe('resolveChatAgent', () => { expect(agent.model).toBe(DEFAULT_CHAT_MODEL); }); + it('MVP-A4: with no configured default, the model comes from the first provider that has a key', () => { + const resolve = (keyed: readonly string[]) => + resolveChatAgent(undefined, { + cwd: dir, + projectConfigDir: undefined, + defaultModel: undefined, + hasKey: (id) => keyed.includes(id), + }); + // Anthropic keeps the long-standing default; every other provider gets the wizard's starter model. + expect(resolve(['anthropic', 'openai'])).toMatchObject({ + model: DEFAULT_CHAT_MODEL, + provider: 'anthropic', + }); + for (const id of ['openai', 'gemini', 'deepseek'] as const) { + expect(resolve([id])).toMatchObject({ model: KNOWN_PROVIDERS[id].testModel, provider: id }); + } + // In KNOWN_PROVIDER_IDS order when several are keyed. + expect(resolve(['deepseek', 'gemini'])).toMatchObject({ provider: 'gemini' }); + // No key anywhere: the built-in default, so the session pre-flight names the remedy for it. + expect(resolve([])).toMatchObject({ model: DEFAULT_CHAT_MODEL, provider: 'anthropic' }); + }); + + it('MVP-A4: a configured model, or a provider a caller supplies, wins over the keys', () => { + const keyedOpenAi = (id: string) => id === 'openai'; + expect( + resolveChatAgent(undefined, { + cwd: dir, + projectConfigDir: undefined, + defaultModel: 'claude-haiku-4-5', + defaultProvider: 'anthropic', + hasKey: keyedOpenAi, + }), + ).toMatchObject({ model: 'claude-haiku-4-5', provider: 'anthropic' }); + // A provider passed without a model gets that provider's starter, never the Anthropic default. Only a direct + // caller reaches this: config resolution drops a persisted provider that has no model. + expect( + resolveChatAgent(undefined, { + cwd: dir, + projectConfigDir: undefined, + defaultModel: undefined, + defaultProvider: 'gemini', + hasKey: keyedOpenAi, + }), + ).toMatchObject({ model: KNOWN_PROVIDERS.gemini.testModel, provider: 'gemini' }); + }); + it('bakes opts.reasoningEffort onto the DEFAULT agent (ADR-0066)', () => { const agent = resolveChatAgent(undefined, { cwd: dir, diff --git a/apps/cli/src/chat/agent-source.ts b/apps/cli/src/chat/agent-source.ts index db94f0566..5beb3e87e 100644 --- a/apps/cli/src/chat/agent-source.ts +++ b/apps/cli/src/chat/agent-source.ts @@ -1,6 +1,7 @@ import { parseAgent, type AgentDefinition } from '@relavium/core'; import type { LlmProviderId, ReasoningEffort } from '@relavium/shared'; +import { KNOWN_PROVIDER_IDS, KNOWN_PROVIDERS } from '../engine/providers.js'; import { sourceLabel } from '../process/source-label.js'; import { resolveYamlSource } from '../workflows/resolve.js'; import { buildDefaultChatAgent, DEFAULT_CHAT_MODEL } from './default-agent.js'; @@ -16,6 +17,40 @@ export interface ResolveChatAgentOptions { /** The resolved `[chat].reasoning_effort` (ADR-0066) — baked onto the DEFAULT agent only (an authored `--agent` * owns its own `reasoning_effort`, never overridden by config). Absent ⇒ no reasoning control. */ readonly reasoningEffort?: ReasoningEffort; + /** + * Whether a provider has a resolvable key — a boolean, never the key (MVP-A4). Consulted only for the DEFAULT + * agent when neither `defaultModel` nor `defaultProvider` is configured, so the zero-config chat binds a model a + * keyed provider serves. Absent ⇒ the built-in default, as before. + */ + readonly hasKey?: (id: LlmProviderId) => boolean; +} + +/** + * The default agent's model and provider (MVP-A4). + * + * A configured `defaultModel` wins, with its persisted provider (ADR-0059). Otherwise the provider is a + * `defaultProvider` the caller passed, else the first provider in {@link KNOWN_PROVIDER_IDS} order that has a key, + * else Anthropic. Config resolution never passes a provider without a model (it drops a stray one), so from config + * the choice is always the key-aware one. Anthropic keeps the long-standing zero-config {@link DEFAULT_CHAT_MODEL}; any other provider gets the + * onboarding wizard's starter model (`KNOWN_PROVIDERS[id].testModel`), so a user with only an OpenAI key reaches a + * working chat instead of an Anthropic credential failure. + */ +export function defaultChatModel(opts: ResolveChatAgentOptions): { + readonly model: string; + readonly provider: LlmProviderId | undefined; +} { + if (opts.defaultModel !== undefined) { + return { model: opts.defaultModel, provider: opts.defaultProvider }; + } + const hasKey = opts.hasKey; + const provider = + opts.defaultProvider ?? + (hasKey === undefined ? undefined : KNOWN_PROVIDER_IDS.find((id) => hasKey(id))) ?? + 'anthropic'; + return { + model: provider === 'anthropic' ? DEFAULT_CHAT_MODEL : KNOWN_PROVIDERS[provider].testModel, + provider, + }; } /** @@ -56,12 +91,9 @@ export function resolveChatAgentSource( opts: ResolveChatAgentOptions, ): ResolvedChatAgent { if (agentRef === undefined) { + const { model, provider } = defaultChatModel(opts); return { - agent: buildDefaultChatAgent( - opts.defaultModel ?? DEFAULT_CHAT_MODEL, - opts.reasoningEffort, - opts.defaultProvider, - ), + agent: buildDefaultChatAgent(model, opts.reasoningEffort, provider), artifact: undefined, }; } diff --git a/apps/cli/src/chat/compaction-persistence.test.ts b/apps/cli/src/chat/compaction-persistence.test.ts index 0c24bf99a..101474269 100644 --- a/apps/cli/src/chat/compaction-persistence.test.ts +++ b/apps/cli/src/chat/compaction-persistence.test.ts @@ -18,6 +18,7 @@ const chat: ResolvedChatConfig = { autoCompact: true, compactThreshold: 0.7, maxCostMicrocents: undefined, + maxTokens: undefined, onExceed: undefined, strictCostCap: false, allowedCommands: undefined, diff --git a/apps/cli/src/chat/export-project.test.ts b/apps/cli/src/chat/export-project.test.ts new file mode 100644 index 000000000..5b56edea4 --- /dev/null +++ b/apps/cli/src/chat/export-project.test.ts @@ -0,0 +1,376 @@ +import { + chmodSync, + existsSync, + lstatSync, + mkdirSync, + mkdtempSync, + readFileSync, + readdirSync, + renameSync, + rmSync, + statSync, + symlinkSync, + writeFileSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { + createClient, + createSessionStore, + runMigrations, + type DbClient, + type SessionStore, +} from '@relavium/db'; +import { MAX_SOURCE_CHARS, readSessionExportMarker } from '@relavium/core'; +import { AgentSchema, type AgentSessionRecord, type SessionMessage } from '@relavium/shared'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { isCliError } from '../process/errors.js'; +import { exportSessionToProject } from './export.js'; + +/** A hook on the temp file's creation: the moment between recognizing the own export and replacing it. */ +const fsHooks = vi.hoisted(() => ({ onOpen: undefined as ((path: string) => void) | undefined })); +vi.mock('node:fs', async (original) => { + const native = await original(); + return { + ...native, + openSync: (...args: Parameters) => { + const [path] = args; + if (typeof path === 'string') fsHooks.onOpen?.(path); + return native.openSync(...args); + }, + }; +}); + +const ISO = '2026-11-05T00:00:00.000Z'; +const AGENT = AgentSchema.parse({ + id: 'coder', + model: 'claude-sonnet-4-6', + provider: 'anthropic', + system_prompt: 'You are concise.', +}); + +const record = (id: string): AgentSessionRecord => ({ + id, + agentSlug: AGENT.id, + agentSnapshot: AGENT, + title: 'My Session', + context: { workingDir: '/workspace', fsScopeTier: 'sandboxed' }, + status: 'active', + totalInputTokens: 0, + totalOutputTokens: 0, + totalCostMicrocents: 0, + totalConservativeMicrocents: 0, + createdAt: ISO, + updatedAt: ISO, +}); + +const message = (sessionId: string, seq: number, role: 'user' | 'assistant'): SessionMessage => ({ + id: `${sessionId}-m${String(seq)}`, + sessionId, + sequenceNumber: seq, + role, + content: [{ type: 'text', text: role === 'user' ? 'hello' : 'hi there' }], + timestamp: ISO, +}); + +/** A valid workflow whose `workflow.id` is `id` — what another author's file in the catalog looks like. */ +const workflowYaml = (id: string): string => `schema_version: '1.0' +workflow: + id: ${id} + nodes: + - { id: a, type: input } + - { id: b, type: output } + edges: + - { from: a, to: b } +`; + +describe('exportSessionToProject (MVP-A3)', () => { + let client: DbClient; + let store: SessionStore; + let cwd: string; + const workflows = (): string => join(cwd, '.relavium', 'workflows'); + const file = (id: string): string => join(workflows(), `${id}.relavium.yaml`); + const idIn = (path: string): RegExpMatchArray | null => + readFileSync(path, 'utf8').match(/^ {2}id: (\S+)$/mu); // `workflow.id`: the one id at that depth + + beforeEach(() => { + client = createClient(':memory:'); + runMigrations(client.db); + store = createSessionStore(client.db); + for (const id of ['s1', 's2']) { + store.createSession(record(id)); + store.appendMessage(message(id, 0, 'user')); + store.appendMessage(message(id, 1, 'assistant')); + } + cwd = mkdtempSync(join(tmpdir(), 'relavium-export-project-')); + }); + afterEach(() => { + client.sqlite.close(); + rmSync(cwd, { recursive: true, force: true }); + }); + + const exportS1 = (projectConfigDir: string | undefined = join(cwd, '.relavium')) => + exportSessionToProject({ store, sessionId: 's1', cwd, projectConfigDir }); + + it('writes into .relavium/workflows/ under the title slug, never a UUID file in the cwd', () => { + const result = exportSessionToProject({ + store, + sessionId: 's1', + cwd, + projectConfigDir: undefined, + }); + expect(result.path).toBe(file('my-session')); + expect(result.workflowId).toBe('my-session'); + expect(idIn(result.path)?.[1]).toBe('my-session'); + expect(existsSync(join(cwd, 's1.relavium.yaml'))).toBe(false); + }); + + it('never overwrites another workflow: the next free id, written into the file too', () => { + mkdirSync(workflows(), { recursive: true }); + writeFileSync(file('my-session'), workflowYaml('my-session')); + const result = exportS1(); + expect(result.workflowId).toBe('my-session-2'); + expect(result.path).toBe(file('my-session-2')); + expect(idIn(result.path)?.[1]).toBe('my-session-2'); + expect(readFileSync(file('my-session'), 'utf8')).toBe(workflowYaml('my-session')); + }); + + it('treats a file stem and a differently named file’s workflow.id as taken', () => { + mkdirSync(workflows(), { recursive: true }); + // `relavium run my-session` would resolve `my-session.relavium.yaml` before `my-session.yaml`: shadowing. + writeFileSync(join(workflows(), 'my-session.yaml'), workflowYaml('something-else')); + expect(exportS1().workflowId).toBe('my-session-2'); + // …and an id already claimed inside another file is not reused either. + writeFileSync(join(workflows(), 'other.relavium.yaml'), workflowYaml('my-session-3')); + expect( + exportSessionToProject({ + store, + sessionId: 's2', + cwd, + projectConfigDir: join(cwd, '.relavium'), + }).workflowId, + ).toBe('my-session-4'); + }); + + it('leaves a workflow put at its export’s name after the check, and takes the next free id', () => { + // Recognizing the file as this session's export and renaming over it are separate steps, and serializing a + // long transcript in between takes long enough for an editor, a checkout or another process to put a + // different workflow there. The rewrite re-checks the file first and leaves a changed one alone. + const first = exportS1(); + fsHooks.onOpen = (path) => { + if (!path.endsWith('.tmp')) return; + fsHooks.onOpen = undefined; + rmSync(first.path); + writeFileSync(first.path, workflowYaml('hand-authored')); + }; + try { + const second = exportS1(); + expect(readFileSync(first.path, 'utf8')).toBe(workflowYaml('hand-authored')); + expect(second.path).toBe(file('my-session-2')); + expect(idIn(second.path)?.[1]).toBe('my-session-2'); + expect(readdirSync(workflows()).filter((name) => name.endsWith('.tmp'))).toEqual([]); + } finally { + fsHooks.onOpen = undefined; + } + }); + + it('rewrites this session’s own earlier export in place — recognized from the file, not from memory', () => { + const first = exportS1(); + // A new REPL (a `/models` reseat, a `chat-resume`) holds no record of the first export; the file names s1. + const again = exportS1(); + expect(again.path).toBe(first.path); + expect(again.workflowId).toBe('my-session'); + expect(readdirSync(workflows()).filter((name) => name.endsWith('.tmp'))).toEqual([]); + // Another session's export is not s1's: s2 takes the next id and s1's file is untouched. + const other = exportSessionToProject({ + store, + sessionId: 's2', + cwd, + projectConfigDir: join(cwd, '.relavium'), + }); + expect(other.workflowId).toBe('my-session-2'); + expect(readSessionExportMarker(readFileSync(first.path, 'utf8'))).toBe('s1'); + }); + + it('skips a file the catalog scan did not see, because the write refuses an existing target', () => { + // With no project config there is no catalog to consult; the `wx` create is what keeps the file. + mkdirSync(workflows(), { recursive: true }); + writeFileSync(file('my-session'), workflowYaml('someone-else')); + const result = exportSessionToProject({ + store, + sessionId: 's1', + cwd, + projectConfigDir: undefined, + }); + expect(result.workflowId).toBe('my-session-2'); + expect(readFileSync(file('my-session'), 'utf8')).toBe(workflowYaml('someone-else')); + }); + + it('treats an agent id, a differently-cased file name and a Windows device name as taken', () => { + mkdirSync(join(cwd, '.relavium', 'agents'), { recursive: true }); + writeFileSync( + join(cwd, '.relavium', 'agents', 'my-session.agent.yaml'), + 'id: my-session\nmodel: claude-sonnet-4-6\nprovider: anthropic\nsystem_prompt: x\n', + ); + mkdirSync(workflows(), { recursive: true }); + writeFileSync(join(workflows(), 'My-Session-2.yaml'), workflowYaml('deploy-prod')); + expect(exportS1().workflowId).toBe('my-session-3'); + + store.createSession({ ...record('s3'), title: 'aux' }); + store.appendMessage(message('s3', 0, 'user')); + expect( + exportSessionToProject({ + store, + sessionId: 's3', + cwd, + projectConfigDir: join(cwd, '.relavium'), + }).workflowId, + ).toBe('aux-2'); + }); + + it('never overwrites a workflow that now sits where its earlier export was', () => { + const first = exportS1(); + // The scaffold is renamed away and a hand-authored workflow takes the freed name. + rmSync(first.path); + writeFileSync(first.path, workflowYaml('my-session')); + const again = exportS1(); + expect(again.workflowId).toBe('my-session-2'); + expect(readFileSync(first.path, 'utf8')).toBe(workflowYaml('my-session')); + }); + + it('finds its own export at whatever id the search gave it, even once a lower id is free again', () => { + mkdirSync(workflows(), { recursive: true }); + writeFileSync(file('my-session'), workflowYaml('my-session')); + expect(exportS1().workflowId).toBe('my-session-2'); + rmSync(file('my-session')); + expect(exportS1().workflowId).toBe('my-session-2'); + expect(existsSync(file('my-session'))).toBe(false); + }); + + it.skipIf(process.platform === 'win32')( + 'refuses a workflows directory that resolves outside the project, even for its own export', + () => { + const first = exportS1(); + const outside = join(cwd, 'outside-workflows'); + renameSync(workflows(), outside); + symlinkSync(outside, workflows()); + const before = readFileSync(join(outside, 'my-session.relavium.yaml'), 'utf8'); + store.appendMessage(message('s1', 2, 'user')); // a rewrite would now change the file + let caught: unknown; + try { + exportS1(); + } catch (error) { + caught = error; + } + if (!isCliError(caught)) throw new Error('expected a CliError'); + expect(caught.code).toBe('invalid_invocation'); + expect(readFileSync(join(outside, 'my-session.relavium.yaml'), 'utf8')).toBe(before); + expect(first.workflowId).toBe('my-session'); + }, + ); + + it.skipIf(process.platform === 'win32')( + 'never follows or replaces a link planted at its export’s name', + () => { + const first = exportS1(); + const outside = join(cwd, 'outside.relavium.yaml'); + renameSync(first.path, outside); + symlinkSync(outside, first.path); + const again = exportS1(); + expect(again.workflowId).toBe('my-session-2'); + expect(lstatSync(first.path).isSymbolicLink()).toBe(true); + expect(readSessionExportMarker(readFileSync(outside, 'utf8'))).toBe('s1'); + }, + ); + + it('a parent that is not a directory is one invocation refusal, not a walk through every id', () => { + const refusal = (): string => { + let caught: unknown; + try { + exportSessionToProject({ store, sessionId: 's1', cwd, projectConfigDir: undefined }); + } catch (error) { + caught = error; + } + if (!isCliError(caught)) throw new Error('expected a CliError'); + expect(caught.code).toBe('invalid_invocation'); + return caught.message; + }; + // `.relavium` is a file: the mkdir fails ENOTDIR. + writeFileSync(join(cwd, '.relavium'), ''); + expect(refusal()).toContain('a parent of the target path is not a directory'); + // `.relavium/workflows` is a file: the mkdir fails EEXIST — the code a target collision also uses, which is + // why the mkdir has its own catch. + rmSync(join(cwd, '.relavium')); + mkdirSync(join(cwd, '.relavium')); + writeFileSync(workflows(), ''); + expect(refusal()).toContain('a parent of the target path is not a directory'); + }); + + it.skipIf(process.platform === 'win32')( + 'keeps the permission bits of its own earlier export across the rewrite', + () => { + const first = exportS1(); + chmodSync(first.path, 0o600); // narrowed by the user: its transcript may be sensitive + expect(exportS1().path).toBe(first.path); + expect(statSync(first.path).mode & 0o777).toBe(0o600); + }, + ); + + it.skipIf(process.platform === 'win32')( + 'rewrites its own export by replacing the file, never by writing into the old one', + () => { + // A write into the old file keeps its inode, and a crash part-way through it leaves a truncated export that + // the next /export can no longer recognize. The rename puts a complete new file in its place. + const first = exportS1(); + const before = statSync(first.path, { bigint: true }).ino; + expect(exportS1().path).toBe(first.path); + expect(statSync(first.path, { bigint: true }).ino).not.toBe(before); + }, + ); + + it.skipIf(process.platform === 'win32')( + 'a dangling link where the workflows directory belongs is an invocation refusal, not an internal fault', + () => { + mkdirSync(join(cwd, '.relavium')); + symlinkSync(join(cwd, 'nowhere'), workflows()); + let caught: unknown; + try { + exportS1(); + } catch (error) { + caught = error; + } + if (!isCliError(caught)) throw new Error('expected a CliError'); + expect(caught.code).toBe('invalid_invocation'); + expect(caught.message).toContain('a parent of the target path is not a directory'); + }, + ); + + it('does not take as its own an export larger than the workflow parser accepts', () => { + const first = exportS1(); + // Past the parser's character bound the file could not be run, and decoding it would cost seconds. + writeFileSync( + first.path, + `${readFileSync(first.path, 'utf8')}\n# ${'x'.repeat(MAX_SOURCE_CHARS)}\n`, + ); + expect(exportS1().workflowId).toBe('my-session-2'); + }); + + it('refuses once every candidate id is taken, naming the escape hatch', () => { + mkdirSync(workflows(), { recursive: true }); + for (let n = 1; n <= 100; n += 1) { + writeFileSync(file(n === 1 ? 'my-session' : `my-session-${String(n)}`), ''); + } + let caught: unknown; + try { + exportS1(); + } catch (error) { + caught = error; + } + if (!isCliError(caught)) throw new Error('expected a CliError'); + expect(caught.code).toBe('invalid_invocation'); + expect(caught.message).toContain("no free workflow id near 'my-session'"); + expect(caught.message).toContain('relavium chat-export --out '); + }); +}); diff --git a/apps/cli/src/chat/export.test.ts b/apps/cli/src/chat/export.test.ts index 05c81b9e7..a1d2ea8c6 100644 --- a/apps/cli/src/chat/export.test.ts +++ b/apps/cli/src/chat/export.test.ts @@ -202,8 +202,11 @@ describe('exportSession (2.P)', () => { } catch (err) { thrown = err; } - expect(isCliError(thrown)).toBe(true); // the sibling ENOTDIR arm maps to exit 2, like EISDIR - expect(isCliError(thrown) && thrown.message).toMatch(/not a file/); + expect(isCliError(thrown)).toBe(true); // the mkdir's ENOTDIR maps to exit 2, like EISDIR + // Named for what is wrong: a PARENT is a file (the mkdir's own catch, MVP-A3), not the target. + expect(isCliError(thrown) && thrown.message).toMatch( + /a parent of the target path is not a directory/, + ); }); it('exports a session with no completed turns as a minimal input→output scaffold', () => { diff --git a/apps/cli/src/chat/export.ts b/apps/cli/src/chat/export.ts index e0464f94c..113cde24c 100644 --- a/apps/cli/src/chat/export.ts +++ b/apps/cli/src/chat/export.ts @@ -1,16 +1,28 @@ +import { closeSync, constants, fstatSync, lstatSync, openSync, readFileSync } from 'node:fs'; import { dirname, join, resolve } from 'node:path'; -import { serializeWorkflow, sessionToWorkflow } from '@relavium/core'; +import { + MAX_SOURCE_CHARS, + readSessionExportMarker, + serializeWorkflow, + sessionToWorkflow, + type WorkflowDefinition, +} from '@relavium/core'; import type { SessionStore } from '@relavium/db'; -import type { AgentSessionRecord } from '@relavium/shared'; +import type { AgentSessionRecord, SessionMessage } from '@relavium/shared'; import { CliError } from '../process/errors.js'; import { mkdirForStorage, + replaceFileForStorage, + StorageTargetChangedError, + storageFileIdentity, + type StorageFileIdentity, writeFileForStorage, storageBoundaryError, } from '../process/owned-storage.js'; import { sourceLabel } from '../process/source-label.js'; +import { takenWorkflowIds } from '../workflows/catalog.js'; /** * Session → workflow export (2.P / [ADR-0026](../../../../docs/decisions/0026-session-export-to-workflow.md)) — @@ -39,7 +51,7 @@ export interface ExportSessionOptions { /** * `--out ` override (absolute, or relative to {@link cwd}); the default is `.relavium.yaml` * in cwd — keyed on the UNIQUE session id, not the (possibly shared/absent) title, so two sessions never - * collide on one path (which the in-REPL `/export`'s `force` would otherwise silently clobber). + * collide on one path (which `force` would otherwise silently clobber). */ readonly outPath?: string; /** Overwrite an existing file at the target path; without it an existing file is a clean exit-2 fault. */ @@ -63,13 +75,8 @@ export interface ExportResult { * written path + the loaded record (for an optional row-marking by the caller). */ export function exportSession(opts: ExportSessionOptions): ExportResult { - const loaded = opts.store.loadFull(opts.sessionId); - if (loaded === undefined) { - throw new CliError('invalid_invocation', `no session found with id ${opts.sessionId}`); - } - + const loaded = loadSession(opts.store, opts.sessionId); const definition = sessionToWorkflow(loaded.session, loaded.messages); - const yaml = serializeWorkflow(definition); // Default the filename to the UNIQUE session id (collision-free), not `workflow.id` (the title-slug, which // two untitled sessions share) — the in-file `workflow.id` stays the human-renameable title-slug. @@ -78,24 +85,219 @@ export function exportSession(opts: ExportSessionOptions): ExportResult { ? join(opts.cwd, `${opts.sessionId}.relavium.yaml`) : resolve(opts.cwd, opts.outPath); const label = sourceLabel(opts.cwd, path); + if (writeScaffold(path, serializeWorkflow(definition), opts.force, label) === 'exists') { + throw new CliError('invalid_invocation', `${label} already exists — pass --force to overwrite`); + } + return exportResult(path, definition, loaded); +} + +export interface ExportToProjectOptions { + readonly store: SessionStore; + readonly sessionId: string; + /** The launch cwd — the base for display labels, and for `.relavium/` when there is no project. */ + readonly cwd: string; + /** The discovered `.relavium/` directory; absent ⇒ `/.relavium`, created by the write. */ + readonly projectConfigDir: string | undefined; +} + +/** + * The names Windows reserves for devices, with or without an extension. A file named `aux.relavium.yaml` cannot be + * added to git on Windows and may open the device instead, so the export treats these ids as taken everywhere — + * the repository is shared across platforms. + */ +const WINDOWS_DEVICE_NAMES: ReadonlySet = new Set([ + 'con', + 'prn', + 'aux', + 'nul', + ...Array.from({ length: 9 }, (_, i) => `com${String(i + 1)}`), + ...Array.from({ length: 9 }, (_, i) => `lpt${String(i + 1)}`), +]); + +/** How many `-N` ids are tried before the export refuses rather than search on. */ +const MAX_EXPORT_ID_ATTEMPTS = 100; + +/** + * The kebab grammar of `workflow.id`. Restated rather than imported because `@relavium/shared` keeps its schema + * primitives internal; here it is a file-name guard, so a mismatch can only refuse, never widen. + */ +const WORKFLOW_ID_GRAMMAR = /^[a-z0-9]+(?:-[a-z0-9]+)*$/u; + +/** + * The in-REPL `/export` (MVP-A3): write the scaffold where workflows live, + * `/.relavium/workflows/.relavium.yaml`, with the session title's slug as the id, so + * `relavium run ` finds it — never a UUID-named file in the working directory. + * + * It never overwrites another workflow. The id is the first of ``, `-2`, … that no catalog entry or + * file stem already holds ({@link takenWorkflowIds}), and the file is created with `wx`, so a file that appears + * in between is skipped too. The chosen id is written INTO the file as `workflow.id`, keeping the file name and + * the id one thing. + * + * The one overwrite is this session's OWN earlier export, recognized from the file itself rather than from + * memory: a candidate file whose `metadata.relaviumExport` names this session ({@link ownExport}). So a second + * `/export` updates the scaffold in place across a `/models` reseat, a `chat-resume` or a Home reopen, and a file + * that merely sits at an old export's path — the scaffold renamed away and another workflow put there — is never + * mistaken for it. The rewrite is atomic, and it replaces only the file it recognized: one changed or replaced + * after the check is left alone, and the export moves on through the same names, rewriting another of this + * session's own exports there or else taking the next free id. A swap in the moment between the last check and the + * rename is not detected ({@link replaceFileForStorage}). + */ +export function exportSessionToProject(opts: ExportToProjectOptions): ExportResult { + const loaded = loadSession(opts.store, opts.sessionId); + const scaffold = sessionToWorkflow(loaded.session, loaded.messages); + const root = opts.projectConfigDir ?? join(opts.cwd, '.relavium'); + const dir = join(root, 'workflows'); + const base = scaffold.workflow.id; + const candidates = Array.from({ length: MAX_EXPORT_ID_ATTEMPTS }, (_, index) => + index === 0 ? base : `${base}-${String(index + 1)}`, + ); + // The id becomes a file name: refuse anything that is not a kebab id, whatever the scaffold produced. + if (!WORKFLOW_ID_GRAMMAR.test(base)) { + throw new CliError('cli_internal', 'the export produced an unusable workflow id'); + } + // The catalog scan comes first: it also refuses a workflows directory that resolves outside the project, which + // the own-export rewrite below must not write into either. + const taken = + opts.projectConfigDir === undefined + ? new Set() + : takenWorkflowIds(opts.projectConfigDir, opts.cwd); + for (const id of candidates) { + const path = join(dir, `${id}.relavium.yaml`); + const own = ownExport(path, opts.sessionId); + if (own === undefined) continue; + const definition = withWorkflowId(scaffold, id); + try { + replaceFileForStorage(path, serializeWorkflow(definition), own.mode, own.identity); + } catch (err) { + // Changed since it was recognized: no longer known to be ours, so it stays and the export moves on. Its id + // is in `taken`, so the next loop never writes there either. + if (err instanceof StorageTargetChangedError) continue; + throw storageBoundaryError(err); + } + return exportResult(path, definition, loaded); + } + for (const id of candidates) { + if (taken.has(id) || WINDOWS_DEVICE_NAMES.has(id)) continue; + const path = join(dir, `${id}.relavium.yaml`); + const definition = withWorkflowId(scaffold, id); + const label = sourceLabel(opts.cwd, path); + if (writeScaffold(path, serializeWorkflow(definition), false, label) === 'written') { + return exportResult(path, definition, loaded); + } + } + throw new CliError( + 'invalid_invocation', + `no free workflow id near '${base}' in the project's workflows/ — export with \`relavium chat-export --out \``, + ); +} + +/** This session's own earlier export: its permission bits, and its identity for the replace to re-check. */ +interface OwnExport { + readonly mode: number; + readonly identity: StorageFileIdentity; +} + +/** + * `path` when it holds this session's own earlier export: a regular file — never a link, so a link planted at an + * export's name is not followed or replaced as if it were ours — within the workflow parser's size bound, whose + * `metadata.relaviumExport` names `sessionId`. Any doubt (missing, unreadable, swapped between checks, larger than + * the parser accepts, undecodable) is `undefined`, which only ever means "do not overwrite". The workflow itself is + * not validated: an export whose stored agent no longer validates is still this session's own. + */ +function ownExport(path: string, sessionId: string): OwnExport | undefined { + let fd: number | undefined; + try { + const linked = lstatSync(path, { bigint: true }); + if (!linked.isFile() || linked.size > BigInt(MAX_SOURCE_CHARS * 4)) return undefined; + // `O_NOFOLLOW` is absent on Windows; the identity check below is its swap guard there. + fd = openSync(path, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0) | constants.O_NONBLOCK); + const opened = fstatSync(fd, { bigint: true }); + if (!opened.isFile() || opened.dev !== linked.dev || opened.ino !== linked.ino) + return undefined; + if (opened.size > BigInt(MAX_SOURCE_CHARS * 4)) return undefined; + const bytes = readFileSync(fd); + const text = bytes.toString('utf8'); + // The parser's own bound, in characters, before any YAML is decoded. + if (text.length > MAX_SOURCE_CHARS) return undefined; + return readSessionExportMarker(text) === sessionId + ? { mode: Number(opened.mode & 0o777n), identity: storageFileIdentity(opened, bytes) } + : undefined; + } catch { + return undefined; + } finally { + if (fd !== undefined) { + try { + closeSync(fd); + } catch { + // a close fault changes nothing about the answer + } + } + } +} + +function loadSession( + store: SessionStore, + sessionId: string, +): { readonly session: AgentSessionRecord; readonly messages: readonly SessionMessage[] } { + const loaded = store.loadFull(sessionId); + if (loaded === undefined) { + throw new CliError('invalid_invocation', `no session found with id ${sessionId}`); + } + return loaded; +} + +function withWorkflowId(definition: WorkflowDefinition, id: string): WorkflowDefinition { + return { ...definition, workflow: { ...definition.workflow, id } }; +} + +function exportResult( + path: string, + definition: WorkflowDefinition, + loaded: { readonly session: AgentSessionRecord; readonly messages: readonly SessionMessage[] }, +): ExportResult { + // The session is append-only; the next event/seq is one past the durable MAX (a fold, not a spread). + const sequenceNumber = + loaded.messages.reduce((max, m) => Math.max(max, m.sequenceNumber), -1) + 1; + return { path, workflowId: definition.workflow.id, sequenceNumber, record: loaded.session }; +} + +/** + * Create the parent directory and write the scaffold. Returns `'exists'` when the target is already there and + * `force` is off — the caller decides whether that is a refusal or a reason to try the next id. The two steps keep + * separate catches: a parent that is a file makes `mkdir` fail with `EEXIST` too, and reading that as "the target + * exists" would send the project export through every candidate id to a misleading refusal. + */ +function writeScaffold( + path: string, + yaml: string, + force: boolean, + label: string, +): 'written' | 'exists' { try { // Create the parent directory (so `--out exports/wf.yaml` just works) before writing the scaffold. mkdirForStorage(dirname(path)); - // Atomic create: `wx` fails with EEXIST if the target already exists — no TOCTOU window between a - // separate existence check and the write; `w` truncates/overwrites under `--force`. - writeFileForStorage(path, yaml, opts.force); } catch (err) { const code = exportTargetCode(err); - // An existing target without `--force` (EEXIST) is the no-overwrite fault; a structurally-invalid - // `--out` (a directory → EISDIR; a file used as a dir → ENOTDIR) is also an INVOCATION fault (exit 2). - // Other native write faults retain their cause behind a fixed safe diagnostic. - if (code === 'EEXIST') { + // ENOENT/ELOOP here mean a dangling or looping link where a directory belongs; `recursive` creates the rest. + if (code === 'EEXIST' || code === 'ENOTDIR' || code === 'ENOENT' || code === 'ELOOP') { throw new CliError( 'invalid_invocation', - `${label} already exists — pass --force to overwrite`, + `cannot write ${label}: a parent of the target path is not a directory`, { cause: err }, ); } + throw storageBoundaryError(err); + } + try { + // Atomic create: `wx` fails with EEXIST if the target already exists — no TOCTOU window between a + // separate existence check and the write; `w` truncates/overwrites under `force`. + writeFileForStorage(path, yaml, force); + return 'written'; + } catch (err) { + const code = exportTargetCode(err); + if (code === 'EEXIST') return 'exists'; + // A structurally-invalid target (a directory → EISDIR; a file used as a dir → ENOTDIR) is an INVOCATION + // fault (exit 2). Other native write faults retain their cause behind a fixed safe diagnostic. if (code === 'EISDIR' || code === 'ENOTDIR') { throw new CliError( 'invalid_invocation', @@ -105,11 +307,6 @@ export function exportSession(opts: ExportSessionOptions): ExportResult { } throw storageBoundaryError(err); } - - // The session is append-only; the next event/seq is one past the durable MAX (a fold, not a spread). - const sequenceNumber = - loaded.messages.reduce((max, m) => Math.max(max, m.sequenceNumber), -1) + 1; - return { path, workflowId: definition.workflow.id, sequenceNumber, record: loaded.session }; } /** Target-shape refusals are admitted only from the export's actual mkdir/write catch. */ diff --git a/apps/cli/src/chat/persister.test.ts b/apps/cli/src/chat/persister.test.ts index e7e064f0a..c71b6d64b 100644 --- a/apps/cli/src/chat/persister.test.ts +++ b/apps/cli/src/chat/persister.test.ts @@ -37,6 +37,7 @@ const EMPTY_CHAT: ResolvedChatConfig = { autoCompact: undefined, compactThreshold: undefined, maxCostMicrocents: undefined, + maxTokens: undefined, onExceed: undefined, strictCostCap: false, allowedCommands: undefined, diff --git a/apps/cli/src/chat/session-host.test.ts b/apps/cli/src/chat/session-host.test.ts index 40bab87d0..d60cb30d7 100644 --- a/apps/cli/src/chat/session-host.test.ts +++ b/apps/cli/src/chat/session-host.test.ts @@ -6,7 +6,7 @@ import { createCliInvocation } from '../process/invocation.js'; import { invocationFixture } from '../process/invocation.test-support.js'; import { SessionWorkOwner } from './session-work-owner.js'; import { nativeProcessesForInvocation } from '../process/native-process-owner.js'; -import { prepareOutputCapPlan } from '@relavium/llm'; +import { catalogModel, DEFAULT_ATTEMPT_TIMEOUT_MS, prepareOutputCapPlan } from '@relavium/llm'; import type { PreEgressInfo } from '@relavium/core'; import { execFileSync } from 'node:child_process'; import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; @@ -28,7 +28,7 @@ import { type McpClient, type McpConnection, } from '@relavium/mcp'; -import type { AbortSignalLike, AgentSessionRecord, SessionMessage } from '@relavium/shared'; +import type { AbortSignalLike, Agent, AgentSessionRecord, SessionMessage } from '@relavium/shared'; import { describe, expect, it, vi } from 'vitest'; import type { ResolvedChatConfig } from '../config/resolve.js'; @@ -41,12 +41,16 @@ import type { ProviderResolver } from '../engine/providers.js'; import { createChatStore } from '../render/tui/chat-store.js'; import { INLINE_TRANSCRIPT_BOUND } from '../render/tui/session-view-model.js'; import { applyChatMode, makeChatModeEnv } from './chat-mode-host.js'; +import { isCliError } from '../process/errors.js'; import { buildDefaultChatAgent } from './default-agent.js'; import { buildChatSession as buildFreshSession, buildGovernorWiring, + chatAttemptDeadline, + MAX_CHAT_ATTEMPT_TIMEOUT_MS, buildResumedChatSession as buildRestoredSession, swapAgentModel, + withChatOutputDefault, type ChatBudgetWarning, } from './session-host.js'; import { @@ -163,6 +167,7 @@ const EMPTY_CHAT: ResolvedChatConfig = { autoCompact: undefined, compactThreshold: undefined, maxCostMicrocents: undefined, + maxTokens: undefined, onExceed: undefined, strictCostCap: false, allowedCommands: undefined, @@ -519,6 +524,23 @@ describe('buildChatSession + MCP host wiring (2.R)', () => { return path; } + it('refuses a keyless provider BEFORE asking for MCP consent or starting a server (MVP-A4)', async () => { + const consentGate = vi.fn(() => Promise.resolve(new Map())); + const startMcpClient = vi.fn(() => Promise.reject(new Error('must not start'))); + const caught = await build({ + agentRef: writeMcpAgent(), + providers: { ...scriptedResolver([textTurn('hi')]), hasKey: () => false }, + consentGate, + startMcpClient, + }).then( + () => undefined, + (error: unknown) => error, + ); + expect(isCliError(caught) && caught.message).toContain('set-key anthropic'); + expect(consentGate).not.toHaveBeenCalled(); + expect(startMcpClient).not.toHaveBeenCalled(); + }); + it('grants the discovered tool, routes a call to the connection by original name, and tears it down', async () => { const calls: { name: string; args: unknown }[] = []; let closed = 0; @@ -2192,3 +2214,292 @@ describe('session MCP native owner forwarding during pending startup', () => { 15_000, ); }); + +describe('the interactive output default (MVP-A7, ADR-0101 amendment of 2026-11-05)', () => { + const official = (): boolean => true; + const custom = (): boolean => false; + const ceiling = catalogModel('claude-sonnet-4-6')?.maxOutputTokens; + const uncapped = { maxTokens: undefined, costCapped: false }; + const configured = { maxTokens: 2000, costCapped: true }; + const anthropic = buildDefaultChatAgent('claude-sonnet-4-6'); + const openai = { ...anthropic, provider: 'openai' as const, model: 'gpt-4o' }; + + it("gives an Anthropic chat on the official endpoint its model's catalog ceiling, not the implicit 4,096", () => { + expect(ceiling).toBeGreaterThan(4096); + expect(withChatOutputDefault(anthropic, uncapped, official).max_tokens).toBe(ceiling); + }); + + it('uses [chat].max_tokens for any provider and endpoint, even under a cost cap, never over an authored cap', () => { + expect(withChatOutputDefault(anthropic, configured, custom).max_tokens).toBe(2000); + expect(withChatOutputDefault(openai, configured, official).max_tokens).toBe(2000); + expect( + withChatOutputDefault({ ...anthropic, max_tokens: 777 }, configured, official).max_tokens, + ).toBe(777); + }); + + it("keeps today's behaviour on a custom endpoint, for another provider, for a chain and under a cost cap", () => { + expect(withChatOutputDefault(anthropic, uncapped, custom).max_tokens).toBeUndefined(); + expect(withChatOutputDefault(openai, uncapped, official).max_tokens).toBeUndefined(); + const chained = { + ...anthropic, + fallback_chain: [ + { model: 'claude-haiku-4-5', provider: 'anthropic' as const, max_attempts: 1 }, + ], + }; + expect(withChatOutputDefault(chained, uncapped, official).max_tokens).toBeUndefined(); + // The governor reserves the effective cap: a 128,000-token ceiling would refuse the first turn of a modest + // session cap (the configured-fallback tests above caught exactly that). + expect( + withChatOutputDefault(anthropic, { maxTokens: undefined, costCapped: true }, official) + .max_tokens, + ).toBeUndefined(); + }); + + it('reaches the wire for the default chat and leaves the persisted agent unchanged', async () => { + for (const [maxTokens, expected] of [ + [undefined, ceiling], + [2000, 2000], + ] as const) { + const { providers, requests } = capturingResolver([textTurn('hi')]); + const built = await build({ providers, chat: { ...EMPTY_CHAT, maxTokens } }); + built.session.start(); + await built.session.sendMessage('go'); + built.session.cancel(); + await built.recordedEvents; + expect(requests[0]?.maxTokens).toBe(expected); + expect(built.agent.max_tokens).toBeUndefined(); + } + }); + + it('scales the per-attempt deadline to the output cap, never below the default nor above 30 minutes', () => { + expect(chatAttemptDeadline(anthropic)).toEqual({}); + expect(chatAttemptDeadline({ ...anthropic, max_tokens: 2000 })).toEqual({}); + expect(chatAttemptDeadline({ ...anthropic, max_tokens: 8192 })).toEqual({ + attemptTimeoutMs: 328_000, + }); + expect(chatAttemptDeadline({ ...anthropic, max_tokens: 128_000 })).toEqual({ + attemptTimeoutMs: MAX_CHAT_ATTEMPT_TIMEOUT_MS, + }); + }); + + it('arms the scaled deadline on a fresh and a resumed chat, and leaves a one-shot at the default cap and deadline', async () => { + if (ceiling === undefined) throw new Error('the catalog has no ceiling for claude-sonnet-4-6'); + const scaled = Math.min(MAX_CHAT_ATTEMPT_TIMEOUT_MS, Math.ceil(ceiling / 25) * 1000); + expect(scaled).toBeGreaterThan(DEFAULT_ATTEMPT_TIMEOUT_MS); + const armed = (): { delays: number[]; deadlineTimer: (ms: number) => () => void } => { + const delays: number[] = []; + return { + delays, + deadlineTimer: (ms) => { + delays.push(ms); + return () => undefined; + }, + }; + }; + const turn = async ( + built: { + session: { + start: () => void; + sendMessage: (text: string) => Promise; + cancel: () => void; + }; + recordedEvents: Promise; + }, + start = true, + ): Promise => { + if (start) built.session.start(); + await built.session.sendMessage('go'); + built.session.cancel(); + await built.recordedEvents; + }; + + const fresh = armed(); + const freshWire = capturingResolver([textTurn('hi')]); + await turn(await build({ providers: freshWire.providers, deadlineTimer: fresh.deadlineTimer })); + expect(freshWire.requests[0]?.maxTokens).toBe(ceiling); + expect(fresh.delays).toContain(scaled); + + const oneShot = armed(); + const oneShotWire = capturingResolver([textTurn('hi')]); + await turn( + await build({ + providers: oneShotWire.providers, + oneShot: true, + deadlineTimer: oneShot.deadlineTimer, + }), + ); + expect(oneShotWire.requests[0]?.maxTokens).toBeUndefined(); + expect(oneShot.delays).toContain(DEFAULT_ATTEMPT_TIMEOUT_MS); + expect(oneShot.delays).not.toContain(scaled); + + // A resumed (or `/models`-reseated) session recomputes the default and its deadline from the current config. + const ISO = '2026-06-25T00:00:00.000Z'; + const resumed = armed(); + const resumedWire = capturingResolver([textTurn('hi')]); + const restored = await buildResumedChatSession({ + chat: EMPTY_CHAT, + record: { + id: 'sess-cap', + agentSlug: anthropic.id, + agentSnapshot: anthropic, + context: { workingDir: '/workspace', fsScopeTier: 'project' }, + status: 'ended', + totalInputTokens: 0, + totalOutputTokens: 0, + totalCostMicrocents: 0, + totalConservativeMicrocents: 0, + createdAt: ISO, + updatedAt: ISO, + }, + messages: [], + now: () => Date.parse(ISO), + providers: resumedWire.providers, + deadlineTimer: resumed.deadlineTimer, + }); + await turn(restored, false); + expect(resumedWire.requests[0]?.maxTokens).toBe(ceiling); + expect(resumed.delays).toContain(scaled); + }); +}); + +describe('the default model follows the stored key (MVP-A4)', () => { + const keyedOnly = (keyed: readonly string[]): ProviderResolver => ({ + ...scriptedResolver([textTurn('hi')]), + hasKey: (id) => keyed.includes(id), + }); + /** The refusal's message, after proving it is the typed exit-2 error. */ + const refusalOf = async (pending: Promise): Promise => { + const caught = await pending.then( + () => undefined, + (error: unknown) => error, + ); + if (!isCliError(caught)) throw new Error('expected a CliError refusal'); + expect(caught.code).toBe('invalid_invocation'); + return caught.message; + }; + + it('binds a keyed provider’s model when nothing is configured', async () => { + const built = await build({ providers: keyedOnly(['openai']) }); + expect(built.agent).toMatchObject({ provider: 'openai', model: 'gpt-5.4-mini' }); + built.session.cancel(); + await built.whenHostSafeToClose(); + }); + + it('refuses a session whose provider has no key, with the remedy instead of a credential failure', async () => { + expect(await refusalOf(build({ providers: keyedOnly([]) }))).toContain( + '`relavium provider set-key anthropic`', + ); + // A configured model is checked the same way: configuring it does not skip the pre-flight. + expect( + await refusalOf( + build({ + providers: keyedOnly(['anthropic']), + chat: { ...EMPTY_CHAT, defaultModel: 'gpt-4o', defaultProvider: 'openai' }, + }), + ), + ).toContain('set-key openai'); + }); + + it('refuses a RESUMED keyless session BEFORE asking for MCP consent or starting a server (MVP-A4)', async () => { + const ISO = '2026-06-25T00:00:00.000Z'; + const agent: Agent = { + ...buildDefaultChatAgent('claude-sonnet-4-6'), + mcp_servers: [{ id: 'fs', transport: 'stdio', command: 'my-server' }], + }; + const consentGate = vi.fn(() => Promise.resolve(new Map())); + const startMcpClient = vi.fn(() => Promise.reject(new Error('must not start'))); + const resumed = buildResumedChatSession({ + chat: EMPTY_CHAT, + record: { + id: 'sess-mcp', + agentSlug: agent.id, + agentSnapshot: agent, + context: { workingDir: '/workspace', fsScopeTier: 'project' }, + status: 'ended', + totalInputTokens: 0, + totalOutputTokens: 0, + totalCostMicrocents: 0, + totalConservativeMicrocents: 0, + createdAt: ISO, + updatedAt: ISO, + }, + messages: [], + now: () => Date.parse(ISO), + providers: keyedOnly(['openai']), + consentGate, + startMcpClient, + }); + expect(await refusalOf(resumed)).toContain('set-key anthropic'); + expect(consentGate).not.toHaveBeenCalled(); + expect(startMcpClient).not.toHaveBeenCalled(); + }); + + it('refuses a RESUMED session whose provider has lost its key', async () => { + const ISO = '2026-06-25T00:00:00.000Z'; + const agent = buildDefaultChatAgent('claude-sonnet-4-6'); + const resumed = buildResumedChatSession({ + chat: EMPTY_CHAT, + record: { + id: 'sess-k', + agentSlug: agent.id, + agentSnapshot: agent, + context: { workingDir: '/workspace', fsScopeTier: 'project' }, + status: 'ended', + totalInputTokens: 0, + totalOutputTokens: 0, + totalCostMicrocents: 0, + totalConservativeMicrocents: 0, + createdAt: ISO, + updatedAt: ISO, + }, + messages: [], + now: () => Date.parse(ISO), + providers: keyedOnly(['openai']), + consentGate: () => Promise.resolve(new Map()), + }); + expect(await refusalOf(resumed)).toContain('set-key anthropic'); + + // An interactive resume defers it: the session builds and names the provider, so `/models` can still rescue it. + const deferred = await buildResumedChatSession({ + chat: EMPTY_CHAT, + record: { + id: 'sess-k', + agentSlug: agent.id, + agentSnapshot: agent, + context: { workingDir: '/workspace', fsScopeTier: 'project' }, + status: 'ended', + totalInputTokens: 0, + totalOutputTokens: 0, + totalCostMicrocents: 0, + totalConservativeMicrocents: 0, + createdAt: ISO, + updatedAt: ISO, + }, + messages: [], + now: () => Date.parse(ISO), + providers: keyedOnly(['openai']), + consentGate: () => Promise.resolve(new Map()), + deferMissingKey: true, + }); + expect(deferred.missingKeyProvider).toBe('anthropic'); + deferred.session.cancel(); + await deferred.whenHostSafeToClose(); + }); + + it('asks the key store once per provider per build, so the choice and the pre-flight cannot disagree', async () => { + const asked: string[] = []; + const built = await build({ + providers: { + ...scriptedResolver([textTurn('hi')]), + hasKey: (id) => { + asked.push(id); + return id === 'anthropic'; + }, + }, + }); + expect(built.agent.provider).toBe('anthropic'); + expect(asked).toEqual(['anthropic']); // the default-model choice asked; the pre-flight reused its answer + built.session.cancel(); + await built.whenHostSafeToClose(); + }); +}); diff --git a/apps/cli/src/chat/session-host.ts b/apps/cli/src/chat/session-host.ts index f2c8e2081..f1c0349e8 100644 --- a/apps/cli/src/chat/session-host.ts +++ b/apps/cli/src/chat/session-host.ts @@ -30,6 +30,8 @@ import { unwiredEffectJournal, } from '@relavium/core'; import { + catalogModel, + DEFAULT_ATTEMPT_TIMEOUT_MS, effortTiersFor, type EndpointKind, type PricingOverlay, @@ -57,7 +59,12 @@ import { type ConnectAgentMcpOptions, type StdioConsentGate, } from '../engine/mcp-servers.js'; -import { createProviderResolver, type ProviderResolver } from '../engine/providers.js'; +import { + createProviderResolver, + missingKeyMessage, + providerHasKey, + type ProviderResolver, +} from '../engine/providers.js'; import { assembleToolEnv, clampChatTier, wiredToolIds } from '../engine/tool-host/assemble.js'; import { assertChatWorkspaceAdmission } from '../engine/tool-host/fs-admission.js'; import { @@ -101,6 +108,13 @@ export interface BuildChatSessionOptions { /** The resolved `[chat]` block (default model, fs scope, turn cap, cost cap). */ readonly chat: ResolvedChatConfig; readonly maxTokensEstimate?: number; + /** + * `relavium agent run`: a non-interactive one-shot. MVP-A7's chat output default and the deadline scaled to it + * apply to chat surfaces only, so a one-shot keeps the adapter's implicit cap and ADR-0082's default deadline. + */ + readonly oneShot?: boolean; + /** Test seam: the timer ADR-0082 §6's per-attempt deadline arms. Default {@link hostDeadlineTimer}. */ + readonly deadlineTimer?: typeof hostDeadlineTimer; /** Host-owned transient policy; never part of SessionContext or the durable snapshot. */ readonly fsPolicy?: FsPathPolicy; /** `--agent ` (path or bare id); `undefined` ⇒ the built-in default agent over `[chat].default_model`. */ @@ -327,6 +341,7 @@ type SessionRuntimeOptions = Pick< BuildChatSessionOptions, | 'chat' | 'maxTokensEstimate' + | 'deadlineTimer' | 'now' | 'providers' | 'toolHost' @@ -698,7 +713,7 @@ function buildSessionRuntime( sleep: hostSleep, // ADR-0082 §6's per-attempt deadline. The controller this host already supplies for the per-turn cancel // doubles as the deadline's merged signal; the TIMER is what arms it. - setTimer: hostDeadlineTimer, + setTimer: opts.deadlineTimer ?? hostDeadlineTimer, now: opts.now, // Node's AbortController satisfies the engine's structural AbortControllerLike (abort() + signal). newAbortController: () => new AbortController(), @@ -815,12 +830,17 @@ function buildSessionRuntime( * word the user typed instead of the path it resolved to answers a different question than the one asked. A * rebind has no file — the agent is already in memory — so it reports none rather than a stale one. */ -function bindChatAgent(opts: BuildChatSessionOptions): ResolvedChatAgent { +function bindChatAgent( + opts: BuildChatSessionOptions, + hasKey: (id: ProviderId) => boolean, +): ResolvedChatAgent { if (opts.agent !== undefined) return { agent: opts.agent, artifact: undefined }; return resolveChatAgentSource(opts.agentRef, { cwd: opts.cwd, projectConfigDir: opts.projectConfigDir, defaultModel: opts.chat.defaultModel, + // MVP-A4: with no configured default, the default agent's model comes from a provider that has a key. + hasKey, // ADR-0059: the persisted `[chat].default_provider` is used verbatim for the DEFAULT agent so a // live-discovered id whose prefix the inference cannot place still resolves; absent ⇒ inference. ...(opts.chat.defaultProvider === undefined @@ -834,6 +854,34 @@ function bindChatAgent(opts: BuildChatSessionOptions): ResolvedChatAgent { }); } +/** + * Refuse a session whose model provider has no key, before anything is spawned or sent (MVP-A4). + * + * Without this the first turn fails inside the chain, which replaces the resolver's message with a fixed + * "credential resolution failed" — deliberately, since a host error could carry a secret — so the user saw an + * opaque failure instead of the remedy. This asks the boolean `hasKey`, never for the key, and throws the same + * actionable message `keyFor` would. Only the PRIMARY provider is checked: an `auth` failure is fatal to the whole + * chain, so a keyless primary cannot answer until a key is stored or the model changes, while a keyless fallback + * entry fails only if it is reached. An interactive resume defers this refusal to each send + * ({@link BuildResumedChatSessionOptions.deferMissingKey}), because there `/models` can still change the model. + */ +function assertProviderKey(agent: AgentDefinition, hasKey: (id: ProviderId) => boolean): void { + if (hasKey(agent.provider)) return; + throw new CliError('invalid_invocation', missingKeyMessage(agent.provider)); +} + +/** {@link providerHasKey}, asked at most once per provider for the life of the returned function. */ +function memoizedHasKey(providers: ProviderResolver): (id: ProviderId) => boolean { + const answers = new Map(); + return (id) => { + const known = answers.get(id); + if (known !== undefined) return known; + const answer = providerHasKey(providers, id); + answers.set(id, answer); + return answer; + }; +} + /** * The MCP connect options, with every absent one OMITTED rather than passed as an explicit `undefined`. * @@ -933,7 +981,13 @@ export async function buildChatSession(opts: BuildChatSessionOptions): Promise => attached.handle?.whenConsumersReady() ?? Promise.resolve(); + const wired = narrowToWired(withMcpGrant(agent, mcp), host, deps.tools); + const sessionAgent = + opts.oneShot === true + ? wired + : withChatOutputDefault(wired, chatOutputSettings(opts.chat), officialEndpointOf(deps)); const session = new AgentSession({ sessionId, agentRef: agent.id, - agent: narrowToWired(withMcpGrant(agent, mcp), host, deps.tools), + agent: sessionAgent, context, - deps: { ...deps, whenReady }, + deps: { + ...deps, + ...(opts.oneShot === true ? {} : chatAttemptDeadline(sessionAgent)), + whenReady, + }, }); const handle = createSessionHandle( bus, @@ -1024,25 +1087,34 @@ export async function buildChatSession(opts: BuildChatSessionOptions): Promise opts.workOwner?.cleanupUncertain(cause), - opts.workOwner?.invocation.registry.budget, - ); - } catch (cause) { - opts.workOwner?.cleanupUncertain(cause); - } - } + await releaseFailedBuild(opts.workOwner, mcp, err); throw err; } } +/** + * The cleanup after a session build fails: report the failure to the work owner, then close the MCP children the + * build already spawned. Best-effort: a teardown rejection is reported as uncertain cleanup and must NOT mask the + * build's own error, which the caller rethrows (preserve the primary). + */ +async function releaseFailedBuild( + workOwner: SessionWorkOwner | undefined, + mcp: McpClient | undefined, + err: unknown, +): Promise { + workOwner?.failure(err); + if (mcp === undefined) return; + try { + await closePreparedMcp( + mcp, + workOwner === undefined ? undefined : (_id, cause) => workOwner.cleanupUncertain(cause), + workOwner?.invocation.registry.budget, + ); + } catch (cause) { + workOwner?.cleanupUncertain(cause); + } +} + /** * Return the agent the runtime session binds: the original `agent` with its `tools` grant **unioned** with the * discovered MCP tool ids (2.R). Declaring an `mcp_servers` entry implicitly grants that server's discovered @@ -1109,15 +1181,32 @@ export interface BuiltResumedChatSession extends BuiltChatSession { * session does not collide on the `(session_id, sequence_number)` UNIQUE index. */ readonly nextSequenceNumber: number; + /** + * The bound provider that has no key, when the caller asked to defer that refusal + * ({@link BuildResumedChatSessionOptions.deferMissingKey}). Absent ⇒ the provider has a key. + */ + readonly missingKeyProvider?: ProviderId; } export interface BuildResumedChatSessionOptions extends Pick< BuildChatSessionOptions, - 'afterTurnCompaction' | 'onCompactionStart' | 'fsPolicy' | 'nativeProcesses' | 'workOwner' + | 'afterTurnCompaction' + | 'onCompactionStart' + | 'fsPolicy' + | 'nativeProcesses' + | 'workOwner' + | 'deadlineTimer' > { /** The resolved `[chat]` block (turn cap, cost cap) — applied to the resumed session's deps. */ readonly chat: ResolvedChatConfig; readonly maxTokensEstimate?: number; + /** + * Build even when the snapshot's provider has no key, and report it as + * {@link BuiltResumedChatSession.missingKeyProvider} instead of refusing (MVP-A4). Only an INTERACTIVE + * `chat-resume` sets it: there the user can still `/models` to a provider that has a key, which a refusal before + * the REPL would make impossible. Absent/`false` (every reseat, the Home, a non-interactive resume) ⇒ refuse. + */ + readonly deferMissingKey?: boolean; /** * Sink for a WITHHELD reasoning tier (ADR-0071 §6) — see {@link BuildChatSessionOptions.onEffortWithheld}. A * RESUMED session is where a stale tier is likeliest: the snapshot carries the tier the agent was authored @@ -1204,6 +1293,11 @@ export async function buildResumedChatSession( `session ${record.id} has no stored agent snapshot and cannot be resumed`, ); } + const providers = opts.providers ?? createProviderResolver(); + const missingKeyProvider = providerHasKey(providers, agent.provider) ? undefined : agent.provider; + if (missingKeyProvider !== undefined && opts.deferMissingKey !== true) { + throw new CliError('invalid_invocation', missingKeyMessage(missingKeyProvider)); + } // Clamp the restored fs-scope tier to the host-allowed ceiling (full→project for the chat surface), // mirroring buildChatSession — so a PRE-2.5.A session persisted with a broader `full` scope resumes at the tier // the host actually jails to, keeping the dispatch context, the host jail, and the persisted record consistent. @@ -1241,19 +1335,25 @@ export async function buildResumedChatSession( attachDurabilityProbe, attachEffectJournal, attachEffectTurnAllocator, - } = buildSessionRuntime(opts, record.id, mcp, context); + } = buildSessionRuntime({ ...opts, providers }, record.id, mcp, context); // The same late-bound producer-await slot as the fresh-session path (`CR-30`), declared again because // this is a separate function. A resumed session streams exactly like a new one, so leaving it out // would have shipped the bound on one of the two ways a chat starts. const attached: { handle?: SessionHandle } = {}; + const sessionAgent = withChatOutputDefault( + narrowToWired(withMcpGrant(agent, mcp), host, deps.tools), + chatOutputSettings(opts.chat), + officialEndpointOf(deps), + ); const session = AgentSession.resume( { sessionId: record.id, agentRef: agent.id, - agent: narrowToWired(withMcpGrant(agent, mcp), host, deps.tools), + agent: sessionAgent, context, deps: { ...deps, + ...chatAttemptDeadline(sessionAgent), whenReady: () => attached.handle?.whenConsumersReady() ?? Promise.resolve(), }, }, @@ -1286,6 +1386,7 @@ export async function buildResumedChatSession( emitSessionEvent: emit, resumeState, nextSequenceNumber, + ...(missingKeyProvider === undefined ? {} : { missingKeyProvider }), mcpSkipped: mcp?.skipped ?? [], ...(mcp === undefined ? {} @@ -1302,21 +1403,7 @@ export async function buildResumedChatSession( }; } catch (err) { // Self-clean: a post-connect fault must not leak the just-spawned MCP children (see {@link buildChatSession}). - // Best-effort: a teardown rejection must NOT mask the original resume error (preserve the primary). - opts.workOwner?.failure(err); - if (mcp !== undefined) { - try { - await closePreparedMcp( - mcp, - opts.workOwner === undefined - ? undefined - : (_id, cause) => opts.workOwner?.cleanupUncertain(cause), - opts.workOwner?.invocation.registry.budget, - ); - } catch (cause) { - opts.workOwner?.cleanupUncertain(cause); - } - } + await releaseFailedBuild(opts.workOwner, mcp, err); throw err; } } @@ -1466,6 +1553,75 @@ export function buildGovernorWiring( }; } +/** + * The output cap for a chat agent that authors none (MVP-A7, ADR-0101's 2026-11-05 amendment): `[chat].max_tokens` + * when set; otherwise, for an Anthropic agent with no fallback chain on the official endpoint, the model's catalog + * ceiling — that wire would otherwise carry the adapter's implicit 4,096-token default. Another provider sends no + * cap unless one is configured, and a chain keeps today's behaviour, since an entry may not take that ceiling. + * + * A session with a cost cap keeps today's behaviour too: the governor reserves the effective cap before each turn + * (ADR-0101), and a catalog ceiling (128,000 tokens on `claude-sonnet-4-6`) would refuse the first turn of a modest + * cap outright. Applied to the EFFECTIVE agent only, so a resumed or rebound session recomputes it from the current + * config. + */ +export function withChatOutputDefault( + agent: AgentDefinition, + chat: { readonly maxTokens: number | undefined; readonly costCapped: boolean }, + officialEndpoint: (provider: ProviderId) => boolean, +): AgentDefinition { + if (agent.max_tokens !== undefined) return agent; + if (chat.maxTokens !== undefined) return { ...agent, max_tokens: chat.maxTokens }; + if (chat.costCapped) return agent; + if (agent.provider !== 'anthropic' || agent.fallback_chain !== undefined) return agent; + if (!officialEndpoint(agent.provider)) return agent; + const ceiling = catalogModel(agent.model)?.maxOutputTokens; + return ceiling !== undefined && Number.isSafeInteger(ceiling) && ceiling > 0 + ? { ...agent, max_tokens: ceiling } + : agent; +} + +/** The `[chat]` settings the output default reads: the configured cap, and whether a cost cap is set. */ +function chatOutputSettings(chat: ResolvedChatConfig): { + readonly maxTokens: number | undefined; + readonly costCapped: boolean; +} { + return { maxTokens: chat.maxTokens, costCapped: (chat.maxCostMicrocents ?? 0) > 0 }; +} + +/** The output throughput a chat attempt's deadline assumes, thinking included: deliberately slow, in tokens per second. */ +export const CHAT_STREAM_TOKENS_PER_SECOND = 25; +/** The longest per-attempt deadline a chat gets, however large its output cap. */ +export const MAX_CHAT_ATTEMPT_TIMEOUT_MS = 30 * 60_000; + +/** + * The per-attempt deadline for a chat session whose effective agent carries `max_tokens` (MVP-A7 × ADR-0082 §6). + * ADR-0082's deadline is ABSOLUTE per attempt and does not reset per chunk, so with the catalog ceiling (128,000 on + * claude-sonnet-4-6) a healthy reply streaming past 120 s would fail as a billed timeout and be rolled back, where + * the old 4,096 cap ended it cleanly as truncated. The chat host overrides the default, as §6 lets a host, with a + * deadline long enough to stream the whole cap at {@link CHAT_STREAM_TOKENS_PER_SECOND}: never below the default, + * never above {@link MAX_CHAT_ATTEMPT_TIMEOUT_MS}. A stalled stream is still bounded, and the user can cancel it. + */ +export function chatAttemptDeadline(agent: AgentDefinition): { + readonly attemptTimeoutMs?: number; +} { + if (agent.max_tokens === undefined) return {}; + const streamMs = Math.ceil(agent.max_tokens / CHAT_STREAM_TOKENS_PER_SECOND) * 1000; + if (streamMs <= DEFAULT_ATTEMPT_TIMEOUT_MS) return {}; + return { attemptTimeoutMs: Math.min(MAX_CHAT_ATTEMPT_TIMEOUT_MS, streamMs) }; +} + +/** Whether the session reaches a provider's official endpoint; unknown metadata counts as not official. */ +function officialEndpointOf(deps: SessionDeps): (provider: ProviderId) => boolean { + return (provider) => { + try { + const resolved = deps.resolveProvider(provider); + return resolved !== undefined && resolved.customEndpoint !== true; + } catch { + return false; + } + }; +} + /** A catalog alias does not establish the capacity of a custom service. */ function sessionContextWindow(agent: AgentDefinition, deps: SessionDeps): number | undefined { try { diff --git a/apps/cli/src/chat/session-structure.e2e.test.ts b/apps/cli/src/chat/session-structure.e2e.test.ts index 33a2ee8b2..9dc3a7976 100644 --- a/apps/cli/src/chat/session-structure.e2e.test.ts +++ b/apps/cli/src/chat/session-structure.e2e.test.ts @@ -63,6 +63,7 @@ const chat: ResolvedChatConfig = { autoCompact: false, compactThreshold: undefined, maxCostMicrocents: undefined, + maxTokens: undefined, onExceed: undefined, strictCostCap: false, allowedCommands: undefined, diff --git a/apps/cli/src/chat/session-usage-ack.test.ts b/apps/cli/src/chat/session-usage-ack.test.ts index 5cbf9de97..e7731ecb6 100644 --- a/apps/cli/src/chat/session-usage-ack.test.ts +++ b/apps/cli/src/chat/session-usage-ack.test.ts @@ -23,6 +23,7 @@ const chat = { autoCompact: undefined, compactThreshold: undefined, maxCostMicrocents: undefined, + maxTokens: undefined, onExceed: undefined, strictCostCap: false, allowedCommands: undefined, diff --git a/apps/cli/src/chat/usage-dispatch-identity.test.ts b/apps/cli/src/chat/usage-dispatch-identity.test.ts index 2458e1d4a..3201ec200 100644 --- a/apps/cli/src/chat/usage-dispatch-identity.test.ts +++ b/apps/cli/src/chat/usage-dispatch-identity.test.ts @@ -106,6 +106,7 @@ it.each(cases)( autoCompact: undefined, compactThreshold: undefined, maxCostMicrocents: undefined, + maxTokens: undefined, onExceed: undefined, strictCostCap: false, allowedCommands: undefined, diff --git a/apps/cli/src/commands/agent-run.ts b/apps/cli/src/commands/agent-run.ts index 38be940c7..90007f379 100644 --- a/apps/cli/src/commands/agent-run.ts +++ b/apps/cli/src/commands/agent-run.ts @@ -165,6 +165,7 @@ async function agentRunOwned( workOwner, ...(nativeProcesses === undefined ? {} : { nativeProcesses }), chat: config.chat, + oneShot: true, // MVP-A7's chat output default and its scaled deadline are for chat surfaces only. fsPolicy, afterTurnCompaction: false, // One-shot output has no next turn; retain active summary permission. ...(config.maxTokensEstimate === undefined diff --git a/apps/cli/src/commands/chat-export-terminal.test.ts b/apps/cli/src/commands/chat-export-terminal.test.ts new file mode 100644 index 000000000..52ad7fafd --- /dev/null +++ b/apps/cli/src/commands/chat-export-terminal.test.ts @@ -0,0 +1,92 @@ +import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { Readable } from 'node:stream'; + +import { createClient, createSessionStore, runMigrations, type DbClient } from '@relavium/db'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { scriptedResolver, textTurn } from '../chat/test-support.js'; +import type { GlobalOptions } from '../process/options.js'; +import { captureIo } from '../test-support.js'; +import { chatCommand, driveJson, drivePlain } from './chat.js'; + +/** + * A REPL command's output on the plain and `--json` drivers goes straight to the terminal's stderr, so it gets the + * control floor the full-screen view's notice applies. `/export` reads the project configuration, and a refusal + * names what it could not read: a project file's keys are text this process did not author. + */ + +const HOME_ENV_VARS = ['HOME', 'USERPROFILE'] as const; + +function globalOptions(cwd: string, json: boolean): GlobalOptions { + return { json, color: false, cwd, configPath: undefined, verbosity: 'normal' }; +} + +describe('/export output on the plain and --json drivers', () => { + let client: DbClient; + let cwd: string; + let home: string; + const savedHome = new Map(); + + beforeEach(() => { + cwd = realpathSync(mkdtempSync(join(tmpdir(), 'relavium-export-terminal-cwd-'))); + home = mkdtempSync(join(tmpdir(), 'relavium-export-terminal-home-')); + for (const v of HOME_ENV_VARS) { + savedHome.set(v, process.env[v]); + process.env[v] = home; + } + client = createClient(':memory:'); + runMigrations(client.db); + }); + afterEach(() => { + client.sqlite.close(); + for (const v of HOME_ENV_VARS) { + const prev = savedHome.get(v); + if (prev === undefined) delete process.env[v]; + else process.env[v] = prev; + } + rmSync(cwd, { recursive: true, force: true }); + rmSync(home, { recursive: true, force: true }); + }); + + it.each([ + { mode: 'plain', json: false }, + { mode: 'json', json: true }, + ])( + 'strips terminal controls from a refusal that names a project file key ($mode)', + async ({ json }) => { + const projectToml = join(cwd, '.relavium', 'project.toml'); + mkdirSync(join(cwd, '.relavium'), { recursive: true }); + writeFileSync(projectToml, '[defaults]\nmax_tokens_estimate = 17\n'); + // Valid when the chat starts (an invalid one is refused there); replaced before `/export`, as a checkout of + // another branch would, with a quoted key holding a title change, a bell and a screen clear. + function* lines(): Generator { + yield 'hello\n'; + writeFileSync(projectToml, '[defaults]\n"\\u001b]0;PWNED\\u0007\\u001b[2J" = 1\n'); + yield '/export\n'; + } + const { io, err } = captureIo(); + let tick = Date.parse('2026-11-05T00:00:00.000Z'); + let id = 0; + const store = createSessionStore(client.db); + await chatCommand( + { agent: undefined }, + { + io: { ...io, stdin: Readable.from(lines()) }, + global: globalOptions(cwd, json), + providers: scriptedResolver([textTurn('hi there')]), + openSessionStore: () => ({ store, db: client.db, close: () => undefined }), + drive: json ? driveJson : drivePlain, + now: () => tick++, + uuid: () => `id-${id++}`, + }, + ); + const refusal = err() + .split('\n') + .find((line) => line.startsWith('export failed:')); + expect(refusal).toContain('unknown key(s)'); + expect(err().replaceAll('\n', '')).not.toMatch(/\p{Cc}/u); + }, + ); +}); diff --git a/apps/cli/src/commands/chat-resume-missing-key.test.ts b/apps/cli/src/commands/chat-resume-missing-key.test.ts new file mode 100644 index 000000000..1510bf7dd --- /dev/null +++ b/apps/cli/src/commands/chat-resume-missing-key.test.ts @@ -0,0 +1,248 @@ +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { createClient, createSessionStore, runMigrations, type DbClient } from '@relavium/db'; +import type { ProviderId } from '@relavium/llm'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { scriptedResolver, textTurn } from '../chat/test-support.js'; +import type { ProviderResolver } from '../engine/providers.js'; +import { EXIT_CODES } from '../process/exit-codes.js'; +import { isCliError } from '../process/errors.js'; +import type { GlobalOptions } from '../process/options.js'; +import { captureIo } from '../test-support.js'; +import { + chatCommand, + chatResumeCommand, + type ChatDriver, + type ChatResumeCommandDeps, +} from './chat.js'; + +/** + * MVP-A4 on resume: a session whose provider has lost its key. An interactive resume opens anyway, says so, and + * refuses a message until a key exists — so `/models` can still move the conversation to a provider that has one. + * A non-interactive resume, where `/models` does not exist, is refused before anything starts. + */ + +const HOME_ENV_VARS = ['HOME', 'USERPROFILE'] as const; + +/** The interactive path enters the alternate screen; keep its control writes and signal hooks off the real tty. */ +const INERT_HOIST = { + writeControl: (): void => undefined, + lifecycle: { + onProcessExit: (): (() => void) => () => undefined, + onTerminationSignal: (): (() => void) => () => undefined, + onInterrupt: (): (() => void) => () => undefined, + setRawMode: (): void => undefined, + exit: (): void => undefined, + }, + jobControlLifecycle: { + supported: false, + onSuspend: (): (() => void) => () => undefined, + onContinue: (): (() => void) => () => undefined, + suspendSelf: (): void => undefined, + }, +} as const; + +function globalOptions(cwd: string): GlobalOptions { + return { json: false, color: false, cwd, configPath: undefined, verbosity: 'normal' }; +} + +/** A scripted resolver whose key answer the test controls, counting the provider requests it served. */ +function keyedResolver(hasKey: (id: ProviderId) => boolean): { + readonly providers: ProviderResolver; + readonly requests: () => number; +} { + const base = scriptedResolver([[...textTurn('again')]]); + let requests = 0; + return { + providers: { + ...base, + hasKey, + resolveProvider: (id) => { + const provider = base.resolveProvider(id); + if (provider === undefined) return undefined; + return { + ...provider, + stream: (request, signal) => { + requests += 1; + return provider.stream(request, signal); + }, + }; + }, + }, + requests: () => requests, + }; +} + +describe('chat-resume when the session’s provider has no key (MVP-A4)', () => { + let client: DbClient; + let cwd: string; + let home: string; + const savedHome = new Map(); + + beforeEach(() => { + cwd = mkdtempSync(join(tmpdir(), 'relavium-resume-key-cwd-')); + home = mkdtempSync(join(tmpdir(), 'relavium-resume-key-home-')); + for (const v of HOME_ENV_VARS) { + savedHome.set(v, process.env[v]); + process.env[v] = home; + } + client = createClient(':memory:'); + runMigrations(client.db); + }); + afterEach(() => { + client.sqlite.close(); + for (const v of HOME_ENV_VARS) { + const prev = savedHome.get(v); + if (prev === undefined) delete process.env[v]; + else process.env[v] = prev; + } + rmSync(cwd, { recursive: true, force: true }); + rmSync(home, { recursive: true, force: true }); + }); + + /** One completed turn on the default Anthropic agent, persisted as session `id-0`. */ + const seedSession = async (): Promise> => { + const store = createSessionStore(client.db); + const { io } = captureIo(); + let id = 0; + const lines = ['hello', '/exit']; + const drive: ChatDriver = async (ctx) => { + ctx.startSession(); + await ctx.onActivated?.(() => !ctx.shouldStop()); + for (const line of lines) { + await ctx.processLine(line); + if (ctx.shouldStop()) break; + } + return { kind: ctx.stopReason() }; + }; + expect( + await chatCommand( + { agent: undefined }, + { + io, + global: globalOptions(cwd), + providers: scriptedResolver([textTurn('hi')]), + openSessionStore: () => ({ store, db: client.db, close: () => undefined }), + drive, + now: () => Date.parse('2026-06-25T00:00:00.000Z'), + uuid: () => `id-${String(id++)}`, + }, + ), + ).toBe(EXIT_CODES.chatEnded); + return store; + }; + + /** Resume `id-0` interactively, typing `lines`, and report the notices and user turns the view showed. */ + const resumeInteractively = async ( + store: ReturnType, + providers: ProviderResolver, + lines: readonly string[], + between?: () => void, + ): Promise<{ + readonly code: number; + readonly notices: readonly string[]; + readonly userTurns: readonly string[]; + }> => { + const { io } = captureIo(); + let id = 0; + let notices: readonly string[] = []; + let userTurns: readonly string[] = []; + const drive: ChatDriver = async (ctx) => { + ctx.startSession(); + await ctx.onActivated?.(() => !ctx.shouldStop()); + for (const line of lines) { + await ctx.processLine(line); + between?.(); + if (ctx.shouldStop()) break; + } + const { transcript } = ctx.store.getSnapshot().state; + notices = transcript.filter((entry) => entry.role === 'notice').map((entry) => entry.text); + userTurns = transcript.filter((entry) => entry.role === 'user').map((entry) => entry.text); + return { kind: ctx.stopReason() }; + }; + const deps: ChatResumeCommandDeps = { + ...INERT_HOIST, + io: { ...io, stdoutIsTty: true }, // interactive ⇒ the deferred refusal + global: globalOptions(cwd), + providers, + openSessionStore: () => ({ store, db: client.db, close: () => undefined }), + drive, + now: () => Date.parse('2026-06-25T01:00:00.000Z'), + uuid: () => `r-${String(id++)}`, + }; + const code = await chatResumeCommand({ sessionId: 'id-0' }, deps); + return { code, notices, userTurns }; + }; + + it('opens an interactive resume, names both remedies, and sends nothing while the key is missing', async () => { + const store = await seedSession(); + const persistedBefore = store.loadFull('id-0')?.messages.length; + const { providers, requests } = keyedResolver(() => false); + + const { code, notices, userTurns } = await resumeInteractively(store, providers, [ + 'hello again', + '/compact', + '/exit', + ]); + + expect(code).toBe(EXIT_CODES.chatEnded); // opened and ended normally — not refused with exit 2 + const missing = notices.filter((text) => text.includes("no API key for provider 'anthropic'")); + expect(missing).toHaveLength(3); // on open, for the refused message, and for /compact's summary call + expect(notices.some((text) => /compaction failed/iu.test(text))).toBe(false); + for (const text of missing) { + expect(text).toContain('`relavium provider set-key anthropic`'); + expect(text).toContain('/models'); + } + expect(requests()).toBe(0); // nothing reached the provider + expect(store.loadFull('id-0')?.messages.length).toBe(persistedBefore); // nor the transcript + expect(userTurns).not.toContain('hello again'); // nor the view, as a turn that was never sent + }); + + it('lets the next message through once a key exists', async () => { + const store = await seedSession(); + let keyed = false; + const { providers, requests } = keyedResolver(() => keyed); + + // The key is stored (say, in another terminal) after the first, refused message. + const { code } = await resumeInteractively( + store, + providers, + ['refused', 'sent', '/exit'], + () => { + keyed = true; + }, + ); + + expect(code).toBe(EXIT_CODES.chatEnded); + expect(requests()).toBe(1); + const users = (store.loadFull('id-0')?.messages ?? []).filter((m) => m.role === 'user'); + expect(users.map((m) => m.content)).not.toContainEqual([{ type: 'text', text: 'refused' }]); + expect(users.map((m) => m.content)).toContainEqual([{ type: 'text', text: 'sent' }]); + }); + + it('refuses a NON-interactive resume before it starts, with the remedy', async () => { + const store = await seedSession(); + const { io } = captureIo(); + const { providers } = keyedResolver(() => false); + const caught = await chatResumeCommand( + { sessionId: 'id-0' }, + { + io, // not a TTY ⇒ the plain driver, which has no /models + global: globalOptions(cwd), + providers, + openSessionStore: () => ({ store, db: client.db, close: () => undefined }), + now: () => Date.parse('2026-06-25T01:00:00.000Z'), + uuid: () => 'n-0', + }, + ).then( + () => undefined, + (error: unknown) => error, + ); + if (!isCliError(caught)) throw new Error('expected a CliError refusal'); + expect(caught.code).toBe('invalid_invocation'); + expect(caught.message).toContain('set-key anthropic'); + }); +}); diff --git a/apps/cli/src/commands/chat.test.ts b/apps/cli/src/commands/chat.test.ts index 6169b3cc2..8fc626192 100644 --- a/apps/cli/src/commands/chat.test.ts +++ b/apps/cli/src/commands/chat.test.ts @@ -81,6 +81,7 @@ const EMPTY_CHAT: ResolvedChatConfig = { autoCompact: undefined, compactThreshold: undefined, maxCostMicrocents: undefined, + maxTokens: undefined, onExceed: undefined, strictCostCap: false, allowedCommands: undefined, @@ -99,6 +100,17 @@ function writeOutputEstimate(cwd: string): void { writeFileSync(join(cwd, '.relavium', 'project.toml'), '[defaults]\nmax_tokens_estimate = 17\n'); } +/** Where `/export` writes (MVP-A3): the project's workflows/ under the session title's slug. */ +function exportedAt(cwd: string, slug: string): string { + return join(cwd, '.relavium', 'workflows', `${slug}.relavium.yaml`); +} + +/** Make `/export` refuse deterministically: `.relavium/workflows` is a FILE, so the catalog cannot be read. */ +function blockWorkflowsDir(cwd: string): void { + mkdirSync(join(cwd, '.relavium'), { recursive: true }); + writeFileSync(join(cwd, '.relavium', 'workflows'), ''); +} + /** A headless driver that feeds a fixed line list through the command core (no TTY / ink). */ function linesDriver(lines: readonly string[]): ChatDriver { return async (ctx) => { @@ -1107,14 +1119,21 @@ describe('chatCommand', () => { ); expect(err()).not.toContain('PRIVATE_'); expect(out()).not.toContain('note: exported workflow'); - expect(existsSync(join(cwd, 'id-0.relavium.yaml'))).toBe(true); + // The file name is the title's slug by design (MVP-A3) — the transcript text itself is not echoed. + expect(existsSync(exportedAt(cwd, 'private-user-sentinel'))).toBe(true); if (json) { const events = parseNdjson<{ type: string; workflowPath?: string }>(out()); expect(events.filter((event) => event.type === 'session:exported')).toEqual([ - expect.objectContaining({ workflowPath: join(cwd, 'id-0.relavium.yaml') }), + expect.objectContaining({ workflowPath: exportedAt(cwd, 'private-user-sentinel') }), ]); + // The documented split: under --json the event carries the path, and no human path or run-hint line is + // printed on either stream. + expect(err()).not.toContain('exported session to'); + expect(`${err()}${out()}`).not.toContain('run it with'); } else { - expect(err()).toContain(`exported session to ${join(cwd, 'id-0.relavium.yaml')}`); + expect(err()).toContain( + `exported session to ${join('.relavium', 'workflows', 'private-user-sentinel.relavium.yaml')} — run it with \`relavium run private-user-sentinel\``, + ); } }, ); @@ -1126,7 +1145,7 @@ describe('chatCommand', () => { 'omits the live $mode continuity notice and exported event after a refused write', async ({ json }) => { const { d, out, err } = deps([], [textTurn('hi')]); - mkdirSync(join(cwd, 'id-0.relavium.yaml')); + blockWorkflowsDir(cwd); await chatCommand( { agent: undefined }, { @@ -1155,7 +1174,7 @@ describe('chatCommand', () => { client = createClient(join(home, 'export-notice.db')); runMigrations(client.db); const { d, err } = deps([], [textTurn('PRIVATE_ASSISTANT_SENTINEL')]); - if (refuse) mkdirSync(join(cwd, 'id-0.relavium.yaml')); + if (refuse) blockWorkflowsDir(cwd); const stdout = new OwnedTtyOutput(); const stdin = new OwnedTtyInput(); let app: ReturnType | undefined; @@ -1204,15 +1223,27 @@ describe('chatCommand', () => { expect(frames).toContain('EXPORT_RENDER_POSITIVE_CONTROL'); const warning = notices.filter((text) => text.startsWith('note: exported workflow')); expect(warning, 'W2_EXPORT_LIVE_RENDERED_NOTICE').toHaveLength(refuse ? 0 : 1); + // MVP-A3: the path, the run hint and a refusal ride the transcript too — a raw stderr line is + // overdrawn by the next full-screen frame and discarded with the alternate buffer. if (refuse) { expect(frames).not.toContain('exported workflow is a scaffold'); - expect(err()).toContain('export failed:'); + expect(notices.some((text) => text.startsWith('export failed:'))).toBe(true); + expect(frames).toContain('export failed:'); + expect(err()).not.toContain('export failed:'); } else { expect(frames, 'W2_EXPORT_LIVE_RENDERED_NOTICE').toContain( 'exported workflow is a scaffold', ); expect(warning[0]).not.toContain('PRIVATE_'); - expect(existsSync(join(cwd, 'id-0.relavium.yaml'))).toBe(true); + expect(existsSync(exportedAt(cwd, 'private-user-sentinel'))).toBe(true); + // The notice wraps at the frame width, so the frame shows its start and the store holds it whole. + expect(frames).toContain('exported session to'); + expect( + notices.some((text) => + text.includes('run it with `relavium run private-user-sentinel`'), + ), + ).toBe(true); + expect(err()).not.toContain('exported session to'); } expect(err()).not.toContain('note: exported workflow'); await ctx.processLine('/exit'); @@ -1249,37 +1280,40 @@ describe('chatCommand', () => { }; await chatCommand({ agent: undefined }, { ...d, drive: probingDrive }); - const path = join(cwd, 'id-0.relavium.yaml'); - expect(existsSync(path)).toBe(true); - expect(err()).toContain(`exported session to ${path}`); + expect(existsSync(exportedAt(cwd, 'hello'))).toBe(true); + expect(err()).toContain( + `exported session to ${join('.relavium', 'workflows', 'hello.relavium.yaml')} — run it with \`relavium run hello\``, + ); expect(statusAfterExport).toBe('active'); // /export left the live row untouched (NOT 'exported') expect(store.loadFull(sessionId)?.messages).toHaveLength(4); // both turns persisted — REPL continued expect(store.loadFull(sessionId)?.session.status).toBe('ended'); // /exit's terminal }); it('re-exports on a second /export (force overwrites the session OWN scaffold)', async () => { - // The path is keyed on the session id, so a 2nd /export targets the same file — it must overwrite, not - // fail "already exists" (which a force:false regression would produce on the second pass). + // A 2nd /export rewrites this session's own file in place (MVP-A3, recognized from its + // `metadata.relaviumExport`) — it must neither fail nor take the next id as if its own earlier export were + // another workflow. const { d, err } = deps([], [textTurn('hi')]); const twiceDrive: ChatDriver = async (ctx) => { ctx.startSession(); void ctx.onActivated?.(() => !ctx.shouldStop()); await ctx.processLine('hello'); - await ctx.processLine('/export'); // creates id-0.relavium.yaml - await ctx.processLine('/export'); // must overwrite it (force:true), not error + await ctx.processLine('/export'); // creates .relavium/workflows/hello.relavium.yaml + await ctx.processLine('/export'); // must rewrite it in place, not error and not add hello-2 await ctx.processLine('/exit'); return { kind: ctx.stopReason() }; }; await chatCommand({ agent: undefined }, { ...d, drive: twiceDrive }); - expect(existsSync(join(cwd, 'id-0.relavium.yaml'))).toBe(true); - expect(err()).not.toContain('export failed:'); // force:false would fail the 2nd pass + expect(existsSync(exportedAt(cwd, 'hello'))).toBe(true); + expect(existsSync(exportedAt(cwd, 'hello-2'))).toBe(false); + expect(err()).not.toContain('export failed:'); }); it('reports a real /export failure on stderr without crashing the REPL', async () => { - // A DIRECTORY at the target path makes writeFileSync throw EISDIR — a deterministic /export failure that - // exercises the catch arm. The REPL must report it and still drive to /exit (status 'ended'), not crash. + // A FILE where `.relavium/workflows` should be makes the export refuse deterministically, exercising the + // catch arm. The REPL must report it and still drive to /exit (status 'ended'), not crash. const { d, err, store, sessionId } = deps(['hello', '/export', '/exit'], [textTurn('hi')]); - mkdirSync(join(cwd, 'id-0.relavium.yaml')); // occupy the scaffold path with a dir ⇒ write fails + blockWorkflowsDir(cwd); await chatCommand({ agent: undefined }, d); expect(err()).toContain('export failed:'); // the catch arm reported the fault expect(store.loadFull(sessionId)?.session.status).toBe('ended'); // the REPL survived and ended cleanly @@ -1677,7 +1711,8 @@ describe('chatCommand', () => { // The initial session builds fine; the /clear rebuild REJECTS (e.g. a transient key/MCP fault). const buildSession: typeof buildChatSession = (opts) => { if (builds++ === 0) return buildChatSession(opts); - return Promise.reject(new Error('no API key for the fresh session')); + // A message that already ends its sentence, as the key pre-flight's remedy does (MVP-A4). + return Promise.reject(new Error('no API key for the fresh session.')); }; const driveClear: ChatDriver = (ctx) => { ctx.startSession(); @@ -1689,6 +1724,7 @@ describe('chatCommand', () => { expect(code).toBe(EXIT_CODES.chatEnded); // ends cleanly despite the failed rebuild (never hangs/loops) expect(err()).toContain('could not start a new session after /clear'); // actionable, swap-kind-aware hint expect(err()).toContain('relavium chat-resume id-0'); // names the OLD, still-resumable conversation + expect(err()).toContain('fresh session. Your previous conversation is saved'); // one full stop, not two expect(store.loadFull('id-0')?.session.status).toBe('ended'); // the prior session is persisted + resumable }); @@ -2035,7 +2071,7 @@ describe('chatCommand', () => { (e) => e.type === 'session:exported', ); expect(exported).toBeDefined(); // /export emits the event on the --json stream, not just a stderr line - expect(exported?.workflowPath).toBe(join(cwd, 'id-0.relavium.yaml')); + expect(exported?.workflowPath).toBe(exportedAt(cwd, 'hello')); }); it('strips control bytes from an unknown-slash echo (terminal-injection guard) and lists /export', async () => { diff --git a/apps/cli/src/commands/chat.ts b/apps/cli/src/commands/chat.ts index 12865b47b..d19aefc23 100644 --- a/apps/cli/src/commands/chat.ts +++ b/apps/cli/src/commands/chat.ts @@ -29,7 +29,8 @@ import { type Memory, type ReasoningEffort, } from '@relavium/shared'; -import { exportSession, SESSION_EXPORT_REVIEW_NOTICE } from '../chat/export.js'; +import { exportSessionToProject, SESSION_EXPORT_REVIEW_NOTICE } from '../chat/export.js'; +import { sourceLabel } from '../process/source-label.js'; import { createConsentGate } from '../engine/mcp-consent-gate.js'; import { createConsentPrompter } from '../mcp/consent-prompt.js'; import { createOwnedSessionConsentGate } from '../chat/session-consent.js'; @@ -92,7 +93,12 @@ import { createModelCatalogPort, type ModelCatalogPort } from '../engine/model-c import { assembleToolEnv } from '../engine/tool-host/assemble.js'; import { loadUserPricingOverlay, readUserPricingOverlay } from '../engine/pricing-overlay.js'; import { mcpSkippedLines, surfaceMcpSkipped } from '../engine/mcp-servers.js'; -import { createProviderResolver, type ProviderResolver } from '../engine/providers.js'; +import { + createProviderResolver, + missingKeyMessage, + providerHasKey, + type ProviderResolver, +} from '../engine/providers.js'; import { openSessionStore, type OpenedSessionStore } from '../history/session-open.js'; import { CliError, toUserFacing } from '../process/errors.js'; import type { CliIo } from '../process/io.js'; @@ -904,6 +910,9 @@ async function chatResumeCommandBody( let consentGate: NonNullable; let built: BuiltChatSession; + // The snapshot's provider when it has no key and the REPL is interactive (MVP-A4, deferred): the session + // opens so `/models` can move it to a provider that has a key, and a message is refused until then. + let missingKey: ProviderId | undefined; let store: ChatStoreController; let persister: SessionPersister; let intro: string; @@ -989,6 +998,8 @@ async function chatResumeCommandBody( ), record: loaded.session, messages: loaded.messages, + // A keyless snapshot provider is refused before the REPL only where `/models` cannot rescue it. + deferMissingKey: chatIsInteractive(deps.io, deps.global), now, providers, mcpSecretResolver, @@ -1007,6 +1018,7 @@ async function chatResumeCommandBody( closeMcp = resumed.closeMcp; surfaceMcpSkipped(deps.io, resumed.mcpSkipped); built = resumed; + missingKey = resumed.missingKeyProvider; // Seed the view header + persister via the SHARED resumed-wiring assembly (the same the `/models` reseat uses): // a resumed session never re-emits `session:started`, so the seeded store shows the model + carried cost/turns // from the first frame, and the persister continues past the last durable sequence number. @@ -1126,8 +1138,10 @@ async function chatResumeCommandBody( persister, doctorProbes, startSession: () => undefined, - onActivated: (isActive, flushNotice) => - reconcileResumedSessionEffects({ + ...(missingKey === undefined ? {} : { sendGuard: missingKeyGuard(providers, missingKey) }), + onActivated: (isActive, flushNotice) => { + if (missingKey !== undefined) store.notice(resumeMissingKeyNotice(missingKey)); + return reconcileResumedSessionEffects({ io: deps.io, ...(built.workOwner === undefined ? {} : { owner: built.workOwner }), db: opened.db, @@ -1139,7 +1153,8 @@ async function chatResumeCommandBody( if (chatIsInteractive(deps.io, deps.global)) return store.notice(text); else return deps.io.writeErrAcknowledged(`${text}\n`); }, - }), + }); + }, intro, modelPicker: buildChatModelsPort( opened, @@ -1173,6 +1188,23 @@ async function chatResumeCommandBody( ); } +/** The in-view notice a resumed session whose provider has no key opens with, and repeats at each refused send. */ +function resumeMissingKeyNotice(provider: ProviderId): string { + return `${missingKeyMessage(provider)} Or switch to a model whose provider has a key with /models.`; +} + +/** + * Refuse a message while `provider` still has no key — asked at each send, so a key stored meanwhile (in another + * terminal) lets the next message through. Without it the turn would fail inside the chain with a fixed, opaque + * "credential resolution failed". + */ +function missingKeyGuard( + providers: ProviderResolver, + provider: ProviderId, +): () => string | undefined { + return () => (providerHasKey(providers, provider) ? undefined : resumeMissingKeyNotice(provider)); +} + /** What the shared REPL loop needs: a built (fresh or resumed) session, its store/persister, and how to open it. */ interface ReplWiring { readonly built: BuiltChatSession; @@ -1183,6 +1215,12 @@ interface ReplWiring { readonly doctorProbes: DoctorProbes; /** Open the session: `built.session.start()` for a fresh session, a no-op for a resumed one (already idle). */ readonly startSession: () => void; + /** + * Asked before a message is sent and before `/compact` summarises: a refusal text keeps the message from being + * sent, recorded or persisted (and the summary from being asked for), and is shown instead. Set only by an interactive resume whose provider has no key (MVP-A4); a reseat or `/clear` + * wiring never carries it, because those builds refuse a keyless provider outright. + */ + readonly sendGuard?: () => string | undefined; readonly onActivated?: ( isActive: () => boolean, flushNotice?: NoticeFlush, @@ -1381,7 +1419,7 @@ function validateSlashTokens(command: ReplCommand, tokens: readonly string[]): s export function createChatLineHandler( wiring: Pick< ReplWiring, - 'built' | 'opened' | 'store' | 'persister' | 'doctorProbes' | 'chatMaxMessages' + 'built' | 'opened' | 'store' | 'persister' | 'doctorProbes' | 'chatMaxMessages' | 'sendGuard' >, deps: ChatReplDeps, ): ChatLineHandler { @@ -1426,7 +1464,9 @@ export function createChatLineHandler( if (interactive) { store.notice(text); } else { - deps.io.writeErr(`${text}\n`); + // The floor the store's notice applies: a refusal can carry text this process did not author (a config + // error names the project file's keys), and the plain and `--json` drivers write straight to the terminal. + deps.io.writeErr(`${stripTerminalControls(text)}\n`); } }; @@ -1464,13 +1504,17 @@ export function createChatLineHandler( // the file; it does NOT mark the row `exported` (a later turn's persist would clobber that — the // standalone `chat-export` command marks it). A failure is reported, never crashing the REPL. try { - const result = exportSession({ + // MVP-A3: into the project's `.relavium/workflows/` under the title's slug, never over another workflow; + // re-export rewrites this session's own file, recognized from its `metadata.relaviumExport`. + const { projectConfigDir } = loadResolvedConfig({ + cwd: deps.global.cwd, + configPath: deps.global.configPath, + }); + const result = exportSessionToProject({ store: opened.store, sessionId: built.sessionId, cwd: deps.global.cwd, - // Re-export overwrites the session's OWN scaffold: the default path is keyed on the unique session - // id, so `force` here can only ever clobber this session's prior export, never another session's. - force: true, + projectConfigDir, }); emitOutput(SESSION_EXPORT_REVIEW_NOTICE.trimEnd()); if (noticesStopped()) return; @@ -1481,7 +1525,12 @@ export function createChatLineHandler( // driveJson serializer (subscribed) writes it to stdout, keeping the stream pure + complete. built.emitSessionEvent({ type: 'session:exported', workflowPath: result.path }); } else { - deps.io.writeErr(`exported session to ${result.path}\n`); + // The cwd-relative path and the command that runs it — the export offers the next step. Through the + // notice channel, like the review note above: in the full-screen view a raw stderr line is overdrawn by + // the next frame and discarded with the alternate buffer. + emitOutput( + `exported session to ${sourceLabel(deps.global.cwd, result.path)} — run it with \`relavium run ${result.workflowId}\``, + ); } } catch (err) { workOwner?.failure(err); @@ -1492,7 +1541,7 @@ export function createChatLineHandler( ? err.message : String(err) : toUserFacing(err).message; - deps.io.writeErr(`export failed: ${message}\n`); + emitOutput(`export failed: ${message}`); } }, // The curated command list — a clean in-view notice in the ink TTY, a stderr list on the plain / `--json` paths. @@ -1675,6 +1724,13 @@ export function createChatLineHandler( // await, then report the deltas. The engine emits session:compacted (→ the persister writes the boundary // marker); this notice is the user-facing report. Never crashes the REPL — a failure is reported as output. compactHistory: async () => { + // A summary is a provider call too: while the session's provider has no key (MVP-A4), say so with the + // same remedy a refused message gets, not the fallback chain's opaque credential failure. + const keyRefusal = wiring.sendGuard?.(); + if (keyRefusal !== undefined) { + emitOutput(keyRefusal); + return; + } const refusal = built.session.compactionRefusal; if (refusal !== undefined) { emitOutput(compactionNotice(refusal)); @@ -1788,6 +1844,11 @@ export function createChatLineHandler( await handleSlashCommand(line); return; } + const refusal = wiring.sendGuard?.(); + if (refusal !== undefined) { + emitOutput(refusal); + return; + } // `display` is the COMPACT transcript form (prose + chip note) when a message carried `@`/`!` attachments; the // model + the durable transcript get the full framed `line` (resume fidelity), the live transcript the compact one. store.appendUser(display ?? line); @@ -3024,13 +3085,14 @@ export async function runReplLoop( // message verbatim (session-host.ts), which could carry an ANSI/OSC escape from a spawned MCP server's // error text; strip it exactly as the id + every other display string on this surface is stripped. `could not start a new session after ${outcome.kind === 'reseat' ? 'a model switch' : '/clear'}: ` + + // One full stop: a message that already ends a sentence (the missing-key remedy does) keeps its own. `${sanitizeInline( deps.chatLifetime === undefined ? err instanceof Error ? err.message : String(err) : toUserFacing(err).message, - )}. ` + + ).replace(/\.$/u, '')}. ` + `Your previous conversation is saved — resume it with \`relavium chat-resume ${sanitizeInline(oldSessionId)}\`.\n`; return { summaryText, errorText }; } diff --git a/apps/cli/src/commands/descriptor-actions.test.ts b/apps/cli/src/commands/descriptor-actions.test.ts index 281a0b268..7315bb413 100644 --- a/apps/cli/src/commands/descriptor-actions.test.ts +++ b/apps/cli/src/commands/descriptor-actions.test.ts @@ -171,8 +171,10 @@ const cases: readonly Case[] = [ }, { id: 'gate.list', argv: ['gate', 'list', 'r1'], positionals: ['r1'], options: {} }, { id: 'list', argv: ['list', '--agents'], positionals: [], options: { agents: true } }, + { id: 'list', argv: ['list', '--runs'], positionals: [], options: { runs: true } }, { id: 'logs', argv: ['logs', 'r1'], positionals: ['r1'], options: {} }, { id: 'status', argv: ['status'], positionals: [], options: {} }, + { id: 'status', argv: ['status', 'r1'], positionals: ['r1'], options: {} }, { id: 'models', argv: ['models'], positionals: [], options: {} }, { id: 'models.refresh', @@ -303,26 +305,29 @@ describe('descriptor-derived real Commander actions (compatibility grammar only) it('retains all actual command paths and mandatory/variadic grammar independently of model schema', () => { const nodes = walk(buildProgram(capture().io)); + // A command with mutually exclusive options (`list --agents` / `--runs`) or an optional positional needs + // several cases; its grammar is the union of theirs. + const ids = [...new Set(cases.map((testCase) => testCase.id))]; expect(nodes.map((node) => node.path).sort()).toEqual( - [ - ...cases.map((testCase) => testCase.id), - 'agent', - 'budget', - 'provider', - 'media', - 'init', - ].sort(), + [...ids, 'agent', 'budget', 'provider', 'media', 'init'].sort(), ); - for (const testCase of cases) { - const command = nodes.find((node) => node.path === testCase.id)?.command; - if (command === undefined) throw new Error(`missing command ${testCase.id}`); + for (const id of ids) { + const group = cases.filter((testCase) => testCase.id === id); + // The case with the most positionals states the full positional grammar. + const testCase = group.reduce((widest, next) => + next.positionals.length > widest.positionals.length ? next : widest, + ); + const command = nodes.find((node) => node.path === id)?.command; + if (command === undefined) throw new Error(`missing command ${id}`); expect(command.options.map((option) => option.long).sort()).toEqual( - [...new Set(testCase.argv.filter((token) => token.startsWith('--')))].sort(), + [ + ...new Set(group.flatMap((each) => each.argv.filter((token) => token.startsWith('--')))), + ].sort(), ); expect( command.options.map((option) => [option.attributeName(), option.required]).sort(), ).toEqual( - Object.entries(testCase.options) + Object.entries(group.reduce((all, each) => ({ ...all, ...each.options }), {})) .map(([key, value]) => [key, typeof value !== 'boolean']) .sort(), ); @@ -341,6 +346,7 @@ describe('descriptor-derived real Commander actions (compatibility grammar only) 'gate.list': 'runId', 'budget.resume': 'runId', logs: 'runId', + status: 'runId', 'models.pricing': 'model', 'provider.add': 'name', 'provider.set-key': 'name', @@ -348,7 +354,11 @@ describe('descriptor-derived real Commander actions (compatibility grammar only) 'provider.test': 'name', }; expect(index).toBe(0); - return [names[testCase.id], !['gate', 'gate.list'].includes(testCase.id), false]; + return [ + names[testCase.id], + !['gate', 'gate.list', 'status'].includes(testCase.id), + false, + ]; }), ); } @@ -426,6 +436,31 @@ describe('descriptor-derived real Commander actions (compatibility grammar only) }, ); + it('refuses list --agents with --runs before any action runs (MVP-1)', async () => { + const parse = async (argv: readonly string[]): Promise => { + const { io } = capture(); + const execute = vi.fn(() => Promise.resolve(0 as const)); + const context = { + io, + global: { + json: false, + color: false, + cwd: '/test', + configPath: undefined, + verbosity: 'normal' as const, + }, + result: {}, + }; + const program = buildProgram(io, { context, execute }); + program.exitOverride(); + await program.parseAsync(['node', 'relavium', ...argv]).catch(() => undefined); + return execute.mock.calls.length; + }; + expect(await parse(['list', '--runs'])).toBe(1); + expect(await parse(['list', '--agents'])).toBe(1); + expect(await parse(['list', '--agents', '--runs'])).toBe(0); + }); + it('keeps the init stub as an explicit unavailable action', async () => { const execute = vi.fn(() => Promise.resolve(0 as const)); await expect( diff --git a/apps/cli/src/commands/descriptors.ts b/apps/cli/src/commands/descriptors.ts index ab5957355..62007819d 100644 --- a/apps/cli/src/commands/descriptors.ts +++ b/apps/cli/src/commands/descriptors.ts @@ -1079,7 +1079,8 @@ export const COMMAND_DESCRIPTORS: readonly CommandDescriptor[] = freezeData([ { path: ['list'], label: 'List workflows', - description: 'List discovered workflows (or, with --agents, agents) in the current project.', + description: + 'List discovered workflows (or, with --agents, agents) in the current project, or with --runs every recorded run.', effect: 'read', availability: 'implemented', aliases: [], @@ -1095,7 +1096,19 @@ export const COMMAND_DESCRIPTORS: readonly CommandDescriptor[] = freezeData([ negated: false, required: false, description: 'list agents instead of workflows', - conflicts: [], + conflicts: ['runs'], + }, + { + binding: 'runs', + spellings: ['--runs'], + deprecatedSpellings: [], + arity: 'none', + type: 'boolean', + repetition: 'single', + negated: false, + required: false, + description: 'list recorded runs, newest first, instead of workflows', + conflicts: ['agents'], }, ], executorId: 'list', @@ -1125,11 +1138,20 @@ export const COMMAND_DESCRIPTORS: readonly CommandDescriptor[] = freezeData([ { path: ['status'], label: 'Show status', - description: 'Show active/paused runs and their per-node status.', + description: 'Show active/paused runs, or one run by id, and their per-node status.', effect: 'read', availability: 'implemented', aliases: [], - positionals: [], + positionals: [ + { + name: 'runId', + binding: 'runId', + type: 'string', + required: false, + variadic: false, + description: 'one run to show, active or finished (omit ⇒ every active run)', + }, + ], options: [], executorId: 'status', legacyModelOrder: 14, diff --git a/apps/cli/src/commands/dispatch.ts b/apps/cli/src/commands/dispatch.ts index 4c8c3ca7d..6ad8162eb 100644 --- a/apps/cli/src/commands/dispatch.ts +++ b/apps/cli/src/commands/dispatch.ts @@ -490,12 +490,20 @@ const executeGateList: CommandExecutor = (input, ctx) => { }; const executeList: CommandExecutor = (input, ctx) => - Promise.resolve(listCommand({ agents: boolFlag(input.options['agents']) }, ctx)); + Promise.resolve( + listCommand( + { agents: boolFlag(input.options['agents']), runs: boolFlag(input.options['runs']) }, + ctx, + ), + ); const executeLogs: CommandExecutor = (input, ctx) => Promise.resolve(logsCommand({ runId: reqPositional(input, 0, 'runId') }, ctx)); -const executeStatus: CommandExecutor = (_input, ctx) => statusCommand(ctx); +const executeStatus: CommandExecutor = (input, ctx) => { + const runId = optPositional(input, 0); + return statusCommand(ctx, runId === undefined ? {} : { runId }); +}; /** * The lazy `llm_providers`-UUID → provider-slug (e.g. `anthropic`) resolver the `models` list path uses for its diff --git a/apps/cli/src/commands/drive-gate-queued-decisions.test.ts b/apps/cli/src/commands/drive-gate-queued-decisions.test.ts index 7a178763e..a9909fbac 100644 --- a/apps/cli/src/commands/drive-gate-queued-decisions.test.ts +++ b/apps/cli/src/commands/drive-gate-queued-decisions.test.ts @@ -243,11 +243,16 @@ for (const scenario of [ observedPrompts = prompts; releaseSurplus.release(); // Bounded baseline cleanup after observing the stale-card bug. } - expect(await execution).toBe(scenario === 'budget-reject' ? 1 : 0); + // The competing decision closed the first card under the user, so the second gate is not prompted here (keys + // still being typed for the first card would answer it): the run parks on it. + const lost = scenario === 'losing-reject'; + expect(await execution).toBe(scenario === 'budget-reject' ? 1 : lost ? 3 : 0); expect(closes).toBe(1); expect(writerErrors).toBe(0); - expect(calls).toBe(scenario === 'ordinary-reject' || scenario === 'budget-reject' ? 0 : 2); - expect(prompts).toBe(scenario === 'budget-reject' ? 1 : 2); + expect(calls).toBe( + scenario === 'ordinary-reject' || scenario === 'budget-reject' ? 0 : lost ? 1 : 2, + ); + expect(prompts).toBe(scenario === 'budget-reject' || lost ? 1 : 2); const inspector = createClient(dbPath); try { const store = createRunHistoryStore(inspector.db, { @@ -259,24 +264,25 @@ for (const scenario of [ expect(events.at(-1)).toMatchObject( scenario === 'budget-reject' ? { type: 'run:failed', error: { code: 'budget_exceeded' } } - : { type: 'run:completed' }, + : lost + ? { type: 'run:paused' } + : { type: 'run:completed' }, ); const authorizations = events.filter( (event) => event.type === 'budget:authorization' && event.authorization.state === 'decided', ); expect(authorizations).toHaveLength( - scenario === 'ordinary-reject' ? 0 : scenario === 'budget-reject' ? 1 : 2, + scenario === 'ordinary-reject' ? 0 : scenario === 'budget-reject' || lost ? 1 : 2, ); - if (scenario === 'losing-reject') + if (lost) expect(authorizations).toMatchObject([ { authorization: { decision: 'approved', decidedBy: 'competing-human' } }, - { authorization: { decision: 'approved', decidedBy: 'test-human' } }, ]); expect(await createRunLeasePort(store).read(runId)).toBeUndefined(); expect(summaries).toHaveLength(1); expect(summaries[0]).toContain( - scenario === 'budget-reject' ? 'run failed' : 'run completed', + scenario === 'budget-reject' ? 'run failed' : lost ? 'run paused' : 'run completed', ); expect(process.listenerCount('SIGINT')).toBe(initialSignals); if (scenario === 'budget-reject') expect(observedPrompts).toBe(1); diff --git a/apps/cli/src/commands/drive.test.ts b/apps/cli/src/commands/drive.test.ts index d912b29e1..58b8e481d 100644 --- a/apps/cli/src/commands/drive.test.ts +++ b/apps/cli/src/commands/drive.test.ts @@ -30,6 +30,37 @@ workflow: - { from: g, to: out } `; +// g has an approve deadline and g2 none: the deadline decides g, and g2 waits for its own decision. +const GATED_DEADLINE = `schema_version: '1.0' +workflow: + id: drive-gated-deadline + nodes: + - { id: start, type: input } + - { id: g, type: human_gate, gate_type: approval, timeout_ms: 1000, timeout_action: approve } + - { id: g2, type: human_gate, gate_type: approval } + - { id: out, type: output } + edges: + - { from: start, to: g } + - { from: g, to: g2 } + - { from: g2, to: out } +`; + +// Two gates in parallel: a with no deadline, b with an approve deadline. +const PARALLEL_DEADLINE = `schema_version: '1.0' +workflow: + id: drive-parallel-deadline + nodes: + - { id: start, type: input } + - { id: a, type: human_gate, gate_type: approval } + - { id: b, type: human_gate, gate_type: approval, timeout_ms: 1000, timeout_action: approve } + - { id: out, type: output } + edges: + - { from: start, to: a } + - { from: start, to: b } + - { from: a, to: out } + - { from: b, to: out } +`; + async function startGatedRun(): Promise<{ engine: WorkflowEngine; handle: ReturnType; @@ -91,6 +122,295 @@ describe('driveRun — interactive gate', () => { expect(types.at(-1)).toBe('run:completed'); // and continued past the (ignored) run:paused to the terminal }); + it('closes an open card when its deadline decides the gate, says so, and prompts no later gate here', async () => { + // Before, the card stayed open: the user's later Reject reached `engine.resume`, which ignores a gate already + // decided, and the run went on approved with nothing said. The next card is not opened either: keys the user + // was still typing for the closed card would answer it (Clack's `y` approves), so the run parks on g2. + const host = createInMemoryHost(); + const engine = await buildEngine({ host }); + const handle = engine.start({ + workflow: parseWorkflow(GATED_DEADLINE, { source: 'drive.test' }), + inputs: {}, + }); + const { renderer, events } = recordingRenderer(); + const captured = captureIo(); + const { err } = captured; + // Order the notice against the live view's re-mount: it belongs under the card, before the view returns. + const order: string[] = []; + vi.mocked(renderer.resume)?.mockImplementation(() => { + order.push('resume'); + }); + const io = { + ...captured.io, + writeErr: (text: string) => { + order.push('err'); + captured.io.writeErr(text); + }, + }; + const signals: AbortSignal[] = []; + const prompter: GatePrompter = { + prompt: vi.fn( + (event: { readonly nodeId: string }, _budget: unknown, signal?: AbortSignal) => { + if (event.nodeId !== 'g') + return Promise.resolve({ decision: 'approved', decidedBy: 'cli' }); + if (signal !== undefined) signals.push(signal); + return new Promise((resolve) => { + signal?.addEventListener('abort', () => resolve(null)); + // The deadline fires while the card is open; the user's Reject comes a moment later. + setTimeout(() => { + host.fireTimers(); + setTimeout(() => resolve({ decision: 'rejected', decidedBy: 'cli' }), 20); + }, 0); + }); + }, + ), + }; + const lifetime = new WorkflowLifetime( + invocationFixture().invocation, + io, + () => () => undefined, + ); + + const outcome = await driveRun({ + lifetime, + engine, + handle, + makeRenderer: () => renderer, + gatePrompter: prompter, + io, + }); + await lifetime.finish(); + + expect(outcome).toBe('paused'); + expect(signals[0]?.aborted).toBe(true); + expect(err()).toMatch( + /Gate \S+ was already decided \(approved by its deadline\); no answer from this terminal was recorded\. Later gates in this run are not prompted here\./u, + ); + expect(err().match(/was already decided/gu)).toHaveLength(1); + expect(order.slice(0, 2)).toEqual(['err', 'resume']); + // The live view is re-mounted once after the card, not once per later event: the pause that parked g is stale + // once g resumes, and must not keep driving a departure attempt. + expect(renderer.resume).toHaveBeenCalledTimes(1); + // Only g's card was drawn; g2 waits for an out-of-band decision. + expect(prompter.prompt).toHaveBeenCalledTimes(1); + const decided = events.filter((e) => e.type === 'human_gate:resumed'); + expect(decided).toEqual([ + expect.objectContaining({ nodeId: 'g', decision: 'approved', decidedBy: 'timeout' }), + ]); + expect(events).toContainEqual( + expect.objectContaining({ type: 'human_gate:paused', nodeId: 'g2' }), + ); + }); + + it('closes the card when a `cli` decision it did not make arrives before the card answered', async () => { + // `cli` is every terminal's `decidedBy`, so it cannot tell this card's decision from another's; what this card + // returned can. + const { engine, handle } = await startGatedRun(); + const { renderer, events } = recordingRenderer(); + const { io, err } = captureIo(); + const signals: AbortSignal[] = []; + const prompter: GatePrompter = { + prompt: vi.fn( + ( + event: { readonly runId: string; readonly gateId: string }, + _budget: unknown, + signal?: AbortSignal, + ) => { + if (signal !== undefined) signals.push(signal); + return new Promise((resolve) => { + signal?.addEventListener('abort', () => resolve(null)); + setTimeout(() => { + void engine.resume(event.runId, event.gateId, { + decision: 'approved', + decidedBy: 'cli', + }); + setTimeout(() => resolve({ decision: 'rejected', decidedBy: 'cli' }), 20); + }, 0); + }); + }, + ), + }; + const lifetime = new WorkflowLifetime( + invocationFixture().invocation, + io, + () => () => undefined, + ); + + const outcome = await driveRun({ + lifetime, + engine, + handle, + makeRenderer: () => renderer, + gatePrompter: prompter, + io, + }); + await lifetime.finish(); + + expect(signals[0]?.aborted).toBe(true); + expect(err()).toContain('was already decided (approved by cli)'); + expect(outcome).toBe('completed'); + expect(renderer.resume).toHaveBeenCalledTimes(1); // the run goes on with one live view + const decided = events.filter((e) => e.type === 'human_gate:resumed'); + expect(decided).toEqual([expect.objectContaining({ decision: 'approved', decidedBy: 'cli' })]); + }); + + it('says so when a differing `cli` decision lands after the card answered, before its resume', async () => { + // Both are `cli`; only the decision tells the other terminal's approval from this card's own rejection. + const { engine, handle } = await startGatedRun(); + const resume = engine.resume.bind(engine); + vi.spyOn(engine, 'resume').mockImplementation(async (runId, gateId, decision) => { + if (decision.decision === 'rejected') + await resume(runId, gateId, { decision: 'approved', decidedBy: 'cli' }); + return resume(runId, gateId, decision); + }); + const { renderer, events } = recordingRenderer(); + const { io, err } = captureIo(); + const lifetime = new WorkflowLifetime( + invocationFixture().invocation, + io, + () => () => undefined, + ); + + const outcome = await driveRun({ + lifetime, + engine, + handle, + makeRenderer: () => renderer, + gatePrompter: prompterReturning({ decision: 'rejected', decidedBy: 'cli' }), + io, + }); + await lifetime.finish(); + + expect(outcome).toBe('completed'); + expect(err()).toContain('was already decided (approved by cli)'); + const decided = events.filter((e) => e.type === 'human_gate:resumed'); + expect(decided).toEqual([expect.objectContaining({ decision: 'approved', decidedBy: 'cli' })]); + }); + + it('leaves the open card alone when a deadline decides another gate, and says nothing', async () => { + const host = createInMemoryHost(); + const engine = await buildEngine({ host }); + const handle = engine.start({ + workflow: parseWorkflow(PARALLEL_DEADLINE, { source: 'drive.test' }), + inputs: {}, + }); + const { renderer, events } = recordingRenderer(); + const { io, err } = captureIo(); + const signals: AbortSignal[] = []; + const prompted: string[] = []; + const prompter: GatePrompter = { + prompt: vi.fn( + (event: { readonly nodeId: string }, _budget: unknown, signal?: AbortSignal) => { + prompted.push(event.nodeId); + if (signal !== undefined) signals.push(signal); + return new Promise((resolve) => { + signal?.addEventListener('abort', () => resolve(null)); + // b's deadline fires while a's card is open; the user then approves a. + setTimeout(() => { + host.fireTimers(); + setTimeout(() => resolve({ decision: 'approved', decidedBy: 'cli' }), 20); + }, 0); + }); + }, + ), + }; + const lifetime = new WorkflowLifetime( + invocationFixture().invocation, + io, + () => () => undefined, + ); + + const outcome = await driveRun({ + lifetime, + engine, + handle, + makeRenderer: () => renderer, + gatePrompter: prompter, + io, + }); + await lifetime.finish(); + + expect(prompted).toEqual(['a']); + expect(signals[0]?.aborted).toBe(false); + expect(err()).not.toContain('was already decided'); + expect(outcome).toBe('completed'); + const decided = events + .filter((e) => e.type === 'human_gate:resumed') + .map((e) => + e.type === 'human_gate:resumed' ? `${e.nodeId}:${e.decision}:${e.decidedBy}` : '', + ); + expect(decided.sort()).toEqual(['a:approved:cli', 'b:approved:timeout']); + }); + + it('says nothing about cleanup while a person takes their time on a card', async () => { + // A line written under an open card throws off its redraws, and nothing is being cleaned up. + const { engine, handle } = await startGatedRun(); + const { renderer } = recordingRenderer(); + const { io, err } = captureIo(); + const prompter: GatePrompter = { + prompt: () => + new Promise((resolve) => + setTimeout(() => resolve({ decision: 'approved', decidedBy: 'cli' }), 300), + ), + }; + const lifetime = new WorkflowLifetime( + invocationFixture().invocation, + io, + () => () => undefined, + ); + + const outcome = await driveRun({ + lifetime, + engine, + handle, + makeRenderer: () => renderer, + gatePrompter: prompter, + io, + }); + await lifetime.finish(); + + expect(outcome).toBe('completed'); + expect(err()).not.toContain('cleanup is still pending'); + }); + + it('says cleanup is pending when a closed card does not settle within 250 ms of its abort', async () => { + const host = createInMemoryHost(); + const engine = await buildEngine({ host }); + const handle = engine.start({ + workflow: parseWorkflow(GATED_DEADLINE, { source: 'drive.test' }), + inputs: {}, + }); + const { renderer } = recordingRenderer(); + const { io, err } = captureIo(); + const prompter: GatePrompter = { + // Ignores its abort: the deadline closes the card, and the prompt still takes 400 ms to return. + prompt: () => + new Promise((resolve) => { + setTimeout(() => { + host.fireTimers(); + setTimeout(() => resolve(null), 400); + }, 0); + }), + }; + const lifetime = new WorkflowLifetime( + invocationFixture().invocation, + io, + () => () => undefined, + ); + + await driveRun({ + lifetime, + engine, + handle, + makeRenderer: () => renderer, + gatePrompter: prompter, + io, + }); + await lifetime.finish(); + + expect(err()).toContain('Run cleanup is still pending'); + }); + it('passes the built decision through to the engine (the resumed event carries it)', async () => { const { engine, handle } = await startGatedRun(); const { renderer, events } = recordingRenderer(); diff --git a/apps/cli/src/commands/drive.ts b/apps/cli/src/commands/drive.ts index e15b64fe6..5f54dc16d 100644 --- a/apps/cli/src/commands/drive.ts +++ b/apps/cli/src/commands/drive.ts @@ -1,15 +1,18 @@ import { EngineStateError, WorkflowValidationError, + validateWorkflowToolArms, validateWorkflowWithCatalog, type RunHandle, type RunDeparture, type WorkflowDefinition, type WorkflowEngine, + type ToolHostArm, type WorkflowModelCatalog, } from '@relavium/core'; import type { ErrorCode, + GateDecision, HumanGatePausedEvent, RunDurability, RunEvent, @@ -18,7 +21,7 @@ import type { import type { GatePrompter } from '../gate/prompter.js'; import { budgetIdentifier, budgetPromptContext, type BudgetPromptContext } from '../gate/budget.js'; -import { sanitizeUntrustedInline, stringifyJsonLine } from '../render/sanitize.js'; +import { sanitizeInline, sanitizeUntrustedInline, stringifyJsonLine } from '../render/sanitize.js'; import { CliError, toUserFacing } from '../process/errors.js'; import { EXIT_CODES, type ExitCode } from '../process/exit-codes.js'; import { detectOutputMode, isCiEnv } from '../process/output-mode.js'; @@ -48,6 +51,25 @@ export function assertWorkflowCatalogValid( } } +/** + * The run-path tool pre-flight (2.6.A, MVP-1): a workflow granting a built-in whose host arm `relavium run` does + * not wire (`web_search`, `http_request`, `read_clipboard`, `notify`) is refused before the run starts, as an + * `invalid_invocation` CliError (exit 2), instead of failing with `tool_unavailable` partway through. + */ +export function assertWorkflowToolArmsWired( + workflow: WorkflowDefinition, + unwired: readonly ToolHostArm[], +): void { + try { + validateWorkflowToolArms(workflow, { unwired }); + } catch (err) { + if (err instanceof WorkflowValidationError) { + throw new CliError('invalid_invocation', err.message, { cause: err }); + } + throw err; + } +} + /** A run's terminal disposition (`undefined` means the stream ended with no terminal/paused — an abnormal unwind). */ export type RunOutcome = 'completed' | 'failed' | 'cancelled' | 'paused'; @@ -103,6 +125,41 @@ export async function driveRun(deps: DriveRunDeps): Promise { + const card = openCard; + if (card === undefined) return; + if (card.answered?.decision === decision && card.answered.decidedBy === decidedBy) return; + // A human-gate resume (and a legacy companion) names the node, not the gate. + if (gateId === undefined ? nodeId !== card.gate.nodeId : gateId !== card.gate.gateId) return; + card.decided ??= `${decision}${decidedBy === 'timeout' ? ' by its deadline' : ` by ${decidedBy}`}`; + card.controller.abort(); + }; let renderer: RunRenderer | undefined; let driveError: { readonly cause: unknown } | undefined; const recordFault = (cause: unknown): void => { @@ -155,19 +212,30 @@ export async function driveRun(deps: DriveRunDeps): Promise(work: Promise): Promise => { - const timer = setTimeout( - () => - diagnostic( - 'cleanup_pending', - 'Run cleanup is still pending; input and run resources remain owned.', - ), - 250, - ); + /** + * Await `work`, and say on stderr when it has not settled 250 ms after it was asked to stop. Without `stopped` the + * work is cleanup from the start. A gate card is not: a person answering it takes as long as they take, and a line + * written under an open card throws off its redraws, so its notice waits for the card's abort. + */ + const joined = async (work: Promise, stopped?: AbortSignal): Promise => { + let timer: ReturnType | undefined; + const arm = (): void => { + timer ??= setTimeout( + () => + diagnostic( + 'cleanup_pending', + 'Run cleanup is still pending; input and run resources remain owned.', + ), + 250, + ); + }; + if (stopped === undefined || stopped.aborted) arm(); + else stopped.addEventListener('abort', arm, { once: true }); try { return await work; } finally { - clearTimeout(timer); + stopped?.removeEventListener('abort', arm); + if (timer !== undefined) clearTimeout(timer); } }; const retainUnacknowledged = async (): Promise => { @@ -264,6 +332,10 @@ export async function driveRun(deps: DriveRunDeps): Promise = { gate, controller }; + openCard = card; const recorded = budgetContexts.get(gate.gateId); try { const handled = await joined( @@ -376,6 +451,10 @@ export async function driveRun(deps: DriveRunDeps): Promise terminal.runPrompt(invoke), + decidedElsewhere: () => card.decided, + answered: (decision) => { + card.answered = { decision: decision.decision, decidedBy: decision.decidedBy }; + }, runViewOperation: (invoke, mayAcquire) => terminal.runViewOperation(invoke, mayAcquire), failed: recordFault, @@ -386,16 +465,21 @@ export async function driveRun(deps: DriveRunDeps): Promise ReturnType, ) => ReturnType; + /** How the gate was decided while its card was open, when something other than the card decided it. */ + readonly decidedElsewhere: () => string | undefined; + /** Record the decision the card returned, so the resumed event that carries it is not taken for another's. */ + readonly answered: (decision: GateDecision) => void; readonly failed: (cause: unknown) => void; readonly runViewOperation: ( invoke: () => void | Promise, @@ -528,8 +616,9 @@ async function promptUntilStopped( /** * Resolve an interactive human gate without leaving the live run: hand the terminal to the `@clack/prompts` - * card (suspend the TUI), collect a decision, then re-mount. A `null` decision (the user aborted the prompt - * with Ctrl-C / ESC) cooperatively cancels the whole run; otherwise the decision is applied to the in-memory + * card (suspend the TUI), collect a decision, then re-mount. A `null` decision (the user aborted the card with + * Ctrl-C / ESC at its approve or input step, or with Ctrl-C at the optional rejection reason, where Esc instead + * keeps the rejection) cooperatively cancels the whole run; otherwise the decision is applied to the in-memory * run via `engine.resume`, which emits `human_gate:resumed` and continues — both flow back through the loop. */ async function resolveGateInline( @@ -542,6 +631,17 @@ async function resolveGateInline( budget?: BudgetPromptContext, ): Promise { let decision: Awaited>; + let reported = false; + /** Say, once, that nothing from this terminal was recorded because something else decided the gate first. */ + const reportDecidedElsewhere = (): void => { + const decided = lifecycle.decidedElsewhere(); + if (reported || decided === undefined) return; + reported = true; + io.writeErr( + `Gate ${sanitizeInline(event.gateId)} was already decided (${sanitizeInline(decided)}); ` + + 'no answer from this terminal was recorded. Later gates in this run are not prompted here.\n', + ); + }; try { await lifecycle.runViewOperation(() => renderer.suspend?.(), false); } catch (error) { @@ -554,6 +654,9 @@ async function resolveGateInline( () => lifecycle.runPrompt(() => prompter.prompt(event, budget, lifecycle.signal)), lifecycle.signal, ); + if (decision !== null) lifecycle.answered(decision); + // Said while the terminal is still the prompt's, so it lands under the card rather than inside the live view. + reportDecidedElsewhere(); } catch (cause) { // Preserve the prompt failure before a competing restoration failure enters its finally. lifecycle.failed(cause); @@ -570,13 +673,19 @@ async function resolveGateInline( io.writeErr('The live view could not be restored after the gate prompt.\n'); } } - if (lifecycle.signal.aborted) return true; + if (lifecycle.signal.aborted) { + // The deadline may have decided the gate after the prompt returned, aborting this card too. + reportDecidedElsewhere(); + return true; + } if (decision === null) { lifecycle.requestCancellation(); return true; } try { await engine.resume(event.runId, event.gateId, decision); + // A resume the engine ignores because the gate is already decided is silent; say so if that is why. + reportDecidedElsewhere(); return true; } catch (err) { // A Ctrl-C during the prompt cooperatively cancels the run (the SIGINT handler calls handle.cancel()); diff --git a/apps/cli/src/commands/fixtures/legacy-command-manifest.json b/apps/cli/src/commands/fixtures/legacy-command-manifest.json index 2bee3d9a5..937c5f27e 100644 --- a/apps/cli/src/commands/fixtures/legacy-command-manifest.json +++ b/apps/cli/src/commands/fixtures/legacy-command-manifest.json @@ -273,12 +273,17 @@ { "id": "list", "label": "List workflows", - "description": "List discovered workflows (or, with --agents, agents) in the current project.", + "description": "List discovered workflows (or, with --agents, agents) in the current project, or with --runs every recorded run.", "args": [ { "name": "agents", "type": "boolean", "description": "list agents instead of workflows" + }, + { + "name": "runs", + "type": "boolean", + "description": "list recorded runs, newest first, instead of workflows" } ], "effect": "read" @@ -300,7 +305,14 @@ { "id": "status", "label": "Show status", - "description": "Show active/paused runs and their per-node status.", + "description": "Show active/paused runs, or one run by id, and their per-node status.", + "args": [ + { + "name": "runId", + "type": "string", + "description": "one run to show, active or finished (omit ⇒ every active run)" + } + ], "effect": "read" }, { diff --git a/apps/cli/src/commands/gate-list.test.ts b/apps/cli/src/commands/gate-list.test.ts index 9d79f6233..ee4c0b1f9 100644 --- a/apps/cli/src/commands/gate-list.test.ts +++ b/apps/cli/src/commands/gate-list.test.ts @@ -28,6 +28,31 @@ describe('gateListCommand', () => { return { io, global: globalOptions(json), openDb: () => ({ db, close: () => {} }) }; } + it('prints forged run, gate and node ids on one line, without their controls', async () => { + await seedRun(db, { + slug: 'a', + runId: 'r\u001b]0;PWNED\u0007', + state: 'paused', + gate: { gateId: 'g\u001b[2J\nFAKE', gateType: 'approval', message: 'A?', nodeId: 'n\u202e' }, + }); + const { io, out } = captureIo(); + expect(gateListCommand({}, deps(io))).toBe(EXIT_CODES.success); + const rows = out().trimEnd().split('\n'); + expect(rows).toHaveLength(2); // the heading and one row + expect(out()).toContain('FAKE'); + for (const control of ['\u001b', '\u0007', '\u202e']) expect(out()).not.toContain(control); + }); + + it('prints a forged run id on one line when that run has no pending gate', async () => { + // The id is typed by the user, but it names a history.db run, whose id has no CHECK behind it. + await seedRun(db, { slug: 'a', runId: 'r\u001b]0;PWNED\u0007', state: 'completed' }); + const { io, out } = captureIo(); + expect(gateListCommand({ runId: 'r\u001b]0;PWNED\u0007' }, deps(io))).toBe(EXIT_CODES.success); + expect(out()).toContain('has no pending human gate'); + expect(out()).not.toContain('\u001b'); + expect(out()).not.toContain('\u0007'); + }); + it('lists pending human gates across every paused run', async () => { const { io, out } = captureIo(); await seedRun(db, { diff --git a/apps/cli/src/commands/gate-list.ts b/apps/cli/src/commands/gate-list.ts index d703e0e04..2275ec6c2 100644 --- a/apps/cli/src/commands/gate-list.ts +++ b/apps/cli/src/commands/gate-list.ts @@ -133,8 +133,9 @@ function targetRunIds(reader: RunHistoryReader, runId: string | undefined): stri if (run === undefined) { throw new CliError('invalid_invocation', `no run found with id ${runId}`); } - // A non-paused run cannot hold a pending human gate — skip it (don't replay its whole event log only to - // find none), the same guard `statusCommand` applies. The empty result yields the clear "no pending" message. + // Only a `paused` run is read (don't replay a whole event log only to find no gate); the empty result yields the + // clear "no pending" message. `statusCommand` differs: it shows nothing for a finished run, but reads the gates of + // a `running` one too, which a crash between a gate's authority and its pause can leave holding one. return run.status === 'paused' ? [run.id] : []; } @@ -144,7 +145,7 @@ function renderGateRows(io: CliIo, rows: readonly GateRow[], runId: string | und io.writeOut( runId === undefined ? 'No pending human gates.\n' - : `Run ${runId} has no pending human gate.\n`, + : `Run ${sanitizeInline(runId)} has no pending human gate.\n`, ); return; } @@ -152,6 +153,10 @@ function renderGateRows(io: CliIo, rows: readonly GateRow[], runId: string | und for (const row of rows) { // Same untrusted `human_gate:paused.message` as `status.ts` — see the note there. const message = row.message === '' ? '' : ` "${sanitizeInline(row.message)}"`; - io.writeOut(` ${row.runId} ${row.gateId} ${row.gateType} node=${row.nodeId}${message}\n`); + // The ids are history.db values too, with no CHECK behind them, and this is a terminal write. + io.writeOut( + ` ${sanitizeInline(row.runId)} ${sanitizeInline(row.gateId)} ${row.gateType} ` + + `node=${sanitizeInline(row.nodeId)}${message}\n`, + ); } } diff --git a/apps/cli/src/commands/gate.test.ts b/apps/cli/src/commands/gate.test.ts index c6d4a4b4f..2d793a679 100644 --- a/apps/cli/src/commands/gate.test.ts +++ b/apps/cli/src/commands/gate.test.ts @@ -66,14 +66,14 @@ vi.mock('../mcp/consent-prompt.js', () => ({ * A WorkflowEngine stub for the closed-handle / EngineStateError paths that the real engine can't be driven * into deterministically (they need a concurrent-settle race). * - * `drainTerminalOutbox` is stubbed too because `gateCommand` calls it at start (ADR-0078 §4/§5). A stub that + * `reconcile` is stubbed too because `gateCommand` calls it at start (ADR-0078 §4/§5, 2.6.G). A stub that * omits it throws a `TypeError` the command's own error mapping then reports as an invocation fault — which * is how these two tests found the wiring rather than the wiring finding them. */ function stubEngine(resumeFromCheckpoint: WorkflowEngine['resumeFromCheckpoint']): WorkflowEngine { return { resumeFromCheckpoint, - drainTerminalOutbox: () => Promise.resolve([]), + reconcile: () => Promise.resolve([]), } as unknown as WorkflowEngine; } @@ -530,6 +530,42 @@ describe('gateCommand', () => { } }); + it('resumes a historical input gate that approves on timeout — refused for new authoring, never for a parked run', async () => { + const base = parseWorkflow(GATED, { source: 'gate.test' }); + const original: WorkflowDefinition = { + ...base, + workflow: { + ...base.workflow, + nodes: base.workflow.nodes.map((node) => + node.id === 'g' + ? { ...node, gate_type: 'input', timeout_ms: 86_400_000, timeout_action: 'approve' } + : node, + ), + }, + }; + const freshError = /an input gate cannot approve on timeout/; + expect(() => WorkflowSchema.parse(original)).toThrow(freshError); + expect(() => parseWorkflow(JSON.stringify(original), { source: 'gate.test' })).toThrow( + freshError, + ); + const definition = WorkflowSnapshotSchema.parse(original); + expect(definition).toEqual(original); + + const { runId } = await setupPausedWorkflow(definition, { n: 7 }); + expect(reader().loadRun(runId)?.status).toBe('paused'); + const snapshot = loadRunSnapshot(db, runId)?.workflowDefinitionSnapshot; + + const { io } = captureIo(); + expect(await gateCommand({ runId, input: '"us-east-1"' }, deps(io))).toBe(EXIT_CODES.success); + expect(reader().loadRun(runId)?.status).toBe('completed'); + expect( + reader() + .loadRunEvents(runId) + .filter((event) => event.type === 'human_gate:resumed'), + ).toEqual([expect.objectContaining({ runId, nodeId: 'g', decision: 'input_provided' })]); + expect(loadRunSnapshot(db, runId)?.workflowDefinitionSnapshot).toBe(snapshot); + }); + describe('secret re-supply (ADR-0083 §6)', () => { const SECRET = 'sk-live-not-in-the-log'; const seedSecretRun = (): Promise<{ runId: string; gateIds: string[] }> => @@ -1360,6 +1396,44 @@ workflow: }); }); + it("refuses a decision the gate's type does not accept before the engine is built, and keeps the run parked (2.6.K)", async () => { + const approval = await setupPausedRun(); + const input = await setupPausedRun(INPUT_GATED, {}); + for (const [runId, flags, hint] of [ + [approval.runId, { input: 'us-east-1' }, 'is an approval gate — use --approve or --reject'], + [input.runId, { approve: true }, 'is an input gate — use --input or --reject'], + ] as const) { + const { io } = captureIo(); + let built = false; + const caught: unknown = await gateCommand( + { runId, ...flags }, + { + ...deps(io), + buildEngine: (options) => { + built = true; + return buildEngine(options); + }, + }, + ).then( + () => undefined, + (error: unknown) => error, + ); + expect(isCliError(caught) && caught.code).toBe('invalid_invocation'); + expect(isCliError(caught) && caught.message).toContain(hint); + expect(built).toBe(false); + expect( + reader() + .loadRunEvents(runId) + .some((e) => e.type === 'human_gate:resumed'), + ).toBe(false); + } + // A rejection fits every human gate type. + const { io } = captureIo(); + expect(await gateCommand({ runId: input.runId, reject: true }, deps(io))).toBe( + EXIT_CODES.success, + ); + }); + it.each([false, true])( 'maps a closed-handle resume to an idempotent exit 0; json=%s', async (json) => { @@ -1602,6 +1676,17 @@ describe('selectGate', () => { } }); + it('prints an already-resolved gate id from the checkpoint on one line', () => { + const forged = 'g\u001b[2J\nFAKE'; + const sel = selectGate(checkpoint({ resolvedGateIds: [forged] }), 'paused', forged); + expect(sel.kind).toBe('idempotent'); + if (sel.kind === 'idempotent') { + expect(sel.message).toContain('FAKE'); + expect(sel.message).not.toContain('\u001b'); + expect(sel.message).not.toContain('\n'); + } + }); + it('--gate naming an already-resolved id is idempotent; an unknown id is invalid', () => { expect(selectGate(checkpoint({ resolvedGateIds: ['g1'] }), 'paused', 'g1').kind).toBe( 'idempotent', diff --git a/apps/cli/src/commands/gate.ts b/apps/cli/src/commands/gate.ts index 7abae0490..f7c6f7332 100644 --- a/apps/cli/src/commands/gate.ts +++ b/apps/cli/src/commands/gate.ts @@ -54,7 +54,7 @@ import { import { buildMediaEngineWiring } from '../engine/media-wiring.js'; import { readBudgetPricingOverlay, readUserPricingOverlay } from '../engine/pricing-overlay.js'; import { createProviderResolver, type ProviderResolver } from '../engine/providers.js'; -import { decisionFromFlags, type GateFlags } from '../gate/decision.js'; +import { assertDecisionFitsGateType, decisionFromFlags, type GateFlags } from '../gate/decision.js'; import { assertBudgetDecision, budgetDecisionFromFlags, @@ -388,6 +388,9 @@ async function resumeGateOwned( if (kind === 'budget') { const gate = checkpoint.pendingGates.find((entry) => entry.gateId === selection.gateId); assertBudgetDecision(gate?.allowance, decision); + } else { + const gate = checkpoint.pendingGates.find((entry) => entry.gateId === selection.gateId); + assertDecisionFitsGateType(workflow, gate?.nodeId, decision); } // Recorded priced quantities let stale rates refuse before secrets/key-resolver construction/MCP. // A match grants nothing; the later full preparation reads a fresh price snapshot again. @@ -560,10 +563,11 @@ async function resumeGateOwned( ...(resolvePrice.size === 0 ? {} : { resolvePrice }), }); lifetime.adoptEngine(engine, args.runId); - // Same drain as the `run` path (ADR-0078 §4/§5) — a gate resume is equally "the next `relavium` start", - // and it is the one a user reaches for after seeing the `durabilityUncertain` exit code on a gated run. + // Same reconcile as the `run` path (ADR-0078 §4/§5, ADR-0079 §7) — a gate resume is equally "the next + // `relavium` start", and it is the one a user reaches for after seeing the `durabilityUncertain` exit code + // on a gated run. The run this command resumes is parked at its gate, so `reconcile()` leaves it alone. lifetime.checkpoint(); - await engine.drainTerminalOutbox().catch(() => undefined); + await engine.reconcile().catch(() => undefined); lifetime.checkpoint(); const handle = await resumeOrFail(engine, { runId: args.runId, @@ -619,7 +623,7 @@ async function resumeGateOwned( // is the worst available answer: this process cannot report settlement, even when its earlier gate // decision is already durable, and an automation loop records success. The disposition separates them at no // cost: `createClosedRunHandle` reports `durable`, a fenced handle reports `uncertain`. - const notice = `run ${args.runId} already settled; nothing to resume\n`; + const notice = `run ${sanitizeInline(args.runId)} already settled; nothing to resume\n`; if (deps.global.json) deps.io.writeErr(notice); else deps.io.writeOut(notice); return EXIT_CODES.success; @@ -719,7 +723,7 @@ export function selectGate( if (resolved.has(requested)) { return { kind: 'idempotent', - message: `gate ${requested} already resolved`, + message: `gate ${sanitizeInline(requested)} already resolved`, }; } return { diff --git a/apps/cli/src/commands/list.test.ts b/apps/cli/src/commands/list.test.ts index 33a31eb73..624ebba1f 100644 --- a/apps/cli/src/commands/list.test.ts +++ b/apps/cli/src/commands/list.test.ts @@ -7,7 +7,7 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { EXIT_CODES } from '../process/exit-codes.js'; import type { GlobalOptions } from '../process/options.js'; -import { captureIo, parseNdjson, seedRun } from '../test-support.js'; +import { captureIo, forgeRunStatus, parseNdjson, seedRun } from '../test-support.js'; import type { CatalogEntry } from '../workflows/catalog.js'; import { listCommand, type ListCommandDeps } from './list.js'; @@ -65,6 +65,58 @@ describe('listCommand', () => { }, ]; + it('--runs lists every recorded run newest first with its workflow slug, outside any project (MVP-1)', async () => { + await seedRun(db, { slug: 'code-review', runId: 'r-old', state: 'completed', atMs: 1_000 }); + await seedRun(db, { slug: 'hello', runId: 'r-new', state: 'failed', atMs: 2_000 }); + const outside = mkdtempSync(join(tmpdir(), 'relavium-list-runs-')); + try { + const human = captureIo(); + expect( + listCommand({ agents: false, runs: true }, deps(human.io, { catalog: [], cwd: outside })), + ).toBe(EXIT_CODES.success); + const lines = human.out().trim().split('\n'); + expect(lines).toHaveLength(2); + expect(lines[0]).toMatch(/^r-new {2}failed {5}hello {2}/u); + expect(lines[1]).toMatch(/^r-old {2}completed {2}code-review {2}/u); + const machine = captureIo(); + listCommand( + { agents: false, runs: true }, + deps(machine.io, { catalog: [], cwd: outside, json: true }), + ); + const records = parseNdjson(machine.out()); + expect(records.map((record) => record['runId'])).toEqual(['r-new', 'r-old']); + expect(records[1]).toMatchObject({ + kind: 'runs', + workflow: 'code-review', + status: 'completed', + }); + expect(records[1]).not.toHaveProperty('totalCostMicrocents'); + } finally { + rmSync(outside, { recursive: true, force: true }); + } + }); + + it('--runs reports an empty history plainly', () => { + const { io, out } = captureIo(); + expect(listCommand({ agents: false, runs: true }, deps(io, { catalog: [] }))).toBe( + EXIT_CODES.success, + ); + expect(out()).toBe('No runs recorded.\n'); + }); + + it('prints a forged run status on one line, in --runs and in the catalog line', async () => { + await seedRun(db, { slug: 'code-review', runId: 'r-forged', state: 'completed' }); + forgeRunStatus(client, 'r-forged', 'completed\u001b[2J\nforged row'); + for (const runs of [true, false]) { + const { io, out } = captureIo(); + listCommand({ agents: false, runs }, deps(io, { catalog: runs ? [] : WORKFLOWS })); + const text = out(); + expect(text).toContain('forged row'); + expect(text).not.toContain('\u001b'); + expect(text).not.toContain('\nforged row'); + } + }); + it('groups workflows by tag and overlays last-run status from durable history', async () => { const { io, out } = captureIo(); await seedRun(db, { slug: 'code-review', runId: 'r1', state: 'completed' }); // last-run overlay @@ -154,6 +206,61 @@ describe('listCommand', () => { expect(text).not.toContain('\u202e'); // nor a Trojan-Source bidi override }); + it('SANITIZES every catalog name, tag and file-name slug before it reaches the terminal', () => { + // `/export` writes a session's title as `workflow.name`, and a cloned repository can commit any name, tag + // or file name: a window-title OSC, a screen clear or a bidi override must not reach the terminal through + // a workflow line, a tag header or an agent line. + const hostile = '\u001b]0;PWNED\u0007\u001b[2J\u202e'; + const workflows: CatalogEntry[] = [ + { + slug: 'fix-bug', + name: `fix ${hostile} bug`, + tags: [`ops${hostile}`], + path: '.relavium/workflows/fix-bug.relavium.yaml', + valid: true, + }, + { + slug: `stem${hostile}`, + name: undefined, + tags: [], + path: `.relavium/workflows/stem${hostile}.relavium.yaml`, + valid: false, + error: 'invalid: workflow.id', + }, + ]; + const agents: CatalogEntry[] = [ + { + slug: 'helper', + name: `Helper ${hostile}`, + tags: [], + path: '.relavium/agents/helper.agent.yaml', + valid: true, + }, + ]; + for (const [isAgents, catalog] of [ + [false, workflows], + [true, agents], + ] as const) { + const { io, out } = captureIo(); + listCommand({ agents: isAgents }, deps(io, { catalog })); + const text = out(); + expect(text).toContain(isAgents ? 'helper' : 'fix-bug'); + expect(text).not.toContain('\u001b'); + expect(text).not.toContain('\u0007'); + expect(text).not.toContain('\u202e'); + } + const { io, out } = captureIo(); + listCommand({ agents: false }, deps(io, { catalog: workflows })); + const lines = out().split('\n'); + expect(lines.some((line) => line.startsWith(' #ops'))).toBe(true); + expect(lines.some((line) => line.includes('— fix ') && line.endsWith(' bug [last: —]'))).toBe( + true, + ); + expect(lines.some((line) => line.startsWith(' stem') && line.includes('(invalid'))).toBe( + true, + ); + }); + it('does not borrow a real workflow last-run for an invalid entry sharing its slug', async () => { const { io, out } = captureIo(); // Seed a completed run for the real 'code-review' workflow. The catalog has a VALID entry ('hello', so the @@ -203,4 +310,39 @@ describe('listCommand', () => { rmSync(outside, { recursive: true, force: true }); } }); + + it('prints a directory name with control characters on one line, on stdout and under --json on stderr', () => { + const outside = mkdtempSync(join(tmpdir(), 'relavium-noproj-')); + const hostile = join(outside, 'x\u001b]0;PWNED\u0007\u001b[2J'); + mkdirSync(hostile); + try { + const human = captureIo(); + listCommand({ agents: false }, deps(human.io, { catalog: [], cwd: hostile })); + expect(human.out()).toContain('No .relavium/ project found from '); + expect(human.out()).not.toContain('\u001b'); + expect(human.out()).not.toContain('\u0007'); + const machine = captureIo(); + listCommand({ agents: false }, deps(machine.io, { catalog: [], cwd: hostile, json: true })); + expect(machine.err()).toContain('no .relavium/ project found from '); + expect(machine.err()).not.toContain('\u001b'); + expect(machine.err()).not.toContain('\u0007'); + } finally { + rmSync(outside, { recursive: true, force: true }); + } + }); + + it('--runs prints history identifiers with control characters on one line', async () => { + await seedRun(db, { + slug: 'wf\u001b[2J\u202e', + runId: 'r\u001b]0;PWNED\u0007', + state: 'completed', + }); + const { io, out } = captureIo(); + listCommand({ agents: false, runs: true }, deps(io, { catalog: [] })); + const text = out(); + expect(text).toMatch(/^r.*completed {2}wf/u); + expect(text).not.toContain('\u001b'); + expect(text).not.toContain('\u0007'); + expect(text).not.toContain('\u202e'); + }); }); diff --git a/apps/cli/src/commands/list.ts b/apps/cli/src/commands/list.ts index 0a3067550..fd5956c8b 100644 --- a/apps/cli/src/commands/list.ts +++ b/apps/cli/src/commands/list.ts @@ -8,13 +8,15 @@ import { EXIT_CODES, type ExitCode } from '../process/exit-codes.js'; import type { CliIo } from '../process/io.js'; import type { GlobalOptions } from '../process/options.js'; import { writeRecordLines } from '../render/records.js'; -import { sanitizeUntrustedInline } from '../render/sanitize.js'; +import { sanitizeInline, sanitizeUntrustedInline } from '../render/sanitize.js'; import { discoverCatalog, type CatalogEntry, type CatalogKind } from '../workflows/catalog.js'; import { openHistoryReader } from '../history/reader.js'; export interface ListCommandArgs { /** `--agents`: list the agent catalog instead of the workflow catalog. */ readonly agents: boolean; + /** `--runs`: list every recorded run, newest first, instead of a catalog (MVP-1). */ + readonly runs?: boolean; } export interface ListCommandDeps { @@ -48,12 +50,20 @@ export function listCommand(args: ListCommandArgs, deps: ListCommandDeps): ExitC }); const json = deps.global.json; + // Run history lives in the home directory, not the project, so `--runs` needs no `.relavium/`. + if (args.runs === true) { + listRuns(deps, homeDir); + return EXIT_CODES.success; + } + if (projectConfigDir === undefined) { - // Not in a project — clearly reported (2.B acceptance), not an error. stdout stays pure under --json. + // Not in a project — clearly reported (2.B acceptance), not an error. stdout stays pure under --json. A + // directory name can hold control characters (a cloned repository's), so the path is printed one-line. + const cwd = sanitizeInline(deps.global.cwd); if (json) { - deps.io.writeErr(`no .relavium/ project found from ${deps.global.cwd}\n`); + deps.io.writeErr(`no .relavium/ project found from ${cwd}\n`); } else { - deps.io.writeOut(`No .relavium/ project found from ${deps.global.cwd}.\n`); + deps.io.writeOut(`No .relavium/ project found from ${cwd}.\n`); } return EXIT_CODES.success; } @@ -101,6 +111,58 @@ export function listCommand(args: ListCommandArgs, deps: ListCommandDeps): ExitC return EXIT_CODES.success; } +/** + * `relavium list --runs` — every recorded run, newest first (`listRuns`), each with its workflow's slug where + * history knows it. No amounts: a total needs its usage coverage qualified (ADR-0104), which `status ` + * reads per run; a listing replays no event log. + */ +function listRuns(deps: ListCommandDeps, homeDir: string): void { + const { reader, close } = acquireCommandResource(deps.commandLifetime, (captureClose) => + openHistoryReader(homeDir, deps.openDb, captureClose, { + io: deps.io, + invocation: deps.commandLifetime?.invocation, + }), + ); + try { + // Every workflow with a run appears once in the latest-run pick, which gives each run's slug. + const slugById = new Map( + reader + .loadLatestRunPerWorkflow() + .map((summary): [string, string] => [summary.lastRun.workflowId, summary.slug]), + ); + const runs = reader.listRuns(); + if (deps.global.json) { + writeRecordLines( + deps.io, + runs.map((run) => ({ + kind: 'runs', + runId: run.id, + workflowId: run.workflowId, + workflow: slugById.get(run.workflowId) ?? null, + status: run.status, + startedAt: run.startedAt ?? null, + completedAt: run.completedAt ?? null, + })), + ); + return; + } + if (runs.length === 0) { + deps.io.writeOut('No runs recorded.\n'); + return; + } + for (const run of runs) { + const workflow = sanitizeUntrustedInline(slugById.get(run.workflowId) ?? run.workflowId); + const started = run.startedAt ?? run.createdAt; + // The run id is read back from history.db: one line, controls stripped, never masked (it is copied). + deps.io.writeOut( + `${sanitizeInline(run.id)} ${sanitizeInline(run.status).padEnd(9)} ${workflow} ${started}\n`, + ); + } + } finally { + if (deps.commandLifetime === undefined) close(); + } +} + /** One catalog entry as a machine record: identity + path + validity, plus the last-run overlay for workflows. */ function toJson(entry: CatalogEntry, kind: CatalogKind, last: RunRecord | undefined): unknown { return { @@ -164,7 +226,8 @@ function renderHuman( return byCodeUnit(a, b); }); for (const tag of tags) { - const header = tag === UNTAGGED ? UNTAGGED : `#${tag}`; + // An authored tag is artifact text, sanitized at the sink like every catalog field this listing prints. + const header = tag === UNTAGGED ? UNTAGGED : `#${sanitizeUntrustedInline(tag)}`; io.writeOut(` ${header}\n`); for (const entry of byTag.get(tag) ?? []) { // `?? null` so a valid never-run workflow (and any invalid entry, which never gets an overlay) shows @@ -174,14 +237,21 @@ function renderHuman( } } -/** A single catalog line: `slug — name [last: status] (invalid: reason)`. `last` is omitted for agents. */ +/** + * A single catalog line: `slug — name [last: status] (invalid: reason)`. `last` is omitted for agents. + * + * Every field read from a catalog file is **sanitized at the sink**, as `relavium list --runs` does for a workflow + * id. An invalid entry's slug is its file name, and a name is free authored text: `/export` writes a session's + * title there, and a cloned repository can commit any name, tag or file name. This line goes to + * `process.stdout.write` with no `renderError` boundary in front of it. + */ function entryLine(entry: CatalogEntry, last: RunRecord | undefined | null): string { - const parts = [entry.slug]; + const parts = [sanitizeUntrustedInline(entry.slug)]; if (entry.name !== undefined && entry.name !== entry.slug) { - parts.push(`— ${entry.name}`); + parts.push(`— ${sanitizeUntrustedInline(entry.name)}`); } if (last !== undefined) { - parts.push(`[last: ${last === null ? '—' : last.status}]`); + parts.push(`[last: ${last === null ? '—' : sanitizeInline(last.status)}]`); } if (!entry.valid) { // **Sanitized at the SINK.** A parse reason is artifact-derived by definition — it names fields an author diff --git a/apps/cli/src/commands/logs.test.ts b/apps/cli/src/commands/logs.test.ts index 9ee21da7e..68bdc0cc3 100644 --- a/apps/cli/src/commands/logs.test.ts +++ b/apps/cli/src/commands/logs.test.ts @@ -12,7 +12,7 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { isCliError } from '../process/errors.js'; import { EXIT_CODES } from '../process/exit-codes.js'; import type { GlobalOptions } from '../process/options.js'; -import { captureIo, parseNdjson, seedRun } from '../test-support.js'; +import { captureIo, forgeRunStatus, parseNdjson, seedRun } from '../test-support.js'; import { logsCommand, type LogsCommandDeps } from './logs.js'; function globalOptions(json = false): GlobalOptions { @@ -49,6 +49,27 @@ describe('logsCommand', () => { } }); + it('prints a forged run id on one line in its header', async () => { + // `logs` looks the run up by the id the user typed, so a stored id with an escape is reached only by typing it. + const forgedId = 'run\u001b[2J\nforged'; + await seedRun(db, { slug: 'demo', runId: forgedId, state: 'completed' }); + const { io, out } = captureIo(); + logsCommand({ runId: forgedId }, deps(io)); + const header = out().split('\n')[0] ?? ''; + expect(header).toContain('forged'); + expect(header).not.toContain('\u001b'); + }); + + it('prints a forged run status on one line in its header', async () => { + await seedRun(db, { slug: 'demo', runId: 'run-forged', state: 'completed' }); + forgeRunStatus(client, 'run-forged', 'completed\u001b[2J\nforged row'); + const { io, out } = captureIo(); + logsCommand({ runId: 'run-forged' }, deps(io)); + const header = out().split('\n')[0] ?? ''; + expect(header).toContain('forged row'); + expect(header).not.toContain('\u001b'); + }); + it('prints a terse human line per persisted event in seq order', async () => { const { io, out } = captureIo(); await seedRun(db, { slug: 'demo', runId: 'run-1', state: 'completed' }); @@ -131,6 +152,29 @@ describe('logsCommand', () => { } }); + it('prints a forged skipped-row seq on one line, without its controls', async () => { + // `run_events.seq` is an integer column with no CHECK, so SQLite stores text there as it is given. + await seedRun(db, { slug: 'demo', runId: 'run-1', state: 'completed' }); + client.sqlite + .prepare( + 'INSERT INTO run_events (id, run_id, seq, event_type, payload_json, ts) VALUES (?, ?, ?, ?, ?, ?)', + ) + .run( + '00000000-0000-4000-8000-0000000000fe', + 'run-1', + '9\u001b]0;PWNED\u0007\u001b[2J\nFAKE: all clear', + 'test:never_a_real_event', + JSON.stringify({ type: 'test:never_a_real_event' }), + 0, + ); + const { io, err } = captureIo(); + expect(logsCommand({ runId: 'run-1' }, deps(io))).toBe(EXIT_CODES.success); + expect(err()).toContain('was skipped (seq 9'); + expect(err()).not.toContain('\u001b'); + expect(err()).not.toContain('\u0007'); + expect(err().trimEnd().split('\n')).toHaveLength(1); + }); + it('says nothing when the log is clean — no note on the happy path', async () => { await seedRun(db, { slug: 'demo', runId: 'run-1', state: 'completed' }); const { io, err } = captureIo(); diff --git a/apps/cli/src/commands/logs.ts b/apps/cli/src/commands/logs.ts index 44ae981e5..a21e4297b 100644 --- a/apps/cli/src/commands/logs.ts +++ b/apps/cli/src/commands/logs.ts @@ -1,6 +1,6 @@ import type { LocalDbNoticeContext } from '../db/checkpoint-notice.js'; import { acquireCommandResource, type CommandLifetime } from '../process/command-lifetime.js'; -import type { Db } from '@relavium/db'; +import type { Db, RunRecord, SkippedRunEvent } from '@relavium/db'; import type { RunEvent } from '@relavium/shared'; import { loadResolvedConfig } from '../config/load.js'; @@ -9,6 +9,8 @@ import { EXIT_CODES, type ExitCode } from '../process/exit-codes.js'; import type { CliIo } from '../process/io.js'; import type { GlobalOptions } from '../process/options.js'; import { writeRecordLines } from '../render/records.js'; +import { formatRunOutputs } from '../render/run-outputs.js'; +import { sanitizeInline } from '../render/sanitize.js'; import { openHistoryReader } from '../history/reader.js'; export interface LogsCommandArgs { @@ -58,36 +60,57 @@ export function logsCommand(args: LogsCommandArgs, deps: LogsCommandDeps): ExitC // column would otherwise present a short log with a hole in its sequence as a complete one, and // `sse-event-schema.md` §Transport tells consumers to diagnose exactly that as data loss. It goes to // STDERR in both modes, so `--json`'s stdout stays a pure NDJSON `RunEvent` stream (the flag's contract). - if (skipped.length > 0) { - // BOUNDED, like the fixed native background-diagnostic bound: a run replayed under a much newer binary could - // skip thousands of rows, and one unbounded line of comma-separated integers is not a diagnostic. The count - // is the signal; the first few numbers are the lead. - const shown = skipped.slice(0, MAX_REPORTED_SKIPS).map((row) => row.sequenceNumber); - const seqs = skipped.length > shown.length ? `${shown.join(', ')}, …` : shown.join(', '); - const one = skipped.length === 1; - deps.io.writeErr( - `note: ${skipped.length} event${one ? '' : 's'} written by a newer version of Relavium ` + - `${one ? 'was' : 'were'} skipped (seq ${seqs}). Upgrade to read ${one ? 'it' : 'them'}.\n`, - ); - } + writeSkippedNote(deps.io, skipped); if (deps.global.json) { writeRecordLines(deps.io, events); // raw RunEvent per line — identical to the `run --json` stream return EXIT_CODES.success; } - deps.io.writeOut( - `run ${run.id} — ${run.status} (${events.length} event${events.length === 1 ? '' : 's'})\n`, - ); - for (const event of events) { - deps.io.writeOut(`${formatLogLine(event)}\n`); - } + writeHumanLog(deps.io, run, events); return EXIT_CODES.success; } finally { if (deps.commandLifetime === undefined) close(); } } +/** The stderr note for rows a newer binary wrote (ADR-0074 §5); nothing when no row was skipped. */ +function writeSkippedNote(io: CliIo, skipped: readonly SkippedRunEvent[]): void { + if (skipped.length === 0) return; + // BOUNDED, like the fixed native background-diagnostic bound: a run replayed under a much newer binary could + // skip thousands of rows, and one unbounded line of comma-separated integers is not a diagnostic. The count + // is the signal; the first few numbers are the lead. Each is read back from history.db, whose `seq` column takes + // any text, so it gets the one-line floor too. + const shown = skipped + .slice(0, MAX_REPORTED_SKIPS) + .map((row) => sanitizeInline(String(row.sequenceNumber))); + const seqs = skipped.length > shown.length ? `${shown.join(', ')}, …` : shown.join(', '); + const one = skipped.length === 1; + io.writeErr( + `note: ${skipped.length} event${one ? '' : 's'} written by a newer version of Relavium ` + + `${one ? 'was' : 'were'} skipped (seq ${seqs}). Upgrade to read ${one ? 'it' : 'them'}.\n`, + ); +} + +/** The human rendering: a header line, one row per event, and the workflow's outputs under the terminal row. */ +function writeHumanLog(io: CliIo, run: RunRecord, events: readonly RunEvent[]): void { + // Both are read back from history.db, the trust boundary, so each gets the one-line floor the rows below get. + io.writeOut( + `run ${sanitizeInline(run.id)} — ${sanitizeInline(run.status)} (${events.length} event${events.length === 1 ? '' : 's'})\n`, + ); + for (const event of events) { + // One row per event: the line carries stored fields (a gate id, `decidedBy`, a node id), so it gets the + // one-line floor before it reaches the terminal, as the plain run renderer does. + io.writeOut(`${sanitizeInline(formatLogLine(event))}\n`); + // The workflow's outputs under the terminal row (MVP-A1), bounded and sanitized by the shared formatter. + if (event.type === 'run:completed') { + for (const line of formatRunOutputs(event.outputs, ' ', run.id)) { + io.writeOut(`${line}\n`); + } + } + } +} + /** A terse one-line human rendering of a durable event: `[] `. */ function formatLogLine(event: RunEvent): string { const head = `[${event.sequenceNumber}] ${event.type}`; diff --git a/apps/cli/src/commands/run.test.ts b/apps/cli/src/commands/run.test.ts index f004b8893..01c284656 100644 --- a/apps/cli/src/commands/run.test.ts +++ b/apps/cli/src/commands/run.test.ts @@ -1297,6 +1297,50 @@ describe('runCommand', () => { expect(engineBuilt).toBe(false); }); + it('refuses a grant the run path cannot serve before any key is read or the engine built (2.6.A)', async () => { + const path = writeWorkflow( + 'agent-search.relavium.yaml', + AGENT_WF.replace('id: cli-run-agent', 'id: cli-run-agent-search').replace( + 'system_prompt: inspect }', + "system_prompt: inspect, tools: ['read_file', 'web_search'] }", + ), + ); + const { io } = captureIo(); + let keysRead = 0; + let engineBuilt = false; + let caught: unknown; + try { + await runCommand( + { workflow: path, input: [], allowMcpStdio: [] }, + { + io, + global: globalOptions(), + resolveKeys: () => { + keysRead += 1; + return { + providers: createProviderResolver({ RELAVIUM_ANTHROPIC_API_KEY: 'present' }), + mcpSecretResolver: vi.fn(() => 'unused'), + }; + }, + buildEngine: () => { + engineBuilt = true; + return Promise.reject(new Error('the engine must not be built')); + }, + }, + ); + } catch (err) { + caught = err; + } + expect(isCliError(caught)).toBe(true); + if (isCliError(caught)) { + expect(caught.code).toBe('invalid_invocation'); + expect(caught.message).toContain('node `a`.tools'); + expect(caught.message).toContain('web_search needs a host capability'); + } + expect(keysRead).toBe(0); + expect(engineBuilt).toBe(false); + }); + it('names the matching env var for a non-anthropic primary provider (gemini)', async () => { const path = writeWorkflow('agent-gemini.relavium.yaml', AGENT_WF_GEMINI); const { io } = captureIo(); // io.env = {} → no RELAVIUM_GEMINI_API_KEY diff --git a/apps/cli/src/commands/run.ts b/apps/cli/src/commands/run.ts index 3466340f9..3a8802a22 100644 --- a/apps/cli/src/commands/run.ts +++ b/apps/cli/src/commands/run.ts @@ -23,8 +23,10 @@ import { warnSnapshotLiterals } from '../authoring/snapshot-notice.js'; import { loadResolvedConfig } from '../config/load.js'; import { buildEngine as defaultBuildEngine, + RUN_TOOL_PROFILE, type BuildEngineOptions, } from '../engine/build-engine.js'; +import { unwiredToolArms } from '../engine/tool-host/assemble.js'; import { onceEffortNotice, unpricedModelNote } from '../chat/effort-notice.js'; import { createEffectJournalPort, @@ -76,6 +78,7 @@ import { import { resolveWorkflowSource } from '../workflows/resolve.js'; import { assertWorkflowCatalogValid, + assertWorkflowToolArmsWired, driveRun, isTerminalOutcome, outcomeToExitCode, @@ -235,6 +238,8 @@ async function runOwned( } const inputs = admitted.inputs; warnSnapshotLiterals(def, deps.io); + // Before any key is resolved: a grant this path cannot serve is refused with nothing read (2.6.A, MVP-1). + assertWorkflowToolArmsWired(def, unwiredToolArms(RUN_TOOL_PROFILE)); lifetime.checkpoint(); const keys = deps.resolveKeys?.(); lifetime.checkpoint(); @@ -466,13 +471,14 @@ async function runOwned( const engine = await build(engineOptions); lifetime.adoptEngine(engine); lifetime.checkpoint(); - // **Drain the terminal outbox before starting (ADR-0078 §4/§5).** A prior process may have produced a - // terminal its store would not take; it is held in `~/.relavium/terminal-outbox.ndjson` and this is - // "the next `relavium` start" that the `durabilityUncertain` exit code tells the user to wait for. Draining - // rather than `reconcile()`: this writes only terminals the engine itself already produced, for runs whose - // log still lacks one, and switches on nothing else. Best-effort — a run that cannot start because an - // unrelated run's terminal could not be retried would be the wrong trade. - await engine.drainTerminalOutbox().catch(() => undefined); + // **Reconcile before starting (ADR-0078 §4/§5, ADR-0079 §7; 2.6.G liveness).** This is "the next + // `relavium` start" that the `durabilityUncertain` exit code tells the user to wait for: `reconcile()` + // first drains the terminal outbox (`~/.relavium/terminal-outbox.ndjson`), then settles every run a dead + // process left `running` as `run:failed` (`effect_needs_attention` for an unresolved external effect, else + // `budget_exceeded` after a recorded budget rejection, else `internal`). It leaves alone a run parked at a + // gate, a run younger than one lease TTL and a run whose lease is live — another process is executing it. Best-effort — a run that cannot start because an unrelated run could not be repaired + // would be the wrong trade. + await engine.reconcile().catch(() => undefined); // The AUGMENTED workflow — the same one the store froze above, so the durable snapshot and the executed // graph are one thing rather than two that happen to be close. // `toolGrantsFinal`: the MCP servers were connected and the workflow rewritten above, so every inline diff --git a/apps/cli/src/commands/status.test.ts b/apps/cli/src/commands/status.test.ts index 5cc19eb24..0f0f14b99 100644 --- a/apps/cli/src/commands/status.test.ts +++ b/apps/cli/src/commands/status.test.ts @@ -3,7 +3,7 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { EXIT_CODES } from '../process/exit-codes.js'; import type { GlobalOptions } from '../process/options.js'; -import { captureIo, parseNdjson, seedRun } from '../test-support.js'; +import { captureIo, forgeRunStatus, parseNdjson, seedRun } from '../test-support.js'; import { statusCommand, type StatusCommandDeps } from './status.js'; function globalOptions(json = false): GlobalOptions { @@ -27,6 +27,234 @@ describe('statusCommand', () => { return { io, global: globalOptions(json), openDb: () => ({ db, close: () => {} }) }; } + it('prints a forged run or step status on one line', async () => { + await seedRun(db, { slug: 'wf', runId: 'forged-1', state: 'completed' }); + forgeRunStatus(client, 'forged-1', 'completed\u001b[2J\nforged row'); + const { io, out } = captureIo(); + await statusCommand( + { ...deps(io), readTerminalOutbox: () => Promise.resolve([]) }, + { runId: 'forged-1' }, + ); + const text = out(); + expect(text).toContain('forged row'); + expect(text).not.toContain('\u001b'); + expect(text).not.toContain('\nforged row'); + }); + + it('prints the attempt of a step that ran more than once', async () => { + await seedRun(db, { slug: 'wf', runId: 'retried-1', state: 'completed' }); + client.sqlite + .prepare('UPDATE step_executions SET attempt_number = 3 WHERE run_id = ?') + .run('retried-1'); + const { io, out } = captureIo(); + await statusCommand( + { ...deps(io), readTerminalOutbox: () => Promise.resolve([]) }, + { runId: 'retried-1' }, + ); + expect(out()).toContain('(attempt 3)'); + }); + + it('prints no attempt for a forged attempt number that is not an integer', async () => { + await seedRun(db, { slug: 'wf', runId: 'forged-2', state: 'completed' }); + // Text SQLite keeps as text in the integer column, which JavaScript still reads as 2. + client.sqlite + .prepare('UPDATE step_executions SET attempt_number = ? WHERE run_id = ?') + .run('\u30002\u000c', 'forged-2'); + const { io, out } = captureIo(); + await statusCommand( + { ...deps(io), readTerminalOutbox: () => Promise.resolve([]) }, + { runId: 'forged-2' }, + ); + expect(out()).toContain('n1'); + expect(out()).not.toContain('attempt'); + expect(out()).not.toContain('\u000c'); + }); + + it('shows one run by id, finished or active, and refuses an unknown id (MVP-1)', async () => { + await seedRun(db, { slug: 'done', runId: 'done-1', state: 'completed' }); + await seedRun(db, { slug: 'live', runId: 'live-1', state: 'running' }); + const noOutbox = { readTerminalOutbox: () => Promise.resolve([]) }; + const finished = captureIo(); + expect(await statusCommand({ ...deps(finished.io), ...noOutbox }, { runId: 'done-1' })).toBe( + EXIT_CODES.success, + ); + expect(finished.out()).toContain('run done-1 — completed'); + expect(finished.out()).not.toContain('live-1'); + const active = captureIo(); + await statusCommand({ ...deps(active.io, true), ...noOutbox }, { runId: 'live-1' }); + const records = parseNdjson(active.out()); + expect(records).toHaveLength(1); + expect(records[0]).toMatchObject({ runId: 'live-1', status: 'running' }); + // Without an id the listing is unchanged: active runs only. + const listing = captureIo(); + await statusCommand({ ...deps(listing.io), ...noOutbox }); + expect(listing.out()).toContain('run live-1 — running'); + expect(listing.out()).not.toContain('done-1'); + await expect( + statusCommand({ ...deps(captureIo().io), ...noOutbox }, { runId: 'missing' }), + ).rejects.toMatchObject({ code: 'invalid_invocation' }); + }); + + it('shows nothing pending for a run that ended while a gate waited, and marks its open step stopped (MVP-1)', async () => { + // Esc at an inline gate prompt cancels the run without resolving the gate, so its log still holds the open + // human and budget gates and the gate node's open step row. `gate` and `budget resume` answer a finished run + // with an exit-0 `nothing to resume`. + await seedRun(db, { + slug: 'demo', + runId: 'cancelled-1', + state: 'paused', + gate: { gateId: 'gate-1', gateType: 'approval', message: 'ship it?' }, + budgetGateId: 'budget-1', + cancelAfterPause: true, + }); + const noOutbox = { readTerminalOutbox: () => Promise.resolve([]) }; + const human = captureIo(); + await statusCommand({ ...deps(human.io), ...noOutbox }, { runId: 'cancelled-1' }); + const text = human.out(); + expect(text).toContain('run cancelled-1 — cancelled'); + expect(text).toContain('stopped g [human_in_the_loop]'); + expect(text).not.toContain('pending'); + expect(text).not.toContain('budget resume'); + const machine = captureIo(); + await statusCommand({ ...deps(machine.io, true), ...noOutbox }, { runId: 'cancelled-1' }); + const [record] = parseNdjson<{ steps: readonly { nodeId: string; status: string }[] }>( + machine.out(), + ); + expect(record).toMatchObject({ + status: 'cancelled', + pendingGates: [], + pendingBudgetGates: [], + }); + // `stopped` is the human rendering only; the machine record keeps the status history.db recorded. + expect(record?.steps.find((step) => step.nodeId === 'g')?.status).toBe('running'); + }); + + it('shows a decided gate as completed, though no node:completed closes its step row (MVP-1)', async () => { + // `relavium gate --approve` resolves the gate and the run completes, but the projection leaves the gate node's + // step row `running`. The engine marks the vertex completed, so `status` does too. + await seedRun(db, { + slug: 'demo', + runId: 'decided-1', + state: 'paused', + gate: { gateId: 'gate-1', gateType: 'approval', message: 'ship it?' }, + decideAfterPause: true, + }); + const noOutbox = { readTerminalOutbox: () => Promise.resolve([]) }; + const human = captureIo(); + await statusCommand({ ...deps(human.io), ...noOutbox }, { runId: 'decided-1' }); + const text = human.out(); + expect(text).toContain('run decided-1 — completed'); + expect(text).toContain(' completed g [human_in_the_loop]\n'); + expect(text).not.toContain('stopped'); + const machine = captureIo(); + await statusCommand({ ...deps(machine.io, true), ...noOutbox }, { runId: 'decided-1' }); + const [record] = parseNdjson<{ steps: readonly { nodeId: string; status: string }[] }>( + machine.out(), + ); + // The machine record keeps the status history.db recorded. + expect(record?.steps.find((step) => step.nodeId === 'g')?.status).toBe('running'); + }); + + it('raises no gate warning for a finished run whose log is damaged: nothing was pending to lose (MVP-1)', async () => { + await seedRun(db, { + slug: 'demo', + runId: 'decided-3', + state: 'paused', + gate: { gateId: 'gate-1', gateType: 'approval', message: 'ship it?' }, + decideAfterPause: true, + }); + client.db + .insert(runEvents) + .values({ + id: '00000000-0000-4000-8000-0000000000de', + runId: 'decided-3', + seq: 900, + eventType: 'node:started', + payloadJson: JSON.stringify({ type: 'node:started', nodeId: 42 }), + ts: 0, + }) + .run(); + const noOutbox = { readTerminalOutbox: () => Promise.resolve([]) }; + const human = captureIo(); + await statusCommand({ ...deps(human.io), ...noOutbox }, { runId: 'decided-3' }); + expect(human.out()).toContain('usage coverage unavailable'); + expect(human.out()).not.toContain('pending gates unavailable'); + const machine = captureIo(); + await statusCommand({ ...deps(machine.io, true), ...noOutbox }, { runId: 'decided-3' }); + expect(parseNdjson(machine.out())[0]).not.toHaveProperty('gatesUnavailable'); + }); + + it('relabels only a gate node: another open step under a finished run stays stopped (MVP-1)', async () => { + // A budget gate's decision names its agent node, which can still be open when the run ends. + await seedRun(db, { + slug: 'demo', + runId: 'decided-2', + state: 'paused', + gate: { gateId: 'gate-1', gateType: 'approval', message: 'ship it?' }, + decideAfterPause: true, + }); + client.sqlite + .prepare("UPDATE step_executions SET node_type = 'agent' WHERE run_id = 'decided-2'") + .run(); + const { io, out } = captureIo(); + await statusCommand( + { ...deps(io), readTerminalOutbox: () => Promise.resolve([]) }, + { runId: 'decided-2' }, + ); + expect(out()).toContain(' stopped g [agent]\n'); + }); + + it('says no node ran for a finished run with no step, rather than that none was recorded yet (MVP-1)', async () => { + await seedRun(db, { slug: 'demo', runId: 'empty-1', state: 'failed' }); + client.sqlite.prepare("DELETE FROM step_executions WHERE run_id = 'empty-1'").run(); + const { io, out } = captureIo(); + await statusCommand( + { ...deps(io), readTerminalOutbox: () => Promise.resolve([]) }, + { runId: 'empty-1' }, + ); + expect(out()).toContain(' (no node ran)'); + }); + + it('prints every history identifier on one line, terminal controls stripped (MVP-1)', async () => { + // history.db is the trust boundary: a hand-edited or crafted file must not repaint the terminal through + // `status`. The run id, the workflow, a step's node id and type, and a pending gate's id and node all come + // from it. + const attack = '\u001b]0;PWNED\u0007\u202e'; + await seedRun(db, { + slug: `wf${attack}`, + runId: `r${attack}`, + state: 'paused', + gate: { + gateId: `gate${attack}`, + gateType: 'approval', + message: 'ship it?', + nodeId: `x${attack}`, + }, + }); + client.sqlite + .prepare('UPDATE step_executions SET node_id = ?, node_type = ?') + .run(`n${attack}`, `t${attack}`); + // The workflow id the header names is the row's foreign key, so only a crafted file carries a hostile one. + client.sqlite.pragma('foreign_keys = OFF'); + client.sqlite.prepare('UPDATE runs SET workflow_id = ?').run(`w${attack}`); + client.sqlite.pragma('foreign_keys = ON'); + const { io, out } = captureIo(); + await statusCommand( + { ...deps(io), readTerminalOutbox: () => Promise.resolve([]) }, + { runId: `r${attack}` }, + ); + const text = out(); + expect(text).toMatch(/^run r.* — paused \(workflow w/u); + expect(text).toMatch(/pending gate gate.* \(approval\) at x/u); + expect(text).toMatch(/\[t[^\]]*\]/u); // the step row printed, with its type + // A recorded identifier that is not shell-inert never becomes a copyable command. + expect(text).toContain( + ' Use relavium gate with the recorded run and gate IDs and --approve or --reject.\n', + ); + expect(text).not.toContain('relavium gate r'); + for (const control of ['\u001b', '\u0007', '\u202e']) expect(text).not.toContain(control); + }); + it('keeps healthy status beside a future event and explicitly marks unavailable usage in human and JSON output', async () => { await seedRun(db, { slug: 'healthy', runId: 'healthy', state: 'running' }); await seedRun(db, { slug: 'future', runId: 'future', state: 'running' }); @@ -80,10 +308,9 @@ describe('statusCommand', () => { it('NAMES a run whose terminal is held in the outbox — the exit-5 state (ADR-0078 §4/§5)', async () => { // A run in this state is still `running` in the derived projection, so it listed here as an ordinary - // active run with nothing saying its outcome was already known. Exit 5 tells a script to "re-check - // `relavium status ` after a subsequent invocation" — and only `run` and `gate` drain the outbox, - // because only they construct a `WorkflowEngine`. A user who does the natural thing and asks for status - // again saw the same stale truth forever. This is what makes the documented remedy discoverable. + // active run with nothing saying its outcome was already known. Exit 5 tells a script to read + // `relavium status ` — and status itself never drains the outbox. A user who does the natural thing + // and asks for status again saw the same stale truth. This is what makes the documented remedy discoverable. const { io, out } = captureIo(); await seedRun(db, { slug: 'demo', runId: 'running-1', state: 'running' }); await statusCommand({ @@ -106,6 +333,37 @@ describe('statusCommand', () => { expect(out()).toContain('relavium run'); }); + it.each([false, true])( + 'does not call a run already recorded failed "held" — the reconcile race leaves a stale entry (json=%s)', + async (json) => { + // Exit 5's race: the reconciler's run:failed is durable and the run's own refused terminal is in the + // outbox too. The next drain DROPS that entry; "finished, not durable yet" would send a script waiting. + const { io, out } = captureIo(); + await seedRun(db, { slug: 'raced', runId: 'raced-1', state: 'failed' }); + await statusCommand( + { + ...deps(io, json), + readTerminalOutbox: () => + Promise.resolve([ + { + type: 'run:completed', + runId: 'raced-1', + sequenceNumber: 9, + timestamp: '2026-01-01T00:00:00.000Z', + outputs: {}, + totalTokensUsed: { input: 1, output: 1 }, + totalCostMicrocents: 1, + durationMs: 1, + }, + ]), + }, + { runId: 'raced-1' }, + ); + if (json) expect(out()).toContain('"terminalHeld":false'); + else expect(out()).not.toContain('FINISHED'); + }, + ); + it.each([false, true])( 'warns without failing or leaking an unreadable outbox (json=%s)', async (json) => { @@ -181,10 +439,45 @@ describe('statusCommand', () => { expect(text).toContain('run paused-1 — paused'); expect(text).toContain('n1 [transform]'); // the completed node step expect(text).toContain('g [human_in_the_loop]'); // the gate node still 'running' - expect(text).toContain('pending gate gate-1 (approval) at g — "ship it?"'); // the gate message is shown + expect(text).toContain( + ' ⏸ pending gate gate-1 (approval) at g — "ship it?"\n' + + ' approve: relavium gate paused-1 --gate gate-1 --approve\n' + + ' reject: relavium gate paused-1 --gate gate-1 --reject\n', + ); // the gate message is shown, then the commands that continue it expect(text).not.toContain('run done'); // a terminal run is not active }); + it('prints the command that continues each pending human gate, by its type (MVP-1)', async () => { + // A resumed run's final summary cannot classify a gate its engine restored from the checkpoint and sends the + // user here for the command, so every pending human gate gets one — an input gate takes its value or a reject. + await seedRun(db, { + slug: 'demo', + runId: 'fan-1', + state: 'paused', + gates: [ + { gateId: 'review-1', gateType: 'review', message: 'look' }, + { gateId: 'input-1', gateType: 'input', message: 'name?' }, + ], + }); + const { io, out } = captureIo(); + await statusCommand( + { ...deps(io), readTerminalOutbox: () => Promise.resolve([]) }, + { runId: 'fan-1' }, + ); + const text = out(); + expect(text).toContain( + ' ⏸ pending gate review-1 (review) at g — "look"\n' + + ' approve: relavium gate fan-1 --gate review-1 --approve\n' + + ' reject: relavium gate fan-1 --gate review-1 --reject\n', + ); + expect(text).toContain( + ' ⏸ pending gate input-1 (input) at g — "name?"\n' + + ' input: relavium gate fan-1 --gate input-1 --input \n' + + ' reject: relavium gate fan-1 --gate input-1 --reject\n', + ); + expect(text).not.toContain('--gate input-1 --approve'); + }); + it('keeps listing every HEALTHY run when one run`s event log is damaged (ADR-0074 §5 blast radius)', async () => { // Before `readPerRunOrDegrade`, one unreadable row in ONE run threw straight out of this `.map` and the whole // command printed nothing — nineteen healthy runs taken away by the twentieth, with no CLI command able to diff --git a/apps/cli/src/commands/status.ts b/apps/cli/src/commands/status.ts index 8f15b7f06..3d3a154d9 100644 --- a/apps/cli/src/commands/status.ts +++ b/apps/cli/src/commands/status.ts @@ -1,17 +1,24 @@ import type { LocalDbNoticeContext } from '../db/checkpoint-notice.js'; import { acquireCommandResource, type CommandLifetime } from '../process/command-lifetime.js'; -import type { RunEvent } from '@relavium/shared'; +import type { RunEvent, RunStatus } from '@relavium/shared'; import type { Db, RunHistoryReader, RunRecord, StepRecord } from '@relavium/db'; import { loadResolvedConfig } from '../config/load.js'; -import { pendingGateDisplays, type PendingGate, type PendingBudgetGate } from '../gate/pending.js'; +import { + decidedGateNodes, + humanGateResumeHints, + pendingGateDisplays, + type PendingGate, + type PendingBudgetGate, +} from '../gate/pending.js'; import { budgetIdentifier, budgetPromptDetails, budgetPromptLabel, budgetResumeHints, } from '../gate/budget.js'; +import { CliError } from '../process/errors.js'; import { EXIT_CODES, type ExitCode } from '../process/exit-codes.js'; import type { CliIo } from '../process/io.js'; import type { GlobalOptions } from '../process/options.js'; @@ -23,6 +30,11 @@ import { readRunUsageCoverage, UNVERIFIED_USAGE } from '../history/usage-coverag import { createFileTerminalOutbox } from '../engine/terminal-outbox.js'; import { terminalOutboxPath } from '../history/open.js'; +/** `relavium status [runId]` — one run, active or finished, instead of every active run (MVP-1). */ +export interface StatusCommandArgs { + readonly runId?: string; +} + export interface StatusCommandDeps { readonly commandLifetime?: CommandLifetime; readonly io: CliIo; @@ -60,6 +72,13 @@ async function heldTerminalRunIds( } } +/** A run in one of these already has its durable terminal; an outbox entry for it is stale, not held. */ +const TERMINAL_STATUSES: ReadonlySet = new Set([ + 'completed', + 'failed', + 'cancelled', +]); + const defaultReadTerminalOutbox = (homeDir: string): Promise => createFileTerminalOutbox(terminalOutboxPath(homeDir)).list(); @@ -72,6 +91,8 @@ interface ActiveRunStatus { readonly steps: readonly StepRecord[]; readonly pendingGates: readonly PendingGate[]; readonly pendingBudgetGates: readonly PendingBudgetGate[]; + /** The nodes whose human gate was decided, read from the log (empty when it could not be read). */ + readonly decidedGateNodes: ReadonlySet; /** `true` when this run's event log could not be read, so `pendingGates` is a stand-in (#W15-15). */ readonly gatesUnavailable: boolean; } @@ -81,9 +102,12 @@ interface ActiveRunStatus { * step status (`loadStepExecutions`). For a run paused at a human gate it also surfaces the pending `gateId`(s) * with gate type + node id, so an operator can pass the right one to `relavium gate --gate ` * (canonical: [commands.md](../../../docs/reference/cli/commands.md)). `--json` emits one record per run. - * Framework-free; no `runId` argument (it lists every active run). + * Framework-free. With a `runId` it shows that one run, active or finished; without, every active run. */ -export async function statusCommand(deps: StatusCommandDeps): Promise { +export async function statusCommand( + deps: StatusCommandDeps, + args: StatusCommandArgs = {}, +): Promise { const { homeDir } = loadResolvedConfig({ cwd: deps.global.cwd, configPath: deps.global.configPath, @@ -97,17 +121,23 @@ export async function statusCommand(deps: StatusCommandDeps): Promise // Runs whose TERMINAL is held in the outbox (ADR-0078 §4/§5) — read, never drained. // // A run in that state is still `running` in the derived projection, so it appears here as an ordinary - // active run with nothing saying its outcome is already known. Exit code 5 tells a script to "re-check - // `relavium status ` after a subsequent invocation", and a review found that instruction can never - // resolve on its own: only `run` and `gate` drain the outbox, because only they construct a - // `WorkflowEngine`. A user who does the natural thing — ask for status again — sees the same stale truth - // forever. Naming the state here is what makes the documented remedy actionable. + // active run with nothing saying its outcome is already known. Exit code 5 tells a script to read + // `relavium status `, and a review found that instruction could never resolve on its own: status + // itself never drains (only a later `run`, `gate`, `budget resume` or Home open does). A user who does the + // natural thing — ask for status again — would see the same stale truth. Naming the state here is what makes + // the documented remedy actionable. + // + // Only for a run that is still non-terminal. In the reconcile race (commands.md, exit 5) the reconciler's + // `run:failed` is durable and the run's own refused terminal is ALSO held; that entry is stale, and the next + // drain drops it rather than landing it. Calling such a run "finished, not durable yet" would be false. // // READ-ONLY, deliberately. Draining is a write that claims a run lease; a status read must not take // ownership of a run another process may be finishing right now. try { const held = await heldTerminalRunIds(homeDir, deps.readTerminalOutbox, deps.io); - const statuses: ActiveRunStatus[] = reader.listActiveRuns().map((run) => { + const runs = + args.runId === undefined ? reader.listActiveRuns() : [requireRun(reader, args.runId)]; + const statuses: ActiveRunStatus[] = runs.map((run) => { let steps: readonly StepRecord[] = []; const usage = readPerRunOrDegrade( { @@ -128,13 +158,16 @@ export async function statusCommand(deps: StatusCommandDeps): Promise run, usage: usage.value, usageUnavailable: usage.degraded, - terminalHeld: held.has(run.id), + terminalHeld: held.has(run.id) && !TERMINAL_STATUSES.has(run.status), steps, // Native authority and companion crash prefixes can leave either active status with pending gates. // Per-run isolation (`readPerRunOrDegrade`): one damaged row in one run must not blank the whole list. // The degradation travels WITH the value (#W15-15) — an empty `pendingGates` on a damaged run would // otherwise read as "this run has no gates" when the truth is "its gates could not be read". - ...gatesOf(reader, run), + // A FINISHED run (`status `) has nothing pending, whatever its log left open: a run cancelled or + // failed while a gate waited never resolves that gate: `gate` / `budget resume` answer it with an exit-0 + // `nothing to resume`, and `gate list` skips it. + ...gatesOf(reader, run, TERMINAL_STATUSES.has(run.status)), }; }); @@ -156,20 +189,42 @@ export async function statusCommand(deps: StatusCommandDeps): Promise } } -/** Reconstruct once per active run: a companion crash may leave a running projection with pending gates. */ +/** The run `status ` names, active or finished (soft-deleted excluded), as `logs` resolves it. */ +function requireRun(reader: RunHistoryReader, runId: string): RunRecord { + const run = reader.loadRun(runId); + if (run === undefined) { + throw new CliError('invalid_invocation', `no run found with id ${runId}`); + } + return run; +} + +interface RunGates { + readonly pendingGates: readonly PendingGate[]; + readonly pendingBudgetGates: readonly PendingBudgetGate[]; + readonly decidedGateNodes: ReadonlySet; +} + +/** + * Read a run's log once for its gates: what is pending, and which gates were decided. An active run may hold pending + * gates even with a running projection (a companion crash). A finished run has nothing pending (see the call site), + * so an unreadable log costs it only the decided-gate labels and raises no `gatesUnavailable`. + */ function gatesOf( reader: RunHistoryReader, run: RunRecord, -): { - pendingGates: readonly PendingGate[]; - pendingBudgetGates: readonly PendingBudgetGate[]; - gatesUnavailable: boolean; -} { - const read = readPerRunOrDegrade>( - { pendingGates: [], pendingBudgetGates: [] }, - () => pendingGateDisplays(reader.loadRunEvents(run.id)), + finished: boolean, +): RunGates & { gatesUnavailable: boolean } { + const read = readPerRunOrDegrade( + { pendingGates: [], pendingBudgetGates: [], decidedGateNodes: new Set() }, + () => { + const events = reader.loadRunEvents(run.id); + const pending = finished + ? { pendingGates: [], pendingBudgetGates: [] } + : pendingGateDisplays(events); + return { ...pending, decidedGateNodes: decidedGateNodes(events) }; + }, ); - return { ...read.value, gatesUnavailable: read.degraded }; + return { ...read.value, gatesUnavailable: !finished && read.degraded }; } /** One active run as a machine record: identity + status + per-node steps + any pending human gates. */ @@ -220,25 +275,40 @@ function toJson(status: ActiveRunStatus): unknown { }; } +/** + * A step's human label; `--json` keeps the recorded status. No `node:completed` closes a human gate's step row, + * though the engine marks the gate vertex completed when it is decided, so a decided gate prints `completed`. Any + * other step still `running` under a finished run never finished: a run terminal does not close its row. + */ +function shownStepStatus( + step: StepRecord, + finished: boolean, + decided: ReadonlySet, +): string { + if (step.status !== 'running') return step.status; + if (step.nodeType === 'human_in_the_loop' && decided.has(step.nodeId)) return 'completed'; + return finished ? 'stopped' : step.status; +} + /** Render one run's status block: a header line, its per-node steps, then any pending gates. */ function renderRun(io: CliIo, status: ActiveRunStatus): void { - io.writeOut(`run ${status.run.id} — ${status.run.status} (workflow ${status.run.workflowId})\n`); + // Every identifier here is read back from history.db, so each is printed one-line with controls stripped, as + // the gate message below is (the run path writes only validated ids, but the file is the trust boundary). + const finished = TERMINAL_STATUSES.has(status.run.status); + io.writeOut( + `run ${sanitizeInline(status.run.id)} — ${sanitizeInline(status.run.status)} (workflow ${sanitizeInline(status.run.workflowId)})\n`, + ); if (status.usageUnavailable) { io.writeOut(" ⚠ usage coverage unavailable — this run's event log could not be read\n"); } - if (status.steps.length === 0) { - io.writeOut(' (no node activity recorded yet)\n'); - } - for (const step of status.steps) { - const attempt = step.attemptNumber > 1 ? ` (attempt ${step.attemptNumber})` : ''; - io.writeOut(` ${step.status.padEnd(9)} ${step.nodeId} [${step.nodeType}]${attempt}\n`); - } + renderSteps(io, status, finished); if (status.terminalHeld) { // The run reads `running` because its terminal never became durable; without this line the exit-5 // instruction ("re-check `relavium status`") has nothing to show the user on the re-check. io.writeOut( ' ⚠ this run has FINISHED — its terminal is held in the outbox and is not durable yet.\n' + - ' Recovery is attempted by `relavium run`, `relavium gate` and `relavium budget resume`; the next one retries it.\n', + ' Recovery is attempted by `relavium run`, `relavium gate`, `relavium budget resume` and opening the\n' + + ' `relavium` Home; the next one retries it.\n', ); } if (status.gatesUnavailable) { @@ -247,6 +317,30 @@ function renderRun(io: CliIo, status: ActiveRunStatus): void { // they would come to find out which run is broken. io.writeOut(" ⚠ pending gates unavailable — this run's event log could not be read\n"); } + renderPendingGates(io, status); +} + +/** One row per recorded step, or a line saying no node ran (or none has yet, on an active run). */ +function renderSteps(io: CliIo, status: ActiveRunStatus, finished: boolean): void { + if (status.steps.length === 0) { + io.writeOut(finished ? ' (no node ran)\n' : ' (no node activity recorded yet)\n'); + } + for (const step of status.steps) { + // A history.db value: text SQLite keeps as text but JavaScript reads as a number past 1 ('\u30002\f') must not + // reach the terminal raw. + const attempt = + Number.isSafeInteger(step.attemptNumber) && step.attemptNumber > 1 + ? ` (attempt ${step.attemptNumber})` + : ''; + const shown = shownStepStatus(step, finished, status.decidedGateNodes); + io.writeOut( + ` ${sanitizeInline(shown).padEnd(9)} ${sanitizeInline(step.nodeId)} [${sanitizeInline(step.nodeType)}]${attempt}\n`, + ); + } +} + +/** Each pending budget gate, then each pending human gate, with the commands that continue it. */ +function renderPendingGates(io: CliIo, status: ActiveRunStatus): void { for (const gate of status.pendingBudgetGates) { const gateId = budgetIdentifier(gate.gateId); io.writeOut( @@ -262,6 +356,11 @@ function renderRun(io: CliIo, status: ActiveRunStatus): void { // model-controlled string in the product. The TUI leaf was hardened; this command leaf was not, so a // pasted OSC 52 / OSC 8 / CSI payload reached the terminal intact here (G34/#57's class). const message = gate.message === '' ? '' : ` — "${sanitizeInline(gate.message)}"`; - io.writeOut(` ⏸ pending gate ${gate.gateId} (${gate.gateType}) at ${gate.nodeId}${message}\n`); + io.writeOut( + ` ⏸ pending gate ${sanitizeInline(gate.gateId)} (${gate.gateType}) at ${sanitizeInline(gate.nodeId)}${message}\n`, + ); + // The command that continues it: a resumed run's final summary sends a gate it cannot classify here. + for (const hint of humanGateResumeHints(status.run.id, gate.gateId, gate.gateType)) + io.writeOut(` ${hint}\n`); } } diff --git a/apps/cli/src/commands/workflow-preparation.test.ts b/apps/cli/src/commands/workflow-preparation.test.ts index c344e8ffb..881eda200 100644 --- a/apps/cli/src/commands/workflow-preparation.test.ts +++ b/apps/cli/src/commands/workflow-preparation.test.ts @@ -226,15 +226,15 @@ describe('early workflow preparation ownership', () => { f.cleanup(); } }); - it.each(['build', 'outbox'] as const)( + it.each(['build', 'reconcile'] as const)( 'does not start a run after cancellation during held %s preparation', async (stage) => { const f = fixture('plain'); const entered = deferred(); const release = deferred(); const start = vi.spyOn(f.engine, 'start'); - if (stage === 'outbox') - vi.spyOn(f.engine, 'drainTerminalOutbox').mockImplementation(async () => { + if (stage === 'reconcile') + vi.spyOn(f.engine, 'reconcile').mockImplementation(async () => { entered.resolve(); await release.promise; return []; diff --git a/apps/cli/src/config/resolve.test.ts b/apps/cli/src/config/resolve.test.ts index 1af04594b..62685f445 100644 --- a/apps/cli/src/config/resolve.test.ts +++ b/apps/cli/src/config/resolve.test.ts @@ -220,6 +220,17 @@ describe('resolveConfig', () => { expect(resolved.maxCostMicrocents).toBe(0); }); + it('resolves [chat].max_tokens with the project over the workspace layer (MVP-A7)', () => { + expect( + resolveConfig({ + workspace: { chat: { max_tokens: 9000 } }, + project: { chat: { max_tokens: 2000 } }, + }).chat.maxTokens, + ).toBe(2000); + expect(resolveConfig({ workspace: { chat: { max_tokens: 9000 } } }).chat.maxTokens).toBe(9000); + expect(resolveConfig({}).chat.maxTokens).toBeUndefined(); + }); + it('resolves an absent [chat] block: non-model fields stay undefined; default_model falls back to global [preferences] (ADR-0063)', () => { const empty = resolveConfig({}).chat; expect(empty.maxTurns).toBeUndefined(); diff --git a/apps/cli/src/config/resolve.ts b/apps/cli/src/config/resolve.ts index 160e681b4..f4cc02ea8 100644 --- a/apps/cli/src/config/resolve.ts +++ b/apps/cli/src/config/resolve.ts @@ -47,6 +47,8 @@ export interface ResolvedChatConfig { readonly compactThreshold: ChatConfig['compact_threshold']; /** `[chat].max_cost_microcents` — per-session pre-egress cost cap (0/absent ⇒ unbounded; same ADR-0028 governor). */ readonly maxCostMicrocents: ChatConfig['max_cost_microcents']; + /** `[chat].max_tokens` — the output cap for a chat agent that authors none (MVP-A7; ADR-0101 amendment). */ + readonly maxTokens: ChatConfig['max_tokens']; /** `[chat].on_exceed` — action when the cost cap trips (in an interactive REPL, `pause_for_approval` * degrades to a loud turn-end since the prompt itself is the approval gate). */ readonly onExceed: ChatConfig['on_exceed']; @@ -205,6 +207,7 @@ function resolveChat( autoCompact: p?.auto_compact ?? w?.auto_compact, compactThreshold: p?.compact_threshold ?? w?.compact_threshold, maxCostMicrocents: p?.max_cost_microcents ?? w?.max_cost_microcents, + maxTokens: p?.max_tokens ?? w?.max_tokens, onExceed: p?.on_exceed ?? w?.on_exceed, strictCostCap: (p?.strict_cost_cap ?? w?.strict_cost_cap) === true, allowedCommands: projectSetsAllowlist ? p?.allowed_commands : w?.allowed_commands, diff --git a/apps/cli/src/engine/budget-rejection-reconcile.test.ts b/apps/cli/src/engine/budget-rejection-reconcile.test.ts index 0dc4cd687..bac8a35be 100644 --- a/apps/cli/src/engine/budget-rejection-reconcile.test.ts +++ b/apps/cli/src/engine/budget-rejection-reconcile.test.ts @@ -7,7 +7,7 @@ import { type DbClient, type RunHistoryStore, } from '@relavium/db'; -import { RunEventSchema, type RunEvent } from '@relavium/shared'; +import { RUN_LEASE_TTL_MS, RunEventSchema, type RunEvent } from '@relavium/shared'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; const epoch = Date.parse('2026-10-04T00:00:00.000Z'); @@ -107,7 +107,8 @@ describe('native budget rejection crash recovery', () => { host: createInMemoryHost({ store, runLeases: createRunLeasePort(store), - baseEpochMs: epoch, + // A later start: reconcile leaves a run younger than one lease TTL alone. + baseEpochMs: epoch + RUN_LEASE_TTL_MS + 1, }), executor: { execute }, }); diff --git a/apps/cli/src/engine/build-engine.ts b/apps/cli/src/engine/build-engine.ts index c7e00d139..22d37565b 100644 --- a/apps/cli/src/engine/build-engine.ts +++ b/apps/cli/src/engine/build-engine.ts @@ -23,7 +23,7 @@ import { effortWithheldNote, reasoningWithheldByCapFor } from '../chat/effort-no import { hostAbortController, hostDeadlineTimer, hostSleep } from '../process/sleep.js'; import { createCliHost } from './host.js'; import { createProviderResolver, type ProviderResolver } from './providers.js'; -import { assembleToolEnv } from './tool-host/assemble.js'; +import { assembleToolEnv, type ToolEnvProfile } from './tool-host/assemble.js'; export interface BuildEngineOptions { /** @@ -106,6 +106,9 @@ export interface BuildEngineOptions { readonly onLegacyMediaJobHold?: (nodeIds: readonly string[]) => void; } +/** The tool-host profile of `relavium run` and `gate` (the author-trusted run host; see {@link buildEngine}). */ +export const RUN_TOOL_PROFILE: ToolEnvProfile = 'workflow-read-write'; + /** * Assemble a {@link WorkflowEngine} for a CLI run: a node-backed host, the standard node executor * (the six non-agent handlers + the agent arm), the expression sandbox, and the `ToolHost`. @@ -117,8 +120,8 @@ export interface BuildEngineOptions { * conditional spread — a true merge, never a replace. The `egress` / `os` arms are **intentionally never wired * on this run path** — they belong only to the ADR-0057 approval-gated `chat-read-write` profile (a permanent * scope boundary, not a 2.5.E deferral: workflow-run egress/os is a separate author-trusted concern) — so a - * tool needing one is cleanly `tool_unavailable`. Absent `toolEnv` (the in-memory unit/harness path) ⇒ a - * fail-closed `{}` base host. + * tool needing one is cleanly `tool_unavailable`, and `relavium run` refuses such a grant before the engine is + * built (2.6.A). Absent `toolEnv` (the in-memory unit/harness path) ⇒ a fail-closed `{}` base host. */ export async function buildEngine(options: BuildEngineOptions = {}): Promise { const host = options.host ?? createCliHost(); @@ -137,7 +140,7 @@ export async function buildEngine(options: BuildEngineOptions = {}): Promise\s*Promise\.reject\(/], }, ]; diff --git a/apps/cli/src/engine/fixtures/usage-coverage-crash-worker.ts b/apps/cli/src/engine/fixtures/usage-coverage-crash-worker.ts index 6a0a63522..d2bd85c67 100644 --- a/apps/cli/src/engine/fixtures/usage-coverage-crash-worker.ts +++ b/apps/cli/src/engine/fixtures/usage-coverage-crash-worker.ts @@ -264,6 +264,7 @@ async function runSession() { autoCompact: false, compactThreshold: undefined, maxCostMicrocents: cap === 'capped' ? 1_000_000 : undefined, + maxTokens: undefined, onExceed: 'fail', strictCostCap: true, allowedCommands: undefined, @@ -280,7 +281,10 @@ async function runSession() { uuid: () => ownerId, disableMcp: true, resolvePrice: prices, - providers: { resolveProvider: () => provider, keyFor }, + // The key EXISTS when the session is built — the MVP-A4 pre-flight asks `hasKey` and passes — and its + // resolution is refused at the turn (a key deleted or a keychain locked in between). That turn-time refusal + // is the case this cut exercises; a key absent at build is now refused before the session starts. + providers: { resolveProvider: () => provider, keyFor, hasKey: () => true }, onListenerError: (note) => { if (!expectedRefusal) throw new Error(note); }, diff --git a/apps/cli/src/engine/media-wiring.test.ts b/apps/cli/src/engine/media-wiring.test.ts index 2d9fe3157..2db879e34 100644 --- a/apps/cli/src/engine/media-wiring.test.ts +++ b/apps/cli/src/engine/media-wiring.test.ts @@ -33,6 +33,7 @@ const EMPTY_CONFIG: ResolvedConfig = { autoCompact: undefined, compactThreshold: undefined, maxCostMicrocents: undefined, + maxTokens: undefined, onExceed: undefined, strictCostCap: false, allowedCommands: undefined, diff --git a/apps/cli/src/engine/providers.ts b/apps/cli/src/engine/providers.ts index 81e0007d5..908df6590 100644 --- a/apps/cli/src/engine/providers.ts +++ b/apps/cli/src/engine/providers.ts @@ -87,6 +87,15 @@ export function providerKeyEnvVar(id: ProviderId): string { return `RELAVIUM_${id.toUpperCase()}_API_KEY`; } +/** + * The actionable "no key" message: names both ways to provide one, never a key. One source for + * {@link createProviderResolver}'s `keyFor` and the session pre-flight (MVP-A4), so the user reads the same + * remedy whichever caught it first. + */ +export function missingKeyMessage(id: ProviderId): string { + return `no API key for provider '${id}' — store one with \`relavium provider set-key ${id}\` or set ${providerKeyEnvVar(id)}.`; +} + /** A non-secret display hint for a key — masked, with the last 4 chars (or fully masked when too short). NEVER the full key. */ export function keyHint(key: string): string { return key.length <= 4 ? '••••' : `••••${key.slice(-4)}`; @@ -382,10 +391,7 @@ export function createProviderResolver( const key = resolveKey(id); if (key === undefined) { // A clean invocation error naming both ways to provide the key (never the key itself). - throw new CliError( - 'invalid_invocation', - `no API key for provider '${id}' — store one with \`relavium provider set-key ${id}\` or set ${providerKeyEnvVar(id)}.`, - ); + throw new CliError('invalid_invocation', missingKeyMessage(id)); } return key; }, diff --git a/apps/cli/src/engine/run-history-projection-reconcile.test.ts b/apps/cli/src/engine/run-history-projection-reconcile.test.ts index 8eb13ce66..1bfc1d863 100644 --- a/apps/cli/src/engine/run-history-projection-reconcile.test.ts +++ b/apps/cli/src/engine/run-history-projection-reconcile.test.ts @@ -12,7 +12,7 @@ import { type DbClient, type RunHistoryStore, } from '@relavium/db'; -import { RunEventSchema, type RunEvent } from '@relavium/shared'; +import { RUN_LEASE_TTL_MS, RunEventSchema, type RunEvent } from '@relavium/shared'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; describe('native reconciliation refuses a corrupted known suspension discriminator', () => { @@ -93,7 +93,9 @@ describe('native reconciliation refuses a corrupted known suspension discriminat host: createInMemoryHost({ store, runLeases: createRunLeasePort(store), - baseEpochMs: epoch, + // A later start: reconcile leaves a run younger than one lease TTL alone, which would decide this + // test's outcome before the resumable skip it is about. + baseEpochMs: epoch + RUN_LEASE_TTL_MS + 1, }), executor: { execute }, }), diff --git a/apps/cli/src/engine/terminal-outbox.test.ts b/apps/cli/src/engine/terminal-outbox.test.ts index 3dff3772b..cd605b224 100644 --- a/apps/cli/src/engine/terminal-outbox.test.ts +++ b/apps/cli/src/engine/terminal-outbox.test.ts @@ -163,15 +163,18 @@ describe('createFileTerminalOutbox', () => { await expect(outbox.remove('r1')).resolves.toBeUndefined(); }); - it('NEVER throws when the file is unreadable', async () => { + it('never throws when the file is unreadable, and its list says so rather than reading as empty', async () => { const outbox = createFileTerminalOutbox(path); await outbox.put(terminal('r1')); // A directory where a file is expected is the portable way to make a read fail (chmod 000 is a no-op // for root and on Windows). rmSync(path); mkdirSync(path); - await expect(outbox.list()).resolves.toEqual([]); + // "Nothing held" and "could not look" differ: a reconcile that read the second as the first would repair a + // run whose held terminal it could not see. The rejection is fixed text — the path stays out of it. + await expect(outbox.list()).rejects.toThrow(/^the terminal outbox could not be read$/u); await expect(outbox.put(terminal('r2'))).resolves.toBeUndefined(); + await expect(outbox.remove('r1')).resolves.toBeUndefined(); }); it('refuses to write THROUGH a symlink — the guard added with the consent store (ADR-0084 §5)', async () => { diff --git a/apps/cli/src/engine/terminal-outbox.ts b/apps/cli/src/engine/terminal-outbox.ts index 26f103c17..ac0c8a589 100644 --- a/apps/cli/src/engine/terminal-outbox.ts +++ b/apps/cli/src/engine/terminal-outbox.ts @@ -22,7 +22,8 @@ * **Nothing here throws for a caller that cannot recover.** The engine calls `put` from inside its terminal * path, having already failed one write; a throw would break exactly-one-terminal on the way out of the code * that exists to protect it. `put` reports failure by leaving the entry unwritten — the run already reports - * `uncertain`, which is the honest floor ADR-0078 §4's Consequences records. + * `uncertain`, which is the honest floor ADR-0078 §4's Consequences records. `list` alone rejects, on a file it + * cannot read: its callers (the engine's drain, `status`) catch that, and an empty answer would be a lie. */ import { appendFileSync, chmodSync, existsSync, lstatSync, readFileSync } from 'node:fs'; @@ -35,6 +36,15 @@ import { stringifyJsonLine } from '../render/sanitize.js'; /** Owner-only, matching `history.db` (ADR-0050). A no-op on Windows, as the at-rest guard there is the ACL. */ const FILE_MODE = 0o600; +/** The `errno` code of a Node fs error (`ENOENT`, `EACCES`, …), or `undefined` if it is not one. */ +function errnoCode(err: unknown): string | undefined { + if (typeof err === 'object' && err !== null && 'code' in err) { + const code: unknown = err.code; + return typeof code === 'string' ? code : undefined; + } + return undefined; +} + /** The tombstone marker key. Deliberately not a `RunEvent` field, so a tombstone can never parse as one. */ const TOMBSTONE_KEY = '__relaviumOutboxRemoved'; @@ -45,15 +55,22 @@ function tombstoneRunId(raw: unknown): string | undefined { return typeof value === 'string' ? value : undefined; } -/** Read every line, keeping the LAST entry per run; unreadable lines are skipped, never fatal. */ +/** + * Read every line, keeping the LAST entry per run; unreadable LINES are skipped, never fatal. + * + * An absent file holds nothing. A file that exists but cannot be read is NOT empty: it throws a fixed error, so + * `list` rejects and a reader can tell "nothing held" from "could not look". That difference is what keeps a + * reconcile from repairing a run whose held terminal it could not see (ADR-0078 §4), and what lets `status` warn. + */ function readEntries(path: string): Map { const held = new Map(); - if (!existsSync(path)) return held; let text: string; try { text = readFileSync(path, 'utf8'); - } catch { - return held; // an unreadable outbox is a lost retry, not a crash — the run already reported uncertain + } catch (error) { + if (errnoCode(error) === 'ENOENT') return held; + // Fixed text: a filesystem error may carry a private path. + throw new Error('the terminal outbox could not be read'); } for (const line of text.split('\n')) { if (line.trim() === '') continue; @@ -137,7 +154,13 @@ export function createFileTerminalOutbox(path: string): TerminalOutbox { } return Promise.resolve(); }, - list: () => Promise.resolve([...readEntries(path).values()]), + list: () => { + try { + return Promise.resolve([...readEntries(path).values()]); + } catch (error) { + return Promise.reject(error instanceof Error ? error : new Error(String(error))); + } + }, remove: (runId) => { try { if (!existsSync(path)) return Promise.resolve(); diff --git a/apps/cli/src/engine/tool-host/assemble.ts b/apps/cli/src/engine/tool-host/assemble.ts index c7b316981..d7248ee57 100644 --- a/apps/cli/src/engine/tool-host/assemble.ts +++ b/apps/cli/src/engine/tool-host/assemble.ts @@ -1,5 +1,5 @@ import type { NativeProcessController } from '../../process/native-process-owner.js'; -import type { FsScopeTier, ToolDef, ToolDelegateName, ToolHost } from '@relavium/core'; +import type { FsScopeTier, ToolDef, ToolDelegateName, ToolHost, ToolHostArm } from '@relavium/core'; import type { ToolPolicy } from '@relavium/shared'; import { createNodeEgressCapability } from './egress.js'; @@ -45,6 +45,14 @@ export { ProcessCapabilityError, ProcessDeniedError } from './process.js'; /** The three host profiles: read-only chat, the full-capability (approval-gated) chat host, and the run host. */ export type ToolEnvProfile = 'chat-read-only' | 'chat-read-write' | 'workflow-read-write'; +/** + * The host arms a profile leaves unwired: only the approval-gated `chat-read-write` host wires `egress` and `os`. + * {@link assembleToolEnv} wires from this, and `relavium run`'s pre-flight refuses a grant that needs one (2.6.A). + */ +export function unwiredToolArms(profile: ToolEnvProfile): readonly ToolHostArm[] { + return profile === 'chat-read-write' ? [] : ['egress', 'os']; +} + export interface AssembleToolEnvOptions { /** Invocation-owned native creation authority, inert until a capability actually spawns. */ readonly nativeProcesses?: NativeProcessController; @@ -97,7 +105,9 @@ export function assembleToolEnv(opts: AssembleToolEnvOptions): AssembledToolEnv const tier: FsScopeTier = isChat ? clampChatTier(opts.fsScopeTier) : opts.fsScopeTier; // The full-capability chat host (ADR-0057): `egress` + `os` ride on top of fs-write + process, made safe by // the per-tool approval floor (the regime + advertise-filter the session-host activates), NOT by absence. - const wireEgressOs = opts.profile === 'chat-read-write'; + // Each arm reads its own entry, so a profile that leaves only one unwired wires the other, and the pre-flight + // refuses exactly the arm this host lacks. + const unwired = unwiredToolArms(opts.profile); const host: ToolHost = { // Strings in committed config cannot grant roots: only the pre-admitted host policy can widen project scope. fs: createNodeFsCapability({ @@ -120,18 +130,22 @@ export function assembleToolEnv(opts: AssembleToolEnvOptions): AssembledToolEnv // native notification), so both are denied in `ask`/`plan` and prompt in `accept-edits`. The read-only chat // + workflow profiles wire neither: a read-only chat must not reach the network or the clipboard, and the // workflow run path's egress/os is a separate (author-trusted) concern, not part of the chat approval story. - ...(wireEgressOs - ? { + ...(unwired.includes('egress') + ? {} + : { egress: createNodeEgressCapability( opts.egressCredentialResolver === undefined ? {} : { resolveCredential: opts.egressCredentialResolver }, ), + }), + ...(unwired.includes('os') + ? {} + : { os: createNodeOsCapability( opts.nativeProcesses === undefined ? {} : { nativeProcesses: opts.nativeProcesses }, ), - } - : {}), + }), }; // Chat default: empty allowedCommands ⇒ `run_command` denied; `git_status` is pre-approved and exposes no // model-controlled command, so an empty allowlist never blocks it (ADR-0055). NOTE: this is inert today — diff --git a/apps/cli/src/gate/budget-excluded-display.test.ts b/apps/cli/src/gate/budget-excluded-display.test.ts index 355caf931..e19a11f7b 100644 --- a/apps/cli/src/gate/budget-excluded-display.test.ts +++ b/apps/cli/src/gate/budget-excluded-display.test.ts @@ -213,6 +213,7 @@ describe('excluded allowance candidates on human surfaces (ADR-0097)', () => { message: 'Approve exactly 20 microcents?', active: 'Approve exact amount', inactive: 'Reject', + initialValue: false, // a bare Enter rejects the spend }); expect(result).toEqual({ decision: 'approved', diff --git a/apps/cli/src/gate/clack-prompter-lifecycle.test.ts b/apps/cli/src/gate/clack-prompter-lifecycle.test.ts index b4c143d29..f1ed613f1 100644 --- a/apps/cli/src/gate/clack-prompter-lifecycle.test.ts +++ b/apps/cli/src/gate/clack-prompter-lifecycle.test.ts @@ -58,6 +58,47 @@ for (const gateType of ['approval', 'input'] as const) { }); } +for (const [label, budget] of [ + ['approval card', undefined], + ['budget amount card', { kind: 'amount', microcents: 20 }], +] as const) { + it(`a bare Enter on the actual Clack ${label} rejects`, async () => { + // Clack's confirm defaults to Yes; the card passes `initialValue: false` so Enter is the safe answer, as at + // the MCP consent prompt. Enter on the approval card then reaches the optional reason, where Enter again + // submits an empty one. + const input = new SyntheticInput(); + const output = new PassThrough(); + output.resume(); + const prompter = createClackGatePrompter({ + note: () => {}, + confirm: (options) => confirm({ ...options, input, output }), + text: (options) => text({ ...options, input, output }), + isCancel, + }); + const enter = (): void => { + input.emit('keypress', '\r', { name: 'return', sequence: '\r' }); + }; + const pending = prompter.prompt(gate, budget); + try { + enter(); + if (budget === undefined) { + // Let the reason prompt mount before answering it. + for (let spin = 0; spin < 50 && input.listenerCount('keypress') === 0; spin += 1) { + await new Promise((resolve) => setImmediate(resolve)); + } + await new Promise((resolve) => setImmediate(resolve)); + enter(); + } + expect(await pending).toEqual({ decision: 'rejected', decidedBy: 'cli' }); + } finally { + input.emit('keypress', '\u0003', { name: 'c', ctrl: true, sequence: '\u0003' }); + await pending; + input.destroy(); + output.destroy(); + } + }); +} + type Route = | 'approval' | 'review' diff --git a/apps/cli/src/gate/clack-prompter.test.ts b/apps/cli/src/gate/clack-prompter.test.ts index c4cae5642..72aff899d 100644 --- a/apps/cli/src/gate/clack-prompter.test.ts +++ b/apps/cli/src/gate/clack-prompter.test.ts @@ -69,7 +69,7 @@ describe('createClackGatePrompter', () => { expect(note.mock.calls[0]?.[0]).toBe('(no message)'); }); - it('sanitizes a forged runtime timeout action before it reaches the deadline card', async () => { + it('never prints a forged runtime timeout action on the deadline card', async () => { const note = vi.fn<(message: string, title: string) => void>(); const event = gate({ expiresAt: '2026-06-24T11:00:00.000Z', @@ -91,8 +91,9 @@ describe('createClackGatePrompter', () => { for (const forbidden of ['\x1b', '\x9b', '\r', '\b', '\u202E']) { expect(body).not.toContain(forbidden); } - expect(body).toContain('auto-reject'); - expect(body).toContain('forged timeout row'); + // The action is only compared, never interpolated: anything but `approve` reads as the default. + expect(body).toContain('the run fails on timeout'); + expect(body).not.toContain('forged timeout row'); expect(body.split('\n')).toHaveLength(3); // message + blank separator + one deadline row }); @@ -124,13 +125,73 @@ describe('createClackGatePrompter', () => { expect(d).toBeNull(); }); - it('cancelling the rejection-comment prompt returns null (no half-built decision)', async () => { + /** A watcher reporting whether the key that ended the prompt was Ctrl-C (Clack reports Esc and Ctrl-C alike). */ + const pressed = + (interrupted: boolean): NonNullable => + async (prompt) => ({ value: await prompt(), interrupted }); + + it('Esc at the optional rejection reason keeps the rejection, without a reason', async () => { + const d = await createClackGatePrompter( + deps({ + confirm: () => Promise.resolve(false), + text: () => Promise.resolve(CANCEL), + watchInterrupt: pressed(false), + }), + ).prompt(gate()); + expect(d).toEqual({ decision: 'rejected', decidedBy: 'cli' }); + }); + + it('Ctrl-C at the optional rejection reason cancels the run, as at the card', async () => { + const d = await createClackGatePrompter( + deps({ + confirm: () => Promise.resolve(false), + text: () => Promise.resolve(CANCEL), + watchInterrupt: pressed(true), + }), + ).prompt(gate()); + expect(d).toBeNull(); + }); + + it('a typed reason stands even when the watcher saw Ctrl-C earlier', async () => { + const d = await createClackGatePrompter( + deps({ + confirm: () => Promise.resolve(false), + text: () => Promise.resolve('too risky'), + watchInterrupt: pressed(true), + }), + ).prompt(gate()); + expect(d).toEqual({ decision: 'rejected', decidedBy: 'cli', comment: 'too risky' }); + }); + + it('without a key watcher a cancel at the reason is taken as Ctrl-C and cancels the run', async () => { const d = await createClackGatePrompter( deps({ confirm: () => Promise.resolve(false), text: () => Promise.resolve(CANCEL) }), ).prompt(gate()); expect(d).toBeNull(); }); + it('an abort during the rejection reason still returns null', async () => { + const controller = new AbortController(); + const d = await createClackGatePrompter( + deps({ + confirm: () => Promise.resolve(false), + text: () => { + controller.abort(); + return Promise.resolve(CANCEL); + }, + }), + ).prompt(gate(), undefined, controller.signal); + expect(d).toBeNull(); + }); + + it('a bare Enter on the approval card rejects: the confirm defaults to Reject', async () => { + const confirm = vi.fn(() => Promise.resolve(false)); + await createClackGatePrompter(deps({ confirm, text: () => Promise.resolve('') })).prompt( + gate(), + ); + expect(confirm.mock.calls[0]?.[0]).toMatchObject({ initialValue: false }); + }); + it('input gate → text value yields an input_provided decision (raw string)', async () => { const confirm = vi.fn(() => Promise.resolve(true)); const d = await createClackGatePrompter( @@ -168,12 +229,45 @@ describe('createClackGatePrompter', () => { expect(cancelled).toBeNull(); }); - it('the card defaults to auto-reject when the gate has a deadline but no timeoutAction', async () => { + it('a deadline with no timeoutAction fails the run, and the card says so', async () => { + // The default action is `reject`, and the engine settles a reject deadline as `run_timeout` rather than + // recording a rejection, so no rejection branch runs. const note = vi.fn<(message: string, title: string) => void>(); await createClackGatePrompter(deps({ note })).prompt( - gate({ expiresAt: '2026-06-24T11:00:00.000Z' }), // timeoutAction omitted → `?? 'reject'` + gate({ expiresAt: '2026-06-24T11:00:00.000Z' }), ); - expect(note.mock.calls[0]?.[0]).toContain('auto-reject'); + expect(note.mock.calls[0]?.[0]).toContain('the run fails on timeout'); + expect(note.mock.calls[0]?.[0]).not.toContain('auto-reject'); + }); + + it('an approve deadline on an approval or review gate says it approves', async () => { + for (const gateType of ['approval', 'review'] as const) { + const note = vi.fn<(message: string, title: string) => void>(); + await createClackGatePrompter(deps({ note })).prompt( + gate({ + gateType, + expiresAt: '2026-06-24T11:00:00.000Z', + timeoutMs: 1000, + timeoutAction: 'approve', + }), + ); + expect(note.mock.calls[0]?.[0]).toContain('auto-approve on timeout'); + } + }); + + it('never promises an input gate it will be approved on timeout: the run fails instead', async () => { + const note = vi.fn<(message: string, title: string) => void>(); + await createClackGatePrompter(deps({ note, text: () => Promise.resolve('value') })).prompt( + gate({ + gateType: 'input', + expiresAt: '2026-06-24T11:00:00.000Z', + timeoutMs: 1000, + timeoutAction: 'approve', + }), + ); + const [body] = note.mock.calls[0] ?? []; + expect(body).toContain('the run fails on timeout'); + expect(body).not.toContain('auto-approve'); }); it('shows the deadline in the card when the gate has one', async () => { @@ -187,7 +281,7 @@ describe('createClackGatePrompter', () => { ); const [body] = note.mock.calls[0] ?? []; expect(body).toContain('Expires at 2026-06-24T11:00:00.000Z'); - expect(body).toContain('auto-reject'); + expect(body).toContain('the run fails on timeout'); }); }); @@ -210,6 +304,7 @@ describe('budget confirmation from recorded scalar context', () => { expect(d.confirm).toHaveBeenCalledWith( expect.objectContaining({ message: `Approve exactly ${amount} microcents?`, + initialValue: false, // a bare Enter rejects the spend }), ); expect(d.note).toHaveBeenCalledWith( @@ -256,6 +351,8 @@ describe('budget confirmation from recorded scalar context', () => { expect.stringContaining('current budget checks still apply'), expect.anything(), ); + // A bare Enter must not continue without an allowance: the confirm defaults to Reject, as the amount card's. + expect(d.confirm).toHaveBeenCalledWith(expect.objectContaining({ initialValue: false })); }); it('amount rejection is binary and cancel never constructs approval', async () => { expect( diff --git a/apps/cli/src/gate/clack-prompter.ts b/apps/cli/src/gate/clack-prompter.ts index 72f37e821..a3ad7cb7e 100644 --- a/apps/cli/src/gate/clack-prompter.ts +++ b/apps/cli/src/gate/clack-prompter.ts @@ -1,3 +1,5 @@ +import type { Readable } from 'node:stream'; + import type { CliIo } from '../process/io.js'; import { nativePromptIo } from '../process/native-prompt-io.js'; import { confirm, isCancel, note, text } from '@clack/prompts'; @@ -23,6 +25,8 @@ export interface ClackPromptDeps { message: string; active: string; inactive: string; + /** The answer a bare Enter gives. Clack's default is `true`, so an approving card must pass `false`. */ + initialValue?: boolean; signal?: AbortSignal; }) => Promise; readonly text: (opts: { @@ -32,6 +36,51 @@ export interface ClackPromptDeps { }) => Promise; /** Clack's cancel sentinel guard (Ctrl-C / ESC) — a real type guard so a non-cancel value narrows. */ readonly isCancel: (value: unknown) => value is symbol; + /** + * Run one prompt and report whether Ctrl-C ended it. Clack answers Ctrl-C and Esc with the same cancel, but at the + * optional rejection reason they differ: Esc skips the reason and the rejection stands, while Ctrl-C cancels the + * run, as it does at the card. Without a watcher a cancel there is taken as Ctrl-C. + */ + readonly watchInterrupt?: (prompt: () => Promise) => Promise>; +} + +/** A prompt's answer and whether Ctrl-C ended it. */ +export interface InterruptWatched { + readonly value: T; + readonly interrupted: boolean; +} + +/** Clack's `cancel` alias for Ctrl-C: the `\x03` byte as a key's character, name or sequence. */ +const CTRL_C = '\u0003'; + +function isCtrlC(char: unknown, key: unknown): boolean { + if (char === CTRL_C) return true; + if (typeof key !== 'object' || key === null) return false; + return ('name' in key && key.name === CTRL_C) || ('sequence' in key && key.sequence === CTRL_C); +} + +/** Watch `input`'s keypresses — the stream Clack reads — for Ctrl-C while one prompt runs. */ +export function watchInterruptOn( + input: Readable, +): (prompt: () => Promise) => Promise> { + return async (prompt) => { + let interrupted = false; + const onKeypress = (char: unknown, key: unknown): void => { + if (isCtrlC(char, key)) interrupted = true; + }; + input.on('keypress', onKeypress); + try { + const value = await prompt(); + return { value, interrupted }; + } finally { + input.off('keypress', onKeypress); + } + }; +} + +/** The fallback when no watcher is wired: every cancel counts as Ctrl-C, which cancels the run. */ +async function assumeInterrupted(prompt: () => Promise): Promise> { + return { value: await prompt(), interrupted: true }; } const defaultDeps: ClackPromptDeps = { @@ -51,6 +100,7 @@ export function createBoundClackGatePrompter(io: CliIo): GatePrompter { confirm: (options) => confirm({ ...options, ...streams }), text: (options) => text({ ...options, ...streams }), isCancel, + watchInterrupt: watchInterruptOn(streams.input), }); } @@ -65,14 +115,23 @@ function cardBody(event: HumanGatePausedEvent): string { const message = stripTerminalControls(event.message); const lines = [message.trim() === '' ? '(no message)' : message]; if (event.expiresAt !== undefined) { - lines.push( - '', - `Expires at ${sanitizeInline(event.expiresAt)} — auto-${sanitizeInline(event.timeoutAction ?? 'reject')} on timeout`, - ); + lines.push('', `Expires at ${sanitizeInline(event.expiresAt)} — ${onTimeout(event)}`); } return lines.join('\n'); } +/** + * What the deadline does (execution-model.md, gate timeouts). A `reject` deadline — the default — does not record + * a rejection: it fails the run with `run_timeout`, so no rejection branch runs. An input gate cannot be + * approved, so a deadline that would approve one fails the run too: admission refuses that pairing now, but a + * run parked before the rule still carries it. Only an `approve` deadline on an approval or review gate decides. + */ +function onTimeout(event: HumanGatePausedEvent): string { + return event.gateType !== 'input' && event.timeoutAction === 'approve' + ? 'auto-approve on timeout' + : 'the run fails on timeout'; +} + export function createClackGatePrompter(deps: ClackPromptDeps = defaultDeps): GatePrompter { return { prompt: async (event, budget, signal) => { @@ -96,10 +155,13 @@ export function createClackGatePrompter(deps: ClackPromptDeps = defaultDeps): Ga return signal?.aborted || deps.isCancel(value) ? null : inputDecision(value); } + // A bare Enter rejects, as at the MCP consent prompt. Clack's y/n and h/j/k/l/space hotkeys still answer the + // card, so a stray paste can (recorded in deferred-tasks.md). const approved = await deps.confirm({ message: 'Approve?', active: 'Approve', inactive: 'Reject', + initialValue: false, ...(signal === undefined ? {} : { signal }), }); if (signal?.aborted || deps.isCancel(approved)) { @@ -108,12 +170,18 @@ export function createClackGatePrompter(deps: ClackPromptDeps = defaultDeps): Ga if (approved) { return approvalDecision(); } - const comment = await deps.text({ - message: 'Reason for rejection (optional)', - placeholder: '', - ...(signal === undefined ? {} : { signal }), - }); - return signal?.aborted || deps.isCancel(comment) ? null : rejectionDecision(comment); + const reason = await (deps.watchInterrupt ?? assumeInterrupted)(() => + deps.text({ + message: 'Reason for rejection (optional)', + placeholder: '', + ...(signal === undefined ? {} : { signal }), + }), + ); + if (signal?.aborted) return null; + if (!deps.isCancel(reason.value)) return rejectionDecision(reason.value); + // The reason is optional, so Esc skips it and the rejection already chosen stands. Ctrl-C cancels the run, as + // it does at the card above and everywhere else on `relavium run`. + return reason.interrupted ? null : rejectionDecision(); }, }; } @@ -158,6 +226,7 @@ async function promptBudget( : 'Continue without a budget allowance?', active: budget.kind === 'amount' ? 'Approve exact amount' : 'Continue', inactive: 'Reject', + initialValue: false, // a bare Enter rejects the spend ...(signal === undefined ? {} : { signal }), }); if (signal?.aborted || deps.isCancel(approved)) return null; diff --git a/apps/cli/src/gate/decision.test.ts b/apps/cli/src/gate/decision.test.ts index 1de409722..5f6960fe0 100644 --- a/apps/cli/src/gate/decision.test.ts +++ b/apps/cli/src/gate/decision.test.ts @@ -1,7 +1,9 @@ +import { parseWorkflow } from '@relavium/core'; import { describe, expect, it } from 'vitest'; import { approvalDecision, + assertDecisionFitsGateType, decisionFromFlags, inputDecision, parseGateInput, @@ -90,3 +92,49 @@ describe('decisionFromFlags', () => { expect(decisionFromFlags({ input: 'x', comment: 'y' })).toMatchObject({ ok: false }); }); }); + +describe('assertDecisionFitsGateType (2.6.K, ruled 2026-11-05)', () => { + const workflow = parseWorkflow( + `schema_version: '1.0'\nworkflow:\n id: gates\n nodes:\n - { id: start, type: input }\n - { id: ok, type: human_gate, gate_type: approval }\n - { id: ask, type: human_gate, gate_type: input }\n - { id: look, type: human_gate, gate_type: review }\n - { id: out, type: output }\n edges:\n - { from: start, to: ok }\n - { from: ok, to: ask }\n - { from: ask, to: look }\n - { from: look, to: out }`, + ); + const decision = (kind: 'approved' | 'rejected' | 'input_provided') => + kind === 'input_provided' + ? inputDecision('x') + : kind === 'approved' + ? approvalDecision() + : rejectionDecision(); + + it('accepts each fitting decision and refuses the rest with the flags to use', () => { + for (const [node, fits, misfit, hint] of [ + [ + 'ok', + ['approved', 'rejected'], + 'input_provided', + 'is an approval gate — use --approve or --reject', + ], + [ + 'look', + ['approved', 'rejected'], + 'input_provided', + 'is a review gate — use --approve or --reject', + ], + [ + 'ask', + ['input_provided', 'rejected'], + 'approved', + 'is an input gate — use --input or --reject', + ], + ] as const) { + for (const kind of fits) { + expect(() => assertDecisionFitsGateType(workflow, node, decision(kind))).not.toThrow(); + } + expect(() => assertDecisionFitsGateType(workflow, node, decision(misfit))).toThrow(hint); + } + }); + + it('leaves a node it cannot find to the engine', () => { + expect(() => + assertDecisionFitsGateType(workflow, 'missing', decision('input_provided')), + ).not.toThrow(); + }); +}); diff --git a/apps/cli/src/gate/decision.ts b/apps/cli/src/gate/decision.ts index 3ae688957..fe7f397ce 100644 --- a/apps/cli/src/gate/decision.ts +++ b/apps/cli/src/gate/decision.ts @@ -1,5 +1,9 @@ +import { decisionsForGateType, gateAcceptsDecision, type WorkflowDefinition } from '@relavium/core'; import type { GateDecision } from '@relavium/shared'; +import { CliError } from '../process/errors.js'; +import { sanitizeInline } from '../render/sanitize.js'; + /** * Pure {@link GateDecision} builders + the `relavium gate` flag→decision mapping (2.G) — framework-free * (no `commander`, no `@clack/prompts`), so both the interactive prompt and the out-of-band command build the @@ -98,3 +102,31 @@ export function decisionFromFlags(flags: GateFlags): GateFlagResult { } return { ok: true, decision: rejectionDecision(flags.comment) }; } + +/** The `relavium gate` flag that records each decision kind, for an actionable refusal and `status`'s resume hints. */ +export const FLAG_FOR_DECISION: Readonly> = { + approved: '--approve', + rejected: '--reject', + input_provided: '--input ', +}; + +/** + * Refuse a decision the pending gate's authored type does not accept, before any engine work (2.6.K, ruled + * 2026-11-05): an `approval` or `review` gate takes `--approve` or `--reject`, an `input` gate `--input` or + * `--reject`. The type is the run's workflow snapshot's `gate_type`; the engine re-checks it as defense in depth. + */ +export function assertDecisionFitsGateType( + workflow: WorkflowDefinition, + nodeId: string | undefined, + decision: GateDecision, +): void { + const node = workflow.workflow.nodes.find((candidate) => candidate.id === nodeId); + if (node?.type !== 'human_gate' || gateAcceptsDecision(node.gate_type, decision.decision)) return; + const flags = decisionsForGateType(node.gate_type) + .map((kind) => FLAG_FOR_DECISION[kind]) + .join(' or '); + throw new CliError( + 'invalid_invocation', + `the gate at node ${sanitizeInline(node.id)} is ${node.gate_type === 'review' ? 'a' : 'an'} ${node.gate_type} gate — use ${flags}`, + ); +} diff --git a/apps/cli/src/gate/pending.test.ts b/apps/cli/src/gate/pending.test.ts index 5cecf2d86..5d942e2d1 100644 --- a/apps/cli/src/gate/pending.test.ts +++ b/apps/cli/src/gate/pending.test.ts @@ -2,7 +2,8 @@ import { createClient, createRunHistoryReader, runMigrations, type DbClient } fr import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { seedRun } from '../test-support.js'; -import { pendingHumanGates } from './pending.js'; +import { decidedGateNodes, humanGateResumeHints, pendingHumanGates } from './pending.js'; +import type { RunEvent } from '@relavium/shared'; describe('pendingHumanGates', () => { let client: DbClient; @@ -40,3 +41,79 @@ describe('pendingHumanGates', () => { expect(pendingHumanGates([])).toEqual([]); }); }); + +describe('humanGateResumeHints', () => { + it('names one copyable command per decision the gate type accepts', () => { + expect(humanGateResumeHints('run-1', 'g-1', 'approval')).toEqual([ + 'approve: relavium gate run-1 --gate g-1 --approve', + 'reject: relavium gate run-1 --gate g-1 --reject', + ]); + expect(humanGateResumeHints('run-1', 'g-1', 'review')).toEqual([ + 'approve: relavium gate run-1 --gate g-1 --approve', + 'reject: relavium gate run-1 --gate g-1 --reject', + ]); + expect(humanGateResumeHints('run-1', 'g-1', 'input')).toEqual([ + 'input: relavium gate run-1 --gate g-1 --input ', + 'reject: relavium gate run-1 --gate g-1 --reject', + ]); + }); + + it.each([ + ['a shell-active run id', 'run;rm -rf ~', 'g-1'], + ['an option-shaped gate id', 'run-1', '--approve'], + ['a gate id with a space', 'run-1', 'g 1'], + ['a terminal control', 'run-1', 'g\u001b]0;x\u0007'], + ])('builds no command from %s, only the flags', (_label, runId, gateId) => { + expect(humanGateResumeHints(runId, gateId, 'input')).toEqual([ + 'Use relavium gate with the recorded run and gate IDs and --input or --reject.', + ]); + }); +}); + +describe('decidedGateNodes', () => { + const at = (sequenceNumber: number) => ({ + runId: 'r1', + timestamp: '2026-11-06T00:00:00.000Z', + sequenceNumber, + }); + const started = (sequenceNumber: number, nodeId: string): RunEvent => ({ + ...at(sequenceNumber), + type: 'node:started', + nodeId, + nodeType: 'human_in_the_loop', + }); + const paused = (sequenceNumber: number, nodeId: string): RunEvent => ({ + ...at(sequenceNumber), + type: 'human_gate:paused', + nodeId, + gateId: `gate-${sequenceNumber}`, + gateType: 'approval', + message: 'ok?', + }); + const resumed = (sequenceNumber: number, nodeId: string): RunEvent => ({ + ...at(sequenceNumber), + type: 'human_gate:resumed', + nodeId, + decision: 'approved', + decidedBy: 'cli', + }); + + it('names a node whose gate was decided, not one still waiting', () => { + const events = [ + started(0, 'a'), + paused(1, 'a'), + started(2, 'b'), + paused(3, 'b'), + resumed(4, 'a'), + ]; + expect([...decidedGateNodes(events)]).toEqual(['a']); + }); + + it.each([ + ['starts again', started], + ['parks again', paused], + ])('forgets a decision once the node %s, as a loop does', (_label, again) => { + const events = [started(0, 'a'), paused(1, 'a'), resumed(2, 'a'), again(3, 'a')]; + expect(decidedGateNodes(events).has('a')).toBe(false); + }); +}); diff --git a/apps/cli/src/gate/pending.ts b/apps/cli/src/gate/pending.ts index 95a5e97cf..1bb0dc2c3 100644 --- a/apps/cli/src/gate/pending.ts +++ b/apps/cli/src/gate/pending.ts @@ -1,6 +1,11 @@ -import { reconstructCheckpointState, type CheckpointState } from '@relavium/core'; -import type { HumanGatePausedEvent, RunEvent } from '@relavium/shared'; -import { budgetPromptContext, type BudgetPromptContext } from './budget.js'; +import { + decisionsForGateType, + reconstructCheckpointState, + type CheckpointState, +} from '@relavium/core'; +import type { GateDecision, HumanGatePausedEvent, RunEvent } from '@relavium/shared'; +import { budgetPromptContext, resumeCommandBase, type BudgetPromptContext } from './budget.js'; +import { FLAG_FOR_DECISION } from './decision.js'; /** A pending human gate on a paused run — the discovery row `relavium gate list` / `status` surface (2.I). */ export interface PendingGate { @@ -23,6 +28,54 @@ export function pendingHumanGates(events: readonly RunEvent[]): PendingGate[] { return pendingGateDisplays(events).pendingGates; } +/** The label each decision's copyable command carries in `status`, beside the budget hints' `approve:` / `reject:`. */ +const HINT_LABEL: Readonly> = { + approved: 'approve', + rejected: 'reject', + input_provided: 'input', +}; + +/** + * The commands that continue a pending human gate, one per decision its type accepts (`decisionsForGateType`) — + * the human-gate counterpart of `budgetResumeHints`, under the same rule: a copyable command is built only from + * unchanged, shell-inert recorded identifiers (`resumeCommandBase`), and any other identifier gets a sentence + * naming the flags instead. An input gate's value is the operator's to supply, so its line keeps the `` + * placeholder, which a shell refuses to run as pasted rather than resolving the gate with it. + */ +export function humanGateResumeHints( + runId: string, + gateId: string, + gateType: PendingGate['gateType'], +): readonly string[] { + const decisions = decisionsForGateType(gateType); + const command = resumeCommandBase('human', runId, gateId); + if (command === undefined) { + const flags = decisions.map((decision) => FLAG_FOR_DECISION[decision]).join(' or '); + return [`Use relavium gate with the recorded run and gate IDs and ${flags}.`]; + } + return decisions.map( + (decision) => `${HINT_LABEL[decision]}: ${command} ${FLAG_FOR_DECISION[decision]}`, + ); +} + +/** + * The nodes whose latest gate event is a decision: a `human_gate:resumed` after the node last started or parked. The + * projection never closes a human gate's step row (no `node:completed` follows the decision, though the engine marks + * the gate vertex completed), so `status` reads the decision here. A node that starts or parks again, as a loop does, + * is undecided until its next decision. A budget gate's events name its agent node, which `status` does not relabel. + */ +export function decidedGateNodes(events: readonly RunEvent[]): ReadonlySet { + const decided = new Set(); + for (const event of events) { + if (event.type === 'node:started' || event.type === 'human_gate:paused') { + decided.delete(event.nodeId); + } else if (event.type === 'human_gate:resumed') { + decided.add(event.nodeId); + } + } + return decided; +} + export interface PendingBudgetGate { readonly gateId: string; readonly nodeId: string; diff --git a/apps/cli/src/gate/prompter.ts b/apps/cli/src/gate/prompter.ts index 72f560aed..ddff9b0ea 100644 --- a/apps/cli/src/gate/prompter.ts +++ b/apps/cli/src/gate/prompter.ts @@ -10,8 +10,9 @@ import type { BudgetPromptContext } from './budget.js'; * depend on the seam without pulling the prompt library — the clack impl lives in its own module, exactly as * the `ink` renderer is split from the `RunRenderer` seam. * - * `prompt` returns the built {@link GateDecision}, or `null` when the user cancels the prompt itself - * (Ctrl-C / ESC) — the run core then cooperatively cancels the whole run. + * `prompt` returns the built {@link GateDecision}, or `null` when the user cancels the card (Ctrl-C, or Esc + * before a rejection is chosen; Esc at the optional rejection reason keeps the rejection) — the run core then + * cooperatively cancels the whole run. * The optional signal invalidates an open card when the run cancels or terminates. Implementations * should release their input resources on abort and return null; the driver discards any late answer. */ diff --git a/apps/cli/src/harness/durability.e2e.test.ts b/apps/cli/src/harness/durability.e2e.test.ts index 318692cc8..99173b32b 100644 --- a/apps/cli/src/harness/durability.e2e.test.ts +++ b/apps/cli/src/harness/durability.e2e.test.ts @@ -232,10 +232,11 @@ describe('CR-10 / CR-92 against the real history.db', () => { }); it('CR-92: `run` DRAINS at start — the retry path the exit code promises actually exists', async () => { - // The defect this closes was severe and was mine: `reconcile()` — the only thing that drained — has no + // The defect this closed was severe and was mine: `reconcile()` — the only thing that drained — had no // shipping caller anywhere in the monorepo, so the outbox, the drain and the certification above were // all unreachable from the real binary. A user who saw exit code 5 had no command that would ever move - // their run to durable, while that exit code's own documentation said one would. + // their run to durable, while that exit code's own documentation said one would. (`run` and `gate` now + // call `reconcile()` at start, which drains first — 2.6.G; this entry point stays public.) const inner = realStore(); let refuse = true; const store = { diff --git a/apps/cli/src/harness/session-chain.e2e.test.ts b/apps/cli/src/harness/session-chain.e2e.test.ts index d8cd526d1..0d2c15d58 100644 --- a/apps/cli/src/harness/session-chain.e2e.test.ts +++ b/apps/cli/src/harness/session-chain.e2e.test.ts @@ -41,6 +41,7 @@ const EMPTY_CHAT: ResolvedChatConfig = { autoCompact: undefined, compactThreshold: undefined, maxCostMicrocents: undefined, + maxTokens: undefined, onExceed: undefined, strictCostCap: false, allowedCommands: undefined, diff --git a/apps/cli/src/history/reconcile.test.ts b/apps/cli/src/history/reconcile.test.ts new file mode 100644 index 000000000..2f46e654f --- /dev/null +++ b/apps/cli/src/history/reconcile.test.ts @@ -0,0 +1,376 @@ +import { randomUUID } from 'node:crypto'; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { parseWorkflow, WorkflowEngine, type RunStore } from '@relavium/core'; +import { + createClient, + createEffectJournalPort, + createEffectJournalStore, + createRunHistoryStore, + createRunLeasePort, + runMigrations, + type DbClient, +} from '@relavium/db'; +import { RunEventSchema, type RunEvent } from '@relavium/shared'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { createCliHost } from '../engine/host.js'; +import { createFileTerminalOutbox } from '../engine/terminal-outbox.js'; +import { terminalOutboxPath } from './open.js'; +import { reconcileInterruptedRuns } from './reconcile.js'; +import { openSessionStore } from './session-open.js'; + +const T0 = '2026-11-05T10:00:00.000Z'; + +const TWO_NODES = parseWorkflow(`schema_version: '1.0' +workflow: + id: liveness + nodes: + - { id: a, type: input } + - { id: b, type: output } + edges: + - { from: a, to: b } +`); + +describe('reconcileInterruptedRuns (2.6.G liveness, #229)', () => { + let dir: string; + let client: DbClient; + + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'relavium-reconcile-')); + mkdirSync(join(dir, '.relavium')); + client = createClient(join(dir, '.relavium', 'history.db')); + runMigrations(client.db); + }); + afterEach(() => { + client.sqlite.close(); + rmSync(dir, { recursive: true, force: true }); + }); + + /** A store for one workflow; `now` lets a test write a lease that has already expired. */ + function store(now: () => number = () => Date.now()) { + return createRunHistoryStore(client.db, { + uuid: () => randomUUID(), + now, + workflow: { slug: 'liveness', name: 'liveness', definitionJson: '{}' }, + }); + } + + /** Persist a run that started and then lost its process: no terminal, and (unless a test adds one) no lease. */ + async function startedRun(extra: (runId: string) => readonly unknown[] = () => []) { + const s = store(); + const runId = randomUUID(); + const workflowId = await s.resolveWorkflowId('liveness'); + const events = [ + { type: 'run:started', runId, workflowId, inputs: {}, executionMode: 'local' }, + { type: 'node:started', runId, nodeId: 'a', nodeType: 'input' }, + ...extra(runId), + ]; + for (const [index, raw] of events.entries()) { + const event = RunEventSchema.parse({ + timestamp: T0, + sequenceNumber: index + 1, + ...(raw as object), + }); + await s.persistEvent(event); + } + return runId; + } + + function events(runId: string): readonly RunEvent[] { + return store().loadRunEvents(runId); + } + + function terminal(runId: string): RunEvent | undefined { + return events(runId).find( + (e) => e.type === 'run:completed' || e.type === 'run:failed' || e.type === 'run:cancelled', + ); + } + + it('settles a run with no owner as run:failed{internal}', async () => { + const runId = await startedRun(); + await reconcileInterruptedRuns(client.db, terminalOutboxPath(dir)); + expect(terminal(runId)).toMatchObject({ type: 'run:failed', error: { code: 'internal' } }); + // The row the Home lists moves off `running` — the user-visible half of the repair. + expect(client.sqlite.prepare('SELECT status FROM runs WHERE id = ?').get(runId)).toEqual({ + status: 'failed', + }); + }); + + it('settles a run that left an external effect unresolved as effect_needs_attention', async () => { + // effect-journal.md §8: an unresolved effect is surfaced and never retried automatically, so the Home's + // reconcile reads the journal too; `internal` would read as an ordinary failure to run again. + const runId = await startedRun(); + const journal = createEffectJournalPort( + createEffectJournalStore(client.db, { uuid: () => randomUUID(), now: () => Date.now() }), + { kind: 'run', runId, nodeId: 'a', attempt: 1 }, + { providerAttempt: 1, toolCallId: 'run' }, + ); + await journal.prepare(0, 'run_command', 3, { command: 'deploy' }); + await reconcileInterruptedRuns(client.db, terminalOutboxPath(dir)); + expect(terminal(runId)).toMatchObject({ + type: 'run:failed', + error: { code: 'effect_needs_attention' }, + }); + }); + + it('leaves a run that started less than one lease TTL ago alone', async () => { + // Its owner may not have taken its lease yet (it can only after `run:started` is durable), or may be a + // version that takes none. The store reports when the run started, and the reconcile reads it. + const s = store(); + const runId = randomUUID(); + await s.persistEvent( + RunEventSchema.parse({ + type: 'run:started', + runId, + workflowId: await s.resolveWorkflowId('liveness'), + inputs: {}, + executionMode: 'local', + timestamp: new Date().toISOString(), + sequenceNumber: 1, + }), + ); + await reconcileInterruptedRuns(client.db, terminalOutboxPath(dir)); + expect(terminal(runId)).toBeUndefined(); + }); + + it('leaves a run alone while another process holds a live lease on it', async () => { + const runId = await startedRun(); + expect(store().leases.acquire(runId, 'another-process', 60_000)).toBeDefined(); + await reconcileInterruptedRuns(client.db, terminalOutboxPath(dir)); + expect(terminal(runId)).toBeUndefined(); + expect(store().leases.read(runId)).toMatchObject({ ownerId: 'another-process', live: true }); + }); + + it('takes over an EXPIRED lease and settles the run', async () => { + const runId = await startedRun(); + const anHourAgo = store(() => Date.now() - 3_600_000); + expect(anHourAgo.leases.acquire(runId, 'a-dead-process', 60_000)).toBeDefined(); + await reconcileInterruptedRuns(client.db, terminalOutboxPath(dir)); + expect(terminal(runId)).toMatchObject({ type: 'run:failed', error: { code: 'internal' } }); + }); + + it('leaves a run parked at a human gate for `relavium gate`', async () => { + const runId = await startedRun((id) => [ + { + type: 'node:completed', + runId: id, + nodeId: 'a', + output: {}, + tokensUsed: { input: 0, output: 0 }, + durationMs: 0, + }, + { type: 'node:started', runId: id, nodeId: 'g', nodeType: 'human_in_the_loop' }, + { + type: 'human_gate:paused', + runId: id, + nodeId: 'g', + gateId: 'gate-1', + gateType: 'approval', + message: 'ok?', + }, + ]); + await reconcileInterruptedRuns(client.db, terminalOutboxPath(dir)); + expect(terminal(runId)).toBeUndefined(); + }); + + it('lands a held terminal from the outbox instead of relabelling the run failed', async () => { + const runId = await startedRun(); + const completed = RunEventSchema.parse({ + type: 'run:completed', + runId, + timestamp: T0, + sequenceNumber: 3, + outputs: {}, + totalTokensUsed: { input: 0, output: 0 }, + totalCostMicrocents: 0, + durationMs: 0, + }); + await createFileTerminalOutbox(terminalOutboxPath(dir)).put(completed); + await reconcileInterruptedRuns(client.db, terminalOutboxPath(dir)); + expect(terminal(runId)?.type).toBe('run:completed'); + expect(await createFileTerminalOutbox(terminalOutboxPath(dir)).list()).toEqual([]); + }); + + it('repairs nothing while the outbox exists but cannot be read, then lands its held terminal', async () => { + const runId = await startedRun(); + const path = terminalOutboxPath(dir); + await createFileTerminalOutbox(path).put( + RunEventSchema.parse({ + type: 'run:completed', + runId, + timestamp: T0, + sequenceNumber: 3, + outputs: { answer: 42 }, + totalTokensUsed: { input: 0, output: 0 }, + totalCostMicrocents: 0, + durationMs: 0, + }), + ); + // A directory where the file was is the portable unreadable outbox (chmod 000 is a no-op for root and on + // Windows). Read as empty, it let this pass write run:failed over a run that had completed. + const held = readFileSync(path, 'utf8'); + rmSync(path); + mkdirSync(path); + await reconcileInterruptedRuns(client.db, path); + expect(terminal(runId)).toBeUndefined(); + + rmSync(path, { recursive: true, force: true }); + writeFileSync(path, held); + await reconcileInterruptedRuns(client.db, path); + expect(terminal(runId)).toMatchObject({ type: 'run:completed', outputs: { answer: 42 } }); + }); + + it('a reconcile inside a starting run’s lease window leaves the run alone, and the run completes', async () => { + // `run:started` is durable a few microtask turns before the run acquires its lease (the lease row needs the + // run row). A reconcile in that window used to settle the run as `internal` before any node ran. It now + // leaves a run younger than one lease TTL alone, so the run takes its lease and finishes normally. Driven + // here by running the real reconcile inside that window. + const s = store(); + let injected = false; + const hooked: RunStore = { + resolveWorkflowId: (slug) => s.resolveWorkflowId(slug), + listInterruptedRuns: () => s.listInterruptedRuns(), + readWorkflowSnapshot: (id) => s.readWorkflowSnapshot(id), + persistEvent: async (event, ctx) => { + await s.persistEvent(event, ctx); + if (event.type === 'run:started' && !injected) { + injected = true; + await reconcileInterruptedRuns(client.db, terminalOutboxPath(dir)); + } + }, + }; + const executed: string[] = []; + const engine = new WorkflowEngine({ + host: createCliHost(hooked, { + runLeases: createRunLeasePort(s), + terminalOutboxPath: terminalOutboxPath(dir), + }), + executor: { + execute: (ctx) => { + executed.push(ctx.vertex.id); + return Promise.resolve({ kind: 'completed' as const, output: {} }); + }, + }, + }); + const handle = engine.start({ workflow: TWO_NODES, inputs: {} }); + for await (const event of handle.events) void event; + + expect(injected).toBe(true); + const terminals = events(handle.runId).filter( + (e) => e.type === 'run:completed' || e.type === 'run:failed' || e.type === 'run:cancelled', + ); + expect(terminals.map((e) => e.type)).toEqual(['run:completed']); + expect(executed).toEqual(['a', 'b']); + expect(handle.durability()).toBe('durable'); + expect(await createFileTerminalOutbox(terminalOutboxPath(dir)).list()).toEqual([]); + }); + + it('…and a reconciler that claims the run anyway still leaves one terminal', async () => { + // A reconciler without the TTL grace (a development build without it, or one whose clock runs more than a TTL + // ahead) can still claim the run inside that window. Its terminal is durable and its lease is still live when the run tries to + // acquire. The acquire is refused, and the run settles at once — so its terminal takes the reconciler's + // sequence number. The store's terminal refusal stops it, and `UNIQUE(run_id, seq)` would stop it if that + // refusal were gone, which is why removing the refusal alone does not fail this case. Pinned for the + // outcome, which must not depend on the order. + const s = store(); + let injected = false; + const hooked: RunStore = { + resolveWorkflowId: (slug) => s.resolveWorkflowId(slug), + listInterruptedRuns: () => s.listInterruptedRuns(), + readWorkflowSnapshot: (id) => s.readWorkflowSnapshot(id), + persistEvent: async (event, ctx) => { + await s.persistEvent(event, ctx); + if (event.type === 'run:started' && !injected) { + injected = true; + const fence = s.leases.acquire(event.runId, 'a-reconciler', 60_000); + if (fence === undefined) throw new Error('the reconciler could not claim the run'); + await s.persistEvent( + RunEventSchema.parse({ + type: 'run:failed', + runId: event.runId, + timestamp: T0, + sequenceNumber: event.sequenceNumber + 1, + error: { code: 'internal', message: 'reconciled', retryable: false }, + partialOutputs: {}, + }), + { + expectedLastSequenceNumber: event.sequenceNumber, + fence: { ownerId: fence.ownerId, generation: fence.generation }, + }, + ); + } + }, + }; + const executed: string[] = []; + const engine = new WorkflowEngine({ + host: createCliHost(hooked, { + runLeases: createRunLeasePort(s), + terminalOutboxPath: terminalOutboxPath(dir), + }), + executor: { + execute: (ctx) => { + executed.push(ctx.vertex.id); + return Promise.resolve({ kind: 'completed' as const, output: {} }); + }, + }, + }); + const handle = engine.start({ workflow: TWO_NODES, inputs: {} }); + for await (const event of handle.events) void event; + + expect(injected).toBe(true); + const terminals = events(handle.runId).filter( + (e) => e.type === 'run:completed' || e.type === 'run:failed' || e.type === 'run:cancelled', + ); + expect(terminals).toHaveLength(1); + expect(terminals[0]).toMatchObject({ error: { message: 'reconciled' } }); + expect(executed).toEqual([]); + }); + + it('openSessionStore pairs the home’s database with that home’s outbox', async () => { + // The pairing matters because the drain prunes an entry whose run is not open in the database it reads. + // The Home reaches reconcile only through this opener, so this is the wiring a mismatched path would break. + const runId = await startedRun(); + await createFileTerminalOutbox(terminalOutboxPath(dir)).put( + RunEventSchema.parse({ + type: 'run:completed', + runId, + timestamp: T0, + sequenceNumber: 3, + outputs: {}, + totalTokensUsed: { input: 0, output: 0 }, + totalCostMicrocents: 0, + durationMs: 0, + }), + ); + const opened = openSessionStore(dir); + try { + expect(opened.reconcileInterruptedRuns).toBeTypeOf('function'); + await opened.reconcileInterruptedRuns?.(); + } finally { + opened.close(); + } + expect(terminal(runId)?.type).toBe('run:completed'); + expect(await createFileTerminalOutbox(terminalOutboxPath(dir)).list()).toEqual([]); + }); + + it('never rejects, even over a closed connection', async () => { + const runId = await startedRun(); + client.sqlite.close(); + await expect( + reconcileInterruptedRuns(client.db, terminalOutboxPath(dir)), + ).resolves.toBeUndefined(); + // Reopen so afterEach can close it, and show the run was not touched by the failed attempt. + client = createClient(join(dir, '.relavium', 'history.db')); + expect(terminal(runId)).toBeUndefined(); + }); + + it('writes nothing when there is nothing to reconcile', async () => { + writeFileSync(terminalOutboxPath(dir), ''); + await reconcileInterruptedRuns(client.db, terminalOutboxPath(dir)); + expect(client.sqlite.prepare('SELECT COUNT(*) AS n FROM runs').get()).toEqual({ n: 0 }); + expect(client.sqlite.prepare('SELECT COUNT(*) AS n FROM workflows').get()).toEqual({ n: 0 }); + }); +}); diff --git a/apps/cli/src/history/reconcile.ts b/apps/cli/src/history/reconcile.ts new file mode 100644 index 000000000..db10da729 --- /dev/null +++ b/apps/cli/src/history/reconcile.ts @@ -0,0 +1,72 @@ +import { randomUUID } from 'node:crypto'; + +import { type NodeExecutor, WorkflowEngine } from '@relavium/core'; +import { + createEffectJournalStore, + createEffectResumePort, + createRunHistoryStore, + createRunLeasePort, + type Db, +} from '@relavium/db'; + +import { createCliHost } from '../engine/host.js'; + +/** + * The workflow scope the store is constructed with. The store reads its scope only to insert a `runs` row on + * `run:started` and to mint a `workflows` row in `resolveWorkflowId`; `reconcile()` does neither, so these + * values are never written. + */ +const RECONCILE_ONLY_SCOPE = { + slug: 'relavium-reconcile-only', + name: 'relavium-reconcile-only', + definitionJson: '{}', +} as const; + +/** The engine needs an executor; a reconcile-only engine starts no run, so any call to it is a wiring bug. */ +const RECONCILE_ONLY_EXECUTOR: NodeExecutor = { + execute: () => Promise.reject(new Error('a reconcile-only engine runs no node')), +}; + +/** + * Settle the runs a dead process left behind, from a surface that has no engine of its own (2.6.G liveness, + * #229). The Home calls this (through `openSessionStore`) when it opens, before its first read of the runs, + * so a crashed run shows as failed rather than as a `running` row forever. `run`, `gate` and `budget resume` + * call `engine.reconcile()` on the engine they already build. + * + * `reconcile()` first drains the terminal outbox (ADR-0078 §4), then writes `run:failed` for each interrupted + * run: `effect_needs_attention` when the run left an external effect unresolved (or its effect journal cannot be + * read), `budget_exceeded` after a recorded budget rejection, `internal` otherwise. It leaves alone a run parked at + * a gate, a run that started less than one lease TTL ago, a run whose lease is live, which another process is + * executing (ADR-0079 §7), and a run whose held terminal it could not write — every run, while the outbox exists + * but cannot be read. The store, the lease port and the + * media-reference port are built over `db`, the same connection the caller reads from. + * + * `terminalOutboxPath` must belong to the same `~/.relavium` home as `db`: the drain DROPS an outbox entry + * whose run is not open in `db`, so pairing one home's outbox with another home's database would delete + * entries that still matter. `openSessionStore` makes that pairing; nothing else should. + * + * Best-effort and never rejects: a store fault here must not keep the Home from opening, and the next start + * tries again. + */ +export async function reconcileInterruptedRuns(db: Db, terminalOutboxPath: string): Promise { + try { + const store = createRunHistoryStore(db, { + uuid: () => randomUUID(), + now: () => Date.now(), + workflow: RECONCILE_ONLY_SCOPE, + }); + const host = createCliHost(store, { + media: { referenceDb: db }, + terminalOutboxPath, + runLeases: createRunLeasePort(store), + }); + // The journal's READ half, so a crashed run with an unresolved external effect is settled as + // `effect_needs_attention` rather than as an ordinary `internal` failure (effect-journal.md §8). + const effectResume = createEffectResumePort( + createEffectJournalStore(db, { uuid: () => randomUUID(), now: () => Date.now() }), + ); + await new WorkflowEngine({ host, executor: RECONCILE_ONLY_EXECUTOR, effectResume }).reconcile(); + } catch { + // Best-effort, as documented above. + } +} diff --git a/apps/cli/src/history/session-open.ts b/apps/cli/src/history/session-open.ts index 5583eefff..403bc8ec9 100644 --- a/apps/cli/src/history/session-open.ts +++ b/apps/cli/src/history/session-open.ts @@ -3,12 +3,22 @@ import { createSessionStore, type Db, type SessionStore } from '@relavium/db'; import { openLocalDb } from '../db/open.js'; import { storageBoundaryError } from '../process/owned-storage.js'; +import { terminalOutboxPath } from './open.js'; +import { reconcileInterruptedRuns } from './reconcile.js'; /** An opened session store plus the handle to close its SQLite connection at REPL end. */ export interface OpenedSessionStore { readonly store: SessionStore; /** The `~/.relavium/history.db` connection the store runs on (ADR-0050) — closed once by {@link close}. */ readonly db: Db; + /** + * Settle the runs a dead process left behind in THIS home (2.6.G liveness): {@link db} with the terminal + * outbox beside it (ADR-0078 §4), paired here because the drain prunes an outbox entry whose run is not + * open in the database it reads. Set by {@link openSessionStore}; the Home awaits it before its first read + * of the runs. A test that injects an in-memory store leaves it unset, so it never reaches the real home's + * outbox. + */ + readonly reconcileInterruptedRuns?: () => Promise; readonly close: () => void; } @@ -34,5 +44,11 @@ export function openSessionStore( } catch (err) { throw storageBoundaryError(err); } - return { store: createSessionStore(opened.db), db: opened.db, close: opened.close }; + return { + store: createSessionStore(opened.db), + db: opened.db, + reconcileInterruptedRuns: () => + reconcileInterruptedRuns(opened.db, terminalOutboxPath(homeDir)), + close: opened.close, + }; } diff --git a/apps/cli/src/home/drive-home.test.ts b/apps/cli/src/home/drive-home.test.ts index 1f794e503..92eeba667 100644 --- a/apps/cli/src/home/drive-home.test.ts +++ b/apps/cli/src/home/drive-home.test.ts @@ -14,11 +14,12 @@ import { fileURLToPath } from 'node:url'; import { createClient, createEffectJournalStore, + createRunHistoryStore, createSessionStore, runMigrations, type DbClient, } from '@relavium/db'; -import { REASONING_EFFORTS, type ReasoningEffort } from '@relavium/shared'; +import { REASONING_EFFORTS, RunEventSchema, type ReasoningEffort } from '@relavium/shared'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { render as renderInk, type Instance } from 'ink'; import { createElement, isValidElement } from 'react'; @@ -26,6 +27,7 @@ import { createElement, isValidElement } from 'react'; import { buildChatSession, buildResumedChatSession } from '../chat/session-host.js'; import { scriptedResolver, textTurn } from '../chat/test-support.js'; import type { ProviderResolver } from '../engine/providers.js'; +import { reconcileInterruptedRuns } from '../history/reconcile.js'; import type { OpenedSessionStore } from '../history/session-open.js'; import type { ClackOnboardingDeps } from '../onboarding/wizard.js'; import { EXIT_CODES } from '../process/exit-codes.js'; @@ -540,6 +542,67 @@ describe('driveHome (2.5.B / ADR-0054)', () => { expect(await drivePromise).toBe(EXIT_CODES.success); }); + it('reconciles a crashed run BEFORE the first strip read (2.6.G liveness, #229)', async () => { + // A run whose process died: `run:started` is durable, there is no terminal and no lease. + const runs = createRunHistoryStore(client.db, { + uuid: () => randomUUID(), + now: () => Date.now(), + workflow: { slug: 'crashed', name: 'crashed', definitionJson: '{}' }, + }); + const runId = randomUUID(); + await runs.persistEvent( + RunEventSchema.parse({ + type: 'run:started', + runId, + timestamp: '2026-11-05T10:00:00.000Z', + sequenceNumber: 1, + workflowId: await runs.resolveWorkflowId('crashed'), + inputs: {}, + executionMode: 'local', + }), + ); + const order: string[] = []; + let captured: RootAppProps | undefined; + const { deps } = makeDeps( + (p) => { + order.push('mount'); + captured = p; + }, + { + openSessionStore: () => ({ + store: createSessionStore(client.db), + db: client.db, + close: closeSpy, + reconcileInterruptedRuns: async () => { + order.push('reconcile'); + await reconcileInterruptedRuns(client.db, join(cwd, 'terminal-outbox.ndjson')); + }, + }), + // The #50 rule for an await before mount: the signal and exit nets are already registered. + subscribeSignals: () => { + order.push('signals'); + return () => undefined; + }, + subscribeProcessExit: () => { + order.push('exit-net'); + return () => undefined; + }, + }, + ); + const drivePromise = driveHome(deps); + await flush(); + const props = captured; + if (props === undefined) throw new Error('the injected render was never invoked'); + + expect(order).toEqual(['signals', 'exit-net', 'reconcile', 'mount']); + // The first snapshot already shows the run as failed, in "attention" — never as a `running` row. + const snap = props.controller.getSnapshot().snapshot; + expect(snap.attention.failedRuns.map((run) => run.runId)).toEqual([runId]); + + props.controller.handleKey('c', CTRL_C); + expect(await drivePromise).toBe(EXIT_CODES.success); + }); + it('startChat builds the default-agent session and persists its row; a clean return ends it', async () => { const invocation = createCliInvocation({ exit: vi.fn(), setExitCode: vi.fn() }); mkdirSync(join(cwd, '.relavium'), { recursive: true }); diff --git a/apps/cli/src/home/drive-home.tsx b/apps/cli/src/home/drive-home.tsx index 94121fde9..7e6a0d0d6 100644 --- a/apps/cli/src/home/drive-home.tsx +++ b/apps/cli/src/home/drive-home.tsx @@ -1261,6 +1261,14 @@ async function driveHomeBody(deps: HomeDeps, ownership?: SessionHomeLifetime): P } } + // **Liveness (2.6.G, #229): settle what a dead process left `running` before the first read of the runs**, + // so the first frame does not show a crashed run as `running` once its dead owner's lease has expired + // (until then the lease still claims it, and it reads `running` for up to a minute). Here, after the signal + // and exit nets, for the same reason as the onboarding wizard above (#50): an await before mount must not + // open a listener-free window. Best-effort and never rejects. The opener supplies it, so the database and the outbox belong to + // one home; an injected test store has none and mounts without the extra turn. + if (opened.reconcileInterruptedRuns !== undefined) await opened.reconcileInterruptedRuns(); + ownership?.surface.work.assertAdmitting(); // Bracketed paste (DECSET 2004) is enabled by ink 7's `usePaste` on mount (home-app.tsx). The defensive // `DISABLE_BRACKETED_PASTE` writes in `restoreTerminalControls` are belt-and-suspenders (usePaste also diff --git a/apps/cli/src/process/exit-codes.ts b/apps/cli/src/process/exit-codes.ts index 34921188d..b62082a10 100644 --- a/apps/cli/src/process/exit-codes.ts +++ b/apps/cli/src/process/exit-codes.ts @@ -26,7 +26,8 @@ export const EXIT_CODES = { * well have COMPLETED — the outputs are in the delivered terminal — and only its durable record is missing. * Reporting it as a failure would be as wrong as reporting it as a success. The terminal is held in the * host's terminal outbox and retried on the next `relavium` start; a caller scripting against this should - * treat the run as done-but-unrecorded and re-check `relavium status` after a subsequent invocation. + * read the durable outcome with `relavium status ` rather than assume one: in the reconcile race + * (commands.md, exit 5) the run never executed and is already recorded failed. * * **Scoped to ADR-0078's case: a terminal was PRODUCED and its write did not land.** A run fenced out * mid-flight (ADR-0079 §5) shares the `uncertain` disposition but produces no terminal at all and writes diff --git a/apps/cli/src/process/native-prompt-io.test.ts b/apps/cli/src/process/native-prompt-io.test.ts index 31c233794..7bd19534c 100644 --- a/apps/cli/src/process/native-prompt-io.test.ts +++ b/apps/cli/src/process/native-prompt-io.test.ts @@ -106,6 +106,52 @@ describe('captured native prompt ports', () => { }, ); + it.each([ + [ + 'Esc', + '\u001b', + { name: 'escape', sequence: '\u001b' }, + { decision: 'rejected', decidedBy: 'cli' }, + ], + ['Ctrl-C', '\u0003', { name: 'c', ctrl: true, sequence: '\u0003' }, null], + ] as const)( + "%s at the bound card's optional rejection reason gives %j and releases the key watcher", + async (_key, char, key, expected) => { + // Clack reports both keys as one cancel; the bound prompter watches the owned input to tell them apart. + const f = fixture(); + const before = f.input.listeners('keypress'); + try { + const pending = createBoundClackGatePrompter(f.io).prompt({ + type: 'human_gate:paused', + runId: 'offline', + timestamp: '2026-10-16T00:00:00Z', + sequenceNumber: 1, + nodeId: 'gate', + gateId: 'owned-gate', + gateType: 'approval', + message: 'OWNED_GATE_CARD', + }); + await turn(); + f.input.emit('keypress', '\r', { name: 'return', sequence: '\r' }); // a bare Enter rejects + for ( + let spin = 0; + spin < 50 && !f.output.frames.join('').includes('Reason for rejection'); + spin += 1 + ) { + await turn(); + } + expect(f.output.frames.join('')).toContain('Reason for rejection'); + await turn(); + f.input.emit('keypress', char, key); + expect(await pending).toEqual(expected); + expect(f.input.listeners('keypress')).toEqual(before); + expect(f.input.isRaw).toBe(false); + } finally { + f.close(); + } + }, + ); + it('binds installed Clack consent log and confirm to the owned ports and acknowledges cancellation', async () => { const f = fixture(); const controller = new AbortController(); diff --git a/apps/cli/src/process/owned-storage-race.test.ts b/apps/cli/src/process/owned-storage-race.test.ts new file mode 100644 index 000000000..815e8a438 --- /dev/null +++ b/apps/cli/src/process/owned-storage-race.test.ts @@ -0,0 +1,154 @@ +import { execFileSync } from 'node:child_process'; +import { + linkSync, + lstatSync, + mkdirSync, + mkdtempSync, + readdirSync, + readFileSync, + rmSync, + statSync, + symlinkSync, + writeFileSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { + replaceFileForStorage, + StorageTargetChangedError, + storageFileIdentity, +} from './owned-storage.js'; + +/** + * A hook after `lstatSync` returns: the moment between the replace's last stat comparison and its open. The + * replace lstats the path twice, before it starts and just before the rename; the swap lands after the second, so + * only the checks on the opened descriptor can see it. + */ +const fsHooks = vi.hoisted(() => ({ + afterLstat: undefined as ((path: string) => void) | undefined, + opened: [] as string[], +})); +vi.mock('node:fs', async (original) => { + const native = await original(); + return { + ...native, + lstatSync: (...args: Parameters) => { + const result = native.lstatSync(...args); + const [path] = args; + if (typeof path === 'string') fsHooks.afterLstat?.(path); + return result; + }, + openSync: (...args: Parameters) => { + const [path] = args; + if (typeof path === 'string') fsHooks.opened.push(path); + return native.openSync(...args); + }, + }; +}); + +describe('replaceFileForStorage — a swap between its last lstat and its open', () => { + let dir: string; + const temps = (): string[] => readdirSync(dir).filter((name) => name.endsWith('.tmp')); + + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'relavium-replace-race-')); + }); + afterEach(() => { + fsHooks.afterLstat = undefined; + fsHooks.opened = []; + rmSync(dir, { recursive: true, force: true }); + }); + + // Something already at the path when the replace starts is refused on its `lstat`, before anything opens it: on + // Windows, which has no `O_NOFOLLOW`, that is the only thing keeping a link from being followed by the open. + for (const [what, put] of [ + ['a directory', (path: string) => mkdirSync(path)], + ...(process.platform === 'win32' + ? [] + : ([ + ['a link', (path: string) => symlinkSync(join(dir, 'elsewhere.yaml'), path)], + ['a FIFO', (path: string) => execFileSync('mkfifo', [path])], + ] as const)), + ] as const) { + it(`refuses ${what} already at the path without opening it`, () => { + const path = join(dir, 'w.relavium.yaml'); + writeFileSync(join(dir, 'elsewhere.yaml'), 'old'); + writeFileSync(path, 'old'); + const checked = storageFileIdentity(lstatSync(path, { bigint: true }), readFileSync(path)); + rmSync(path); + put(path); + fsHooks.opened = []; + expect(() => { + replaceFileForStorage(path, 'new', 0o644, checked); + }).toThrow(StorageTargetChangedError); + expect(fsHooks.opened.filter((opened) => opened === path)).toEqual([]); + expect(temps()).toEqual([]); + }); + } + + /** Replace `path` after swapping `swap` in at its second lstat; return what the replace threw. */ + const replaceWithSwap = (path: string, swap: () => void): unknown => { + writeFileSync(path, 'old'); + const checked = storageFileIdentity(lstatSync(path, { bigint: true }), readFileSync(path)); + let seen = 0; + fsHooks.afterLstat = (target) => { + if (target !== path) return; + seen += 1; + if (seen !== 2) return; + fsHooks.afterLstat = undefined; + swap(); + }; + try { + replaceFileForStorage(path, 'new', 0o644, checked); + return undefined; + } catch (error) { + return error; + } finally { + fsHooks.afterLstat = undefined; + } + }; + + it('refuses another file with the same bytes put there, which only the descriptor check can tell', () => { + const path = join(dir, 'w.relavium.yaml'); + const other = join(dir, 'other.yaml'); + const thrown = replaceWithSwap(path, () => { + writeFileSync(other, 'old'); + rmSync(path); + linkSync(other, path); // a different inode with the checked bytes + }); + expect(thrown).toBeInstanceOf(StorageTargetChangedError); + expect(readFileSync(other, 'utf8')).toBe('old'); + expect(temps()).toEqual([]); + }); + + it('refuses a directory put there, rather than failing on its read', () => { + const path = join(dir, 'w.relavium.yaml'); + const thrown = replaceWithSwap(path, () => { + rmSync(path); + mkdirSync(path); + }); + expect(thrown).toBeInstanceOf(StorageTargetChangedError); + expect(statSync(path).isDirectory()).toBe(true); + expect(temps()).toEqual([]); + }); + + it.skipIf(process.platform === 'win32')( + 'refuses a link put there, whether or not the open follows it, rather than failing on the open', + () => { + const path = join(dir, 'w.relavium.yaml'); + const target = join(dir, 'target.yaml'); + const thrown = replaceWithSwap(path, () => { + writeFileSync(target, 'old'); + rmSync(path); + symlinkSync(target, path); + }); + expect(thrown).toBeInstanceOf(StorageTargetChangedError); + expect(lstatSync(path).isSymbolicLink()).toBe(true); + expect(readFileSync(target, 'utf8')).toBe('old'); + expect(temps()).toEqual([]); + }, + ); +}); diff --git a/apps/cli/src/process/owned-storage-replace.test.ts b/apps/cli/src/process/owned-storage-replace.test.ts new file mode 100644 index 000000000..120a66679 --- /dev/null +++ b/apps/cli/src/process/owned-storage-replace.test.ts @@ -0,0 +1,238 @@ +import { execFileSync } from 'node:child_process'; +import { + chmodSync, + lstatSync, + mkdirSync, + mkdtempSync, + readdirSync, + readFileSync, + rmSync, + statSync, + symlinkSync, + writeFileSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { isCliError } from './errors.js'; +import { + replaceFileForStorage, + StorageTargetChangedError, + storageFileIdentity, + type StorageFileIdentity, +} from './owned-storage.js'; + +/** + * The atomic rewrite `/export` uses for a session's own earlier export (MVP-A3), on the real filesystem — + * owned-storage.test.ts mocks `node:fs` for its errno boundary and cannot show what lands on disk. + */ +describe('replaceFileForStorage', () => { + let dir: string; + const temps = (): string[] => readdirSync(dir).filter((name) => name.endsWith('.tmp')); + const isRoot = typeof process.getuid === 'function' && process.getuid() === 0; + /** What `/export` captures when it recognizes its own file: the stat fields and the bytes' hash. */ + const identityOf = (path: string): StorageFileIdentity => + storageFileIdentity(lstatSync(path, { bigint: true }), readFileSync(path)); + + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'relavium-replace-')); + }); + afterEach(() => { + rmSync(dir, { recursive: true, force: true }); + }); + + it('replaces the file and leaves no temp beside it', () => { + const path = join(dir, 'w.relavium.yaml'); + writeFileSync(path, 'old'); + replaceFileForStorage(path, 'new', 0o644, identityOf(path)); + expect(readFileSync(path, 'utf8')).toBe('new'); + expect(temps()).toEqual([]); + }); + + it.skipIf(process.platform === 'win32')( + 'refuses a path that became a link after the check, and never writes through it', + () => { + const outside = join(dir, 'outside.yaml'); + writeFileSync(outside, 'not ours'); + const path = join(dir, 'w.relavium.yaml'); + writeFileSync(path, 'old'); + const checked = identityOf(path); + rmSync(path); + symlinkSync(outside, path); + expect(() => { + replaceFileForStorage(path, 'new', 0o644, checked); + }).toThrow(StorageTargetChangedError); + expect(lstatSync(path).isSymbolicLink()).toBe(true); + expect(readFileSync(outside, 'utf8')).toBe('not ours'); + expect(temps()).toEqual([]); + }, + ); + + it('refuses a directory put at the path after the check, removes its temp, and leaves what was there', () => { + const path = join(dir, 'w.relavium.yaml'); + writeFileSync(path, 'old'); + const checked = identityOf(path); + rmSync(path); + mkdirSync(path); + expect(() => { + replaceFileForStorage(path, 'new', 0o644, checked); + }).toThrow(StorageTargetChangedError); + expect(statSync(path).isDirectory()).toBe(true); + expect(temps()).toEqual([]); + }); + + it.skipIf(process.platform === 'win32')( + 'refuses a FIFO put at the path after the check, without blocking', + () => { + const path = join(dir, 'w.relavium.yaml'); + writeFileSync(path, 'old'); + const checked = identityOf(path); + rmSync(path); + execFileSync('mkfifo', [path]); + expect(() => { + replaceFileForStorage(path, 'new', 0o644, checked); + }).toThrow(StorageTargetChangedError); + expect(lstatSync(path).isFIFO()).toBe(true); + expect(temps()).toEqual([]); + }, + ); + + for (const field of ['dev', 'ino', 'size', 'mtimeNs', 'ctimeNs'] as const) { + it(`refuses a file whose ${field} differs from the check, whatever the filesystem clock`, () => { + // A chmod made after recognition moves only the ctime; on a coarse clock it may not move it within one tick, + // so the comparison itself is pinned with the one field forged, the bytes and every other field real. + const path = join(dir, 'w.relavium.yaml'); + writeFileSync(path, 'old'); + const real = identityOf(path); + const checked = { ...real, [field]: real[field] + 1n }; + expect(() => { + replaceFileForStorage(path, 'new', 0o644, checked); + }).toThrow(StorageTargetChangedError); + expect(readFileSync(path, 'utf8')).toBe('old'); + expect(temps()).toEqual([]); + }); + } + + it.skipIf(process.platform === 'win32' || isRoot)( + 'refuses a file made unreadable after the check as changed, rather than failing the export', + () => { + // The stat fields are compared before the bytes are read, so the permission change (a new ctime) is seen as a + // change and the export moves on, instead of an EACCES from the read failing it. + const path = join(dir, 'w.relavium.yaml'); + writeFileSync(path, 'old'); + const checked = identityOf(path); + chmodSync(path, 0o000); + try { + expect(() => { + replaceFileForStorage(path, 'new', 0o644, checked); + }).toThrow(StorageTargetChangedError); + expect(temps()).toEqual([]); + } finally { + chmodSync(path, 0o644); + } + expect(readFileSync(path, 'utf8')).toBe('old'); + }, + ); + + for (const [change, apply] of [ + [ + 'replaced by another file', + (path: string) => { + rmSync(path); + writeFileSync(path, 'another workflow, put here meanwhile'); + }, + ], + [ + 'rewritten in place', + (path: string) => { + writeFileSync(path, 'rewritten in place, meanwhile'); + }, + ], + [ + 'rewritten in place to the same length', + (path: string) => { + writeFileSync(path, 'new'); // as long as 'old': on a coarse clock only the bytes' hash tells + }, + ], + [ + 'removed', + (path: string) => { + rmSync(path); + }, + ], + ] as const) { + it(`refuses a file ${change} after the check, and writes nothing`, () => { + const path = join(dir, 'w.relavium.yaml'); + writeFileSync(path, 'old'); + const checked = identityOf(path); + apply(path); + const before = (() => { + try { + return readFileSync(path, 'utf8'); + } catch { + return undefined; + } + })(); + expect(() => { + replaceFileForStorage(path, 'new', 0o644, checked); + }).toThrow(StorageTargetChangedError); + expect( + (() => { + try { + return readFileSync(path, 'utf8'); + } catch { + return undefined; + } + })(), + ).toBe(before); + expect(temps()).toEqual([]); + }); + } + + it('refuses a file whose bytes differ from the check even when every stat field matches', () => { + // What a same-length rewrite inside one tick of a coarse filesystem clock looks like: the same device, inode, + // size and times, other bytes. Only the content hash can tell, so this pins it whatever the local clock. + const path = join(dir, 'w.relavium.yaml'); + writeFileSync(path, 'old'); + // The real stat fields with other bytes' hash: the check must hash the file's own bytes to tell them apart. + const checked = storageFileIdentity( + lstatSync(path, { bigint: true }), + Buffer.from('other bytes'), + ); + expect(() => { + replaceFileForStorage(path, 'new', 0o644, checked); + }).toThrow(StorageTargetChangedError); + expect(readFileSync(path, 'utf8')).toBe('old'); + expect(temps()).toEqual([]); + }); + + it.skipIf(process.platform === 'win32')( + 'gives the new file exactly the old permission bits, whatever the umask would make of them', + () => { + const path = join(dir, 'w.relavium.yaml'); + writeFileSync(path, 'old'); + for (const mode of [0o600, 0o666]) { + chmodSync(path, mode); + replaceFileForStorage(path, 'new', mode, identityOf(path)); + expect(statSync(path).mode & 0o777).toBe(mode); + } + }, + ); + + it.skipIf(isRoot)('refuses a file made read-only rather than replacing it', () => { + const path = join(dir, 'w.relavium.yaml'); + writeFileSync(path, 'old'); + chmodSync(path, 0o444); + let caught: unknown; + try { + replaceFileForStorage(path, 'new', 0o444, identityOf(path)); + } catch (error) { + caught = error; + } + expect(isCliError(caught) && caught.code).toBe('storage_unavailable'); + expect(readFileSync(path, 'utf8')).toBe('old'); + expect(temps()).toEqual([]); + }); +}); diff --git a/apps/cli/src/process/owned-storage.ts b/apps/cli/src/process/owned-storage.ts index 327b6311f..3a48418f2 100644 --- a/apps/cli/src/process/owned-storage.ts +++ b/apps/cli/src/process/owned-storage.ts @@ -1,5 +1,23 @@ -import { chmodSync, mkdirSync, writeFileSync } from 'node:fs'; +import { createHash, randomUUID } from 'node:crypto'; +import { + accessSync, + type BigIntStats, + chmodSync, + closeSync, + constants, + fchmodSync, + fstatSync, + fsyncSync, + lstatSync, + mkdirSync, + openSync, + readSync, + renameSync, + unlinkSync, + writeFileSync, +} from 'node:fs'; import { mkdtemp, writeFile } from 'node:fs/promises'; +import { basename, dirname, join } from 'node:path'; import { CliError, toCliError } from './errors.js'; @@ -35,6 +53,154 @@ export function writeFileForStorage(path: string, content: string, force: boolea } } +/** + * A file's identity: what {@link replaceFileForStorage} re-checks before it renames. The `lstat` fields catch a file + * put in its place (another device or inode) and most rewrites (a new size or `ctime`), but `ctime` is only as fine + * as the filesystem's clock — a few milliseconds on a Linux kernel before 6.13, a second on HFS+ — and ext4 reuses a + * freed inode. The SHA-256 of the bytes catches what those miss: a same-length change inside one clock tick. + */ +export interface StorageFileIdentity { + readonly dev: bigint; + readonly ino: bigint; + readonly size: bigint; + readonly mtimeNs: bigint; + readonly ctimeNs: bigint; + /** Hex SHA-256 of the file's bytes. */ + readonly sha256: string; +} + +/** The identity of the file `stats` (a `bigint` stat) describes, whose bytes are `content`. */ +export function storageFileIdentity(stats: BigIntStats, content: Uint8Array): StorageFileIdentity { + return { + dev: stats.dev, + ino: stats.ino, + size: stats.size, + mtimeNs: stats.mtimeNs, + ctimeNs: stats.ctimeNs, + sha256: createHash('sha256').update(content).digest('hex'), + }; +} + +/** The file to be replaced is no longer the one the caller checked; nothing was written. */ +export class StorageTargetChangedError extends Error { + constructor() { + super('the file changed after it was checked'); + this.name = 'StorageTargetChangedError'; + } +} + +/** + * Replace the file at `path` with `content` atomically: write a fresh temp beside it (`wx`, so it never follows a + * link or reuses a file), `fsync` it, then rename it over `path`. A crash leaves either the old file or the new one, + * never a truncated mix, and the rename replaces the directory entry rather than writing through a link at `path`. + * The temp is removed when anything fails. + * + * It keeps what a write in place would: a file the user made read-only is refused (`EACCES`), not replaced, and the + * new file gets `mode`, the old file's permission bits, so a file narrowed to `0600` is not widened to the umask's + * default by its own rewrite. + * + * It replaces only the file the caller checked. Before it starts, and again just before the rename, it compares + * the file at `path` with `expected` and throws {@link StorageTargetChangedError} when it was replaced, rewritten + * or removed meanwhile — which, for a transcript big enough to take tens of milliseconds to serialize and write, is a + * window another writer can hit. The stat fields are compared before anything is opened, and the bytes are read + * only from the same regular file, so a link, a FIFO, a directory or a larger file put there is refused rather than + * blocked on or read whole. A link is refused unfollowed when it is there at the `lstat`; on Windows, which has no + * `O_NOFOLLOW`, one swapped in between the `lstat` and the open is followed, and its target refused. Not detected: a + * swap in the moment between that last check and the rename (Node has no rename that compares first). + */ +export function replaceFileForStorage( + path: string, + content: string, + mode: number, + expected: StorageFileIdentity, +): void { + const temp = join(dirname(path), `.${basename(path)}.${randomUUID()}.tmp`); + /** Throw {@link StorageTargetChangedError} unless `path` is still the regular file the caller checked. */ + const ensureUnchanged = (): void => { + const current = identityIfUnchanged(path, expected); + if (current?.sha256 !== expected.sha256) throw new StorageTargetChangedError(); + }; + let fd: number | undefined; + try { + ensureUnchanged(); + accessSync(path, constants.W_OK); + fd = openSync(temp, 'wx', mode); + fchmodSync(fd, mode); // `open`'s mode is narrowed by the umask; this sets exactly the old bits + writeFileSync(fd, content, 'utf8'); + fsyncSync(fd); + closeSync(fd); + fd = undefined; + ensureUnchanged(); + renameSync(temp, path); + } catch (error) { + if (fd !== undefined) { + try { + closeSync(fd); + } catch { + // already failing — a secondary close error adds nothing + } + } + try { + unlinkSync(temp); + } catch { + // best-effort: the temp may never have been created + } + throwOwnedFilesystemFailure(error); + } +} + +/** + * The identity of the regular file at `path`, read only when its `lstat` fields already equal `expected`'s, or + * `undefined` for anything else. The bytes come through a non-blocking, no-follow descriptor whose `fstat` must + * match too (the open `/export`'s own read uses), and no more of them than `expected.size` plus one, to see growth. + * A FIFO swapped in between the `lstat` and the open is the case `O_NONBLOCK` is for; no test drives it, since a + * regression would hang the test worker rather than fail it. + */ +function identityIfUnchanged( + path: string, + expected: StorageFileIdentity, +): StorageFileIdentity | undefined { + const linked = lstatSync(path, { bigint: true, throwIfNoEntry: false }); + if (linked === undefined || !linked.isFile() || !sameStat(linked, expected)) return undefined; + let fd: number; + try { + // `O_NOFOLLOW` is absent on Windows, so a link swapped in after the `lstat` is followed there; the `fstat` check + // below then refuses its target. + fd = openSync(path, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0) | constants.O_NONBLOCK); + } catch { + return undefined; // removed, turned into a link, or no longer readable since the `lstat`: changed + } + try { + const opened = fstatSync(fd, { bigint: true }); + if (!opened.isFile() || !sameStat(opened, expected)) return undefined; + const bytes = Buffer.alloc(Number(expected.size) + 1); + let length = 0; + while (length < bytes.length) { + const read = readSync(fd, bytes, length, bytes.length - length, null); + if (read === 0) break; + length += read; + } + return storageFileIdentity(opened, bytes.subarray(0, length)); + } finally { + try { + closeSync(fd); + } catch { + // a close fault changes nothing about the answer + } + } +} + +/** Whether `stats` has every stat field of `expected` (all but the hash). */ +function sameStat(stats: BigIntStats, expected: StorageFileIdentity): boolean { + return ( + stats.dev === expected.dev && + stats.ino === expected.ino && + stats.size === expected.size && + stats.mtimeNs === expected.mtimeNs && + stats.ctimeNs === expected.ctimeNs + ); +} + /** Concrete native acquisition for the editor's private transcript directory. */ export async function mkdtempForStorage(prefix: string): Promise { try { diff --git a/apps/cli/src/render/renderer.ts b/apps/cli/src/render/renderer.ts index e0382eb3f..22d32c5f3 100644 --- a/apps/cli/src/render/renderer.ts +++ b/apps/cli/src/render/renderer.ts @@ -13,6 +13,7 @@ import { budgetPromptDetails, budgetResumeHints, } from '../gate/budget.js'; +import { formatRunOutputs } from './run-outputs.js'; import { formatProducedMedia } from './tui/format.js'; import { sanitizeInline, stringifyJsonLine } from './sanitize.js'; @@ -172,7 +173,10 @@ function describe(event: RunEvent): string | undefined { case 'human_gate:paused': return ` paused at gate ${sanitizeInline(event.gateId)} (${sanitizeInline(event.gateType)})`; case 'run:completed': - return `done: run completed`; + // The outputs follow as indented rows this file lays out (MVP-A1); every value is sanitized there. + return ['done: run completed', ...formatRunOutputs(event.outputs, ' ', event.runId)].join( + '\n', + ); case 'run:failed': return `done: run failed (${sanitizeInline(event.error.code)})`; case 'run:cancelled': diff --git a/apps/cli/src/render/run-outputs.test.ts b/apps/cli/src/render/run-outputs.test.ts new file mode 100644 index 000000000..ccf64adc3 --- /dev/null +++ b/apps/cli/src/render/run-outputs.test.ts @@ -0,0 +1,414 @@ +import { createClient, runMigrations, type DbClient } from '@relavium/db'; +import type { RunEvent } from '@relavium/shared'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { logsCommand } from '../commands/logs.js'; +import { captureIo, seedRun } from '../test-support.js'; +import { createPlainRenderer } from './renderer.js'; +import { + formatRunOutputs, + MAX_TOTAL_CHARS, + MAX_VALUE_CHARS, + MAX_VALUE_LINES, +} from './run-outputs.js'; +import { renderFinalSummary } from './tui/final-summary.js'; +import { initialRunViewState, reduceRunEvent } from './tui/run-view-model.js'; + +const TS = '2026-11-05T00:00:00.000Z'; +/** A credential shape the shared scrubber masks (`sk-` + a long opaque body). */ +const KEY = `sk-${'A'.repeat(32)}_${'B'.repeat(32)}`; + +const completed = (outputs: Record): RunEvent => ({ + type: 'run:completed', + runId: 'run-1', + timestamp: TS, + sequenceNumber: 9, + outputs, + totalTokensUsed: { input: 1, output: 2 }, + totalCostMicrocents: 0, + durationMs: 5, +}); + +describe('formatRunOutputs (MVP-A1)', () => { + it('prints nothing for a run with no outputs', () => { + expect(formatRunOutputs({}, ' ', 'run-1')).toEqual([]); + }); + + it('puts a one-line value beside its key and anything else as compact JSON, a masked secret still masked', () => { + expect( + formatRunOutputs( + { summary: 'all green', count: 3, token: { secret: true, ref: 'inputs.token' } }, + ' ', + ), + ).toEqual([ + ' outputs:', + ' summary: all green', + ' count: 3', + ' token: {"secret":true,"ref":"inputs.token"}', + ]); + }); + + it('indents every row of a multi-line value under its key, so it cannot forge a summary row', () => { + const lines = formatRunOutputs({ report: 'line one\n✓ forged-node\nline three' }, ' '); + expect(lines).toEqual([ + ' outputs:', + ' report:', + ' line one', + ' ✓ forged-node', + ' line three', + ]); + }); + + it('strips terminal controls and masks secret shapes in values, and collapses a key to one row', () => { + const lines = formatRunOutputs( + { + 'bad\nkey': `ok \u001b[31mred\u001b[0m \u001b]8;;https://x\u0007link\u001b]8;;\u0007 ${KEY}\rover`, + }, + ' ', + ); + const text = lines.join('\n'); + expect(text).not.toContain('\u001b'); + expect(text).not.toContain('\u0007'); + expect(text).not.toContain('\r'); + expect(text).not.toContain(KEY); + expect(text).toContain('[REDACTED]'); + expect(lines[1]?.startsWith(' bad key:')).toBe(true); + }); + + it('bounds each value by lines and by characters, and says where the full record is', () => { + const tall = Array.from({ length: MAX_VALUE_LINES + 10 }, (_, i) => `row ${String(i)}`).join( + '\n', + ); + const tallLines = formatRunOutputs({ tall }, ' ', 'run-7'); + expect(tallLines.filter((line) => line.startsWith(' row '))).toHaveLength(MAX_VALUE_LINES); + expect(tallLines.at(-2)).toBe(' …'); + expect(tallLines.at(-1)).toBe( + ' (outputs shortened — the full record: `relavium logs run-7 --json`)', + ); + + const wide = formatRunOutputs({ wide: 'x'.repeat(MAX_VALUE_CHARS + 50) }, ' '); + expect(wide.join('').split('x')).toHaveLength(MAX_VALUE_CHARS + 1); + expect(wide.at(-1)).toContain('`relavium logs --json`'); + }); + + it('bounds the whole block, then lists the keys it had no room for', () => { + const big = 'y'.repeat(MAX_VALUE_CHARS); + const keys = Object.fromEntries( + Array.from({ length: Math.ceil(MAX_TOTAL_CHARS / MAX_VALUE_CHARS) + 1 }, (_, i) => [ + `k${String(i)}`, + big, + ]), + ); + const lines = formatRunOutputs(keys, ''); + expect(lines.join('').split('y').length - 1).toBe(MAX_TOTAL_CHARS); + expect(lines).toContain(` k${String(Object.keys(keys).length - 1)}: …`); + }); + + it('masks a key inside a non-string value before serializing it, so an escaped newline cannot hide it', () => { + // JSON.stringify writes the newline as the two characters `\n`; masked after that, `nsk-…` had no boundary. + for (const outputs of [ + { v: { text: `config:\n${KEY}` } }, + { fan: { writer: `Your key:\n${KEY}`, reviewer: 'ok' } }, + { tab: [`k\t${KEY}`] }, + { crlf: { t: `k\r\n${KEY}` } }, + ]) { + const text = formatRunOutputs(outputs, ' ', 'run-3').join('\n'); + expect(text).not.toContain(KEY); + expect(text).toContain('[REDACTED]'); + expect(text).toContain('(outputs masked — the full record: `relavium logs run-3 --json`)'); + } + // An object key is masked too. + expect(formatRunOutputs({ v: { [KEY]: 1 } }, '').join('\n')).not.toContain(KEY); + }); + + it('reads each field with its name, so a value named like an API-key header is masked wherever it sits', () => { + // Masked leaf by leaf, neither `x-api-key` nor the opaque value is a shape alone; the scrubber's header rule + // needs the name, the `:` and the value in one text, as a serialized object has them. + const OPAQUE = '9f8e7d6c5b4a39281706f5e4d3c2b1a0'; + for (const name of ['x-api-key', 'X-Api-Key', 'api-key', 'x-goog-api-key']) { + for (const outputs of [ + { v: { [name]: OPAQUE } }, + { request: { url: 'https://svc.internal/v1', headers: { [name]: OPAQUE } } }, + { v: [{ [name]: OPAQUE }] }, + { v: { [name]: Number('1234567890123456') } }, + { [name]: OPAQUE }, + { [name]: Number('1234567890123456') }, + ]) { + const text = formatRunOutputs(outputs, '', 'run-6').join('\n'); + expect(text, `${name} in ${JSON.stringify(Object.keys(outputs))}`).not.toContain( + OPAQUE.slice(0, 12), + ); + expect(text).not.toContain('1234567890'); + expect(text).toContain('(outputs masked — the full record: `relavium logs run-6 --json`)'); + } + } + // The boundary: a name decides only through a shape the scrubber knows. Another name masks nothing, and a + // value already masked leaf by leaf keeps its prose rather than being masked whole. + expect(formatRunOutputs({ v: { note: OPAQUE } }, '')).toEqual([ + 'outputs:', + ` v: {"note":"${OPAQUE}"}`, + ]); + expect(formatRunOutputs({ v: { note: `see ${KEY} now` } }, '')[1]).toBe( + ' v: {"note":"see [REDACTED] now"}', + ); + }); + + it('reads only a field NAME for that, so a token masked in place at a line end never masks the whole value', () => { + // Read with its value, the field's JSON text wrote the newline after `Basic [REDACTED]` as the two characters + // `\n`; the scrubber's token then ran on into the next line, the text changed, and the whole README was masked. + expect( + formatRunOutputs({ readme: '# mytool\n\n## Basic usage\nRun it.' }, '', 'run-7'), + ).toEqual([ + 'outputs:', + ' readme:', + ' # mytool', + ' ', + ' ## Basic [REDACTED]', + ' Run it.', + ' (outputs masked — the full record: `relavium logs run-7 --json`)', + ]); + expect(formatRunOutputs({ result: { body: '## Basic usage\nRun it.' } }, '')[1]).toBe( + ' result: {"body":"## Basic [REDACTED]\\nRun it."}', + ); + expect(formatRunOutputs({ raw: 'Authorization: Bearer abc\r\nnext' }, '').slice(1, 4)).toEqual([ + ' raw:', + ' Authorization: Bearer [REDACTED]', + ' next', + ]); + // Nor does a name join its value: a URL-shaped name and an `@` in the value stay as they are, and a name that is + // itself a secret shape is masked, not the value beneath it. + expect(formatRunOutputs({ links: { 'https://acme.com': 'info@acme.com' } }, '')).toEqual([ + 'outputs:', + ' links: {"https://acme.com":"info@acme.com"}', + ]); + expect(formatRunOutputs({ v: { [KEY]: 1 } }, '')[1]).toBe(' v: {"[REDACTED]":1}'); + // A name that ends in a credential's own prefix is a label, as a header name is: its value is masked. + expect(formatRunOutputs({ v: { 'https://h.example/a?token=': 'plainvalue' } }, '')[1]).toBe( + ' v: {"https://h.example/a?token=":"[REDACTED]"}', + ); + // A member JSON drops is still dropped under a header name, never shown as a masked value. + expect(formatRunOutputs({ v: { 'x-api-key': undefined, y: 1 } }, '')).toEqual([ + 'outputs:', + ' v: {"y":1}', + ]); + }); + + it('a name the scrubber masks up to its end is masked itself and keeps its value', () => { + // In the serialized view such a name's own token has nothing after it, so it runs on into the stand-in value; + // that is the name's secret shape, not a label for the value beneath it. + expect( + formatRunOutputs( + { plans: { 'Basic plan': '$9/month', 'Pro plan': '$29/month' } }, + '', + 'run-8', + ), + ).toEqual([ + 'outputs:', + ' plans: {"Basic [REDACTED]":"$9/month","Pro plan":"$29/month"}', + ' (outputs masked — the full record: `relavium logs run-8 --json`)', + ]); + expect( + formatRunOutputs({ auth: { 'Bearer token': 'supported', 'Basic support': true } }, '')[1], + ).toBe(' auth: {"Bearer [REDACTED]":"supported","Basic [REDACTED]":true}'); + expect( + formatRunOutputs({ links: { 'https://x.example/reset?token=abc': 'HTTP 200' } }, '')[1], + ).toBe(' links: {"https://x.example/reset?token=[REDACTED]":"HTTP 200"}'); + // A top-level output's name is an authored id, shown as written; it masks its value no more than a member's does. + expect(formatRunOutputs({ 'Basic tier': 'free' }, '')[1]).toBe(' Basic tier: free'); + // A label the scrubber leaves whole on its own still masks the value: a header name, with a colon or a space + // after it, a URL ending in a credential's prefix, and a header name after a secret shape the name also holds. + for (const name of [ + 'x-api-key', + 'X-Api-Key:', + 'api-key ', + 'https://h.example/a?b=1&token=', + `${KEY} x-api-key`, + ]) { + expect(formatRunOutputs({ v: { [name]: 'opaque' } }, '')[1]).toMatch(/:"\[REDACTED\]"\}$/u); + } + }); + + it('keeps an own __proto__ member, as JSON.parse makes one, rather than dropping it unsaid', () => { + const parsed: unknown = JSON.parse('{"v":{"__proto__":{"x":"kept"},"y":1}}'); + if (typeof parsed !== 'object' || parsed === null) throw new Error('fixture'); + expect(formatRunOutputs(Object.fromEntries(Object.entries(parsed)), '')).toEqual([ + 'outputs:', + ' v: {"__proto__":{"x":"kept"},"y":1}', + ]); + }); + + it('trims a final newline run in linear time, whatever newline run sits inside the value', () => { + // `/\n+$/` retried from every newline of an interior run: 200,000 of them took about 13 s. A loop takes ms. + const lines = formatRunOutputs({ v: `${'\n'.repeat(200_000)}x` }, ''); + expect(lines[1]).toBe(' v:'); + }, 5_000); + + it('masks each field of a record on its own, so a URL in one and an @ in the next stay whole', () => { + // Masked as one JSON text, the URL rule ran from `https://` across the field boundary to the next `@`, + // swallowed the email field and showed a fake host. + expect( + formatRunOutputs({ contact: { site: 'https://acme.com', email: 'info@acme.com' } }, ''), + ).toEqual(['outputs:', ' contact: {"site":"https://acme.com","email":"info@acme.com"}']); + }); + + it('says when a value was masked and where the exact record is, even when the scrubber masked it whole', () => { + // The shared scrubber's documented fail-closed residual reads a path-less URL, prose and a later `@` as URL + // userinfo once whitespace is removed. The value is masked whole; the note keeps that from reading as silence. + const prose = 'Docs are at https://acme.com and support is reachable at help@acme.com.'; + expect(formatRunOutputs({ summary: prose }, '', 'run-5')).toEqual([ + 'outputs:', + ' summary: [REDACTED]', + ' (outputs masked — the full record: `relavium logs run-5 --json`)', + ]); + }); + + it('says both causes when a block is shortened and masked', () => { + const lines = formatRunOutputs({ a: KEY, b: 'x'.repeat(MAX_VALUE_CHARS + 1) }, '', 'run-4'); + expect(lines.at(-1)).toBe( + ' (outputs shortened and masked — the full record: `relavium logs run-4 --json`)', + ); + }); + + it('reads a value the way JSON does: toJSON is honoured, and a cycle is said rather than thrown', () => { + expect(formatRunOutputs({ at: { when: new Date(0) } }, '')).toEqual([ + 'outputs:', + ' at: {"when":"1970-01-01T00:00:00.000Z"}', + ]); + const cyclic: Record = {}; + cyclic['self'] = cyclic; + expect(formatRunOutputs({ loop: cyclic }, '')).toEqual([ + 'outputs:', + ' loop: [unrepresentable value]', + ]); + }); + + it('masks a key on the original text before clipping, so a cut can never leave a short unmasked prefix', () => { + // Clipped first, the key would end as `sk-AAAA` — too short a fragment for the scrubber to recognize. + const text = formatRunOutputs({ v: `${'x'.repeat(MAX_VALUE_CHARS - 8)} ${KEY}` }, '').join( + '\n', + ); + expect(text).not.toContain('sk-'); + }); + + it('treats a final newline as the end of the value, not an empty row or a cut', () => { + expect(formatRunOutputs({ a: 'one\ntwo\n' }, '')).toEqual([ + 'outputs:', + ' a:', + ' one', + ' two', + ]); + const forty = Array.from({ length: MAX_VALUE_LINES }, (_, i) => `r${String(i)}`).join('\n'); + expect(formatRunOutputs({ a: `${forty}\n` }, '').at(-1)).toBe( + ` r${String(MAX_VALUE_LINES - 1)}`, + ); + }); + + it('never splits a code point at the bound', () => { + // One ASCII unit first, so a UTF-16 code-unit cut would land in the middle of a pair. + const lines = formatRunOutputs({ emoji: `a${'😀'.repeat(MAX_VALUE_CHARS)}` }, ''); + expect(lines.join('')).not.toMatch(/[\uD800-\uDBFF](?![\uDC00-\uDFFF])/u); + }); +}); + +describe('the outputs reach every human run surface (MVP-A1)', () => { + it('the TUI final summary ends with them on a completed run, and not on a failed one', () => { + const done = reduceRunEvent(initialRunViewState(), completed({ answer: '42' })); + expect(renderFinalSummary(done)).toContain(' outputs:\n answer: 42\n'); + const failed = reduceRunEvent(initialRunViewState(), { + type: 'run:failed', + runId: 'run-1', + timestamp: TS, + sequenceNumber: 9, + error: { code: 'internal', message: 'boom', retryable: false }, + partialOutputs: { answer: '42' }, + }); + expect(renderFinalSummary(failed)).not.toContain('outputs:'); + }); + + it('the plain renderer prints them under its done line, sanitized', () => { + const { io, out } = captureIo(); + createPlainRenderer(io).onEvent(completed({ answer: `42 ${KEY}` })); + expect(out()).toBe( + 'done: run completed\n outputs:\n answer: 42 [REDACTED]\n' + + ' (outputs masked — the full record: `relavium logs run-1 --json`)\n', + ); + }); + + describe('relavium logs ', () => { + let client: DbClient; + beforeEach(() => { + client = createClient(':memory:'); + runMigrations(client.db); + }); + afterEach(() => { + client.sqlite.close(); + }); + + it('gives every replayed row the one-line floor — a stored field cannot forge a row or inject a control', async () => { + await seedRun(client.db, { + slug: 'demo', + runId: 'run-2', + state: 'paused', + gate: { + gateId: 'g1\u001b[2J\n[9] run:completed — completed', + gateType: 'approval', + message: 'ok?', + }, + }); + const human = captureIo(); + logsCommand( + { runId: 'run-2' }, + { + io: human.io, + global: { + json: false, + color: false, + cwd: '/', + configPath: undefined, + verbosity: 'normal', + }, + openDb: () => ({ db: client.db, close: () => undefined }), + }, + ); + expect(human.out()).not.toContain('\u001b'); + expect(human.out()).toContain('— gate g1 [9] run:completed — completed (approval)'); + expect( + human + .out() + .split('\n') + .filter((line) => line.startsWith('[9] run:completed')), + ).toEqual([]); + }); + + it('prints them under the run:completed row, and --json keeps the raw event', async () => { + await seedRun(client.db, { + slug: 'demo', + runId: 'run-1', + state: 'completed', + outputs: { answer: `42\u001b[2J ${KEY}` }, + }); + const deps = (json: boolean, io: ReturnType['io']) => ({ + io, + global: { + json, + color: false, + cwd: '/', + configPath: undefined, + verbosity: 'normal' as const, + }, + openDb: () => ({ db: client.db, close: () => undefined }), + }); + const human = captureIo(); + logsCommand({ runId: 'run-1' }, deps(false, human.io)); + expect(human.out()).toContain( + 'run:completed — completed\n outputs:\n answer: 42 [REDACTED]\n', + ); + expect(human.out()).not.toContain('\u001b'); + + const machine = captureIo(); + logsCommand({ runId: 'run-1' }, deps(true, machine.io)); + expect(machine.out()).not.toContain('outputs:\n'); + expect(machine.out()).toContain('"outputs":{"answer":'); + }); + }); +}); diff --git a/apps/cli/src/render/run-outputs.ts b/apps/cli/src/render/run-outputs.ts new file mode 100644 index 000000000..a4ebdb5d2 --- /dev/null +++ b/apps/cli/src/render/run-outputs.ts @@ -0,0 +1,234 @@ +import { scrubSecrets } from '@relavium/llm/scrub-secrets'; + +import { sanitizeInline, stripTerminalControls } from './sanitize.js'; + +/** + * The workflow's outputs on the HUMAN surfaces (MVP-A1): the `run` TUI final summary, the plain (no-TTY) line + * renderer and `relavium logs `. `--json` is untouched — `run:completed` there already carries + * `outputs` verbatim. + * + * **A new terminal sink for untrusted content.** An output value is whatever the last node produced — model text, + * a tool result, an authored literal. Its secret shapes are masked by the shared scrubber on the ORIGINAL text, + * and only then are terminal controls stripped. A value that is not a string is masked **leaf by leaf, before it is + * serialized**: `JSON.stringify` turns a newline into the two characters `\n`, and a key right after it would then + * have no word boundary for the scrubber to see. Masking leaves also keeps one field's text from joining the next + * field's into a false match. A leaf alone cannot show a shape whose name is the member's KEY (`"x-api-key": "…"`), + * so each member with a primitive value, a top-level output included, also has its NAME read: as the one-member JSON + * text `{"":"v"}` with a stand-in value, the form the scrubber reads in a serialized object. When the scrubber + * masks the stand-in, the member's value is masked whole, unless the name is a secret shape of its own that the + * scrubber masks up to its end (`Basic plan`): that name is not a label. The value's own text never joins its name + * or a neighbour, so it cannot turn a token already masked in place into a whole-value mask. Top-level keys are + * authored ids, so they get the one-line {@link sanitizeInline}. A secret input the engine already masked (`{ secret: true, ref }`) + * is rendered as that masked object, never unwrapped. + * + * Outputs are the run's deliverable, not a diagnostic, so they do NOT get `sanitizeUntrusted`'s whole-text + * fallback, which blanks a diagnostic outright when its fragments join into a shape once whitespace is removed: on + * ordinary prose (a bare `https://host` and a later `@handle`) that blanked the whole result. The shared scrubber's + * own normalized views still apply. A value that was masked says so, with the same pointer to the exact record. + * + * **Bounded.** A value shows at most {@link MAX_VALUE_LINES} lines and {@link MAX_VALUE_CHARS} code points, + * and the whole block at most {@link MAX_TOTAL_CHARS}; what is cut is counted and points at the full record + * (`relavium logs --json`). Every value line is indented under its key, so a hard line break in a value + * cannot forge a row at the summary's own indentation. A row longer than the terminal is wide still soft-wraps to + * column 0; wrapping value rows at the terminal width is a recorded follow-up (deferred-tasks.md). + */ + +/** Lines shown per output value before the rest is counted, not printed. */ +export const MAX_VALUE_LINES = 40; +/** Code points shown per output value. */ +export const MAX_VALUE_CHARS = 4000; +/** Code points shown across every output value of one run. */ +export const MAX_TOTAL_CHARS = 12_000; + +/** The text a member masked whole shows — the shared scrubber's own mask. */ +const MEMBER_MASK = '[REDACTED]'; +/** The stand-in value a member's name is read with: one character, which the scrubber's header rule masks. */ +const NAME_PROBE = 'v'; + +/** + * The outputs block as lines, each already prefixed with `indent`. Empty when there are no outputs. `runId` + * names the full record in a truncation note; absent ⇒ the note names `relavium logs --json`. + */ +export function formatRunOutputs( + outputs: Readonly>, + indent: string, + runId?: string, +): string[] { + const keys = Object.keys(outputs); + if (keys.length === 0) return []; + const lines = [`${indent}outputs:`]; + let budget = MAX_TOTAL_CHARS; + let truncated = false; + let masked = false; + for (const key of keys) { + const label = `${indent} ${sanitizeInline(key)}:`; + if (budget <= 0) { + lines.push(`${label} …`); + truncated = true; + continue; + } + const value = displayValue(key, outputs[key]); + masked ||= value.masked; + // A final newline ends the value; it is not an empty last row, and it does not count toward the line bound. + const shown = clip(withoutFinalNewlines(value.text), Math.min(MAX_VALUE_CHARS, budget)); + budget -= shown.chars; + truncated ||= shown.truncated; + const rows = shown.text.split('\n'); + if (rows.length === 1 && !shown.truncated) { + lines.push(`${label} ${rows[0] ?? ''}`); + continue; + } + lines.push(label); + for (const row of rows) lines.push(`${indent} ${row}`); + if (shown.truncated) lines.push(`${indent} …`); + } + if (truncated || masked) { + const id = runId === undefined ? '' : sanitizeInline(runId); + lines.push( + `${indent} (outputs ${noteCause(truncated, masked)} — the full record: \`relavium logs ${id} --json\`)`, + ); + } + return lines; +} + +/** + * `text` without its final run of newlines. A loop, not `/\n+$/`: that pattern retries from every newline of a long + * run that is NOT at the end, which is quadratic in the run's length. + */ +function withoutFinalNewlines(text: string): string { + let end = text.length; + while (end > 0 && text.codePointAt(end - 1) === 0x0a) end -= 1; + return text.slice(0, end); +} + +/** What the closing note says happened to the block. */ +function noteCause(truncated: boolean, masked: boolean): string { + if (truncated && masked) return 'shortened and masked'; + return truncated ? 'shortened' : 'masked'; +} + +/** + * Output `key`'s value as display text: secret shapes masked on the original text, then terminal controls stripped. + * A string is itself; anything else is compact JSON over a copy whose every string — keys included — was masked + * first (see the module note). A primitive value's output key is also read, as a nested member's is. + * `masked` says whether anything was masked. + */ +function displayValue( + key: string, + value: unknown, +): { readonly text: string; readonly masked: boolean } { + let masked = false; + const onMask = (): void => { + masked = true; + }; + const scrub = (text: string): string => { + const out = scrubSecrets(text); + if (out !== text) onMask(); + return out; + }; + if (typeof value === 'string') { + const shown = maskedAsMember(key, scrub(value), onMask); + return { text: stripTerminalControls(typeof shown === 'string' ? shown : MEMBER_MASK), masked }; + } + try { + const json = JSON.stringify( + maskedAsMember(key, scrubLeaves(value, scrub, onMask, new Set()), onMask), + ); + return { text: stripTerminalControls(json ?? String(value)), masked }; + } catch { + // A value JSON cannot represent (a cycle, a BigInt, nesting too deep) — said, not thrown: the summary must + // still print. + return { text: '[unrepresentable value]', masked: false }; + } +} + +/** + * A copy of `value` with every string, and every object key, passed through `scrub`, and every object member with a + * primitive value masked whole when its name labels a credential (see the module note). It follows `JSON.stringify`'s + * own reading of an object that defines `toJSON`, and throws on a cycle, as `JSON.stringify` would. + */ +function scrubLeaves( + value: unknown, + scrub: (text: string) => string, + onMask: () => void, + ancestors: Set, +): unknown { + if (typeof value === 'string') return scrub(value); + if (typeof value !== 'object' || value === null) return value; + if (hasToJson(value)) return scrubLeaves(value.toJSON(), scrub, onMask, ancestors); + if (ancestors.has(value)) throw new TypeError('a cyclic output value'); + ancestors.add(value); + try { + if (Array.isArray(value)) + return value.map((item) => scrubLeaves(item, scrub, onMask, ancestors)); + const copy: Record = {}; + for (const [key, item] of Object.entries(value)) { + const member = scrubLeaves(item, scrub, onMask, ancestors); + // `defineProperty`, not assignment: an own `__proto__` member (JSON.parse makes one) would otherwise hit the + // prototype setter and vanish from the display. Two keys masked to the same text keep the later one; the + // block's note then says it was masked. + Object.defineProperty(copy, scrub(key), { + value: maskedAsMember(key, member, onMask), + enumerable: true, + configurable: true, + writable: true, + }); + } + return copy; + } finally { + ancestors.delete(value); + } +} + +/** + * `member` (already masked leaf by leaf), or {@link MEMBER_MASK} in its place when its name labels a credential + * ({@link namesCredential}); `onMask` is told. Only a value JSON prints as itself is read this way: an object or + * array is read member by member by the walk itself, and `JSON.stringify` drops `undefined`, a function or a symbol. + */ +function maskedAsMember(key: string, member: unknown, onMask: () => void): unknown { + if (typeof member === 'object' && member !== null) return member; + if (member === undefined || typeof member === 'function' || typeof member === 'symbol') { + return member; + } + if (!namesCredential(key)) return member; + onMask(); + return MEMBER_MASK; +} + +/** + * Whether `key` labels a credential: read as the one-member JSON text `{"":"v"}`, the form the scrubber sees in + * a serialized object, with a stand-in value, the scrubber masks that value. Only the name decides. The member's own + * text never enters this view, so its JSON escapes (`\n` for a newline) cannot stretch a token the leaf pass already + * masked at a line end into a whole-value mask. + * + * A name the scrubber masks up to its end on its own (`Basic plan`, `Bearer token`, a URL ending in `?key=1`) is + * itself a secret shape, not a label: its token has nothing after it in the name, so in the serialized view the + * token runs on over the closing quote into the stand-in. It is shown masked and masks only itself, not its value. + */ +function namesCredential(key: string): boolean { + const own = scrubSecrets(key); + if (own !== key && own.endsWith(MEMBER_MASK)) return false; + const tail = `:${JSON.stringify(NAME_PROBE)}}`; + return !scrubSecrets(JSON.stringify({ [key]: NAME_PROBE })).endsWith(tail); +} + +function hasToJson(value: object): value is { toJSON: () => unknown } { + return 'toJSON' in value && typeof value.toJSON === 'function'; +} + +/** Keep at most `maxChars` code points and {@link MAX_VALUE_LINES} lines, never splitting a code point. */ +function clip( + text: string, + maxChars: number, +): { readonly text: string; readonly chars: number; readonly truncated: boolean } { + const points = Array.from(text); + let end = Math.min(points.length, Math.max(maxChars, 0)); + let newlines = 0; + for (let index = 0; index < end; index += 1) { + if (points[index] === '\n' && ++newlines === MAX_VALUE_LINES) { + end = index; + break; + } + } + return { text: points.slice(0, end).join(''), chars: end, truncated: end < points.length }; +} diff --git a/apps/cli/src/render/tui/RunApp.test.tsx b/apps/cli/src/render/tui/RunApp.test.tsx index 1bde57e86..ea2083b5e 100644 --- a/apps/cli/src/render/tui/RunApp.test.tsx +++ b/apps/cli/src/render/tui/RunApp.test.tsx @@ -49,6 +49,21 @@ describe('RunApp terminal projection', () => { expect(frame).not.toContain('\x1b'); }); + it('shows the run id in the live footer once the run has started (MVP-A2)', async () => { + const state = reduceRunEvent(initialRunViewState(), { + type: 'run:started', + runId: 'run-visible-1', + timestamp: '2026-10-16T00:00:00.000Z', + sequenceNumber: 1, + workflowId: '00000000-0000-4000-8000-000000000001', + inputs: {}, + executionMode: 'local', + }); + const { lastFrame } = render(); + await waitFor(() => (lastFrame() ?? '').includes('run run-visible-1')); + expect(lastFrame() ?? '').toContain('run run-visible-1 · cost'); + }); + it('removes terminal and bidi controls from every untrusted display field in a real Ink frame (#56)', async () => { const nodeId = `node visible${ATTACK}\nforged row`; const state: RunViewState = { @@ -69,6 +84,7 @@ describe('RunApp terminal projection', () => { gapDetected: false, warnings: [`warning visible${ATTACK}\nforged warning row`], pendingBudgetNotices: [], + pendingHumanGates: [], producedMedia: [ { nodeId: `media node visible${ATTACK}\nforged media node`, diff --git a/apps/cli/src/render/tui/RunApp.tsx b/apps/cli/src/render/tui/RunApp.tsx index 29b6c1499..0206c6054 100644 --- a/apps/cli/src/render/tui/RunApp.tsx +++ b/apps/cli/src/render/tui/RunApp.tsx @@ -128,6 +128,7 @@ export function RunApp(props: Readonly<{ store: RunStore }>): ReactElement { {/* Running cost footer (and total tokens once the run completes) */} + {state.runId === undefined ? '' : `run ${sanitizeInline(state.runId)} · `} cost{' '} {formatReportedCost( state.cumulativeCostMicrocents, diff --git a/apps/cli/src/render/tui/final-summary.test.ts b/apps/cli/src/render/tui/final-summary.test.ts index 8bda7e326..32b1a14de 100644 --- a/apps/cli/src/render/tui/final-summary.test.ts +++ b/apps/cli/src/render/tui/final-summary.test.ts @@ -99,6 +99,174 @@ describe('renderFinalSummary', () => { expect(renderFinalSummary(state)).toContain('run paused at gate approve-1'); }); + it('shows the run id with the command that continues from the outcome (MVP-A2)', () => { + const started: RunEvent = { + type: 'run:started', + runId: RUN, + timestamp: TS, + sequenceNumber: 1, + workflowId: '00000000-0000-4000-8000-000000000001', + inputs: {}, + executionMode: 'local', + }; + const completed = reduceAll([ + started, + { + type: 'run:completed', + runId: RUN, + timestamp: TS, + sequenceNumber: 2, + outputs: {}, + durationMs: 10, + totalTokensUsed: { input: 0, output: 0 }, + totalCostMicrocents: 0, + }, + ]); + expect(renderFinalSummary(completed)).toContain( + ` run id ${RUN} — replay with \`relavium logs ${RUN}\``, + ); + const paused = reduceAll([ + started, + { + type: 'human_gate:paused', + runId: RUN, + timestamp: TS, + sequenceNumber: 2, + nodeId: 'approve', + gateId: 'approve-1', + gateType: 'approval', + message: 'approve?', + }, + { + type: 'run:paused', + runId: RUN, + timestamp: TS, + sequenceNumber: 3, + pendingGateCount: 1, + gateIds: ['approve-1'], + }, + ]); + expect(renderFinalSummary(paused)).toContain( + ` run id ${RUN} — resume with \`relavium gate ${RUN}\``, + ); + // Before any event the summary has no id to offer. + expect(renderFinalSummary(reduceAll([]))).not.toContain('run id'); + }); + + it('points each outcome at the command that accepts it: logs, gate, budget resume or status (MVP-A2)', () => { + const started: RunEvent = { + type: 'run:started', + runId: RUN, + timestamp: TS, + sequenceNumber: 1, + workflowId: '00000000-0000-4000-8000-000000000001', + inputs: {}, + executionMode: 'local', + }; + const budgetPause: RunEvent = { + type: 'budget:paused', + runId: RUN, + timestamp: TS, + sequenceNumber: 2, + nodeId: 'writer', + spentMicrocents: 11, + limitMicrocents: 10, + gateId: 'budget-1', + }; + const humanPause = (sequenceNumber: number, gateId: string, nodeId = 'approve'): RunEvent => ({ + type: 'human_gate:paused', + runId: RUN, + timestamp: TS, + sequenceNumber, + nodeId, + gateId, + gateType: 'approval', + message: 'approve?', + }); + const paused = (sequenceNumber: number, gateIds: string[]): RunEvent => ({ + type: 'run:paused', + runId: RUN, + timestamp: TS, + sequenceNumber, + pendingGateCount: gateIds.length, + gateIds, + ...(gateIds.length === 0 ? { pendingMediaJobNodeIds: ['render'] } : {}), + }); + const logs = ` run id ${RUN} — replay with \`relavium logs ${RUN}\``; + const gate = ` run id ${RUN} — resume with \`relavium gate ${RUN}\``; + const budget = ` run id ${RUN} — approve or abort the budget gate with \`relavium budget resume ${RUN} --gate budget-1\` (\`relavium status ${RUN}\` shows the amount)`; + const status = ` run id ${RUN} — \`relavium status ${RUN}\` shows each gate it is parked on and the command that continues it`; + const linesOf = (events: readonly RunEvent[]): string[] => + renderFinalSummary(reduceAll(events)) + .split('\n') + .filter((line) => line.startsWith(' run id')); + + const failed: RunEvent = { + type: 'run:failed', + runId: RUN, + timestamp: TS, + sequenceNumber: 2, + error: { code: 'internal', message: 'boom', retryable: false }, + partialOutputs: {}, + }; + const cancelled: RunEvent = { + type: 'run:cancelled', + runId: RUN, + timestamp: TS, + sequenceNumber: 2, + }; + expect(linesOf([started, failed])).toEqual([logs]); + expect(linesOf([started, cancelled])).toEqual([logs]); + expect(linesOf([started])).toEqual([logs]); // no terminal yet: "run ended" + // A budget gate is resumed by `budget resume`, which `gate` refuses. + expect(linesOf([started, budgetPause, paused(3, ['budget-1'])])).toEqual([budget]); + // A human gate beside it still names `gate` first. + expect( + linesOf([ + started, + budgetPause, + humanPause(3, 'approve-1'), + paused(4, ['approve-1', 'budget-1']), + ]), + ).toEqual([gate, budget]); + // A budget gate's own `human_gate:paused` companion is its prompt, not a human gate. + expect( + linesOf([started, budgetPause, humanPause(3, 'budget-1', 'writer'), paused(4, ['budget-1'])]), + ).toEqual([budget]); + // A run parked on a media job only has no gate to resume. + expect(linesOf([started, paused(2, [])])).toEqual([ + ` run id ${RUN} — check it with \`relavium status ${RUN}\``, + ]); + // A resumed stream (`relavium gate`) carries no run:started; the id comes from its first event. + const resumed: RunEvent = { + type: 'human_gate:resumed', + runId: RUN, + timestamp: TS, + sequenceNumber: 7, + nodeId: 'approve', + gateId: 'approve-1', + decision: 'approved', + decidedBy: 'cli', + }; + // A gate the resumed stream announces is classified as on a fresh run… + expect(linesOf([resumed, humanPause(8, 'approve-2'), paused(9, ['approve-2'])])).toEqual([ + gate, + ]); + // …but a resumed engine re-parks on the gates it restored from its checkpoint WITHOUT announcing them, and such a + // gate may be a budget gate, which `gate` declines: only `status`, which reads the whole log, can name its command. + expect(linesOf([resumed, paused(8, ['budget-1'])])).toEqual([status]); + expect( + linesOf([resumed, humanPause(8, 'approve-2'), paused(9, ['approve-2', 'budget-1'])]), + ).toEqual([gate, status]); + expect( + linesOf([ + resumed, + { ...budgetPause, sequenceNumber: 8 }, + paused(9, ['budget-1', 'approve-2']), + ]), + ).toEqual([budget, status]); + }); + it('is plain text — no ANSI escape codes (scrollback-safe / --no-color)', () => { const state = reduceAll([ { type: 'run:cancelled', runId: RUN, timestamp: TS, sequenceNumber: 1 }, diff --git a/apps/cli/src/render/tui/final-summary.ts b/apps/cli/src/render/tui/final-summary.ts index 7d7e820c9..9823114ab 100644 --- a/apps/cli/src/render/tui/final-summary.ts +++ b/apps/cli/src/render/tui/final-summary.ts @@ -7,6 +7,8 @@ import { } from './format.js'; import { nodeSuffix } from './projection.js'; import type { RunViewState } from './run-view-model.js'; +import { resumeCommandBase } from '../../gate/budget.js'; +import { formatRunOutputs } from '../run-outputs.js'; import { sanitizeInline, sanitizeUntrustedInline } from '../sanitize.js'; /** @@ -54,6 +56,11 @@ export function renderFinalSummary(state: RunViewState): string { } lines.push(meta.join(' · ')); + // The run id `logs`, `status` and `gate` accept (MVP-A2), with the command that continues from here. + if (state.runId !== undefined) { + lines.push(...continuationLines(state, state.runId)); + } + if (summary?.errorMessage !== undefined) { // A summary error occupies ONE structural row. Collapse line/column separators as well as terminal // controls, so provider/model text cannot forge a believable node-status row beneath the headline. @@ -83,5 +90,55 @@ export function renderFinalSummary(state: RunViewState): string { } } + // The workflow's outputs (MVP-A1) — bounded and sanitized by the shared formatter, last so they read as the + // run's result rather than as one more node row. + if (summary?.outcome === 'completed' && summary.outputs !== undefined) { + lines.push(...formatRunOutputs(summary.outputs, ' ', state.runId)); + } + return `${lines.join('\n')}\n`; } + +/** + * The run id line, with the command that continues from the outcome: `relavium logs` for a run that ended, and for a + * paused run the command for what it is parked on — `relavium gate` for a human gate, `relavium budget resume` for a + * budget gate (which `gate` declines), and `relavium status` for a run parked on a media job only. + * + * A gate is classified only from what THIS stream announced. A resumed run (`relavium gate`, `budget resume`) re-parks + * on the gates its engine restored from the checkpoint without announcing them again, so such a gate could be either + * kind; `relavium status` reads the whole log and prints the command for each one. + */ +function continuationLines(state: RunViewState, runId: string): string[] { + const id = sanitizeInline(runId); + if (state.summary?.outcome !== 'paused') { + return [` run id ${id} — replay with \`relavium logs ${id}\``]; + } + const parked = state.summary.pausedGateIds ?? []; + const budgetGates = new Set( + state.pendingBudgetNotices.filter((gate) => gate.runId === runId).map((gate) => gate.gateId), + ); + const humanGates = new Set( + state.pendingHumanGates.filter((gate) => gate.runId === runId).map((gate) => gate.gateId), + ); + const lines: string[] = []; + if (parked.some((gateId) => humanGates.has(gateId))) { + lines.push(` run id ${id} — resume with \`relavium gate ${id}\``); + } + for (const gateId of parked.filter((parkedId) => budgetGates.has(parkedId))) { + // The same shell-inert check the plain renderer and `status` apply before printing a copyable command. + const command = resumeCommandBase('budget', runId, gateId); + lines.push( + command === undefined + ? ` run id ${id} — a budget gate is waiting: \`relavium status ${id}\` shows how to approve or abort it` + : ` run id ${id} — approve or abort the budget gate with \`${command}\` (\`relavium status ${id}\` shows the amount)`, + ); + } + if (parked.some((gateId) => !humanGates.has(gateId) && !budgetGates.has(gateId))) { + lines.push( + ` run id ${id} — \`relavium status ${id}\` shows each gate it is parked on and the command that continues it`, + ); + } else if (lines.length === 0) { + lines.push(` run id ${id} — check it with \`relavium status ${id}\``); + } + return lines; +} diff --git a/apps/cli/src/render/tui/run-view-model.test.ts b/apps/cli/src/render/tui/run-view-model.test.ts index d0d99a627..7dc7ea7b7 100644 --- a/apps/cli/src/render/tui/run-view-model.test.ts +++ b/apps/cli/src/render/tui/run-view-model.test.ts @@ -312,6 +312,7 @@ describe('reduceRunEvent', () => { totalCostMicrocents: 3_000_000, totalTokens: { input: 20, output: 10 }, durationMs: 1234, + outputs: {}, // carried for the final summary's outputs block (MVP-A1) }); }); @@ -1087,3 +1088,79 @@ for (const kind of ['cost:updated', 'node:completed', 'node:failed'] as const) { expect(reduceRunEvent(state, duplicate).cumulativeCostMicrocents).toBe(150); }); } + +describe('pending human gates are the ones this stream announced (MVP-A2)', () => { + const humanPause = ( + nodeId: string, + gateId: string, + sequenceNumber: number, + ): Extract => ({ + type: 'human_gate:paused', + runId: RUN, + timestamp: TS, + sequenceNumber, + nodeId, + gateId, + gateType: 'approval', + message: 'approve?', + }); + + it('records an announced human gate, one per run and node', () => { + const state = reduceAll([ + humanPause('a', 'g1', 1), + humanPause('b', 'g2', 2), + humanPause('a', 'g3', 3), + ]); + expect(state.pendingHumanGates).toEqual([ + { runId: RUN, nodeId: 'b', gateId: 'g2' }, + { runId: RUN, nodeId: 'a', gateId: 'g3' }, + ]); + }); + + it("does not record a budget gate's human companion", () => { + const state = reduceAll([ + { + type: 'budget:paused', + runId: RUN, + timestamp: TS, + sequenceNumber: 1, + nodeId: 'a', + gateId: 'budget-1', + spentMicrocents: 11, + limitMicrocents: 10, + }, + humanPause('a', 'budget-1', 2), + ]); + expect(state.pendingHumanGates).toEqual([]); + expect(state.pendingBudgetNotices).toEqual([{ runId: RUN, nodeId: 'a', gateId: 'budget-1' }]); + }); + + it.each([ + ['current gate', RUN, 'a', 'g1', true], + ['old gate', RUN, 'a', 'old', false], + ['other run', 'other-run', 'a', 'g1', false], + ['other node', RUN, 'other-node', 'g1', false], + ['legacy resume', RUN, 'a', undefined, true], + ] as const)('%s removes only its matching gate', (_label, runId, nodeId, gateId, removes) => { + const before = reduceRunEvent(initialRunViewState(), humanPause('a', 'g1', 1)); + const after = reduceRunEvent(before, { + type: 'human_gate:resumed', + runId, + timestamp: TS, + sequenceNumber: 2, + nodeId, + ...(gateId === undefined ? {} : { gateId }), + decision: 'approved', + decidedBy: 'cli', + }); + expect(after.pendingHumanGates).toEqual(removes ? [] : before.pendingHumanGates); + }); + + it('a terminal clears them', () => { + const state = reduceAll([ + humanPause('a', 'g1', 1), + { type: 'run:cancelled', runId: RUN, timestamp: TS, sequenceNumber: 2 }, + ]); + expect(state.pendingHumanGates).toEqual([]); + }); +}); diff --git a/apps/cli/src/render/tui/run-view-model.ts b/apps/cli/src/render/tui/run-view-model.ts index ca0ab4e08..0df31f724 100644 --- a/apps/cli/src/render/tui/run-view-model.ts +++ b/apps/cli/src/render/tui/run-view-model.ts @@ -66,6 +66,15 @@ export interface RunSummary { readonly errorMessage?: string; /** Present on a paused run — the gate ids the run is parked on. */ readonly pausedGateIds?: readonly string[]; + /** Present on a completed run — the workflow's outputs, for the final summary (MVP-A1). Raw; render sanitizes. */ + readonly outputs?: Readonly>; +} + +/** A gate's raw identity — the triple `budgetGateIdentity` keys on. */ +export interface GateIdentity { + readonly runId: string; + readonly nodeId: string; + readonly gateId: string; } export interface RunViewState { @@ -94,11 +103,14 @@ export interface RunViewState { /** Bounded, user-facing warnings (gap, budget, timeout, gate). */ readonly warnings: readonly string[]; /** Active budget identities outlive the bounded warning tail; at most one per paused run/node. */ - readonly pendingBudgetNotices: readonly { - readonly runId: string; - readonly nodeId: string; - readonly gateId: string; - }[]; + readonly pendingBudgetNotices: readonly GateIdentity[]; + /** + * The human gates THIS stream announced (a `human_gate:paused` with no budget companion) and has not seen + * resolved; at most one per run/node. The final summary names `relavium gate` only for these: a resumed engine + * re-parks on the gates it restored from its checkpoint without announcing them again, and such a gate may be a + * budget gate, which `gate` declines. + */ + readonly pendingHumanGates: readonly GateIdentity[]; /** Produced media handles surfaced as nodes complete (2.S) — the run's media deliverables, bounded to the * trailing {@link MAX_PRODUCED_MEDIA}. Handle-only by construction (the engine de-inlines bytes upstream). */ readonly producedMedia: readonly ProducedMediaView[]; @@ -130,6 +142,7 @@ export function initialRunViewState(): RunViewState { gapDetected: false, warnings: [], pendingBudgetNotices: [], + pendingHumanGates: [], producedMedia: [], }; } @@ -330,6 +343,15 @@ export function reduceRunEvent(state: RunViewState, event: RunEvent): RunViewSta }; } + // A resumed stream (`relavium gate`, `budget resume`) never re-emits `run:started`, so the view takes the run id + // from its first applied event: the footer, the summary and its outputs note all name it (MVP-A2). An event newer + // than this reducer leaves the state as it was (the `default` arm below). + const unlatched = base; + const eventRunId: string | undefined = event.runId; + if (base.runId === undefined && eventRunId !== undefined) { + base = { ...base, runId: eventRunId }; + } + switch (event.type) { case 'run:started': return { ...base, runId: event.runId }; @@ -441,6 +463,12 @@ export function reduceRunEvent(state: RunViewState, event: RunEvent): RunViewSta return base; return { ...base, + pendingHumanGates: [ + ...base.pendingHumanGates.filter( + (gate) => gate.runId !== event.runId || gate.nodeId !== event.nodeId, + ), + { runId: event.runId, nodeId: event.nodeId, gateId: event.gateId }, + ], warnings: pushBounded( base.warnings, `gate "${event.gateId}" (${event.gateType}) awaiting ${event.gateType === 'input' ? 'input' : 'decision'}`, @@ -451,16 +479,8 @@ export function reduceRunEvent(state: RunViewState, event: RunEvent): RunViewSta case 'human_gate:resumed': return { ...base, - pendingBudgetNotices: base.pendingBudgetNotices.filter((gate) => - event.gateId === undefined - ? gate.runId !== event.runId || gate.nodeId !== event.nodeId - : budgetGateIdentity(gate) !== - budgetGateIdentity({ - runId: event.runId, - nodeId: event.nodeId, - gateId: event.gateId, - }), - ), + pendingBudgetNotices: withoutResolvedGate(base.pendingBudgetNotices, event), + pendingHumanGates: withoutResolvedGate(base.pendingHumanGates, event), warnings: pushBounded(base.warnings, `gate resumed: ${event.decision}`, MAX_WARNINGS), }; @@ -508,7 +528,7 @@ export function reduceRunEvent(state: RunViewState, event: RunEvent): RunViewSta case 'run:cancelled': // The three terminal arms live in `reduceTerminal` — each folds a durable cost snapshot onto BOTH the // summary and the running total, and keeping them here pushed this switch past its complexity budget. - return reduceTerminal({ ...base, pendingBudgetNotices: [] }, event); + return reduceTerminal({ ...base, pendingBudgetNotices: [], pendingHumanGates: [] }, event); case 'run:paused': return { ...base, summary: { outcome: 'paused', pausedGateIds: event.gateIds } }; @@ -534,11 +554,24 @@ export function reduceRunEvent(state: RunViewState, event: RunEvent): RunViewSta default: { const exhaustive: never = event; void exhaustive; // Keep compile-time exhaustiveness and tolerate newer external runtime events. - return base; + return unlatched; } } } +/** Drop the gate a `human_gate:resumed` resolved: by its id, or — a log that predates gate ids — by its run and node. */ +function withoutResolvedGate( + gates: readonly GateIdentity[], + event: Extract, +): readonly GateIdentity[] { + return gates.filter((gate) => + event.gateId === undefined + ? gate.runId !== event.runId || gate.nodeId !== event.nodeId + : budgetGateIdentity(gate) !== + budgetGateIdentity({ runId: event.runId, nodeId: event.nodeId, gateId: event.gateId }), + ); +} + function reduceBudgetNotice( base: RunViewState, event: { readonly runId: string; readonly nodeId: string; readonly gateId: string }, @@ -584,6 +617,7 @@ function reduceTerminal( totalCostMicrocents: event.totalCostMicrocents, totalTokens: event.totalTokensUsed, durationMs: event.durationMs, + outputs: event.outputs, }, cumulativeCostMicrocents: event.totalCostMicrocents, }; diff --git a/apps/cli/src/test-support.ts b/apps/cli/src/test-support.ts index 11f9f3f5d..5ec9c09d1 100644 --- a/apps/cli/src/test-support.ts +++ b/apps/cli/src/test-support.ts @@ -2,7 +2,7 @@ import { randomUUID } from 'node:crypto'; import { Socket } from 'node:net'; import { Readable } from 'node:stream'; -import { createRunHistoryStore, type Db } from '@relavium/db'; +import { createRunHistoryStore, type Db, type DbClient } from '@relavium/db'; import { CapabilityFlagsSchema, type CapabilityFlags } from '@relavium/llm'; import { RunEventSchema, type NodeSkippedReason, type RunEvent } from '@relavium/shared'; @@ -116,11 +116,13 @@ type EventBody = Omit< /** The schema-validated event emitter `seedRun` threads through its state helpers. */ type EmitFn = (type: T, rest: EventBody) => Promise; -/** One pending human gate to seed on a `paused` run (each gets a `human_gate:paused` event at node `g`). */ +/** One pending human gate to seed on a `paused` run (each gets a `human_gate:paused` event, at node `g` by default). */ export interface SeedGate { readonly gateId: string; readonly gateType: 'approval' | 'input' | 'review'; readonly message?: string; + /** The node the gate event names — only the event's; the seeded step rows stay at `g`. */ + readonly nodeId?: string; } /** @@ -141,6 +143,18 @@ export type SeedRunOptions = { readonly budgetGateId?: string; /** Emit a `node:skipped` after the node lifecycle — for the surfaces that render a skip and its reason. */ readonly skipped?: { readonly nodeId: string; readonly reason: NodeSkippedReason }; + /** A `completed` run's `run:completed.outputs` — for the surfaces that print them (MVP-A1). Default `{}`. */ + readonly outputs?: Readonly>; + /** + * For a `paused` run: then end it with `run:cancelled`, as Esc at an inline gate prompt does, leaving its gate + * unresolved and its gate node's step row open — a FINISHED run whose log still holds an open gate. + */ + readonly cancelAfterPause?: boolean; + /** + * For a `paused` run: then approve each human gate and complete the run, as `relavium gate --approve` does. No + * `node:completed` follows a decision, so the gate node's step row stays open, as the projection leaves it. + */ + readonly decideAfterPause?: boolean; } & SeedGateFields; const DEFAULT_TS_MS = 1_750_000_000_000; @@ -198,9 +212,26 @@ export async function seedRun(db: Db, opts: SeedRunOptions): Promise { } if (opts.state === 'paused') { await emitPausedState(emit, opts, ts, tsMs); + if (opts.cancelAfterPause === true) + await emit('run:cancelled', { cumulativeCostMicrocents: 100 }); + if (opts.decideAfterPause === true) { + await emitInOrder(opts.gates ?? (opts.gate === undefined ? [] : [opts.gate]), (gate) => + emit('human_gate:resumed', { + nodeId: gate.nodeId ?? 'g', + decision: 'approved', + decidedBy: 'cli', + }), + ); + await emit('run:completed', { + outputs: {}, + totalTokensUsed: { input: 1, output: 2 }, + totalCostMicrocents: 100, + durationMs: 9, + }); + } } else if (opts.state === 'completed') { await emit('run:completed', { - outputs: {}, + outputs: opts.outputs ?? {}, totalTokensUsed: { input: 1, output: 2 }, totalCostMicrocents: 100, durationMs: 9, @@ -214,6 +245,33 @@ export async function seedRun(db: Db, opts: SeedRunOptions): Promise { return opts.runId; } +/** + * Write a run's status, and its steps', past the schema's CHECK: what a crafted or damaged `history.db` can hold, + * for the tests that pin the one-line floor on every status a command reads back. + */ +export function forgeRunStatus(client: DbClient, runId: string, status: string): void { + client.sqlite.pragma('ignore_check_constraints = ON'); + try { + client.sqlite.prepare('UPDATE runs SET status = ? WHERE id = ?').run(status, runId); + client.sqlite + .prepare('UPDATE step_executions SET status = ? WHERE run_id = ?') + .run(status, runId); + } finally { + client.sqlite.pragma('ignore_check_constraints = OFF'); + } +} + +/** Emit one event per item, each after the last: an event's sequence number follows the one before it. */ +async function emitInOrder( + items: readonly T[], + emitOne: (item: T) => Promise, +): Promise { + await items.reduce>( + (previous, item) => previous.then(() => emitOne(item)), + Promise.resolve(), + ); +} + /** Emit the `paused`-state events for {@link seedRun} — a human/budget gate park, or (no gate) a media-job park. */ async function emitPausedState( emit: EmitFn, @@ -269,7 +327,7 @@ async function emitPausedState( // Each human gate (distinct gateId) folds into a separate pending entry — exercises the multi-gate fan-out. for (const gate of gates) { await emit('human_gate:paused', { - nodeId: 'g', + nodeId: gate.nodeId ?? 'g', gateId: gate.gateId, gateType: gate.gateType, message: gate.message ?? 'ok?', diff --git a/apps/cli/src/workflows/catalog.ts b/apps/cli/src/workflows/catalog.ts index 1b573a7e0..448ee2d20 100644 --- a/apps/cli/src/workflows/catalog.ts +++ b/apps/cli/src/workflows/catalog.ts @@ -106,6 +106,28 @@ export function discoverCatalog(opts: { return entries.sort((a, b) => byCodeUnit(a.slug, b.slug)); } +/** + * Every id a NEW workflow file must not take in `/workflows/` (MVP-A3): each workflow entry's + * slug — a valid file's `workflow.id`, an invalid one's file stem — and each file's stem, and the same for the + * agents catalog, because an id must be unique across both. The stem counts on its own because `relavium run ` + * resolves a bare id by file name first, so a new `.relavium.yaml` would shadow a file whose name is `.yaml` + * but whose `workflow.id` differs. Every value is also added lower-cased: ids are lower-case, and on a + * case-insensitive filesystem `My-Id.yaml` is the same name as `my-id.yaml`. Throws the catalog's own error for an + * unreadable or out-of-project directory. + */ +export function takenWorkflowIds(projectConfigDir: string, cwd: string): ReadonlySet { + const taken = new Set(); + for (const kind of ['workflows', 'agents'] as const) { + for (const entry of discoverCatalog({ projectConfigDir, cwd, kind })) { + for (const id of [entry.slug, fileStem(basename(entry.path))]) { + taken.add(id); + taken.add(id.toLowerCase()); + } + } + } + return taken; +} + /** A `valid: false` catalog entry whose slug falls back to the filename stem — the secret-free, path-free shape. */ function invalidEntry(path: string, rel: string, error: string): CatalogEntry { return { diff --git a/docs/architecture/execution-model.md b/docs/architecture/execution-model.md index 86912464c..d14a4630b 100644 --- a/docs/architecture/execution-model.md +++ b/docs/architecture/execution-model.md @@ -229,9 +229,10 @@ is computed in `packages/llm`; see [multi-llm-providers.md](multi-llm-providers. optionally adjusting inputs). A required node is never silently skipped. - **Provider failure** — `packages/llm` walks the agent's fallback chain before the node is considered failed. -- **Crash recovery** — the engine exposes reconciliation of interrupted, non-resumable runs; - this is an explicit host operation, not an automatic shipping CLI startup guarantee. Recorded - budget rejection retains `budget_exceeded`, while an ordinary interruption uses `internal`. +- **Crash recovery** — the engine exposes reconciliation of interrupted, non-resumable runs; the + CLI runs it at `run`, `gate` and `budget resume` start and on Home open. A run that left an + external effect unresolved settles as `effect_needs_attention`, a recorded budget rejection as + `budget_exceeded`, any other interruption as `internal`, in that order of precedence. Interrupted-run discovery refuses corrupt suspension history as a whole. The derived metadata, corruption and durable terminal rules live in the [event contract](../reference/contracts/sse-event-schema.md#durable-budget-authorization). diff --git a/docs/decisions/0079-cross-process-run-ownership-lease-and-fencing-token.md b/docs/decisions/0079-cross-process-run-ownership-lease-and-fencing-token.md index 13840fe32..8092adb99 100644 --- a/docs/decisions/0079-cross-process-run-ownership-lease-and-fencing-token.md +++ b/docs/decisions/0079-cross-process-run-ownership-lease-and-fencing-token.md @@ -76,6 +76,32 @@ All contention is on the resume path, which is already async. > row can exist yet — the sole exemption in §2's rule, and the reason the engine models "before the first > acquire" as a state of its own rather than as "not owned". +> **Amended 2026-11-05 (MVP-1 liveness, 2.6.G).** The window is no longer uncontended. `relavium run`, `gate`, +> `budget resume` and Home open now call `reconcile()`, so another process can find a run in the instant between +> its durable `run:started` and its acquire and settle it. What bounds that is the run store's exactly-one-terminal +> refusal ([database-schema.md](../reference/shared-core/database-schema.md#concurrency--transaction-behavior)): +> one terminal lands either way. Usually it is the reconciler's: the run's own terminal is refused and the run +> exits durability-uncertain. If the run's own terminal commits first, the reconciler's guarded write is refused +> instead, and the run exits failed. `run:started` stays unfenced for the reason above. + +> **Amended 2026-11-06 (PR #97 review).** Three corrections to the two notes above. +> +> - **`reconcile()` now leaves a run younger than one lease TTL alone**, measured from its `run:started` timestamp +> by the reconciling host's clock, on either side. That closes the window the 2026-11-05 note describes for a +> reconciler with this rule: a reconcile inside it no longer settles the starting run, which takes its lease and +> runs. A development build without the rule, or a reconciler whose clock runs more than a TTL ahead, can still +> settle it, and the store's terminal refusal keeps that to one terminal. Past one TTL, a run with no live lease +> is still treated as crashed, and so is one whose start lies more than a TTL in the reconciler's future (a clock +> that moved back). The same rule covers a run started by a version that takes no lease at all (relavium 0.1.x): +> it is settled only once it is older than one TTL. +> - **The foreign key does not rule out the same transaction.** The 2026-08-17 note says creating the lease inside +> the transaction that folds `run:started` "fails the foreign key". It does not, once the `runs` row is inserted +> first in that transaction; the key rules out only a lease acquired before the row exists. What shipped is still +> the separate acquire after the fold, and the TTL rule above is what bounds the window it leaves. +> - **`run:started` is not §2's only unfenced write.** A run whose acquire is refused holds no lease, so its own +> terminal is written with no fence and, like every terminal, outside the append guard. Only the store's terminal +> refusal keeps it from landing beside the reconciler's. + ### 4. A losing resume refuses before it reads anything `resumeFromCheckpoint` acquires the lease **first**. A failed acquire throws a new typed `EngineStateError` naming the current holder and the remedy — before the checkpoint is loaded and before any `RunExecution` exists, so a loser never becomes a second producer even briefly. diff --git a/docs/decisions/0101-configured-output-estimates-apply-only-when-the-wire-is-uncapped.md b/docs/decisions/0101-configured-output-estimates-apply-only-when-the-wire-is-uncapped.md index 66edbad62..b0545e9e8 100644 --- a/docs/decisions/0101-configured-output-estimates-apply-only-when-the-wire-is-uncapped.md +++ b/docs/decisions/0101-configured-output-estimates-apply-only-when-the-wire-is-uncapped.md @@ -348,3 +348,31 @@ The approved W7 implementation and scoped independent reviews are complete. Fina acceptance, per-item causal evidence, canonical landing checks and approved residuals are joined in the [W7 closing register](../roadmap/phases/phase-2.6.5-core-reliability-remediation.md#w7-closing-register--2026-10-10). This dated note preserves the earlier decision and status history; W8 and the phase remain open. + +## Interactive chat output default — 2026-11-05 + +> Amended 2026-11-05 (MVP-A7, the maintainer's D68 ruling): a chat whose bound agent authors no `max_tokens` +> now carries one on its effective agent — `[chat].max_tokens` when configured; otherwise, for an Anthropic agent +> with no fallback chain on the official endpoint in a session without a cost cap, the model's catalog ceiling +> instead of the adapter's implicit 4,096-token default. This decision's order is unchanged: the request now carries +> an effective cap, so the governor reserves that cap. A session with a cost cap keeps the adapter default unless +> `[chat].max_tokens` is set, because a catalog-ceiling reservation (128,000 tokens on `claude-sonnet-4-6`) would +> refuse the first turn of a modest cap; another provider and a chain keep today's uncapped or adapter behaviour. +> Workflow runs are unchanged. The key's canonical home is +> [config-spec.md](../reference/contracts/config-spec.md); the code is `withChatOutputDefault` in +> `apps/cli/src/chat/session-host.ts`. + +### Chat surfaces, and the deadline that scales with the cap — 2026-11-06 + +> Amended 2026-11-06 (the MVP-1 1a milestone review, budget-1, budget-2; the maintainer chose the scaled +> deadline): the output default applies to chat surfaces only (`relavium chat`, `chat-resume`, the Home chat, a +> `/models` reseat or `/clear` rebuild); the non-interactive one-shot `relavium agent run` keeps the adapter's +> implicit cap. [ADR-0082](0082-the-stream-grammar-is-a-seam-obligation-and-every-attempt-has-a-deadline.md)'s per-attempt +> deadline is absolute and does not reset per chunk, so under the catalog ceiling a healthy reply streaming past +> its 120-second default failed as a billed timeout and was rolled back, where the 4,096 cap had ended it cleanly as +> truncated. As ADR-0082 §6 lets a host, the chat host now arms a deadline long enough to stream its effective cap +> at 25 tokens per second, never below the default and never above 30 minutes (`chatAttemptDeadline`); the user can +> still cancel a stalled turn. Two side effects of the larger cap are recorded, not changed: pre-send +> auto-compaction reserves the effective cap, so on a 200k-window model with a 64,000-token ceiling it starts at +> about 68% of the window instead of 80%; and on a model whose effort tiers are budget-shaped, the thinking budgets +> derive from the effective cap and grow with it. diff --git a/docs/decisions/0114-in-house-markdown-rendering-for-the-chat-transcript.md b/docs/decisions/0114-in-house-markdown-rendering-for-the-chat-transcript.md new file mode 100644 index 000000000..21b2f45d3 --- /dev/null +++ b/docs/decisions/0114-in-house-markdown-rendering-for-the-chat-transcript.md @@ -0,0 +1,204 @@ +# ADR-0114: In-house markdown rendering for the chat transcript + +- **Status**: Proposed +- **Date**: 2026-11-05 +- **Related**: [ADR-0047](0047-cli-framework-commander-ink-clack.md), [ADR-0049](0049-cli-machine-output-contract.md), [ADR-0068](0068-full-screen-tui-renderer-ink7-harness.md), [ADR-0069](0069-string-width-for-the-cli-renderer.md), [ADR-0088](0088-the-mcp-boundary-is-hostile.md), [phase 2.6 · 2.6.E](../roadmap/phases/phase-2.6-conversational-authoring.md#26e--chat--input-parity-polish), [phase 2.6 ADR row 11](../roadmap/phases/phase-2.6-conversational-authoring.md), [MVP-2](../roadmap/current.md#next-work--the-cli-mvp) + +## Context + +Assistant text reaches both chat renderers as plain text. Models answer in +markdown, so the user reads `**`, backticks, `#` and fence lines instead of +emphasis, code and structure. MVP-2 ("a readable chat") needs this fixed. + +Phase 2.6 ADR row 11 bundled two decisions: a markdown rendering architecture +and a syntax-highlighting dependency. On 2026-11-04 the maintainer split it. +Part 1, this ADR, is an in-house renderer with **no dependency**. The +highlighter decision follows the MVP and will be its own ADR. + +The renderer has to fit what already exists: + +- **Two transcript renderers.** The inline renderer prints entries through + ink's `` and lets ink word-wrap. The full-screen renderer wraps + entries itself into `DisplayLine`s ([ADR-0068](0068-full-screen-tui-renderer-ink7-harness.md) §c), because + its scroll and mouse math rely on **one `DisplayLine` per terminal row**, + measured with the same `string-width` ink uses + ([ADR-0069](0069-string-width-for-the-cli-renderer.md)). +- **One sanitization boundary.** `entryLines` applies `stripTerminalControls` + once, for every consumer: the viewport and the `/copy`, `/edit` and + `/scrollback` hatches. It removes ANSI, OSC, C0/C1 and bidi controls but + keeps newlines and tabs. +- **Streaming.** The answer streams token by token into a live region bounded + by `liveAnswerRowBudget`. A markdown construct can be half-written at any + frame: an open fence, or `**` with no closer yet. +- **Machine and plain surfaces.** `--json`, plain and non-TTY output keep the + [ADR-0049](0049-cli-machine-output-contract.md) contract byte for byte. + +The off-the-shelf terminal markdown renderers (`marked-terminal`, +`ink-markdown`, which wraps it) emit pre-styled ANSI strings. Feeding ANSI into +the transcript breaks the one-row invariant, because the widths are computed on +text ink re-measures. It also bypasses the control-stripping floor. Some of them +emit OSC 8 hyperlinks, whose visible label can hide the real target. Rule 2 of +[CLAUDE.md](../../CLAUDE.md) also requires an ADR for any runtime dependency. + +## Decision + +**Relavium renders assistant markdown with its own pure, two-stage pipeline in +the CLI. The pipeline emits styled text spans, never escape sequences.** + +### 1. Pipeline + +- **Parse.** `parse` turns sanitized text into a small block tree, then parses + inline spans in each block. It is pure TypeScript with no ink import. + - Block parsing is line-oriented. The state at each newline is all that + carries forward, so a prefix that ends at a newline parses the same way + whatever follows it. + - Inline delimiters are matched with one linear scan and a delimiter stack. + The parser uses no regular expression that can backtrack over unbounded + input. Parsing is O(n) in the text length. +- **Layout.** Layout turns blocks into lines of styled spans. A span is text + plus semantic flags: strong, emphasis, code, strike, heading level, quote, + link URL, dim. Prefixes are spans too: the list marker, the quote bar and the + code-block gutter. Each line also has a hanging indent for its wrapped + continuation rows. +- **Draw.** Each renderer draws those lines its own way. + - The full-screen renderer wraps them into `DisplayLine` rows. `DisplayLine` + gains a `spans` field alongside `text`. `text` stays the plain + concatenation, so the selection and copy-on-select code and the width math + are unchanged. + - Wrapping is by words, with a character-wrap fallback for a token wider than + the row. It uses the hanging indent and measures every span and prefix with + `displayWidth`, so the one-row invariant holds. + - The inline renderer maps the same lines to nested ink `` elements and + lets ink wrap, as it does today. + +The two renderers can still break long lines at different points. This is the +same accepted difference ADR-0068 §c records, and only one renderer is active at +a time. + +### 2. The supported subset + +The pipeline renders a documented subset suited to model output: + +- **Blocks:** ATX headings; fenced code blocks (backtick or tilde fences, with + the info string shown as a dim label); bullet and ordered lists, nested by + indentation; blockquotes; thematic breaks; paragraphs. +- **Inline:** code spans; strong and emphasis, with CommonMark's intraword rule + for `_`; strikethrough; links and autolinks; images; backslash escapes; hard + line breaks. + +**Anything outside the subset renders literally, as sanitized text.** That +covers setext headings, indented code blocks, HTML, link reference definitions, +footnotes and lazy continuation. GFM pipe tables also render verbatim, with no +inline processing inside them, because removing markers would misalign the +columns the model laid out. + +The pipeline is not a CommonMark implementation. The goal is predictability: +markdown it does not understand stays readable as the plain text it was written +as. + +The exact subset, glyphs, colors and fallbacks are specified in +`docs/reference/cli/markdown-rendering.md`, written with the implementation. +This ADR does not restate them. + +### 3. Security floor + +- **Sanitize first.** The parser only sees text that `stripTerminalControls` has + already sanitized, at the existing `entryLines` boundary. No stage adds a + control byte. Styling exists only as ink props, so the SGR escapes ink itself + writes are the only escapes on screen. +- **Links show their target.** A link renders as its label followed by its URL. + When the label is the URL, it renders once. An image renders as a dim + `[image: alt]` followed by its URL. The pipeline never emits an OSC 8 + hyperlink, so a label cannot hide where it points. +- **Inline text is collapsed.** An info string, a link label and a URL are + inline values. They get `sanitizeInline` treatment and cannot span or forge + rows. +- **Bounded work.** + - Parsing is linear in the text length. + - Nesting of lists and quotes is capped. Deeper nesting renders literally. + - Tabs expand to 8-cell stops before measuring, the rule #65 adopts, so a + tab-indented code block keeps the one-row invariant. + +### 4. Streaming + +The live answer goes through the same pipeline, with one rule for half-written +constructs: + +- An open fence renders as a code block until its closer arrives. +- An unclosed inline delimiter stays literal until it is closed. That is + CommonMark's rule. + +Because block parsing is line-oriented, the rendering of every completed block +in the live answer is the same as its final rendering. Only the in-progress last +line can change when the turn ends. + +To keep the per-frame cost flat, the block state at the last completed newline +is cached. Each frame re-parses only the text after that newline. + +### 5. Scope and fallbacks + +- **Rendered:** only `assistant` entries, in both chat renderers, which also + covers the in-Home chat. User echoes, notices, the reasoning panel and tool + rows stay plain. Tool-call rendering belongs to phase 2.6 ADR row 9. +- **Hatches get the source.** `/copy`, `/edit` and `/scrollback` receive the + sanitized **markdown source**, not the rendered form. The user pastes what + the model wrote, and nothing is lost to a style the destination cannot show. + Copy-on-select copies the rendered rows the highlight covers, because the + highlight shows exactly what is copied. +- **Color disabled renders the source.** When color is disabled (`--no-color` + or `NO_COLOR`, resolved as the global options define), the transcript shows the sanitized source + unchanged. Emphasis that cannot be styled should not silently lose its + markers, and raw markdown is designed to read as plain text. +- **Machine and plain surfaces are unchanged.** `--json`, plain, non-TTY, the + run renderers and `chat-export` are not affected. + +### 6. Placement + +The parser and layout live in `apps/cli/src/render/markdown/`, beside the +other display boundaries. Only the draw step imports ink. + +A second consumer would be a reason to move the parser into a shared package, +as a refactor rather than a new decision. That move must not cover the desktop +and VS Code webviews. They render HTML, where the threat model is DOM injection +rather than terminal control, and their rendering is a separate decision. + +### Alternatives considered + +- **`marked` or `markdown-it` with `marked-terminal`, or `ink-markdown`.** + Rejected. Each is a new dependency chain. They return ANSI strings that break + the one-row invariant and bypass the sanitization boundary. Some emit OSC 8 + links. +- **Full CommonMark compliance in-house.** Rejected. The spec's hard edges + (lazy continuation, reference definitions, HTML blocks, setext headings, + emphasis tie-breaking) cost more than they return for model output. A + documented subset with a literal fallback is cheaper and easier to predict. +- **Keep plain text.** Rejected. It is the readability gap MVP-2 exists to + close. +- **Highlight code now.** Deferred to part 2's ADR, per the 2026-11-04 split. + The layout already tags a code block's language, so highlighting can be added + later without changing the pipeline. + +## Consequences + +### Positive + +- The transcript becomes readable for the common markdown models emit, with no + new dependency. +- The control-stripping floor and the one-row invariant are untouched, because + styling exists only as ink props on already-sanitized text. +- Links always show their real target. +- The `/copy`, `/edit` and `/scrollback` hatches keep returning faithful source + text. +- The streamed rendering never rewrites a completed block. + +### Negative + +- Some valid markdown renders literally: tables, setext headings, HTML and + reference links. Tables are the most visible gap. Aligning them is a candidate + for a follow-up. +- Relavium now owns a parser and its edge cases, and needs tests for every + construct in the subset, including adversarial and pathological input. +- The two renderers may break long lines at different points, as they already + do. +- With color disabled, the user sees raw markdown rather than a degraded + rendering. diff --git a/docs/decisions/0115-one-interrupt-keymap-for-the-interactive-cli.md b/docs/decisions/0115-one-interrupt-keymap-for-the-interactive-cli.md new file mode 100644 index 000000000..c59641e9a --- /dev/null +++ b/docs/decisions/0115-one-interrupt-keymap-for-the-interactive-cli.md @@ -0,0 +1,152 @@ +# ADR-0115: One interrupt keymap for the interactive CLI + +- **Status**: Proposed +- **Date**: 2026-11-05 +- **Related**: [ADR-0054](0054-cli-bare-invocation-interactive-home.md), [ADR-0057](0057-cli-chat-modes-and-per-tool-approval.md), [ADR-0061](0061-cli-input-layer-file-injection-and-shell-escape.md), [ADR-0068](0068-full-screen-tui-renderer-ink7-harness.md), [ADR-0095](0095-what-an-agent-session-remembers-across-turns.md), [D68](../roadmap/current.md#wave-gating-decisions), [MVP-A12](../roadmap/phases/phase-2.6-conversational-authoring.md#cli-mvp-additions--2026-11-04), [row 11b](../roadmap/phases/phase-2.5.5-hardening-and-remediation.md#proposed-implementation-increments) + +## Context + +Today, Ctrl-C in the interactive chat ends the session. The ink REPL runs in +raw mode, so the keystroke never reaches the kernel as SIGINT. The input +reducer maps it to `/cancel`, which aborts any in-flight turn, persists the +session and exits with code 4. + +- To stop a turn but keep the session, the user has to know to press Esc. +- At a pending tool approval, Ctrl-C does nothing. The approval reducer accepts + only unmodified answer keys, and that fail-closed rule correctly stops a + chord from approving anything. Its side effect is that the user's most + instinctive stop key is silently swallowed while a governed action waits. +- The Home exits on one Ctrl-C. +- Every other terminal agent the user knows interrupts the work on the first + press and leaves on the second. + +The 2026-10-19 Wave 3 ruling for row 11b made Ctrl-C at a pending approval +"map to `/cancel`": reject the approval, then end the session. On 2026-11-04 +the maintainer replaced that wording with D68: + +- Ctrl-C interrupts the turn. +- A second press ends the session. +- Ctrl-C at a pending approval rejects it before anything else. + +The approval path is a security path (ADR-0057's fail-closed floor), so the +ordering has to be exact, not merely intuitive. + +## Decision + +**One interrupt keymap applies to every raw-mode interactive surface: the chat +REPL, both renderers, the in-Home chat and the Home itself.** The exact hint +texts, the arming window and the footer wording live in +[chat-session.md](../reference/cli/chat-session.md) and +[home.md](../reference/cli/home.md); this ADR does not restate them. + +### 1. Ctrl-C, by state + +| State | First Ctrl-C | Second Ctrl-C (while armed) | +|-------|--------------|-----------------------------| +| A tool approval is pending | Reject the approval, then interrupt the turn, then arm | End the session | +| A turn is running | Interrupt the turn (the same abort Esc performs), then arm | End the session | +| Idle, prompt text present | Clear the prompt text and do not arm | — | +| Idle, empty prompt | Arm and show the hint | End the session | +| A non-approval overlay is open (palette, picker, `@`-mention, reverse search) | Close it, as today, and do not arm | — | +| Home, no chat | Arm and show the hint | Exit the Home (code 0, as today) | + +- **Arming expires.** The armed state expires after a short window, or at any + other key. A Ctrl-C long after an interrupt therefore starts over instead of + ending the session. +- **Ending a session works as `/cancel` does now.** The in-flight work is + aborted, the session is persisted and resumable, and the exit code is 4. The + in-Home chat returns to a freshly read Home instead. +- **Clearing prompt text is the one choice beyond D68's wording.** It is the + shell convention, and it keeps one stray Ctrl-C from discarding a half-written + prompt *and* arming an exit. + +### 2. A pending approval is rejected first + +When an approval is pending, the first Ctrl-C resolves it as a **user +rejection** synchronously, before the abort is signalled. + +- A Ctrl-C can never approve anything. +- No abort, re-render or race can leave the decision open long enough for + another key to answer it. +- The tool never runs. + +The rejection is recorded like any other user rejection. What the model is +later told about it follows the denial-feedback amendment (D67). The +interrupted turn itself follows ADR-0095 as amended for the CR-71 residual (a +content-free interruption marker). + +Esc at a pending approval keeps aborting the turn, as today. Both keys now take +the same reject-then-abort path, so the outcome cannot depend on which one was +pressed. + +The reducer's rule that only unmodified keys answer an approval stays exactly as +it is. Ctrl-C is matched **before** that rule and is the only chord that resolves +an approval, and it resolves it only as a rejection. + +### 3. Signals are not keystrokes + +A SIGINT from outside the process (`kill -INT`, or a terminal's Ctrl-C while +raw mode is off) keeps its process-control meaning. + +- **Chat sessions.** The first SIGINT ends the session cooperatively (abort, then + persist, then code 4), and a second one force-exits. This also applies to the + plain and `--json` chat drivers. There, the first SIGINT must reach the + in-flight turn, not only stop reading further input (the #23 defect). Those + drivers have no approver, so no approval can be pending. +- **Hatches.** A SIGINT that arrives while a `/scrollback` or `/edit` hatch owns + the terminal is still dropped, as ADR-0068 records. +- **Out of scope.** `relavium run`, `gate`, `budget resume` and `agent run` keep + their documented signal behaviour. This ADR covers interactive sessions and + the Home. + +### 4. What is superseded + +This ADR replaces three things: + +- The 2026-10-19 row 11b wording that Ctrl-C at a pending approval maps to + `/cancel`. +- The chat reference's statement that Ctrl-C is equivalent to `/cancel`. +- ADR-0061's parenthetical that "Ctrl-C's `/cancel` would end" a `!` command's + session. Under this keymap, Ctrl-C during a `!` command interrupts it as Esc + does. + +`/cancel` and `/exit` keep their meanings. ADR-0061 gets a dated amendment note +pointing here. + +### Alternatives considered + +- **Keep Ctrl-C as `/cancel` and make it reject a pending approval first** (the + 2026-10-19 wording). Rejected by D68. Ending the whole session is a heavy + answer to "stop this", and the only way to keep the session would stay a key + the user has to discover. +- **Ctrl-C at an approval rejects only, and the turn continues.** Rejected. A + Ctrl-C asks for the work to stop. Letting the model carry on with its next + tool after the user pressed the stop key is the wrong default. Answering `n` + remains the way to deny one call and let the turn continue. +- **No arming window.** Rejected. A second press minutes after an interrupt + would end a session the user meant to keep. +- **Leave the Home on a single press.** Rejected for consistency. One keymap + means the same press count on every surface. The Home cost is one extra + keystroke, and it has nothing unsaved to lose. + +## Consequences + +### Positive + +- The stop key behaves the same way everywhere and never costs the user the + session on the first press. +- A pending approval can be stopped with Ctrl-C, and it is always rejected, + never approved. +- Signals keep a script-friendly, single-step meaning. +- The plain and `--json` drivers' first SIGINT finally reaches the in-flight + turn. + +### Negative + +- Leaving a session or the Home takes two presses. The footer hint has to + teach this. +- Ctrl-C now rejects at the approval prompt. This touches the approval path, so + row 11b's approval-path security sitting stays. +- Raw-mode keystroke handling and signal handling diverge on purpose. Tests + have to cover both, and the Windows console path too, where Ctrl-C delivery + under raw mode must be verified rather than assumed. diff --git a/docs/decisions/0116-the-built-in-chat-agent-can-edit-and-the-starting-mode-is-configurable.md b/docs/decisions/0116-the-built-in-chat-agent-can-edit-and-the-starting-mode-is-configurable.md new file mode 100644 index 000000000..542b88c2a --- /dev/null +++ b/docs/decisions/0116-the-built-in-chat-agent-can-edit-and-the-starting-mode-is-configurable.md @@ -0,0 +1,149 @@ +# ADR-0116: The built-in chat agent can edit, and the starting mode is configurable + +- **Status**: Proposed +- **Date**: 2026-11-05 +- **Related**: [ADR-0024](0024-agent-first-entry-point-agentsession.md), [ADR-0029](0029-tool-policy-hardening.md), [ADR-0033](0033-strict-config-files-amends-0023.md), [ADR-0055](0055-cli-host-capability-seam-tool-environment-factory.md), [ADR-0057](0057-cli-chat-modes-and-per-tool-approval.md), [ADR-0063](0063-cli-config-write-contract.md), [ADR-0105](0105-filesystem-roots-are-invocation-grants-not-project-authority.md), [ADR-0112](0112-chat-network-grants-and-onboarding-state-are-operator-owned.md), [D62](../roadmap/current.md#wave-gating-decisions), [MVP-A13, MVP-A14](../roadmap/phases/phase-2.6-conversational-authoring.md#cli-mvp-additions--2026-11-04) + +## Context + +The built-in chat agent, which serves a session that binds no `--agent`, is +granted three read-only tools: `read_file`, `list_directory` and `git_status`. +To get a write or a command, the user has to author and bind an agent YAML. In +practice "fix this test" fails on the first try for every new user. + +[ADR-0057](0057-cli-chat-modes-and-per-tool-approval.md) already makes this +safe to change. The host is bound with full capability, and a mode is a policy +layer on top of it: + +| Mode | Write tools offered to the model | A governed action | +| --- | --- | --- | +| `ask` (the default) | No | Denied | +| `plan` | No | Denied | +| `accept-edits` | Yes | Prompted on every call | +| `auto` | Yes | Approved, except a protected-path write, which still prompts | + +The fail-closed `confirmAction` floor applies in every mode. Both the filesystem +jail and the protected paths hold in every mode, `auto` included. + +So the grant is not what stands between the model and the disk. The mode is, +and the grant only decides what a mode can reach. + +A user who starts every session in `ask` has to press Shift+Tab at the start of +every session before an edit is possible. The critic of the 2026-11-04 reset +called that out. + +A configurable starting mode raises its own question: which configuration may +set it? Project and workspace config files arrive with a cloned repository. If +a repository could start its visitors' sessions in `auto`, then cloning it would +grant approval-free writes and commands. That is the same trust error +[ADR-0112](0112-chat-network-grants-and-onboarding-state-are-operator-owned.md) +avoided for the HTTP authority and D63 avoided for remembered commands. + +## Decision + +**We will widen the built-in agent's grant to editing and searching, keep +`ask` as the default mode, and let the operator choose a different starting +mode. A repository's config may choose a stricter start but never `auto`.** + +### 1. The default grant + +The built-in agent is granted: + +- **Read, search and find.** `read_file`, `list_directory`, `git_status`, and + the MVP-3 tools `search_files` and `find_files`. None of them is governed; all + stay inside the jail and the confidentiality floor. +- **Edit.** `write_file` and `edit_file`. Both are governed `fsWrite` tools: + - Hidden from the model in `ask` and `plan`, and denied there by the floor if + the model names them anyway. + - In `accept-edits`, every call is prompted with its diff, rendered through + the approval-preview contract that phase 2.6 ADR row 9 defines. + - The existing approval scopes are unchanged. `[a]` (always, this tool, this + session instance) remains the user's explicit choice. +- **Commands.** `run_command`, governed by the approval card D63's ADR + defines. It joins the grant only when that card lands, so the default agent + never runs a command under today's allowlist-only rule. +- **Not granted.** Egress (`http_request`, `web_search`, MCP tools) and the + `os` arm. They stay opt-in through an explicitly bound agent and the + operator-owned authorities that already govern them. + +The grant lands only after the approval diff (MVP-3's `write_file` diff, #305). +The model is never offered a write the user cannot review. + +The built-in agent's system prompt stops claiming that it cannot modify files. +It instead says that writes need the user's approval and depend on the mode. + +### 2. The starting mode + +- A session starts in the configured **default mode**. Absent any + configuration, that is `ask`, as today. +- **Which config may set which mode:** + - Project and workspace config may set `[chat].default_mode` to `ask`, `plan` + or `accept-edits`. Each of these denies or prompts for every governed + action. + - A project or workspace layer naming `auto` is a strict-config error + ([ADR-0033](0033-strict-config-files-amends-0023.md)), reported with the + file and the key. It is never silently downgraded. + - Starting in `auto` is an operator choice. Only the operator-owned global + configuration can set it, under the rules ADR-0112 set for + operator-owned settings. +- **The starting mode is visible.** It shows in the footer from the first frame, + as every mode does. +- **A one-time hint** tells a user who starts in `ask` that Shift+Tab changes + the mode and that the default is configurable. It is shown once per operator. + Whether it has been shown is recorded in the operator-owned onboarding state + (ADR-0112), never in project files. +- **Resume, `/clear` and reseat** start in the configured default mode, read + when the session starts. A mode is never persisted with a session. This + keeps ADR-0057's rule that approvals and modes do not survive the session + instance. + +The exact keys, layers, error texts and hint wording live in +[config-spec.md](../reference/contracts/config-spec.md) and +[chat-session.md](../reference/cli/chat-session.md). + +### 3. What does not change + +- Advertising stays best-effort and `confirmAction` stays authoritative. +- The protected-path and jail floors hold in every mode, and the headless + paths have no approver, so they stay fail-closed. +- An explicitly bound `--agent` keeps exactly the grant its YAML declares. +- `agent run` and the other non-interactive paths keep running in `ask`. + +### Alternatives considered + +- **A second built-in "coding" agent, leaving the default read-only** (D62 + option 2). Rejected. It adds a choice before the first useful turn and + duplicates ADR-0057's mode layer, which already separates "can reach" from + "may do". +- **Keep the default read-only until the `/agents` picker lands** (D62 option + 3). Rejected. It pushes the most common first request past the MVP. +- **Let any config layer set any default mode.** Rejected. A cloned repository + could then start sessions in `auto`. +- **Persist the last-used mode per project.** Rejected. A mode chosen once would + silently become every future session's starting authority. An explicit + setting is visible and reviewable. + +## Consequences + +### Positive + +- "Fix this test" works in the first session: Shift+Tab to `accept-edits`, then + review each diff. A user who wants that every time sets it once. +- Shipping an editing default does not weaken a security floor. The floors were + already mode-based, and a repository cannot raise its visitors' starting + authority. +- Every edit the default agent makes is reviewed as a diff unless the user + explicitly chose `[a]` or `auto`. + +### Negative + +- The default agent's reach grows. A prompt-injected model in `accept-edits` can + now *propose* writes and commands, so the user's diff review becomes a + security control. The mandatory security review of this change has to test + that review surface: diff truncation, terminal-safe rendering, and paths + that are not what they appear to be. +- A project that sets `auto` now fails its strict config check. Any existing + file doing so needs the operator to move the setting. +- Some users will set `auto` globally and get approval-free sessions in every + project. That is an explicit operator choice, visible in the footer, and the + protected-path prompt still holds. diff --git a/docs/decisions/0117-a-denied-tool-call-returns-to-the-model.md b/docs/decisions/0117-a-denied-tool-call-returns-to-the-model.md new file mode 100644 index 000000000..1c12939d6 --- /dev/null +++ b/docs/decisions/0117-a-denied-tool-call-returns-to-the-model.md @@ -0,0 +1,146 @@ +# ADR-0117: A denied tool call returns to the model + +- **Status**: Proposed +- **Date**: 2026-11-05 +- **Related**: [ADR-0029](0029-tool-policy-hardening.md), [ADR-0037](0037-engine-tool-execution-boundary.md), [ADR-0057](0057-cli-chat-modes-and-per-tool-approval.md), [ADR-0086](0086-absolute-admission-ceilings-on-authored-values.md), [ADR-0095](0095-what-an-agent-session-remembers-across-turns.md), [ADR-0105](0105-filesystem-roots-are-invocation-grants-not-project-authority.md), [ADR-0115](0115-one-interrupt-keymap-for-the-interactive-cli.md), [D67](../roadmap/current.md#wave-gating-decisions), [MVP-A10](../roadmap/phases/phase-2.6-conversational-authoring.md#cli-mvp-additions--2026-11-04) + +## Context + +[ADR-0057](0057-cli-chat-modes-and-per-tool-approval.md) made a user's denial +of a tool call a typed `ToolDeniedByUserError` with the non-retryable +`tool_denied` code. "A user deny is final": the engine never retries the call. +The turn loop also treats every denial as **fatal to the turn**, so the turn +ends with `tool_denied`. + +That covers: + +- the user answering `[n]` or giving a typed reason; +- a mode denial (a write named in `ask`); +- a protected-path, jail or allowlist refusal. + +The model never learns that the user said no. Nor does it learn why, even when +the user typed the reason, so it cannot propose another approach. The user has +to restate the request in a new turn. + +The turn loop already has a bounded feedback path for correctable failures. +`unknown_tool` and `invalid_args` are fed back to the model as an `isError` +tool result. So is a recoverable host failure on the interactive surfaces +(`recoverToolFailures`). All of them draw on one `maxToolCorrections` budget +inside the `maxToolTurns` egress ceiling. + +Denials were deliberately kept out of that path, and the turn loop's comment +gives the reason. Re-issuing a denied call only gets denied again. And a denial +whose text depends on the target's state would give the model a **probe +oracle**. For example, if "blocked: resolves to a private address" is fed back, +a prompt-injected model can map which hostnames resolve internally. + +On 2026-11-04 the maintainer ruled (D67) that denials return to the model. This +ADR draws the line that keeps the oracle argument intact. + +## Decision + +**On the interactive surfaces, a denial that was decided from the call and the +session's own policy returns to the model as a fixed-text `isError` tool result. +The turn continues within the existing correction budget. Any denial that +depends on the target's state stays fatal.** + +### 1. Which denials return + +**Returned to the model:** + +- **A user rejection.** This covers `[n]`, a rejection with a typed reason (the + reason is the user's own words, bounded as today), and a rejection the + user's Ctrl-C makes (ADR-0115). The Ctrl-C case also interrupts the turn, so + the result is recorded but no further model call follows. +- **A denial decided before any side effect from the call's arguments and the + session's policy alone:** + - a mode denial; + - a protected-path refusal; + - a jail or scope-tier escape (already fed back as recoverable today); + - a command outside the allowlist or the approval card's grants; + - a headless "no approver" denial. + +**Still fatal to the turn, as today:** + +- An SSRF refusal made after name resolution. +- A confidentiality refusal whose outcome or wording could depend on whether + the target exists. +- A process or egress failure after the side effect began. +- `tool_unavailable`. +- A cancellation. An abort is not a denial; it ends the turn as ADR-0057 and + ADR-0115 define. + +**The test for any future denial class:** it returns to the model only if its +result text is the **same for every target state**. A class that cannot meet +that stays fatal. + +### 2. What the model sees + +The tool result carries: + +- a **fixed text per denial class**, for example "The user denied this call" or + "Writes are not available in the current mode"; +- the user's typed reason, when there is one. + +It carries **no** policy contents: not the allowlist, not the configured roots, +not the protected-path list. Every text is the same whether or not the target +exists. + +The exact texts live in [built-in-tools.md](../reference/shared-core/built-in-tools.md), +next to the tool contracts. This ADR does not restate them. + +### 3. "A user deny is final" keeps its meaning + +`tool_denied` stays non-retryable. The engine never re-executes a denied call. + +Within the turn, a **repeat of an invocation the user already denied** (same +tool, same arguments) is denied again **without prompting**, with the same +fixed text. The model can propose something different, but it cannot wear the +user down by asking again. + +Each returned denial draws on the `maxToolCorrections` budget, so a model that +keeps calling denied tools ends the turn as it does for any other correction +loop. The ceilings stay finite and configurable under ADR-0086. + +### 4. Scope + +Only the surfaces that already set `recoverToolFailures` are in scope: `relavium +chat`, the in-Home chat and the one-shot `agent run`. + +A workflow agent node keeps failing fast on a denial. A workflow is an +unattended, committed artifact, so a denial there is a fault its author has to +see, not something the model should route around. + +Across turns, a returned denial is remembered as any tool result is, under +[ADR-0095](0095-what-an-agent-session-remembers-across-turns.md)'s +structure-only rule. Its text is fixed, so it carries no target content. + +### Alternatives considered + +- **Feed back every denial, including SSRF and confidentiality refusals.** + Rejected. This reopens the probe oracle the current rule exists to close. +- **Keep every denial fatal** (D67 option 3). Rejected. The user has to repeat + themselves, and the user's own stated reason is thrown away. +- **Re-prompt the user on a repeated identical call.** Rejected. It turns a + denial into a negotiation. The user already answered. + +## Consequences + +### Positive + +- A denial becomes a conversational turn ("not that file — use the fixture + instead") instead of a dead end. The user's typed reason finally reaches the + model. +- The probe-oracle boundary is now a stated, testable rule: same text for every + target state. +- The correction budget and the turn ceilings still bound the cost. + +### Negative + +- A model can now spend tool rounds on denied calls before giving up. That + costs tokens within the existing budget. MVP-A11's conservative defaults keep + the cost small. +- Each denial class has to be classified and its fixed text kept free of + state. A new tool or a new floor needs that classification before it ships. +- The interactive and workflow paths now differ on denials, as they already + differ on recoverable host failures. diff --git a/docs/decisions/0118-a-command-outside-the-allowlist-asks-instead-of-failing.md b/docs/decisions/0118-a-command-outside-the-allowlist-asks-instead-of-failing.md new file mode 100644 index 000000000..0dbe0b8ce --- /dev/null +++ b/docs/decisions/0118-a-command-outside-the-allowlist-asks-instead-of-failing.md @@ -0,0 +1,245 @@ +# ADR-0118: A command outside the allowlist asks instead of failing + +- **Status**: Proposed +- **Date**: 2026-11-05 (revised 2026-11-06 after the PR #97 review) +- **Related**: [ADR-0006](0006-os-keychain-for-api-keys.md), [ADR-0029](0029-tool-policy-hardening.md) (a), [ADR-0057](0057-cli-chat-modes-and-per-tool-approval.md), [ADR-0061](0061-cli-input-layer-file-injection-and-shell-escape.md), [ADR-0063](0063-cli-config-write-contract.md), [ADR-0112](0112-chat-network-grants-and-onboarding-state-are-operator-owned.md), [ADR-0115](0115-one-interrupt-keymap-for-the-interactive-cli.md), [ADR-0116](0116-the-built-in-chat-agent-can-edit-and-the-starting-mode-is-configurable.md), [ADR-0117](0117-a-denied-tool-call-returns-to-the-model.md), [D63](../roadmap/current.md#wave-gating-decisions), [MVP-A15](../roadmap/phases/phase-2.6-conversational-authoring.md#cli-mvp-additions--2026-11-04) + +## Context + +In a chat session, `run_command`, both the model's tool and the user's `!` +escape, passes two gates in order: + +1. **The allowlist.** `enforcePolicy` checks the `[chat]` allowlist: exact + full-command strings ([ADR-0029](0029-tool-policy-hardening.md) (a)) plus + opt-in globs. +2. **The mode.** If the allowlist admits the command, the mode-aware + `confirmAction` applies (ADR-0057): denied in `ask` and `plan`, prompted in + `accept-edits`, approved in `auto`. + +The allowlist defaults to empty, which disables commands. That is a sound +floor, and `run_command` has no argument confidentiality floor, so `!cat .env` +shows why. In practice it is a wall: + +- A command the user did not list in advance cannot run at all, even with the + user watching and willing to approve it. +- The refusal hint asks the user to edit a config file and restart. +- [ADR-0116](0116-the-built-in-chat-agent-can-edit-and-the-starting-mode-is-configurable.md) + gives the built-in agent `run_command` "under D63", so this has to change + before that grant can mean anything. + +The allowlist lives in project and workspace config. Those files arrive with a +repository, so anything persisted there authorizes every future clone. The +critic of the 2026-11-04 reset flagged exactly this risk for a "remember this +command" choice. A repository's allowlist can already pre-approve commands for +`auto` today; this ADR records that exposure without widening it. + +## Decision + +**A command that is not pre-approved shows a typed approval card instead of +being refused. "Remember" is stored in operator-owned configuration keyed by the +project root, never in repository files. `auto` never runs a command that is +not pre-approved.** + +### 1. Pre-approval and the card + +**A command is pre-approved** when either of these holds: + +- it matches the effective `[chat]` allowlist, exactly or by glob, with today's + layering; +- it matches a **remembered grant** for the current project root (§2). + +**Outcomes by mode:** + +| | `ask` / `plan` | `accept-edits` | `auto` | +| --- | --- | --- | --- | +| Pre-approved | Denied | Prompted with a plain approval | Runs | +| Approved earlier in this session (§1, "Session") | Denied | Runs | Runs | +| Anything else | Denied | **Card** | **Card** | + +**The card shows the command exactly, masked secret spans aside.** It shows: + +- the argv, one argument per element, each delimited so a leading or trailing + space in it stays visible, and spaces and quotes are unambiguous; +- the working directory; +- why the command is not pre-approved. + +Everything is terminal-safe text, under the approval escape of +[ADR-0120](0120-the-toolbelt-grows-and-a-tool-call-shows-what-it-does.md): a +control, a line separator, or an invisible or blank code point that escape +covers shows in an argument as a visible escape. ADR-0120's rules are a starting +set whose completeness its security review confirms. The one thing the card does not show is +a secret span, which ADR-0120 masks, labels and counts, and which the user must +reveal before approving when it holds anything but credential characters. +Masked spans aside, the card never offers an approving answer for a command it +does not show in full. Each argument is drawn on its own line, so right-to-left +text in one cannot be drawn past another. A command too long for the screen must be +expanded before it can be approved. + +**The card's answers:** + +- **Once.** This invocation only. +- **Session.** This exact argv, matched element by element as a remembered + grant is (§2), for the rest of this session instance. It is held in memory + and not persisted, like ADR-0057's `[a]`. +- **Remember.** Persist a grant for this exact argv and this project root (§2). + The configuration never holds the command itself. +- **Deny**, with an optional typed reason. A denial returns to the model under + [ADR-0117](0117-a-denied-tool-call-returns-to-the-model.md). +- **Ctrl-C and Esc** reject the card, then interrupt the turn, as + [ADR-0115](0115-one-interrupt-keymap-for-the-interactive-cli.md) defines. + +Only unmodified keys answer the card, as with every approval (ADR-0057). + +**`accept-edits` keeps prompting for pre-approved commands.** A config +allowlist or a remembered grant removes the *card*, not the user's per-call +consent. Only an explicit "Session" answer, given in this session, removes the +prompt there. This keeps a repository's allowlist from turning `accept-edits` +into silent execution. + +**The `!` escape uses the same card and the same table.** The user typed the +command, but a typo or a pasted line runs with the same authority. The card is +cheap, and one rule for both paths is easier to reason about than two. + +**Headless paths stay fail-closed.** `agent run`, plain, `--json` and every +path without an approver cannot show a card, so a command that is not +pre-approved is denied. That denial returns to the model under ADR-0117. + +### 2. Remembered grants are operator-owned + +- **Where they are stored.** A remembered grant goes into the operator-owned + global configuration (the trust class + [ADR-0112](0112-chat-network-grants-and-onboarding-state-are-operator-owned.md) + set). It is keyed by the session's **canonical project root**: the resolved + real path, so two paths to one tree share grants and a fresh clone elsewhere + shares none. +- **What a grant contains: never the command.** A command can carry a + password or a token, and the + [credential masking](../reference/shared-core/built-in-tools.md#finite-diagnostic-credential-masking) + recognizes only a finite set of shapes, so a masked copy could still hold a + credential in plaintext. A grant holds instead: + - an HMAC-SHA256 of the command's exact argv, each element length-prefixed so + `["cat a b"]` and `["cat", "a b"]` differ, under a per-installation key; + - a label for the revocation view: the program, the argument count and the + date it was remembered. The program is named only when `argv[0]`, taken + whole, is a bare name (`^[A-Za-z0-9._+-]{1,64}$`, so no `/`, `\`, `=`, `:`, + `@` or whitespace) that resolves through the `PATH`, as the spawn would + resolve it, to an existing executable file when the grant is written. + Anything else — a path, a whole command line, an assignment such as + `PGPASSWORD=…`, a name that resolves to nothing — is labelled + `(unnamed program)`. No part of `argv[0]` is taken before the check: a base + name or a last segment of free text is never kept. `argv[0]` is free text — + the model's `command` string, or the first token of `!PGPASSWORD=… psql` — + and can itself be a credential: a whole `curl … https://hooks.example/…/` + line, whose last segment is the secret, or a bare token. The one credential + the rule cannot tell apart is one that is also the name of an executable on + the `PATH`, which is then already on the disk. + + Matching computes the HMAC of the candidate argv and compares it in constant + time. A grant matches one exact argv, never a glob: "Remember `npm test`" + never authorizes `npm test -- --exec=…`. +- **The key.** 32 random bytes from `node:crypto`, generated on first use and + kept in the OS keychain beside the provider keys + ([ADR-0006](0006-os-keychain-for-api-keys.md)); it never enters a config + file, a log or an event. Without a reachable keychain, Remember is not + offered, and the card offers Once, Session and Deny. +- **How they are written.** Through the + [ADR-0063](0063-cli-config-write-contract.md) write primitive: parse, set, + re-validate, write atomically. No project, workspace or agent file is ever + written. A grant holds only a digest and a label whose only text from the + command is the name of an executable found on the `PATH`, so the primitive's + secret-free property covers it: no other command text reaches the file. +- **What a grant does not bind.** It binds the argv, not what the program reads. + "Remember `npm test`" keeps approving `npm test` after a model edits + `package.json`'s `test` script, and in `auto` such an edit is itself + approved, since `package.json` is not a protected path + ([ADR-0116](0116-the-built-in-chat-agent-can-edit-and-the-starting-mode-is-configurable.md)). + A remembered grant therefore extends to whatever the files under the root + make that program do. That is the same exposure a repository allowlist entry + has today; the approval-path security sitting examines both, and whether a + remembered grant should still prompt once in `auto` after the session has + written under the root. +- **Bounds.** Grants are bounded per root and in total. Reading them is strict: + a malformed grant file is a visible configuration error, never a silent + allow. +- **How grants are revoked.** By editing the operator config. The 2.6.G + permission-management view lists and removes them; its UI belongs to that + workstream. + +The exact keys, bounds and file layout live in +[config-spec.md](../reference/contracts/config-spec.md). The card's rendering +contract lives with the approval-preview contract (phase 2.6 ADR row 9). + +### 3. What this amends + +- **ADR-0061.** The `!` escape's "allowlist before approval, refused otherwise" + becomes §1's table. Hardened spawn (`shell: false`, literal argv), the + command jail and the busy and abort affordances are unchanged. +- **ADR-0029 (a).** Unchanged in substance. Remembered grants match one exact + argv, element by element, and globs stay opt-in and config-authored. The + `[chat]` allowlist still compares the space-joined command, so `["cat a b"]` + matches `["cat", "a b"]`; that is recorded separately and fixed before this + ships. +- **ADR-0063.** Gains a second operator-owned write target: remembered command + grants, as digests and argument-free labels. +- **ADR-0006.** The keychain holds one more secret: the grant HMAC key. +- **Workflow `run_command`.** Unchanged. A workflow's allowlist stays the only + authority there, with no card and no remembered grants. A workflow runs + unattended. + +Each amended ADR gets a dated amendment note pointing here when this is +accepted. + +### Alternatives considered + +- **Keep the allowlist wall and add an `/allow ` writer** (D63 option + 2). Rejected. The user still cannot approve a command in the moment, and the + natural place for such a writer is the repository file this ADR must avoid. +- **Store "remember" in the project's `[chat]` allowlist.** Rejected. Every clone + would inherit the grant: one user's consent would become every visitor's + authority. +- **Store the command, masked, in operator config.** Rejected after the PR #97 + review. The masking is finite (built-in-tools.md lists flags it does not + read), so a credential it does not recognize would be written in plaintext. +- **Store a plain hash of the command.** Rejected. A short password is + recoverable from an unkeyed digest by trying candidates; the keychain key + makes that require the key. +- **Let `auto` approve any command.** Rejected. `run_command` has no argument + confidentiality floor, so an approval-free arbitrary command is an + approval-free exfiltration path for a prompt-injected model. +- **Keep the card out of `!`.** Rejected. Two command paths with different + consent rules are harder to reason about. The cost of one card is small. + +## Consequences + +### Positive + +- A command the user is willing to run can run, with the exact argv on screen, + masked secret spans aside. + The config edit and restart are no longer needed. +- A remembered grant serves one operator on one tree and never travels with the + repository, and it holds no command text beyond the name of an executable + found on the `PATH`, so a credential in a command reaches the configuration + only when it is itself such a name (§2). +- `auto` keeps a hard edge: only pre-approved commands run without a prompt. +- A headless path still never runs anything it was not configured to run. + +### Negative + +- More approval prompts in `accept-edits`, by design. A user who trusts a + command for the session answers "Session" once. +- A repository's own allowlist still pre-approves commands for `auto`, as it + does today. This ADR does not widen that, but it does not remove it either. + The security sitting has to examine it, and closing it would be a separate + decision. +- Operator config gains a growing, per-root grant list. It needs bounds, strict + parsing and a revocation view (2.6.G). +- Remember needs the OS keychain; without it the card offers Once, Session and + Deny only. +- The revocation view cannot show a remembered command's arguments, only its + program (or `(unnamed program)`), argument count and date. +- A grant binds the argv, not the files the program interprets (§2), so in + `auto` a model that can edit those files can change what a remembered + command does. +- The card is a new approval surface. It needs the approval-path security + sitting D63 requires: exact rendering, no hidden affirmative key, and no + truncated approval. diff --git a/docs/decisions/0119-an-interrupted-turn-stays-as-a-content-free-marker.md b/docs/decisions/0119-an-interrupted-turn-stays-as-a-content-free-marker.md new file mode 100644 index 000000000..2d2f8e693 --- /dev/null +++ b/docs/decisions/0119-an-interrupted-turn-stays-as-a-content-free-marker.md @@ -0,0 +1,180 @@ +# ADR-0119: An interrupted turn stays in the session as a content-free marker + +- **Status**: Proposed +- **Date**: 2026-11-05 (revised 2026-11-06 after the PR #97 review) +- **Related**: [ADR-0026](0026-session-export-to-workflow.md), [ADR-0057](0057-cli-chat-modes-and-per-tool-approval.md), [ADR-0081](0081-the-compaction-summary-is-untrusted-and-the-system-prompt-is-branded.md), [ADR-0095](0095-what-an-agent-session-remembers-across-turns.md), [ADR-0098](0098-a-session-effect-row-holds-no-result-and-never-replays.md), [ADR-0115](0115-one-interrupt-keymap-for-the-interactive-cli.md), [ADR-0117](0117-a-denied-tool-call-returns-to-the-model.md), [D67](../roadmap/current.md#wave-gating-decisions), [the CR-71 residual](../roadmap/deferred-tasks.md) + +## Context + +[ADR-0095](0095-what-an-agent-session-remembers-across-turns.md) §1 persists a +**completed** turn as content-free structure. Its rows record which tools ran, +in what order, how large their arguments and results were, and whether each +errored. + +A turn that does **not** complete leaves nothing behind. That covers a turn +that fails, one ended by a fatal denial, one the user aborts (Esc, or Ctrl-C +under [ADR-0115](0115-one-interrupt-keymap-for-the-interactive-cli.md)), and one +that hits the turn limit. In each case: + +- the engine rolls back the pending user message (ADR-0057's abort clause, and + the same path on failure); +- the persister writes no row; +- the model's next turn has no idea the request happened. + +The real hazard is a turn that **dispatched tools before it died**. The edit was +applied or the command ran, and the only trace is the effect row +[ADR-0098](0098-a-session-effect-row-holds-no-result-and-never-replays.md) +discloses on the next resume. The model, asked to retry, cannot know that the +first half of the work is already done, so it can apply it twice. + +ADR-0095's Negative section recorded this as a residual, and the deferred-task +ledger carries it. On 2026-11-04 the maintainer pulled it into MVP-4 (D67): the +interrupted turn stays in context as a **content-free interruption marker**. +CR-71 itself stays closed, since it shipped in W7. + +Carrying prior turns' tool **content** into the model's context stays deferred +under CR-70 (ADR-0095 §2). This ADR must not carry any content. + +## Decision + +**A turn that ends without a completed answer stays in the session's in-memory +transcript and durable rows. It is stored as the user's message, the +content-free structure of the tools it dispatched, and one engine-authored +marker naming the outcome from a closed set.** + +### 1. What is kept + +**Scope.** This applies to every `AgentSession` turn whose user message the +session accepted and that ended: + +- aborted by the user; +- failed (any classified error); +- denied fatally (a class ADR-0117 keeps fatal); +- at the turn limit. + +A refusal before the session accepted the message, such as an invocation error +or a pre-flight refusal, adds nothing, as today. + +**What is stored:** + +- **The user's message**, as an ordinary user message. The rollback of the + pending message on abort and on failure is withdrawn. This amends ADR-0057's + abort clause. +- **The tool structure**, under ADR-0095 §1's invariants: registry-resolved + names, engine-assigned ids, sizes and an errored flag, never arguments or + results. + - A call that was dispatched but whose outcome the engine never observed (it + was interrupted mid-execution) is recorded as **outcome unknown**, not as + succeeded or failed. + - These statuses agree with the session's effect rows (ADR-0098). +- **One marker**, carrying: + - a closed-set **reason**: `aborted`, `failed` with the classified error code, + `denied`, `turn_limit`; + - the ordered list of dispatched tool **names** with their statuses + (`completed`, `errored`, `outcome unknown`). + + It carries no message text, no tool content and no provider error text. + +**What is not stored.** Partial assistant text streamed before the end is +neither stored nor sent. It is incomplete model output, and it may be the reason +the user stopped the turn. Reasoning and signatures are dropped at the turn +boundary, as for every turn. + +### 2. What the model sees + +On the next request, the interrupted turn appears as the user's message followed +by the marker, rendered as a **fixed engine-authored note** from the marker's +fields. + +**The memory policy still decides the request.** An interrupted turn is a turn +for [ADR-0095](0095-what-an-agent-session-remembers-across-turns.md) §4's +selection, and its marker travels only with it: + +- with `memory` omitted or `summary`, it is in the transcript the request + carries; +- under `window`, it takes one of the `window_size` slots, like a completed turn + with empty final text, and is sent while it is inside the window; +- under `none`, the request carries only the system prompt and the current + message, so the marker is not sent. A completed turn's tools are equally + invisible there, and the effect-row disclosure at resume (ADR-0098) is the + only trace. + +ADR-0095 §4's `window` row gains a dated amendment note on acceptance: "the last +`N` completed or interrupted turns". Its invariant 1 is unchanged. + +The note sits behind +[ADR-0081](0081-the-compaction-summary-is-untrusted-and-the-system-prompt-is-branded.md)'s +role boundary. It is user-role framing that the engine writes from a closed set +of templates, never inside an assistant message and never in the system prompt. + +**What the note does:** it tells the model the request was interrupted and which +tools had already run. The model can then avoid repeating an applied edit, or +check an unknown outcome before acting. + +**What it does not do:** it carries no tool content, so CR-70 stays deferred, +and a tool's arguments and results still never reach a later turn. + +The note can put two user-role messages next to each other. The engine's +request projection already folds adjacent same-role messages into one for every +provider (ADR-0095's 2026-09-18 note), so no adapter needs a rule of its own. +The exact message shape lives in +[agent-session-spec.md](../reference/contracts/agent-session-spec.md). + +### 3. Durability and the other consumers + +- **Persistence.** + - The interrupted turn commits atomically, on its terminal + `session:turn_completed`, like a completed turn (ADR-0095 §1 invariant 6). + - A failed write latches, as today. + - Resume reproduces the transcript exactly, interrupted turns included + (invariant 4). + - The marker is a new content-free part type with a closed reason enum, + added to the shared schema. +- **Compaction and trim** count an interrupted turn as a turn. Boundaries stay + turn-identified (invariant 5). +- **Reseat** carries the marker, because it has no content. +- **Export** ([ADR-0026](0026-session-export-to-workflow.md), ADR-0095 §3) + shows an interrupted turn as interrupted, with its tool structure. Deriving a + workflow never turns an interrupted turn into a step. +- **ADR-0095 §1** gains a dated amendment note: "a completed turn" becomes "a + completed or interrupted turn". The no-tool-content-at-rest invariant is + unchanged. + +### Alternatives considered + +- **Keep rolling back, and rely on the effect-row disclosure at resume.** + Rejected. The model is the one that needs to know, during the live session, + not only after a resume. +- **Keep the partial assistant text.** Rejected. It is incomplete, possibly + wrong output that the user may have stopped on purpose. Carrying it would + make an abort less effective as a "stop that". +- **Carry the interrupted tools' results so the model can resume the work.** + Rejected for now. That is CR-70's content carrying, deferred with ADR-0096 §5 + to v0.2.1. +- **Put the note in an assistant message.** Rejected by ADR-0081's role + boundary. An engine-authored assistant message would claim words the model + never said. + +## Consequences + +### Positive + +- A retry after an interrupted turn knows what already happened, so a + half-applied edit is not applied twice blindly. +- The transcript is faithful: what the user asked stays visible, on screen and + after resume, even when the answer never came. +- No tool content reaches disk or a later request. ADR-0095's at-rest invariant + and CR-70's deferral both hold. + +### Negative + +- More persisted rows, and one more part type in the shared schema that every + consumer must handle: resume, compaction, export, the coming transcript + repaint and the Home's session views. +- A user who aborted *because* they regretted the request now has it in + context. Removing a turn is `/rewind`'s job (2.6.E), not an abort's. +- Under `memory: none` the marker never reaches the model, so a retry there + learns nothing from it; the effect-row disclosure at resume is the only trace. +- The outcome-unknown status has to be derived faithfully from the dispatch and + effect bookkeeping. A wrong status would mislead the model about a side + effect, so it needs its own tests. diff --git a/docs/decisions/0120-the-toolbelt-grows-and-a-tool-call-shows-what-it-does.md b/docs/decisions/0120-the-toolbelt-grows-and-a-tool-call-shows-what-it-does.md new file mode 100644 index 000000000..d7a29e31a --- /dev/null +++ b/docs/decisions/0120-the-toolbelt-grows-and-a-tool-call-shows-what-it-does.md @@ -0,0 +1,238 @@ +# ADR-0120: The toolbelt grows, and a tool call shows what it does + +- **Status**: Proposed +- **Date**: 2026-11-05 (revised 2026-11-06 after the PR #97 review) +- **Related**: [ADR-0029](0029-tool-policy-hardening.md), [ADR-0037](0037-engine-tool-execution-boundary.md), [ADR-0055](0055-cli-host-capability-seam-tool-environment-factory.md), [ADR-0057](0057-cli-chat-modes-and-per-tool-approval.md), [ADR-0061](0061-cli-input-layer-file-injection-and-shell-escape.md), [ADR-0095](0095-what-an-agent-session-remembers-across-turns.md), [ADR-0114](0114-in-house-markdown-rendering-for-the-chat-transcript.md), [ADR-0116](0116-the-built-in-chat-agent-can-edit-and-the-starting-mode-is-configurable.md), [ADR-0118](0118-a-command-outside-the-allowlist-asks-instead-of-failing.md), [ADR-0119](0119-an-interrupted-turn-stays-as-a-content-free-marker.md), [phase 2.6 · 2.6.M](../roadmap/phases/phase-2.6-conversational-authoring.md#26m--the-first-class-toolbelt-breadth--rendering), [MVP-A8, MVP-A9](../roadmap/phases/phase-2.6-conversational-authoring.md#cli-mvp-additions--2026-11-04) + +## Context + +The registry has no way to edit part of a file, search file contents or find +files by pattern. `write_file` replaces a whole file, and its approval card +shows **no preview** of what will be written. In `auto` mode the write leaves no +visible trace at all (#305). + +The transcript's tool rows also lose information: + +- A row shows ✓ even when the call failed. +- The row's target disappears when the turn ends. +- The assistant's text between tool calls is dropped. + +Since 2.5 the posture has been **never render tool arguments**. That rule kept +model-chosen strings off the terminal, but it now blocks the one thing an +approval needs: showing the user what they are approving. + +[ADR-0116](0116-the-built-in-chat-agent-can-edit-and-the-starting-mode-is-configurable.md) +makes the user's diff review the control that stands between a prompt-injected +model and the disk, so the review surface has to exist and has to be +trustworthy. + +Phase 2.6 ADR row 9 bundled three things: the toolbelt additions, this +render/approval-preview contract, and `ask_user`'s mid-turn pause. On 2026-11-04 +the maintainer split it. The first two land with MVP-3. `ask_user`, which +rides the engine's pause machine, gets its own ADR later. + +## Decision + +**We add the editing, searching and finding tools under the existing +governance floor. We replace "never render arguments" with a closed, +per-tool preview contract: a tool renders only what its definition declares +previewable, every rendered string passes the sanitization floor, and every +truncation is stated.** + +### 1. Toolbelt additions + +Every tool below is engine-pure: Zod arguments, `llmVisibleParams`, a policy +class and bounded results. Each sits under the jail, the protected-path floor +and the confidentiality floor, and is selectable in agent and workflow YAML. +Exact arguments, bounds and texts live in +[built-in-tools.md](../reference/shared-core/built-in-tools.md). + +- **`edit_file`.** Replaces an exact old string with a new one, either a unique + occurrence or `replace_all`. It is `fsWrite`-governed. + - **Read before edit.** It refuses a file the session has not read, or one + whose content changed since that read, so an edit never applies to a view + the model did not see. + - The write is atomic and preserves line endings. Binary files are refused. +- **`search_files`.** Searches file contents. + - The pattern is a **literal** (with case and whole-word options), never a + regular expression, so there is no backtracking surface. + - It respects ignore files, skips binary files and never searches a + confidentiality-floor path. + - It is bounded in files scanned, matches and bytes per line. It is + idempotent and not governed. +- **`find_files`.** Finds files by glob with the in-house matcher. Results are + sorted by mtime and bounded. It respects ignore files and never lists a + confidentiality-floor path. It is idempotent and not governed. +- **`read_file` ranges (MVP-A9).** `read_file` takes a line or byte range. + - A result over the model-facing ceiling spills to the session's **output + store**, as the tool-result bounding contract already defines for runs. + - Chat now wires that store. It is private to the operator, scoped to the + session, and removed when the session ends. Orphans left by a crash are + swept at the next start. + - It is never `history.db`, so ADR-0095's no-tool-content-at-rest invariant + holds. + - The tool's model-visible description states the binary refusal it enforces. +- **No silent caps.** Every bounded result says it was bounded and by how much. + That includes `list_directory`'s 200-entry cap, which today truncates + silently. + +### 2. The preview contract + +- **Each tool declares what may be shown.** A tool definition declares a + **preview function**. It is a pure function from the validated arguments + (and, for a write, the host-prepared before and after content) to a typed + `ToolPreview`: + - a **target**: the path, the argv, or the method and host; + - zero or more named **fields**; + - an optional **diff**. + + A tool that declares none shows its name only. A field marked secret, a + `credentialRef` or a masked input is structurally absent from `ToolPreview`'s + type. It cannot be declared previewable. +- **Every string passes the floor.** In a transcript row, each target and + field passes `sanitizeUntrusted`: secret shapes masked on the original text, + then terminal controls stripped. Each is bounded per field and per row. A + target is one line (`sanitizeInline`). +- **An approval preview is built not to hide what it approves.** Stripping is right for a + transcript row and wrong for an approval: an edit that adds an escape or a + bidi override would render exactly like the original. A code point a terminal + draws as nothing, or as a blank, hides content the same way: a tag-character + run (U+E0000–U+E007F) can spell a whole instruction in zero columns; a + zero-width or no-break space inside a compared literal stops it matching; and + U+2028 or U+2029, which JavaScript reads as a line break and a terminal draws + as nothing, ends a `//` comment without ending the line on screen. So in an + approval card and its full view, every code point in a target, field or diff + line that is not a printable graphic character renders as a visible, styled + escape (for example ``, `` or ``), never stripped and + never left invisible. A literal ``), so the notation stays unambiguous where styling is + off. The set is defined by properties, with one short hand-kept list: + - every `\p{C}` code point: controls, format characters, surrogates, + private-use and unassigned code points; + - every separator other than the ASCII space (`\p{Z}`): the other spaces and + the line and paragraph separators; + - every default-ignorable code point (`\p{Default_Ignorable_Code_Point}`), + which covers the joiners, variation selectors and fillers a transcript + keeps, so an approval shows `` rather than hide a code point; + - the graphic code points known to draw as a blank or as nothing: U+2800 (the + braille blank), U+16FE4 (a filler mark) and U+1D159 (a null notehead). A + font can draw others blank; the mandatory security review owns this list. + + A TAB shows as a styled tab mark instead, so tab-indented code stays readable + and a tab is still told from spaces. Whitespace at the end of a line, the + ASCII space included, shows as a styled mark per character (`·` for a space), + as `git diff` marks whitespace errors, so a trailing space never draws as + nothing. A CR before an LF is part of the line's ending, not trailing + whitespace. The line diff compares each line with its ending. The card states + each side's endings whenever they are not all LF (a styled `CR LF endings` or + `mixed endings` note beside the target, always shown for a new file whose + lines end in CR LF), and a line whose ending differs from its side's or from + the other side's shows a styled `` or `` mark. A CR anywhere else is + escaped. A change to whitespace or to a line ending alone therefore never + renders as an unchanged line, and a file written in CR LF never looks like its + LF twin. The property sets are ones the JavaScript regular-expression engine + knows, so only the short blank-drawing list above is kept by hand. The escape + does not tell confusable letters apart (a Cyrillic `а` for a Latin `a`), and it + does not stop visible right-to-left letters from being drawn right to left: + both are visible, and they stay as they are. Transcript rows may + strip. The order is fixed: secret masking on the original text, then this + escape, which turns every such code point into printable text, and only then + the card's plain drawing: ADR-0114's sanitize step, whose strip then finds + nothing to remove, with no markdown parse (ADR-0114 §5 keeps tool rows plain). + The diff's two sides go through the same escape. +- **A mask hides a secret's value, never what the call does.** A span the secret + masking replaces shows as a labelled mask with its length, and the card states + how many spans were masked. The masker matches shapes, and a model chooses + what fills them: `Bearer $(curl …|sh)` is masked like `Bearer sk-…`. So a + masked span that holds anything other than credential characters (letters, + digits and `-_.+/=~:`), such as whitespace, `$`, a quote, a bracket, a + backquote, `;`, `|`, `&`, `<` or `>`, is flagged on the card, and while any + span is flagged the approving answers stay disabled until the user reveals the + masked spans in the full view with a deliberate key. The full view unmasks only + on that key. Approving approves the bytes the host will write, masked spans + included, and the card says so. Whether a write whose changed lines are wholly + masked may be approved at all is for the mandatory security review. +- **These rules are a starting set, not a proof.** Each closes a way an approval + was shown to hide content while this draft was reviewed: zero-width and tag + runs, the line separators, line-end whitespace and endings, and code inside a + mask. This ADR claims only the rules it states. Whether they are complete + (other code points a font draws blank, a terminal's own rendering, anything + else that makes two different approvals look the same) is for the mandatory + security review before MVP-3 builds the card, which confirms or extends them; + until it has ruled, no wider guarantee is made. +- **Diffs are in-house.** A diff is a line diff computed in-house, with no + dependency. + - It is computed over the **same bytes the host will write**. The write + refuses if the file changed between the preview and the write, so what was + approved, with controls escaped and masks labelled, is what lands. + - Its rendering adapts to the terminal width (stacked or side by side), and + each side passes the floor. +- **Truncation is stated, never hidden.** A preview that does not fit shows the + hidden line and byte counts, and the full view is one key away from the + card. The engine never renders; the CLI draws a `ToolPreview` as plain spans + through ADR-0114's sanitize and layout, with no markdown parse, and writes no + escape sequences. + +### 3. Where previews appear + +- **Approval cards.** + - **Writes and edits** show the target and the diff. + - **Commands** show ADR-0118's exact argv card. + - **Every other governed tool** shows its target and declared fields. + + Answering keys stay ADR-0057's unmodified-key set. +- **Transcript rows** (MVP-A8): + - The row keeps its **sanitized target** after the turn ends. + - Its mark tells the truth: success, failure, denial, or outcome unknown + (ADR-0119). + - The assistant's text between tool calls stays in place. + - A write in `auto` mode, which shows no card, leaves a row with its target + and diff stats. A write is never silent. +- **Details.** A details view showing the full declared preview and a bounded + result summary is part of this contract. Whether it ships with MVP-3 or after + is a scope call recorded in the phase file. +- **Never at rest.** Previews are display state computed live. They are never + persisted. After a resume, the repaint shows each call's name and its + ADR-0095 structure, not its target. A target is a tool argument, and arguments + stay off disk. + +### Alternatives considered + +- **Keep never-render and approve blind.** Rejected. ADR-0116 makes the user's + review a security control. A control the user cannot see is not a control. +- **Render the raw arguments.** Rejected. That would show model-chosen strings + with no declaration of what is safe to show, and secret-bearing arguments + could reach the terminal. +- **A regular-expression `search_files`.** Rejected. A model-supplied pattern + is a ReDoS surface. A literal search covers the common need, and a bounded + pattern language can come later through its own review. +- **A diff library.** Rejected under the dependency rule. A line diff over + bounded input is small enough to own. +- **Persist targets so a resume shows them.** Rejected. A target is a tool + argument, and ADR-0095 keeps arguments off disk. + +## Consequences + +### Positive + +- The agent can make surgical edits, search and find in the tree, and read + large files in ranges. +- Every governed call the user approves is shown through the rules above, which + are built so that what it will do is visible; their completeness is the + security review's to confirm. In particular, every write shows its diff, and + every `auto` write leaves a trace. +- Tool rows tell the truth about outcomes, and the conversation's own text + between tool calls survives. +- The "what may be rendered" question has one typed answer per tool. A new tool + shows nothing until it declares a preview, so the default stays closed. + +### Negative + +- The never-render posture is gone. Its replacement is a contract that every + new tool must implement correctly, and a mistake in a preview function is now + a display bug with security weight. The mandatory security review covers the + preview functions, the diff, truncation and the write's + preview-equals-written check. +- `edit_file`'s read-before-edit rule needs per-session read bookkeeping. +- The output store adds session-scoped files on disk, with their own sweep. +- A resumed session shows less than the live one did, by design. diff --git a/docs/decisions/0121-pre-1-0-releases-publish-the-smoke-verified-package-test-tarball.md b/docs/decisions/0121-pre-1-0-releases-publish-the-smoke-verified-package-test-tarball.md new file mode 100644 index 000000000..14330c460 --- /dev/null +++ b/docs/decisions/0121-pre-1-0-releases-publish-the-smoke-verified-package-test-tarball.md @@ -0,0 +1,184 @@ +# ADR-0121: Pre-1.0 releases publish the smoke-verified package-test tarball + +- **Status**: Proposed +- **Date**: 2026-11-05 +- **Related**: [ADR-0051](0051-cli-distribution-thin-bundle-private-engine.md), [ADR-0067](0067-node-supported-floor-22-reaffirm-better-sqlite3.md), [ADR-0111](0111-cli-meta-operations-use-a-lazy-packaged-runtime.md) (partially superseded for pre-1.0 versions), [D59, D70](../roadmap/current.md#wave-gating-decisions), [MVP-10, MVP-A23](../roadmap/phases/phase-2.6-conversational-authoring.md#cli-mvp-additions--2026-11-04), [release runbook](../runbooks/release-a-surface.md) + +## Context + +### Today's release path + +[ADR-0111](0111-cli-meta-operations-use-a-lazy-packaged-runtime.md) made release +debugging material a custody matter: + +- External TypeScript maps are emitted only in a fresh candidate build. +- They are encrypted to a production GnuPG recipient and archived. +- They are bound to the exact tarball by a release binding receipt. +- They are published through a trusted-tag custody graph. + +It states that "missing custody cannot downgrade to test mode" and that a +package-test artifact is never promoted. + +Building that graph is Wave 3 Step 9c (XL) and 9d (L). The production recipient +is Wave 4b. Until they land, Step 9a.1's interim guard refuses every artifact in +the `publish` job, and has done so on `main` since PR #96. Nothing newer than +`0.1.1` can ship. + +### The MVP ruling + +On 2026-10-26 the maintainer put the CLI MVP first. On 2026-11-04 the +maintainer ruled D59, release path B: + +- Pre-1.0 releases publish the smoke-verified package-test tarball from a + trusted `v*` tag on `main` with a green `ci` at that commit. +- They carry no archived encrypted maps. +- Custody becomes mandatory again at 1.0 or when 9c/9d land, whichever comes + first. +- The guard stays in force until this ADR is Accepted and its guard change + passes a security sitting. + +D70 adds that there is no npm pre-release before v0.2.0. + +### What already holds for every artifact + +The release workflow already: + +- builds once and packs once; +- smokes **that same tarball** on Ubuntu, macOS, Windows and the Node 22.0.0 + floor; +- asserts that the ref is reachable from `main`; +- publishes only on a pushed tag, never on a dispatch; +- names a `release` deployment environment for `publish`. + +The npm token is still a repository-level secret. MVP-A23 requires this ADR to +choose between npm trusted publishing and an environment-scoped token. + +## Decision + +**For versions below 1.0.0, the `publish` job may publish the package-test +tarball the smoke matrix proved, under the admission rules below. The custody +graph is not required for those versions, and only for those versions. +Publication authenticates through npm trusted publishing. The repository-level +token is deleted.** + +### 1. Admission + +The guard is replaced by an **admission check** that keeps the guard's shape: + +- The entry point sets a failing exit code before it evaluates anything. +- It reads metadata, never executes artifact content. +- It admits only on **positive proof of every condition**. + +| # | Condition | Proven by | +|---|-----------|-----------| +| 1 | The run is a pushed `v*` tag, not a dispatch | Workflow event and ref (unchanged) | +| 2 | The tag's commit is reachable from `main` | `pack`'s ancestry assertion (unchanged), re-read from the job outputs | +| 3 | `ci` concluded **success** for that exact commit | The checks API, read-only, at publish time; pending, missing or any other conclusion refuses | +| 4 | The artifact is the one `packed-cli` aggregated as passing on every OS and on the Node floor | The package-test receipt and the aggregate job's outputs | +| 5 | The tarball's `package.json` version equals the tag's version, and is **below 1.0.0** with no pre-release identifier | The packed manifest, read from the tarball | +| 6 | The tarball contains no source map and no map-link trailer ([ADR-0051](0051-cli-distribution-thin-bundle-private-engine.md)) | A scan of tarball entry names and the entry chunk's final bytes | +| 7 | No release binding receipt is present | Directory entries. A custody-shaped artifact belongs to 9d's validator, which does not exist yet; refusing it keeps the two paths from mixing | + +Any condition that cannot be evaluated refuses with a fixed code. The exact +codes, inputs and receipt fields live in +[packaging.md](../reference/cli/packaging.md); the procedure lives in the +release runbook. + +### 2. The sunset is mechanical + +- **At 1.0.** Condition 5 refuses 1.0.0 and above, so the pre-1.0 path cannot + publish a 1.x version even if 9c/9d never land. +- **When 9d lands.** Step 9d's custody validator **replaces** this admission + check rather than sitting beside it. Its change removes package-test admission + outright, so custody is mandatory for every later version, pre-1.0 or not. +- **Recorded.** [current.md](../roadmap/current.md) records the sunset beside + the Publication row. + +### 3. What ADR-0111 loses, and only for pre-1.0 versions + +This ADR **partially supersedes ADR-0111** for versions below 1.0.0. Two +statements stop applying to them: + +- "missing custody cannot downgrade to test mode"; +- "a package-test artifact is not promoted". + +For those versions there is no archived ciphertext of the original maps. A +stack trace from a pre-1.0 release is symbolicated only by rebuilding the tag, +on a best-effort basis. + +ADR-0111's lazy runtime, its build and pack rules, its no-maps-in-the-package +rule and its whole custody design stay in force and become mandatory again at +the sunset. ADR-0111 gets a dated amendment note pointing here. + +### 4. The publish credential + +Publication uses **npm trusted publishing (OIDC)**. The npm package trusts +exactly: + +- this repository; +- the `release.yml` workflow; +- the `release` environment. + +The `release` environment is restricted to `v*` tags and requires the +maintainer's review, which makes it a second human gate after tag creation. + +Provenance stays on. The repository-level `NPM_TOKEN` secret is **deleted** once +trusted publishing has been verified. No long-lived publish token remains. + +The considered alternative, an environment-scoped `NPM_TOKEN` with a required +reviewer, was rejected. It keeps a long-lived token that can leak from any job +that later gains the environment. If trusted publishing cannot be configured for +the package, that alternative is the fallback, and the choice is recorded with +MVP-A23. + +**Maintainer actions.** These are not this ADR's code: + +- configuring the trusted publisher on npm; +- creating the environment's reviewer and its tag policy; +- deleting the secret; +- tagging. + +### 5. Security sitting + +The admission change ships only after a dedicated security sitting, as D59 +requires. The sitting covers: + +- each condition's proof; +- the event and ref spoofing paths (dispatch, re-runs, a tag moved after `ci`); +- the checks-API read: which workflow, which commit, which conclusion; +- the trusted-publisher binding; +- what a failure leaves behind, for example a partial publish or a re-run of + the same version. + +### Alternatives considered + +- **Path A: build 9c/9d and the Wave 4b recipient first.** Rejected by D59. It + takes about 4–6 weeks and would sit on the MVP's critical path. +- **B2: lightweight custody**, encrypting and archiving the maps once without + 9c's validator. Rejected by D59. It is a partial custody graph whose + guarantees nobody verifies. +- **C: ship from source or a GitHub Release tarball only.** Rejected by D59. An + `npm i -g` user is who the MVP is for. +- **Allow pre-release tags (`-rc`, `-beta`).** Rejected by D70, and by + condition 5. + +## Consequences + +### Positive + +- v0.2.0 can ship on the MVP's schedule from exactly the bytes the cross-OS + smoke proved, with provenance, `main` ancestry and a green `ci` checked at + publish time. +- The sunset cannot be forgotten. It is enforced by the version check and by + 9d's replacement of the check. +- No long-lived npm token remains anywhere. + +### Negative + +- Pre-1.0 releases have no archived maps, so symbolicating their crash reports + depends on rebuilding the tag. +- The publish job gains a checks-API read and a tarball scan. Both are new code + on the release path and need tests and the security sitting. +- Trusted publishing ties publication to npm's OIDC trust configuration, which + lives outside this repository. Its correctness is verified operationally, not + by CI. diff --git a/docs/decisions/README.md b/docs/decisions/README.md index 8b9136a29..2d6e4ef40 100644 --- a/docs/decisions/README.md +++ b/docs/decisions/README.md @@ -157,6 +157,14 @@ flowchart TD | 0111 | [CLI meta-operations use a lazy packaged runtime](0111-cli-meta-operations-use-a-lazy-packaged-runtime.md) | Accepted | 2026-10-15 | | 0112 | [Chat network grants and onboarding state are operator-owned](0112-chat-network-grants-and-onboarding-state-are-operator-owned.md) | Accepted | 2026-10-15 | | 0113 | [Secret-derived context keeps provenance at public sinks](0113-secret-derived-context-keeps-provenance-at-public-sinks.md) | Accepted | 2026-10-15 | +| 0114 | [In-house markdown rendering for the chat transcript](0114-in-house-markdown-rendering-for-the-chat-transcript.md) | Proposed | 2026-11-05 | +| 0115 | [One interrupt keymap for the interactive CLI](0115-one-interrupt-keymap-for-the-interactive-cli.md) | Proposed | 2026-11-05 | +| 0116 | [The built-in chat agent can edit, and the starting mode is configurable](0116-the-built-in-chat-agent-can-edit-and-the-starting-mode-is-configurable.md) | Proposed | 2026-11-05 | +| 0117 | [A denied tool call returns to the model](0117-a-denied-tool-call-returns-to-the-model.md) | Proposed | 2026-11-05 | +| 0118 | [A command outside the allowlist asks instead of failing](0118-a-command-outside-the-allowlist-asks-instead-of-failing.md) | Proposed | 2026-11-05 | +| 0119 | [An interrupted turn stays in the session as a content-free marker](0119-an-interrupted-turn-stays-as-a-content-free-marker.md) | Proposed | 2026-11-05 | +| 0120 | [The toolbelt grows, and a tool call shows what it does](0120-the-toolbelt-grows-and-a-tool-call-shows-what-it-does.md) | Proposed | 2026-11-05 | +| 0121 | [Pre-1.0 releases publish the smoke-verified package-test tarball](0121-pre-1-0-releases-publish-the-smoke-verified-package-test-tarball.md) | Proposed | 2026-11-05 | ## Creating a new ADR diff --git a/docs/glossary.md b/docs/glossary.md index 383bb465a..9ce7c0294 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -120,8 +120,8 @@ How a workflow run is initiated. Full spec in Behaviour: [reference/cli/chat-session.md](reference/cli/chat-session.md#model-reseat-models); decision: [ADR-0059](decisions/0059-cli-mid-session-model-reseat.md). - **Human gate** — a node that pauses a run and requires a real human decision - (approve / reject / provide input) before continuing. Supports a timeout with - an auto-approve or auto-reject fallback (an `escalate` action is **reserved** for a + (approve / reject / provide input) before continuing. Supports a timeout that + either auto-approves or fails the run (an `escalate` action is **reserved** for a future phase — see [reference/contracts/workflow-yaml-spec.md](reference/contracts/workflow-yaml-spec.md)). Enables compliance-sensitive workflows. The same gate seam also backs a workflow **budget** pause ([decisions/0028-workflow-resource-governance.md](decisions/0028-workflow-resource-governance.md)). diff --git a/docs/reference/cli/chat-session.md b/docs/reference/cli/chat-session.md index 59947821c..ea808eb57 100644 --- a/docs/reference/cli/chat-session.md +++ b/docs/reference/cli/chat-session.md @@ -51,6 +51,14 @@ A session **binds one agent for its whole lifetime** — there is no mid-session - the model and `fallback_chain` come from that agent, exactly as a workflow `agent` node resolves them; the same fallback chain a workflow uses applies to a chat turn. - when no agent names a model, the `[chat].default_model` from project config is used — the chat-mode **defaults** (`default_model`, `fs_scope`, the command allowlist, `max_turns` (the hard turn cap → `SessionDeps.maxTurns`), `max_messages`) live in the `[chat]` block of [config-spec.md](../contracts/config-spec.md), which references the workflow canonical homes rather than forking them. - when **no** `--agent` is given, 2.M binds a **built-in default chat agent** over `[chat].default_model` (a conservative, read-only tool grant — write/exec/egress need an explicit `--agent`), so `relavium chat` is a zero-config first run. +- **the default model follows the stored key (MVP-A4).** With no `default_model` at any layer, the default agent's provider is the first of `anthropic`, `openai`, `gemini`, `deepseek` that has a key (keychain or `RELAVIUM__API_KEY`). Anthropic keeps `claude-sonnet-4-6`; another provider gets the onboarding wizard's starter model for it, so a user with only an OpenAI key reaches a working chat. A `default_provider` set without a `default_model` is ignored, as [config-spec.md](../contracts/config-spec.md) resolves it. The check is a yes/no question to the key resolver and never reads a key into the session; one session build asks it at most once per provider. +- **a provider with no key is caught before anything is sent (MVP-A4).** Every session build asks whether the bound agent's provider has a key before it asks for MCP consent, spawns an MCP server or sends anything. The message is the one `provider set-key` documents: ``no API key for provider '' — store one with `relavium provider set-key ` or set RELAVIUM__API_KEY.`` What happens next depends on the surface: + - `chat`, `agent run` and a non-interactive `chat-resume` refuse with exit `2`. `agent run --fixture` is the exception: it replays a cassette with no key, so it never refuses. + - An **interactive** `chat-resume` opens anyway, because `/models` can move the conversation to a provider that has a key. Its session build therefore goes on past the check: it still asks for MCP consent and starts the agent's MCP servers, and the notice comes when the view opens. The view says so on open, adding `Or switch to a model whose provider has a key with /models.`, and repeats it instead of sending each message, which is neither sent nor recorded. A key stored meanwhile, in another terminal, lets the next message through. + - The Home shows the message as a banner and stays open. + - A `/models` reseat or a `/clear` whose new session cannot be built ends the `chat` REPL with the reason and the `chat-resume` command. The in-Home chat keeps the conversation instead, with a fixed note. + + Without this the first turn failed inside the fallback chain, which deliberately replaces a credential error with a fixed `credential resolution failed` so a host message cannot leak a secret. Only the primary provider is checked: an `auth` failure ends the whole chain, so a keyless primary cannot answer until a key is stored or the model changes, while a keyless `fallback_chain` entry fails only if it is reached. ### Model reseat (`/models`) @@ -101,7 +109,7 @@ A small, **alias-free**, curated set of slash commands drives the REPL itself (n | `/help` | List the available slash commands (**2.5.C**). Typing `/` at an idle prompt instead opens the interactive, filterable **`/` palette** over these commands (in both the chat and the bare Home). | | `/exit` | End the session cleanly and quit the REPL (**exit code 4**, below). | | `/cancel` | End the session **terminally** (aborting any in-flight turn — also entered as **Ctrl-C**). The session is **persisted and resumable** via `relavium chat-resume ` (2.N). Exits with code 4. For a mid-turn abort that KEEPS the session alive, press **Esc** (2.5.E, below) — that is distinct from `/cancel`. | -| `/export` | Export the session-so-far to a `.relavium.yaml` scaffold (same ADR-0026 contract as `relavium chat-export`). Writes the file (named `.relavium.yaml`) and reports the path; under `--json` it emits a `session:exported` event on the stream. It does **not** mark the session row `exported` (a later turn's persist would clobber that) — use `relavium chat-export` for the durable provenance mark. **Live (2.P / 2.Q).** | +| `/export` | Export the session-so-far to a `.relavium.yaml` scaffold (same ADR-0026 contract as `relavium chat-export`). Writes the file where workflows live — `/.relavium/workflows/.relavium.yaml`, where `/.relavium/` is the nearest one found walking up from the working directory (under your home directory with no project in between, that is the global `~/.relavium/`), or `/.relavium/workflows/` when there is none — with the session title's slug as the id (written into the file as `workflow.id`), and shows the path and the command that runs it (``run it with `relavium run ` ``) (MVP-A3) — in the transcript on the interactive chat and Home, on stderr on the plain driver. Under `--json` no path line or run hint is printed: the path is the `session:exported` event's `workflowPath`. A refusal (`export failed: …`) goes to the transcript, or to stderr on the plain and `--json` drivers. It never overwrites another workflow: the id is the first of ``, `-2`, … that no catalog file or `workflow.id` already holds. The one file it rewrites is the session's own earlier export, recognized from the file itself — a regular file, never a link, at one of those names whose `metadata.relaviumExport` names this session — so a later `/export` of the same session updates it in place, atomically, across a `/models` reseat, a `chat-resume` or a Home reopen, while a workflow put where an old export used to be is left alone. The rewrite re-checks the file just before it replaces it, so a file changed or replaced after it was recognized is left alone too and the export moves on through the same names: another of this session's own exports there is rewritten instead, otherwise the next free id is taken; the check compares the file's bytes as well as its stat, so a same-length rewrite is caught too; a swap in the moment between that check and the replace is not detected. Under `--json` it emits a `session:exported` event on the stream. It does **not** mark the session row `exported` (a later turn's persist would clobber that) — use `relavium chat-export` for the durable provenance mark. **Live (2.P / 2.Q).** | | `/workflows` | List the project's discovered workflows + agents (the disk catalog) as an in-view **notice** (**2.5.C S4**). A project-less cwd is reported, not an error. Chat-only today. | | `/cost` | Show the session's spend as an in-view **notice** — the total, then a **per-model breakdown** (**2.5.C S4**; the breakdown is **2.6.C**, [ADR-0070](../../decisions/0070-durable-per-model-session-cost-attribution.md)). Read from `history.db`, not from memory, so a **resumed** session shows the whole session's models and not just this process's. A `$0` row with real tokens is flagged *price unknown* — an unpriced model is not a free one. A model carrying a **conservative budget commitment** ([ADR-0074](../../decisions/0074-durable-conservative-budget-commitments.md) §1/§4 — money a provider may already have billed for an attempt that returned no trustworthy usage) shows it as *estimated, possibly billed*, **never added into the spend figure or the share**; a commitment made before any completed call on that model reads `(no completed call)` rather than claiming `0 calls`. That is what explains a `$0.0000` session whose next turn the cap refuses. Chat-only. | | `/mode [name]` | Switch the chat **mode** — `ask` / `plan` / `accept-edits` / `auto` (**2.5.E**, below); bare `/mode` shows the current mode + explains each. `Shift+Tab` cycles them. Chat-only. | @@ -287,4 +295,4 @@ Provider keys are read from the **OS keychain** exactly as for `relavium run` ## Export to workflow -`/export` (interactive) and `relavium chat-export ` drive the **one** export contract: the session's assistant turns become a linear chain of `agent` nodes and the persisted text and content-free structural tool history are preserved as YAML metadata, for review before commit ([ADR-0026](../../decisions/0026-session-export-to-workflow.md)). The two differ only in their provenance side-effect: `relavium chat-export` additionally marks the session row `status: exported` and records the written path (a durable provenance mark surfaced by `chat-list`); the in-REPL `/export` writes the scaffold but does **not** mark the row, since a later turn's persist would clobber the marker. The mapping is owned by [agent-session-spec.md](../contracts/agent-session-spec.md#export-to-workflow); it **produces** the format owned by [../contracts/workflow-yaml-spec.md](../contracts/workflow-yaml-spec.md). Parallel / conditional / loop structure is not auto-inferred — the export is a **scaffold**. +`/export` (interactive) and `relavium chat-export ` drive the **one** export contract: the session's assistant turns become a linear chain of `agent` nodes and the persisted text and content-free structural tool history are preserved as YAML metadata, for review before commit ([ADR-0026](../../decisions/0026-session-export-to-workflow.md)). The two differ in where they write — `/export` into the project's `workflows/` under the title slug (MVP-A3), `chat-export` into `.relavium.yaml` in cwd unless `--out` says otherwise — and in their provenance side-effect: `relavium chat-export` additionally marks the session row `status: exported` and records the written path (a durable provenance mark surfaced by `chat-list`); the in-REPL `/export` writes the scaffold but does **not** mark the row, since a later turn's persist would clobber the marker. The mapping is owned by [agent-session-spec.md](../contracts/agent-session-spec.md#export-to-workflow); it **produces** the format owned by [../contracts/workflow-yaml-spec.md](../contracts/workflow-yaml-spec.md). Parallel / conditional / loop structure is not auto-inferred — the export is a **scaffold**. diff --git a/docs/reference/cli/commands.md b/docs/reference/cli/commands.md index eb289b54c..c6db618fd 100644 --- a/docs/reference/cli/commands.md +++ b/docs/reference/cli/commands.md @@ -130,12 +130,12 @@ The command set below is the confirmed surface. Commands ship **per workstream** | `relavium chat-list` | List past agent sessions (id, agent, last activity), the way `relavium list` lists workflows. | | `relavium chat-export ` | Export a session to a `.relavium.yaml` scaffold for review ([ADR-0026](../../decisions/0026-session-export-to-workflow.md)). | | `relavium agent run [--fixture ] [--json] [--allow-mcp-stdio ]` | Run a single agent **one-shot** (non-interactive) on the same AgentSession infra — the prompt is read from stdin, one turn, then exit. See [`relavium agent run`](#relavium-agent-run) and [agent-run-fixture.md](agent-run-fixture.md). | -| `relavium list` | List discovered workflows (and, with a flag, agents) in the current project. | +| `relavium list` | List discovered workflows (and, with a flag, agents) in the current project; with `--runs`, every recorded run. | | `relavium create [--force]` | Scaffold a new agent or a minimal single-agent workflow YAML via an interactive wizard (schema-validated before write). | | `relavium import [--force]` | Import an external `.relavium.yaml` / `.agent.yaml` into the project, validated + slug-deduplicated. | | `relavium export [--out ] [--force]` | Export a workflow/agent to a portable, canonical YAML copy. Serialization drops comments and preserves authored values, including sensitive literals; review before sharing. Configured provider keys use keychain references; author MCP `env` credentials as `{{secrets.*}}`. | | `relavium logs ` | Print the persisted event/log stream for a past run. | -| `relavium status` | Show active runs and their per-node status. | +| `relavium status []` | Show active runs and their per-node status, or one run by id, active or finished. | | `relavium gate ` | Resolve a pending human gate (approve / reject / provide input). | | `relavium gate list []` | List pending human gates (all active runs, or one run) — the multi-gate subcommand for resolving one of several concurrently-pending gates. | | `relavium budget resume [--gate ] --approve-amount \| --abort` | Resolve a recorded budget gate with its exact frozen amount, or reject it. See [`budget resume`](#relavium-budget-resume). | @@ -200,19 +200,27 @@ printf 'api_key=%s\n' "$API_KEY" | relavium run ./workflows/code-review.relavium - Run admission rejects forbidden ordinary secret arguments before reading the secret pipe, constructing credential/provider resolvers, connecting MCP or opening run history. The CLI then coerces ordinary values, merges stdin secrets, and runs the pure core input contract (including length, format, pattern, enum and numeric bounds) before those effects. A rejected invocation creates no run. After unknown CLI keys are refused, core contract refusals identify a bounded set of already-declared inputs with sanitized labels and value-free reasons. Unknown, duplicate and malformed ordinary argument diagnostics keep raw argv names and values opaque. The engine validates again at start; admitted secret input slots and input-node results are [masked](../contracts/workflow-yaml-spec.md#inputs) in run events/history, while the authored workflow snapshot is kept faithful. This does not redact arbitrary authored literals, tool output or other content. - Consuming the secret pipe removes interactive prompt authority for this invocation. A later unresolved human gate pauses with exit `3` for a fresh `relavium gate` call; an unapproved local MCP program refuses instead of prompting. An existing exact consent grant or explicit `--allow-mcp-stdio ` remains the authorization path. The flag itself grants no tool or transport authority. - `--json` switches to NDJSON [RunEvent](../contracts/sse-event-schema.md) output. +- **The human output ends with the workflow's outputs (MVP-A1).** On a completed run, the TUI's final summary and the plain (no-TTY) renderer list `run:completed.outputs` under `outputs:`: a one-line value beside its key, a multi-line value indented beneath it, anything that is not a string as compact JSON, and a secret input the engine masked as that masked object. Every value is untrusted text: the shared scrubber masks the [secret shapes it recognizes](../shared-core/diagnostic-secret-backstop.md) in the original text — a finite list, so a token family it does not know (a `ghp_` or `glpat-` token, an AWS key id, a JWT) is shown — field by field for a value that is not a string (before it is serialized, so an escaped newline cannot hide a key), and terminal controls are stripped after it. A field with a plain value, a top-level output included, also has its name read, on its own, as it appears in serialized JSON, and the value is masked whole, even with no secret shape of its own, when the scrubber reads that name as a credential's label: an API-key header name (`x-api-key`, `api-key`, `x-goog-api-key`), or a name that ends in a credential's own prefix (a URL ending in `?token=`, say). A name the scrubber masks up to its own end (`Basic plan`, `Bearer token`, a URL ending in `?token=abc`) is a secret shape itself, not a label: its value is kept, and as a member's name it is shown masked. Any other name masks nothing, so an opaque value under `password` or `token` is shown, and a value's text is never read together with its name. The scrubber is fail-closed for its non-query shapes, so a value whose text forms one once whitespace is removed — a bare `https://host` and a later `@`, for one — is masked whole; a query name split by whitespace (`?access_ token=`) is not joined. Values are bounded: 40 lines and 4,000 characters per value, 12,000 characters per run. Anything cut is marked `…`, and a cut or masked block says so and where the exact record is: ``(outputs shortened — the full record: `relavium logs --json`)``, with `masked` or `shortened and masked` in place of `shortened` when that is what happened. A failed or cancelled run shows no outputs block, and `--json` is unchanged. - `Ctrl-C` (SIGINT) requests a cooperative cancel; the run drains to `run:cancelled` and exits non-zero (`1`). This is unchanged when the workflow declares `mcp_servers`: the spawned servers are torn down alongside the drain, and the MCP teardown deliberately does **not** take the exit code — a signalled run still records its terminal and releases its lease ([ADR-0088](../../decisions/0088-the-mcp-boundary-is-hostile.md) §1.3). - A missing API key for an inline agent's **primary** provider is caught **pre-flight** as an invalid invocation (exit `2`) naming the `RELAVIUM__API_KEY` to set, before the run starts. The pre-flight is a strict subset of the keys a run may touch, so it never blocks a valid run: a `fallback_chain` provider's key (read only if the chain fails over to it) and a `$ref`-resolved external agent's key (until `$ref` resolution lands, 2.M–2.Q) are conditional and instead surface mid-run as a run failure (exit `1`). - `--allow-mcp-stdio ` (repeatable) authorizes a **local MCP program** for this invocation only, when there is no one to prompt. A workflow declaring a `stdio` MCP server that has not been approved on this machine exits `2` before anything spawns — see [Local MCP servers need consent](#local-mcp-servers-need-consent). - On a `human_gate` node the run **pauses**: in interactive mode it prompts inline; in CI mode it exits with the gate-paused code (`3`, see [Exit codes](#exit-codes)) and can be resumed with `relavium gate`. The emitted `human_gate:paused` event carries the `runId` + `gateId` needed for the resume (`relavium gate --gate `); with `--json` they are on the NDJSON event line, otherwise the plain/TUI renderer prints them inline (`paused at gate ()`, also echoed in the final summary). (`relavium status`, `relavium logs `, and `relavium gate list` also surface pending `gateId`s, 2.I.) -> **Implementation status (as of workstream 2.G).** `run` is wired to the `@relavium/core` engine: path/id resolution, `--input` coercion, the full lifecycle event stream, exit codes `0`/`1`/`2`/`3`, SIGINT→cancel, and the stable `--json` NDJSON machine contract (stdout = pure RunEvent stream, diagnostics → stderr; see [above](#the---json-machine-output-contract)) are live. The interactive **`ink` TUI** (2.E) renders the live run on a TTY — per-node status + spinners, the active node's streaming tokens, a running cost/duration footer, and a persistent final summary. Under `--no-color` it keeps the TUI but suppresses ANSI color; it falls back to the plain line renderer when no TTY is attached or `CI=true`, and to NDJSON under `--json` (the three renderers are one `onEvent` seam over one bus). Provider keys resolve from the **OS keychain → `RELAVIUM__API_KEY` env var → error** (2.C; manage them with `relavium provider`), and runs persist to durable history (2.H). The **interactive human-gate prompt** + out-of-band [`relavium gate`](#relavium-gate) resume are live (2.G): on a TTY a `human_gate` node renders a `@clack/prompts` card inline (approve / reject + comment / input) and the run continues; under `--json`/CI/no-TTY there is no prompt and the run exits `3`, resumable later by `relavium gate `. The CLI supplies reviewed filesystem, process and egress host capabilities; tool availability remains subject to invocation authority, per-tool policy and wired delegates. The [built-in tool reference](../shared-core/built-in-tools.md#cli-authority-and-floors) owns their current host behavior and security floors. +> **Implementation status (as of workstream 2.G).** `run` is wired to the `@relavium/core` engine: path/id resolution, `--input` coercion, the full lifecycle event stream, exit codes `0`/`1`/`2`/`3`, SIGINT→cancel, and the stable `--json` NDJSON machine contract (stdout = pure RunEvent stream, diagnostics → stderr; see [above](#the---json-machine-output-contract)) are live. The interactive **`ink` TUI** (2.E) renders the live run on a TTY — per-node status + spinners, the active node's streaming tokens, a running cost/duration footer that leads with the run id, and a persistent final summary that names the run id with the command that continues from it (MVP-A2): `relavium logs ` for a run that ended; for a paused run, `relavium gate ` when a human gate is pending, `relavium budget resume --gate ` for a budget gate (which `gate` declines), and `relavium status ` for a run parked on a media job only. A run resumed by `gate` or `budget resume` names its id too. The summary classifies a gate only from what its own process saw announced, and a resumed run re-parks on the gates restored from its checkpoint without announcing them again; such a gate could be either kind, so for it the summary points at `relavium status `, which prints the command for each pending gate. Under `--no-color` it keeps the TUI but suppresses ANSI color; it falls back to the plain line renderer when no TTY is attached or `CI=true`, and to NDJSON under `--json` (the three renderers are one `onEvent` seam over one bus). Provider keys resolve from the **OS keychain → `RELAVIUM__API_KEY` env var → error** (2.C; manage them with `relavium provider`), and runs persist to durable history (2.H). The **interactive human-gate prompt** + out-of-band [`relavium gate`](#relavium-gate) resume are live (2.G): on a TTY a `human_gate` node renders a `@clack/prompts` card inline (approve / reject + comment / input) and the run continues. A bare Enter on an approval, review or budget-amount card rejects; Esc at the optional rejection reason keeps the rejection without one; Ctrl-C there cancels the run, as it does at the card. A card whose gate has a deadline says what it does: `auto-approve on timeout` for an approve deadline on an approval or review gate, otherwise `the run fails on timeout`. When the deadline's decision reaches the run view while its card is open, the card closes, stderr says no answer from the terminal was recorded, and the run's later gates are not prompted inline (keys still being typed for the closed card would answer the next one): the run pauses on them, and its summary names the command that resumes it; under `--json`/CI/no-TTY there is no prompt and the run exits `3`, resumable later by `relavium gate `. On `relavium run` the CLI supplies reviewed filesystem and process host capabilities; the egress and os arms are wired only in the approval-gated chat host, and `run` refuses a workflow whose agent node grants a tool needing one (`http_request`, `web_search`, `read_clipboard`, `notify`) with exit `2` before any key is read ([where each tool runs](../shared-core/built-in-tools.md#the-built-in-tools)). Tool availability remains subject to invocation authority, per-tool policy and wired delegates. The [built-in tool reference](../shared-core/built-in-tools.md#cli-authority-and-floors) owns their current host behavior and security floors. ### `relavium list` -Lists the workflows discovered under the project `.relavium/workflows/`, **grouped by tag**, each annotated with its **last-run status** from durable history (the latest run per workflow — a SQLite `ROW_NUMBER() OVER (PARTITION BY workflow_id …)` pick, since SQLite has no `DISTINCT ON`). `--agents` lists the agents under `.relavium/agents/` instead (agents carry no tags or run history, so they list flat). Disk is the catalog source of truth, distinct from run history — a discovered file that fails to parse is listed and flagged `(invalid: )` rather than hidden. Outside a `.relavium/` project this is reported clearly and exits `0` (an empty catalog is not a fault). +Lists the workflows discovered under the project `.relavium/workflows/`, **grouped by tag**, each annotated with its **last-run status** from durable history (the latest run per workflow — a SQLite `ROW_NUMBER() OVER (PARTITION BY workflow_id …)` pick, since SQLite has no `DISTINCT ON`). `--agents` lists the agents under `.relavium/agents/` instead (agents carry no tags or run history, so they list flat). Disk is the catalog source of truth, distinct from run history — a discovered file that fails to parse is listed and flagged `(invalid: )` rather than hidden. In the human listing, every field read from a catalog file (a slug, which for an invalid file is its name, a workflow or agent name, a tag, a parse reason) is printed on one line with terminal controls stripped and the [secret shapes the diagnostic backstop recognizes](../shared-core/diagnostic-secret-backstop.md) masked: a name is free authored text, and `/export` writes a session's title there. `--json` escapes control characters and keeps each value verbatim, as every machine record does. Outside a `.relavium/` project this is reported clearly and exits `0` (an empty catalog is not a fault). Under `--json`, each entry is one NDJSON record — `{ kind, slug, name, tags, path, valid, error?, lastRun }`, where `name` is `null` when the file declares none, `error` is a short, secret-free parse-failure reason present **only** when `valid` is `false`, and `lastRun` is `{ runId, status, completedAt }` or `null` for a never-run workflow (and is omitted entirely for agents). See [Read-command `--json` output](#read-command---json-output). +`relavium list --runs` lists every recorded run instead, newest first, from the home directory's history (no project +is needed): one line per run, ` `, where `` is the slug history knows +or else the workflow id. Under `--json` each run is one record `{ kind: "runs", runId, workflowId, workflow, status, +startedAt, completedAt }` (`workflow` is the slug or `null`; absent times are `null`). It shows no amounts: a total needs +its usage coverage ([ADR-0104](../../decisions/0104-unknown-provider-usage-is-durable-cost-uncertainty.md)), which +`relavium status --json` reports. The status column is the derived projection: a run whose terminal is held in the outbox after exit `5` still reads `running` here, and `relavium status ` names it. `--runs` and `--agents` exclude each other (MVP-1). + ### `relavium create` Interactive scaffolder (`@clack/prompts`) that asks **kind** (an **agent** → `.agent.yaml`, or a **minimal single-agent workflow** → `.relavium.yaml`, i.e. an `input → agent → output` scaffold wrapping one inline agent), then **name → provider → model → system prompt → tools** (comma-separated, optional). The id is the slugified name; the file lands at `.relavium/agents/.agent.yaml` or `.relavium/workflows/.relavium.yaml`. @@ -238,7 +246,7 @@ For this detector probe only, a recognised bare template reference without a pat ### `relavium logs ` -Replays a past run's persisted `run_events` in `seq` order (the same data the desktop run-detail drawer replays) — a terse line per event in human mode. Under `--json` it emits each **raw [RunEvent](../contracts/sse-event-schema.md) as one NDJSON line** — the same `RunEvent` data `relavium run --json` streamed (this is the "raw RunEvent JSON" the run-detail replay consumes — no separate `--raw` flag). For a run paused at a gate, the `human_gate:paused` event surfaces the **`gateId`** to copy into `relavium gate --gate `. An unknown `runId` is an invalid invocation (exit `2`). +Replays a past run's persisted `run_events` in `seq` order (the same data the desktop run-detail drawer replays) — a terse line per event in human mode, with a completed run's outputs beneath its `run:completed` row, bounded and sanitized as [`run`](#relavium-run) prints them (MVP-A1). Under `--json` it emits each **raw [RunEvent](../contracts/sse-event-schema.md) as one NDJSON line** — the same `RunEvent` data `relavium run --json` streamed (this is the "raw RunEvent JSON" the run-detail replay consumes — no separate `--raw` flag). For a run paused at a gate, the `human_gate:paused` event surfaces the **`gateId`** to copy into `relavium gate --gate `. An unknown `runId` is an invalid invocation (exit `2`). Replay retains the stored content; current declared-secret context protection does not scrub or certify older outputs. Apply the [historical output/error guidance](../shared-core/database-schema.md#historical-output-and-error-data) @@ -254,7 +262,7 @@ Two rows the replay may not be able to read, and it says which is which ([ADR-00 ### `relavium status` -Shows the currently active/paused runs (from `runs` + `step_executions`) and each one's per-node status. Pending human and budget gates are reconstructed from the strict ordered event log even when only the authoritative budget pause survived a crash; discovery never claims ownership or supplies a decision. Useful while a long workflow runs in another terminal or was launched detached. For any run paused at a human gate it also prints the **pending `gateId`(s)** (with gate type and node id), so a CI author can pass the right one to `relavium gate --gate ` — required when a run has more than one gate pending at once. It takes **no argument** (it lists every active run; a terminal run is not shown — inspect one with `relavium logs `). A run whose event log is damaged is still listed, without its gate detail, rather than aborting the listing ([ADR-0074](../../decisions/0074-durable-conservative-budget-commitments.md) §5). A run whose TERMINAL is held in the terminal outbox is named as such — it reads `running` in the derived projection because its terminal never became durable, so without the marker it is indistinguishable from a run still working ([exit code `5`](#exit-codes)). The listing READS the outbox and never drains it: draining claims a run lease, and a status read must not take ownership of a run another process may be finishing. Under `--json` each active run is one NDJSON record — `{ runId, workflowId, status, totalCostMicrocents, usageCoverage, unpricedCalls, terminalHeld, startedAt, steps, pendingGates, pendingBudgetGates }`, where each `steps` entry is `{ nodeId, nodeType, status, attemptNumber, startedAt, completedAt, durationMs, costMicrocents, usageCoverage }` and each `pendingGates` entry is `{ gateId, nodeId, gateType, message, expiresAt? }` (the same pending-gate shape [`gate list`](#relavium-gate-list) emits). Each `pendingBudgetGates` entry is `{ gateId, nodeId, allowance, expiresAt? }`: `allowance` is `{ kind: "amount", microcents }`, `{ kind: "legacy" }`, `{ kind: "reject_only", reason: "unpriced" }` or `{ kind: "reject_only", reason: "unrepresentable" }`. Each allowance variant may also carry `excludedEntries?: string[]`: recorded excluded-candidate notices with redacted, quoted, truncated and escaped model identifiers and fixed exclusion reasons. These display strings grant no allowance. The JSON projection exposes no full quote, priced-model identity, endpoint or price provenance. Plain output adds the exact approval/abort commands for an amount-bearing gate. Terminal controls and secret-shaped identifiers are redacted and bounded in the budget display; a modified or shell-active identifier gets generic instructions rather than an executable hint. A damaged log emits empty gate arrays plus `gatesUnavailable: true` and `gatesUnavailableReason: "corrupt_event_log"`, so absence of readable authority is distinguishable from no pending gate. Run amounts and per-step amounts/lifecycle are qualified by usage coverage from one SQLite snapshot. A concurrent completion cannot attach an older complete-usage fold to a newer step amount. If strict usage replay fails, the record carries `usageUnavailable: true`, `usageUnavailableReason: "unreadable_event_log"` and explicitly unverified run/step coverage; independently readable step detail remains visible. +Shows the currently active/paused runs (from `runs` + `step_executions`) and each one's per-node status. Pending human and budget gates are reconstructed from the strict ordered event log even when only the authoritative budget pause survived a crash; discovery never claims ownership or supplies a decision. Useful while a long workflow runs in another terminal or was launched detached. For any run paused at a human gate it also prints the **pending `gateId`(s)** (with gate type and node id), so a CI author can pass the right one to `relavium gate --gate ` — required when a run has more than one gate pending at once. Plain output prints that command under each pending human gate, once for each decision its type accepts (`--approve` or `--reject`; `--input ` or `--reject` for an `input` gate), and a recorded identifier that is not shell-inert gets a sentence naming the flags instead. Without an argument it lists every active run; `relavium status ` shows that one run, active or finished, in the same shape (an unknown id is exit `2`) — the re-check the exit-code-`5` guidance names (MVP-1). A finished run has nothing pending, even when it ended while a gate waited (`gate` and `budget resume` answer it with `nothing to resume` and exit `0`), and a step its node never finished prints as `stopped`. A human gate's step row stays open once its gate is decided, since no `node:completed` follows the decision, so `status` reads the decision from the log and prints that step as `completed`, as the engine marks the gate. Each `--json` step keeps the recorded status. A run's full event history stays `relavium logs `. A run whose event log is damaged is still listed, without its gate detail, rather than aborting the listing ([ADR-0074](../../decisions/0074-durable-conservative-budget-commitments.md) §5). A run whose TERMINAL is held in the terminal outbox is named as such — it reads `running` in the derived projection because its terminal never became durable, so without the marker it is indistinguishable from a run still working ([exit code `5`](#exit-codes)). The listing READS the outbox and never drains it: draining claims a run lease, and a status read must not take ownership of a run another process may be finishing. Under `--json` each active run is one NDJSON record — `{ runId, workflowId, status, totalCostMicrocents, usageCoverage, unpricedCalls, terminalHeld, startedAt, steps, pendingGates, pendingBudgetGates }`, where each `steps` entry is `{ nodeId, nodeType, status, attemptNumber, startedAt, completedAt, durationMs, costMicrocents, usageCoverage }` and each `pendingGates` entry is `{ gateId, nodeId, gateType, message, expiresAt? }` (the same pending-gate shape [`gate list`](#relavium-gate-list) emits). Each `pendingBudgetGates` entry is `{ gateId, nodeId, allowance, expiresAt? }`: `allowance` is `{ kind: "amount", microcents }`, `{ kind: "legacy" }`, `{ kind: "reject_only", reason: "unpriced" }` or `{ kind: "reject_only", reason: "unrepresentable" }`. Each allowance variant may also carry `excludedEntries?: string[]`: recorded excluded-candidate notices with redacted, quoted, truncated and escaped model identifiers and fixed exclusion reasons. These display strings grant no allowance. The JSON projection exposes no full quote, priced-model identity, endpoint or price provenance. Plain output adds the exact approval/abort commands for an amount-bearing gate. Terminal controls and secret-shaped identifiers are redacted and bounded in the budget display; a modified or shell-active identifier gets generic instructions rather than an executable hint. An active run's damaged log emits empty gate arrays plus `gatesUnavailable: true` and `gatesUnavailableReason: "corrupt_event_log"`, so absence of readable authority is distinguishable from no pending gate; a finished run has nothing pending, so its damaged log adds neither field. Run amounts and per-step amounts/lifecycle are qualified by usage coverage from one SQLite snapshot. A concurrent completion cannot attach an older complete-usage fold to a newer step amount. If strict usage replay fails, the record carries `usageUnavailable: true`, `usageUnavailableReason: "unreadable_event_log"` and explicitly unverified run/step coverage; independently readable step detail remains visible. ### `relavium models` @@ -283,7 +291,7 @@ relavium models pricing my-custom-model --provider openai --input 3 --output 9 ### Read-command `--json` output -The non-streaming read commands (`list` / `status` / `gate list` / `chat-list` / `models` / `provider list`, and `logs`) keep the CLI to **one machine-output idiom**: `--json` emits **one result record per line** (NDJSON, `jq`-friendly, stdout-pure with diagnostics on stderr) — the same line-oriented shape `relavium run --json` uses for its `RunEvent` stream ([ADR-0049](../../decisions/0049-cli-machine-output-contract.md)). For `logs --json` the records ARE raw `RunEvent`s — the same `RunEvent` data the run streamed (re-serialized from the persisted log, so the field order may differ from the live `run --json` bytes); for the others they are the per-command result records documented above. An unknown `runId` (`logs` / `gate list`) is the structured pre-run fault on stderr with exit `2`, stdout empty — exactly as for `run`. (`chat-export --json` is **not** a read command — it emits a single `session:exported` **event**, not a result record, since the export is a session-lifecycle action.) +The non-streaming read commands (`list` / `status` / `gate list` / `chat-list` / `models` / `provider list`, and `logs`) keep the CLI to **one machine-output idiom**: `--json` emits **one result record per line** (NDJSON, `jq`-friendly, stdout-pure with diagnostics on stderr) — the same line-oriented shape `relavium run --json` uses for its `RunEvent` stream ([ADR-0049](../../decisions/0049-cli-machine-output-contract.md)). For `logs --json` the records ARE raw `RunEvent`s — the same `RunEvent` data the run streamed (re-serialized from the persisted log, so the field order may differ from the live `run --json` bytes); for the others they are the per-command result records documented above. An unknown `runId` (`logs` / `gate list` / `status `) is the structured pre-run fault on stderr with exit `2`, stdout empty — exactly as for `run`. (`chat-export --json` is **not** a read command — it emits a single `session:exported` **event**, not a result record, since the export is a session-lifecycle action.) ### `relavium gate` @@ -292,13 +300,17 @@ Resolves a pending human gate from the terminal — the surface-agnostic resume ```bash relavium gate --approve relavium gate --reject --comment "Too risky" -relavium gate --input '{"region": "us-east-1"}' # for gate_type=input +relavium gate --input '{"region": "us-east-1"}' # for gate_type=input (or --reject) relavium gate --gate --approve # disambiguate when >1 gate is pending # a run with `secret`-typed inputs: re-supply them on stdin, never in argv printf 'api_key=%s\n' "$API_KEY" | relavium gate --approve --secret-stdin ``` +The decision must fit the gate's `gate_type`: `--approve` or `--reject` for an approval or review gate, `--input` or +`--reject` for an input gate. Any other is refused as an invocation error (exit `2`) before anything is written +([decisions by `gate_type`](../contracts/workflow-yaml-spec.md#human_gate-node)). + - Exactly **one** of `--approve` / `--reject` / `--input` is required and they are mutually exclusive; `--comment ` annotates an approve/reject rationale and is invalid with `--input` (which carries the payload). A bad combination is an invalid invocation (exit `2`). - `--input ` is parsed as **JSON when it parses** (`'{"k":1}'` / `'42'` / `'true'` → a structured payload), else kept as the **raw string** (`--input some-token` → `"some-token"`); the result becomes the gate node's output. (The interactive prompt for a `gate_type=input` gate takes the typed value as a raw string.) - **Do not pass secrets via `--input`.** The value reaches the durable event log (`human_gate:resumed.payload`) and the `--json` stream, and argv itself leaks into `ps` / shell history / CI logs — exactly the exposure `relavium provider set-key`'s stdin-only rule avoids. Use a non-secret gate input. Re-supply declared workflow secret inputs with `--secret-stdin`; provider API keys have their separate OS-keychain / `RELAVIUM__API_KEY` resolution path. @@ -409,8 +421,10 @@ relavium budget resume [--gate ] --abort - An emitted budget rejection stops later queued cards and drains its real failed terminal. A cancellation or terminal arriving during live-view suspension suppresses the stale card; one arriving while a card is open dismisses it without requiring another answer. Late answers cannot - replace the winning decision or terminal. A losing idempotent rejection does not stop subsequent - genuine gates, and ordinary human-gate rejection retains its existing continue behaviour. + replace the winning decision or terminal. A decision made elsewhere (a deadline, or another + process) that reaches the run view while a card is open closes that card, and the run's later gates + are then not prompted inline: the run parks on them, as the run section above says. Ordinary + human-gate rejection retains its existing continue behaviour. - Authority and matching companions produce one budget notice per `(runId, nodeId, gateId)` in plain and TUI views. They never fall through to an ordinary human approval prompt. The durable protocol is [defined once in the event contract](../contracts/sse-event-schema.md#durable-budget-authorization). @@ -439,9 +453,9 @@ No per-session attribution or transcript is loaded for this listing. ### `relavium chat-export` -Exports a persisted session to a `.relavium.yaml` **scaffold** for review before commit ([ADR-0026](../../decisions/0026-session-export-to-workflow.md)) — the same contract the in-REPL `/export` drives. Writes `.relavium.yaml` in cwd by default (the file name is keyed on the unique session id, so two sessions never collide); `--out ` overrides, `--force` overwrites an existing target. The session row is marked `exported` with the written path. Under `--json` it emits a single `session:exported` event (`{ type, sessionId, timestamp, sequenceNumber, workflowPath }`). An unknown sessionId or an existing target without `--force` exits `2`; success is exit `0`. +Exports a persisted session to a `.relavium.yaml` **scaffold** for review before commit ([ADR-0026](../../decisions/0026-session-export-to-workflow.md)) — the same contract the in-REPL `/export` drives. Writes `.relavium.yaml` in cwd by default (the file name is keyed on the unique session id, so two sessions never collide); `--out ` overrides, `--force` overwrites an existing target. (The in-REPL `/export` writes into the project's `.relavium/workflows/` under the session title's slug instead — [chat-session.md](chat-session.md).) The session row is marked `exported` with the written path. Under `--json` it emits a single `session:exported` event (`{ type, sessionId, timestamp, sequenceNumber, workflowPath }`). An unknown sessionId or an existing target without `--force` exits `2`; success is exit `0`. -Successful `chat-export` writes a fixed review/continuity notice on **stderr**, even in JSON mode. Live `/export` uses the rendered transcript in interactive chat and Home, and stderr on plain/JSON drivers. The scaffold cannot reproduce the session's summarised, trimmed or omitted working context; retained user/assistant text, attachments, shell output and authored agent literals may be sensitive. Review before running or committing. Strict structural tool-content exclusion remains in force; there is no raw-tool-result opt-in. Deterministic ASCII title normalisation and bounded session-identity fallback follow the [canonical mapping](../contracts/agent-session-spec.md#export-to-workflow). The notice adds no machine stdout record and is not emitted for a refused export. +Successful `chat-export` writes a fixed review/continuity notice on **stderr**, even in JSON mode. Where the live `/export` puts its path, run hint and refusal is stated with the `/export` command in [chat-session.md](chat-session.md#the-multi-turn-loop), and where it puts its review notice in the [canonical mapping](../contracts/agent-session-spec.md#export-to-workflow). The scaffold cannot reproduce the session's summarised, trimmed or omitted working context; retained user/assistant text, attachments, shell output and authored agent literals may be sensitive. Review before running or committing. Strict structural tool-content exclusion remains in force; there is no raw-tool-result opt-in. Deterministic ASCII title normalisation and bounded session-identity fallback follow the [canonical mapping](../contracts/agent-session-spec.md#export-to-workflow). The notice adds no machine stdout record and is not emitted for a refused export. If `chat-resume` or `chat-export` encounters a session message that fails persistence-format validation, it refuses with exit `2` and a fixed, content-free diagnostic. Restore a safe history backup or start a new session. A refused export writes no scaffold; JSON mode keeps stdout empty and puts one structured error on stderr. @@ -627,7 +641,7 @@ the surface and ownership contracts below describe that implementation. > > Exit code `4` is the canonical **chat-session-ended** code: it marks a deliberate `/exit` (or its `--json` equivalent, a final `session:cancelled`/end event) from the `relavium chat` REPL, kept distinct from a successful workflow run (`0`) and a hard failure (`1`) so a wrapper script can tell "the user quit the chat" apart from either. Other docs reference it as `4`. > -> Exit code `5` is **durability-uncertain**, and it is deliberately neither `0` nor `1`. The run may well have COMPLETED — the outputs are in the delivered terminal — and only its durable record is missing, so reporting success would be as wrong as reporting failure. The terminal is held in the host's terminal outbox (`~/.relavium/terminal-outbox.ndjson`, beside `history.db` and deliberately not inside it) and retried by the next **`relavium run`**, **`relavium gate`** or **`relavium budget resume`** — these three commands construct a `WorkflowEngine` for their run/resume path. `chat`, `agent run` and the bare-invocation Home run on `AgentSession` and have no engine to drain with. A script seeing `5` should treat the run as done-but-unrecorded; `relavium status` NAMES such a run (`terminalHeld` in `--json`) so the state is visible, but it does not itself drain — draining claims a run lease, and a status read must not take ownership of a run another process may be finishing. Other docs reference it as `5`. +> Exit code `5` is **durability-uncertain**, and it is deliberately neither `0` nor `1`. The run may well have COMPLETED — the outputs are in the delivered terminal — and only its durable record is missing, so reporting success would be as wrong as reporting failure. The terminal is held in the host's terminal outbox (`~/.relavium/terminal-outbox.ndjson`, beside `history.db` and deliberately not inside it) and retried by the next **`relavium run`**, **`relavium gate`** or **`relavium budget resume`**, or the next time the bare-invocation Home opens. Each of these **reconciles** before it starts or resumes a run (2.6.G liveness): it drains the outbox, then settles every run a dead process left `running` as `run:failed` — `effect_needs_attention` when the run left an external effect unresolved or its effect journal cannot be read ([effect-journal.md](../shared-core/effect-journal.md#8-needs_attention); the code is on the run's terminal, where `relavium logs ` shows it, and the reconciling command's own exit code is about its own run), `budget_exceeded` for a run with a recorded budget rejection ([sse-event-schema.md](../contracts/sse-event-schema.md#durable-budget-authorization)), `internal` otherwise. It leaves alone a run parked at a gate, a run whose lease is live, because another process is executing it ([ADR-0079](../../decisions/0079-cross-process-run-ownership-lease-and-fencing-token.md) §7), and a run that started less than one lease TTL (60 s) ago, whose owner may not have taken its lease yet or may be a version that takes none. `chat` and `agent run` run on `AgentSession` and do not reconcile. A reconcile that claims a run anyway inside the instant between its durable `run:started` and its lease acquire (a development build without that rule, or a clock more than a TTL ahead) settles it, and the store then refuses the run's own terminal, because a log holds exactly one ([database-schema.md](../shared-core/database-schema.md#concurrency--transaction-behavior)): the run ends without executing a node and exits `5`, and the next start drops the held terminal. A drain that cannot write a held terminal leaves that run unrepaired until a later start can, and a repair first checks the outbox again under its claim, so a run that completed is never relabelled failed. While the outbox exists but cannot be read, every run is left unrepaired this way, and `relavium status` warns that the outbox is unavailable. A script seeing `5` should read the durable outcome rather than assume one: `relavium status ` shows whether the held terminal has landed, or, in the case above, that the run was recorded failed. `relavium status` NAMES a run whose terminal is still held (`terminalHeld` in `--json`) so the state is visible, but it does not itself drain — draining claims a run lease, and a status read must not take ownership of a run another process may be finishing. Other docs reference it as `5`. > > The bare-invocation **interactive Home** (2.5.B, [home.md](home.md)) is a long-lived mode whose **clean exit is `0`** (Ctrl-C / Ctrl-D on an empty prompt). A chat launched from inside the Home has its own exit code `4`, which the **Home loop consumes** — a chat ending returns to the Home, never leaked. An external signal to the Home runs teardown then exits the conventional `128+signo` (**`130`** SIGINT / **`143`** SIGTERM) so a pipeline still detects the interruption. diff --git a/docs/reference/cli/home.md b/docs/reference/cli/home.md index 049f22274..892f20398 100644 --- a/docs/reference/cli/home.md +++ b/docs/reference/cli/home.md @@ -50,6 +50,8 @@ The flow: **pick a provider → paste a hidden (masked) API key → store it in The strip is a **read-only display** (the no-IDE-shell principle of [ADR-0007](../../decisions/0007-desktop-is-not-an-ide.md) applied to the CLI by analogy: management + chat only — no file tree, editor, or embedded terminal). It is a bounded, indexed aggregation over the durable `history.db` ([ADR-0050](../../decisions/0050-cli-history-db-at-rest-posture.md)), re-read on every return-to-Home — never a live subscription. +Before its first read the Home **reconciles** (2.6.G liveness): a run a dead process left `running` is settled and shows under *Attention required*, and a terminal held in the outbox is written. What is settled, how, and which runs are left alone are the rules `run` and `gate` follow, stated once with [exit code `5`](commands.md#exit-codes). The Home waits for this after the signal and exit nets are registered and before it mounts, so a crashed run whose lease has expired is settled before the first frame. A dead process's lease stays live for up to a minute after it died, and until it expires the run still shows as `running`; a later open settles it. Reconciling is best-effort and never keeps the Home from opening. + ```text relavium ← branded header (bold) diff --git a/docs/reference/cli/packaging.md b/docs/reference/cli/packaging.md index b991138c9..8607b6ea5 100644 --- a/docs/reference/cli/packaging.md +++ b/docs/reference/cli/packaging.md @@ -350,7 +350,11 @@ refuses every artifact (the [Interim release guard ruling](../../roadmap/phases/ > tarball, with a sunset at 1.0 or when Steps 9c/9d land. That ADR must explicitly override > [ADR-0111](../../decisions/0111-cli-meta-operations-use-a-lazy-packaged-runtime.md)'s "missing custody cannot > downgrade to test mode". The guard stays in force until the ADR is Accepted and its guard change passes a -> security sitting. The publish job runs in the `release` deployment environment, which holds the npm token. +> security sitting. The publish job names the `release` deployment environment. That environment does not exist +> yet: until MVP-A23 creates it with a `v*`-tag deployment policy and a required reviewer and moves `NPM_TOKEN` into it +> (or adopts npm trusted publishing), the token is a repository secret, GitHub creates an unprotected `release` +> environment on the first run that names it, and only the guard, the `v*` tag ruleset and branch protection stand +> before a publish. ### Private manifest and symbolication implementation boundary — Step 9a.2a @@ -477,7 +481,7 @@ package-test producer, adds the product map controls and only then flips the rec `pnpm run ci` right after `smoke:cli-package`, and as the required `map-controls` CI job: ubuntu, at most 20 minutes, read-only permissions, no remote cache credentials, no pnpm cache, no build step, no custom shell and no upload, inside `ci`'s needs with a guard that refuses every result except success - (the required contexts stay `ci` and `coverage`). That job's production is a second, independent + (the required contexts are `ci`, `coverage` and, since 2026-11-04, the Windows job). That job's production is a second, independent build that is never shipped, not a retry of the packed candidate, so ADR-0111's no-rebuild rule for a candidate is not touched. - **The `package_test` receipt.** The seal and the shared receipt validator (in its check and its diff --git a/docs/reference/cli/regression-harness.md b/docs/reference/cli/regression-harness.md index 3d056e022..750b1aff7 100644 --- a/docs/reference/cli/regression-harness.md +++ b/docs/reference/cli/regression-harness.md @@ -132,9 +132,9 @@ the full `@relavium/db` test suite (the concurrency/retry white-box tests among concurrency/perf/session-chain harness on `windows-latest` (the WAL locking, the retry's `Atomics.wait` sleep, the two-process child spawn + `file://` import, and the native `better-sqlite3` addon are the parts most likely to diverge), plus a headless no-TTY smoke. It is a -**separate** job and, since 2026-11-04, a **required** check beside the ubuntu `ci` job, because it carries -ADR-0106's Windows filesystem-policy scenarios ([ADR-0106](../../decisions/0106-media-publication-requires-bounded-verified-bytes.md) -names required CI for them). POSIX `0600`/`0700` permission +**separate** job and, since the maintainer's 2026-11-04 ruling (PR #96 review), a **required** check beside +the ubuntu `ci` job: it is the CI lane that validates +[ADR-0106](../../decisions/0106-media-publication-requires-bounded-verified-bytes.md)'s Windows-scoped claims. POSIX `0600`/`0700` permission assertions are a documented Windows no-op ([ADR-0050](../../decisions/0050-cli-history-db-at-rest-posture.md)) and are not exercised there; `release.yml` separately smokes the built binary across ubuntu/macOS/Windows. diff --git a/docs/reference/contracts/config-spec.md b/docs/reference/contracts/config-spec.md index 336e28c5b..498206dda 100644 --- a/docs/reference/contracts/config-spec.md +++ b/docs/reference/contracts/config-spec.md @@ -173,7 +173,7 @@ video = 10 # assumed video-SECONDS per media-output turn focus_area = "security and type safety" [chat] # agent-session (chat-mode) defaults — see contracts/agent-session-spec.md -default_model = "claude-sonnet-4-6" # model for a chat session that names none; absent at every [chat] layer ⇒ falls back to global [preferences].default_model (ADR-0063) +default_model = "claude-sonnet-4-6" # model for a chat session that names none; absent at every [chat] layer ⇒ falls back to global [preferences].default_model (ADR-0063); absent there too ⇒ a model of the first provider with a key (MVP-A4, chat-session.md) default_provider = "anthropic" # ADR-0059: the provider serving [chat].default_model, resolved from the SAME config layer as the effective default_model (NEVER independently — a fresh model paired with a stale layer's provider dials the wrong adapter → 404). Absent when the model resolves ⇒ inferred from the id (catalog first, then prefix). reasoning_effort = "medium" # ADR-0066: reasoning-effort tier baked onto the DEFAULT chat agent — off | low | medium | high | max; absent ⇒ no reasoning control (the provider default). Ignored on a model without a controllable reasoning tier. fs_scope = "sandboxed" # SAME tier enum as [defaults].fs_scope above (not re-listed here) @@ -183,6 +183,7 @@ max_messages = 200 # history-trim threshold — consumed by `/tr auto_compact = true # ADR-0096: permit measured next-request compaction when memory is omitted (absent ⇒ true) compact_threshold = 0.8 # ADR-0062: the context-window fraction that triggers auto-compaction; a fraction in (0, 1] (absent ⇒ 0.8) max_cost_microcents = 0 # 0 = unbounded; >0 = per-session pre-egress cost cap (the same governor as a workflow budget — ADR-0028) +max_tokens = 8192 # MVP-A7 + ADR-0101's 2026-11-05 amendment: the output cap for a chat whose bound agent authors no max_tokens, any provider. Absent ⇒ an Anthropic chat with no fallback chain on the official endpoint and NO cost cap uses the model's catalog ceiling (claude-sonnet-4-6: 128,000) instead of the adapter's implicit 4,096; otherwise the adapter default. Chat surfaces only: `relavium agent run` keeps the adapter default. The governor reserves this cap before each turn, so set it below the ceiling when a cost cap is set; the chat's per-attempt deadline scales with it (cap at 25 tokens/s, 120 s to 30 min; ADR-0101 2026-11-06). on_exceed = "pause_for_approval" # fail | pause_for_approval | warn — when a session hits its cap strict_cost_cap = false # ADR-0071 §K7 + ADR-0089 §4: refuse a turn we cannot PRICE. Two cases, one rule # — an unpriced MODEL, and a priced model whose requested media MODALITY carries diff --git a/docs/reference/contracts/sse-event-schema.md b/docs/reference/contracts/sse-event-schema.md index 24115ab5c..cd2709ba2 100644 --- a/docs/reference/contracts/sse-event-schema.md +++ b/docs/reference/contracts/sse-event-schema.md @@ -523,7 +523,10 @@ The derived interrupted-run projection marks a recorded budget rejection separat any gate is still resumable. This is ephemeral store metadata, not a new durable event or status. If an eligible crash reconciliation sees that rejection without a run terminal, it appends the existing `run:failed` with `budget_exceeded` and no dispatch. An ordinary non-resumable interruption keeps -`internal`; outstanding gates retain the existing resumable policy. A decided rejection may leave the +`internal`; outstanding gates retain the existing resumable policy. Both give way to +`effect_needs_attention` when the run left an external effect unresolved or its effect journal cannot be +read ([effect-journal.md §8](../shared-core/effect-journal.md#8-needs_attention)): the precedence is +`effect_needs_attention`, then `budget_exceeded`, then `internal`. A decided rejection may leave the materialized run status `running` until that terminal is durably appended. Neither discovery nor reconciliation fabricates a terminal or output during the ordered suspension fold. diff --git a/docs/reference/contracts/workflow-yaml-spec.md b/docs/reference/contracts/workflow-yaml-spec.md index 157619ed3..949728aef 100644 --- a/docs/reference/contracts/workflow-yaml-spec.md +++ b/docs/reference/contracts/workflow-yaml-spec.md @@ -300,6 +300,8 @@ Each node has an `id` (kebab-case, unique within the workflow) and a `type`. The > **Node overrides narrow, they never escalate.** A node may override `model` / `temperature` / > `max_tokens` / `system_prompt_append`, and its `tools:` list may only **narrow** the agent's granted > tools — it can never add a tool the agent lacks (validator-enforced; [ADR-0029](../../decisions/0029-tool-policy-hardening.md)). +> A granted tool must also be one the running surface wires: `relavium run` refuses an egress or os tool before +> the run starts ([where each tool runs](../shared-core/built-in-tools.md#the-built-in-tools)). > A `model` override is **resolved against the model catalog at parse time**; an unknown model id fails > parse with the list of valid options — never a silent fallback. The optional `output_schema` (also on > `transform`) validates the node's output and powers type-safe downstream interpolation @@ -322,9 +324,17 @@ Each node has an `id` (kebab-case, unique within the workflow) and a `type`. The timeout_action: reject # reject | approve (escalate is reserved — see note) ``` +**Decisions by `gate_type`** (ruled 2026-11-05): an `approval` or `review` gate takes `approved` or `rejected`; an +`input` gate takes `input_provided` (its payload) or `rejected`. Any other decision is refused before anything is +written — by `relavium gate` as an invocation error and by the engine as `invalid_decision` — so a gate never +records a decision kind its type does not define. (The check reads the decision kind only; a payload sent with +`approved` or `rejected` by a direct engine caller is not refused.) There is nothing for a deadline to approve on an `input` gate, so +`timeout_action: approve` on one is refused at admission; a run parked before that rule fails with `run_timeout` +at its deadline, as a refused auto-approval does. + `timeout_action` requires `timeout_ms` at both standalone node and complete workflow admission. A timeout without an action keeps the safe default `reject`; omitting both sets no timeout. -`timeout_action: approve` auto-approves on timeout — dangerous; use sparingly. **`escalate` is reserved in v1.0**: real escalation needs a Phase-2 notification system, so the validator rejects it for now (use `reject` or `approve`); a timeout then resolves with `decidedBy: 'timeout'`. Because parallel branches may each reach a gate, **multiple gates can be pending at once** — each carries an independent timeout and the surfaces show a pending-gate queue (`relavium gate list`). The gate lifecycle (suspend → notify → resume) is described in [sse-event-schema.md](sse-event-schema.md) and [../../architecture/execution-model.md](../../architecture/execution-model.md). +`timeout_action: approve` auto-approves an `approval` or `review` gate on timeout — dangerous; use sparingly. An `input` gate refuses it (above). **`escalate` is reserved in v1.0**: real escalation needs a Phase-2 notification system, so the validator rejects it for now (use `reject` or `approve`); a timeout then resolves with `decidedBy: 'timeout'`. Because parallel branches may each reach a gate, **multiple gates can be pending at once** — each carries an independent timeout and the surfaces show a pending-gate queue (`relavium gate list`). The gate lifecycle (suspend → notify → resume) is described in [sse-event-schema.md](sse-event-schema.md) and [../../architecture/execution-model.md](../../architecture/execution-model.md). ### `condition` node diff --git a/docs/reference/desktop/routes-and-screens.md b/docs/reference/desktop/routes-and-screens.md index 2c9c95389..81e691fca 100644 --- a/docs/reference/desktop/routes-and-screens.md +++ b/docs/reference/desktop/routes-and-screens.md @@ -26,7 +26,7 @@ flowchart TD SET --> PROV["/settings/providers/:id Provider keys"] ``` -A persistent app shell wraps every route: top bar (workspace switcher, global Run/Command-palette trigger), left nav (Dashboard / Chat / Workflows / Agents / Settings), and a root-level human-gate overlay that can surface over any screen when a run pauses for approval. **Chat and Canvas are co-equal top-level tabs** ([ADR-0025](../../decisions/0025-agent-surface-refines-desktop-scope.md)); the neutral Dashboard remains the default landing screen — the app does **not** force-open to Chat. +A persistent app shell wraps every route: top bar (workspace switcher, global Run/Command-palette trigger), left nav (Dashboard / Chat / Workflows / Agents / Settings), and a root-level human-gate overlay that can surface over any screen when a run pauses at a human gate. **Chat and Canvas are co-equal top-level tabs** ([ADR-0025](../../decisions/0025-agent-surface-refines-desktop-scope.md)); the neutral Dashboard remains the default landing screen — the app does **not** force-open to Chat. ## Routes @@ -87,7 +87,7 @@ Configure a single provider. Add or rotate its API key — stored in the OS keyc ## Cross-cutting UI -- **Human-gate overlay.** Mounted at the root layout so a pending [`human_gate:paused`](../contracts/sse-event-schema.md) event can surface an approval prompt over any screen. The same gate is reachable from the system tray (attention state) — see [tauri-plugins.md](tauri-plugins.md). +- **Human-gate overlay.** Mounted at the root layout so a pending [`human_gate:paused`](../contracts/sse-event-schema.md) event can surface its prompt over any screen: an approval, or an input field for an `input` gate ([decisions by `gate_type`](../contracts/workflow-yaml-spec.md#human_gate-node)). The same gate is reachable from the system tray (attention state) — see [tauri-plugins.md](tauri-plugins.md). - **Command palette.** Opened by the global hotkey; lists recent workflows for instant run without surfacing the main window. - **Cost is always visible.** Per-run and per-node cost is a persistent element of the run-related screens, not buried in a separate billing page. diff --git a/docs/reference/shared-core/built-in-tools.md b/docs/reference/shared-core/built-in-tools.md index 8dc999ad9..bc67d930b 100644 --- a/docs/reference/shared-core/built-in-tools.md +++ b/docs/reference/shared-core/built-in-tools.md @@ -33,6 +33,17 @@ A tool declaration in YAML has a `type` discriminator: | `invoke_agent` | Call another agent node in the same workflow by node id, with explicit input. | that agent's output | The dynamic-dispatch mechanism used by orchestrator agents to delegate. | | `read_media` | Read a produced/received media asset by its durable `media://sha256-…` handle (**whole handle** — no byte range), so a media-capable model can look at it. | a short **text descriptor** (mime type, byte length, handle) as the `tool_result`; the media itself arrives as a top-level `media` part on a **marked, engine-synthesized `user` message** appended right after it ([ADR-0089](../../decisions/0089-media-correctness-four-boundaries.md) §1) | **Scope-set authz** — the requesting `session`/`workspace` must be in the handle's `allowedScopes` ([ADR-0044](../../decisions/0044-media-access-governance-read-media-save-to-cost.md) §1); knowing a sha256 is **not** authorization; read-only (bypasses the action gate), secret-free `media_scope_denied`. The tool never touches bytes: it authorizes a handle and describes it, and the chain's egress re-materialization resolves the handle per attempt (ADR-0043 §1), so a failover or a resume re-resolves from the canonical handle. The synthesized message carries a fixed engine-authored preamble naming the tool — it must never present as the user's own words. Because the media rides the ordinary media-INPUT rail it is bounded by it: `read_media` **refuses** a video, a PDF (never inline at any size) or an image/audio handle over `INLINE_MEDIA_CEILING`, with a correctable `invalid_args` naming the limit — so the model picks another handle instead of the next request dying on a schema error after the tool has already run. A response's tool calls may deliver at most `ADMISSION_CEILINGS.mediaAttachmentsPerResponse` attachments, refused by the engine before the seam's own per-message cap. All of a response's attachments arrive on **one** synthesized message appended after the FINAL tool result — after every one of them, never interleaved between two — a tool result must stay contiguous with its siblings or the providers reject the turn. And a model that does not accept image input refuses the turn rather than being handed a descriptor for something it never received. *(The delivery path ships as of `W5`/`CR-50`. The host `MediaReadAccess` delegate and the `session`/`workspace` scope population remain unwired in production — see [deferred-tasks.md](../../roadmap/deferred-tasks.md); absent the delegate the tool fails closed with `tool_unavailable`.)* | +> **Where each tool runs.** `http_request` and `web_search` need the host's egress arm, `read_clipboard` and +> `notify` its os arm. The CLI wires both only in the approval-gated chat host; `relavium run` (and `gate`) wire +> neither, so a workflow whose agent node grants one of these four is refused before the run starts (exit `2`), +> naming the tool, rather than failing with `tool_unavailable` partway through (2.6.A, MVP-1). The check reads the +> grant in the workflow file: a node's `tools:`, or its inline agent's. A `$ref`'d agent's own grant is not +> resolvable from the file and is not checked. MCP tools reach through their own arm and pass, but `relavium run` +> wires that arm only when an inline agent declares `mcp_servers`, so `mcp_call` without a declared server still +> fails at its first call. A run resumed by `relavium gate` or `budget resume` is not re-checked. `invoke_agent` +> and `read_media` are outside this check: they need an engine delegate rather than a host arm, no path wires either +> yet, and the engine never offers them to the model, so a grant of either is inert rather than failing. + > **Mandatory guardrails (security tightenings — [ADR-0029](../../decisions/0029-tool-policy-hardening.md)):** `run_command` only executes commands that **exactly match** the workflow's `allowedCommands` allowlist (opt-in glob via `allowedCommandGlobs`; defined under `spec.tools.allowedCommands` — see [workflow-yaml-spec.md](../contracts/workflow-yaml-spec.md#tool-policy-spectools); empty or absent ⇒ disabled). `http_request` is **HTTPS-only**, matches `allowedDomains` by **exact FQDN** (empty/absent ⇒ disabled), and **blocks private/loopback/link-local/metadata IP ranges** — the same vetted SSRF primitive applied to provider base URLs and MCP server URLs ([security-review.md](../../standards/security-review.md)). `git_commit` retains its registry gate floor and is refused by current production agent producers, as the table records. All are enforced by the engine, not by convention. `run_command` is the **only** allowlisted shell-exec built-in — there is no separate `run_javascript` tool (sandboxed JS evaluation is the engine's job for `condition`/`transform` expressions, not a callable tool). > **Return shapes.** The *Returns* column is the **logical** shape. Until a provider-specific normalizer lands, `http_request` / `web_search` / `mcp_call` / `git_status` dispatch the **raw host response** (`EgressResponse` / the MCP tool result / the `git` `ProcessResult`); a downstream `transform` node shapes it. The file tools (`read_file` / `write_file` / `list_directory`) and `run_command` already return the documented shape. @@ -77,9 +88,19 @@ The result a tool hands **back to the model** (which re-enters the next request) | --- | --- | --- | | Byte ceiling | 50 KB | over ⇒ spill + preview (model-facing only) | | Line ceiling | 2000 lines | over ⇒ spill + preview (model-facing only) | -| Preview | head + tail slice within the ceiling | the marker names the omitted span | +| Preview | head + tail slice within the ceiling | the marker names the omitted span; a result over the ceiling only by the inline media its text omits keeps that media-free text as its preview | | Spill | the host's run-scoped output store; reclaimed at the terminal event | the path is handed to the model; readable via the FS-scope-tiered tools | +**Inline media counts against the ceiling (2026-11-04).** The bound counts the inline media a result still +holds — a base64 `data:` URI, a canonical base64 source, a binary buffer at its `byteLength` — at its full size, +although the summary, spill and preview show only a marker for it. A builtin's media rides the attachment rail +([ADR-0089](../../decisions/0089-media-correctness-four-boundaries.md) §1). Media left in a dispatch result is +settled into the effect journal as it is, and the seam refuses raw media bytes in `tool_result.result` +([ADR-0031](../../decisions/0031-llm-seam-shape-amendment-multimodal-io.md)), so under the ceiling such a result fails the next request (a recorded +low). Over the ceiling, the model gets the media-free preview, and the marker counts the text's bytes plus the +omitted media's (a 30 MiB data URI had been measured as its 25-character marker); when the media-free text fits +the ceiling, that text is the preview. + The ceiling is a **byte/line** bound — no token count, which would need a provider-specific tokenizer and break engine purity. These defaults are tunable and revisited if real workflows hit them (the [deferred-tasks.md](../../roadmap/deferred-tasks.md) tool-output-gate framing). The dispatch lifecycle that applies them is in [tool-registry.md](tool-registry.md#result-bounding-and-spill-to-file). @@ -121,16 +142,15 @@ user, so its value does not run on through the public host and path (`https://x-access-token:@github.com/o/r` → `https://[redacted]@github.com/o/r`), and a key on the line after a split URL masks its value to its own end (`see https://\nDB_PASSWORD=P@ssw0rd123 now` → `see https://\n[redacted] now`). The projection -otherwise differs from that backstop by design: its authority also ends at `"`, `'`, `}` and `]`, -the delimiters of the JSON and quoted texts it reads, so userinfo that contains one of them — an -already-masked `[REDACTED]` password among them — is left as it is; it recognizes any scheme of up to -32 characters rather than a finite list; and a userinfo with no password is kept as a vanity handle -below 16 characters and masked from 16 on, under every scheme. Since 2026-11-04 a `,` or `/` in a -password no longer ends the authority: the userinfo is read on to the first `@` within 512 characters -(`https://user:pa,ss@host` and `https://user:pa/ss@host` → `https://[redacted]@host`). A port -(`https://host:8080/a@b`, `https://host:8080,bob@x.example`), a public query contact as above, another -scheme, or a quote, `}`, `]` or whitespace first keeps the text public; the backstop still stops at a -`/` (a recorded low). +otherwise differs from that backstop by design: its authority also ends at `"`, `'`, `,`, `}` and +`]`, so userinfo that contains one of them — an already-masked `[REDACTED]` password among them — is +left as it is; it recognizes any scheme of up to 32 characters rather than a finite list; and a +userinfo with no password is kept as a vanity handle below 16 characters and masked from 16 on, +under every scheme. A `,` or `/` in a password therefore ends the authority, and the whole userinfo stays +public unless another rule reads it (`https://user:pa,ss@host` keeps `user:pa,ss`; the backstop masks that comma +spelling), a recorded limit (the PR #96 review's A4). A reading past the delimiter was tried and removed on 2026-11-05: +under the repeated projection its masks could change the text other rules read, so it could not be shown to +only add masks. In an argument vector, the element after a credential flag, or the value of its `--flag=value` spelling, is masked whole. A flag is a credential flag when it is one of the anchored names (`-p`, @@ -138,8 +158,21 @@ spelling, is masked whole. A flag is a credential flag when it is one of the anc rule's words as written (`--auth-token`, `--access-token`, `--db-password`, `--pwd`, `--x-api-key`, `--credentials`). A flag that only mentions a credential (`--password-stdin`, `--token-file`, `--max-tokens`) or begins with `no-` does not count, so the argument after it keeps its own identity. -Only `-p` keeps a port exception. curl's `-u`/`--user` value, glued (`-ualice:secret`) or not, is -masked when it holds a `:`, unless it is a `uid[:gid]` or a URL. +Only `-p`/`-P` keep a port exception. A `-u`/`--user` value (curl's `user:password` spelling, glued as +`-ualice:secret` or not) is masked when it holds a `:`, unless it is a `uid[:gid]` or a URL. The rule does +not know the program, so a `name:group` (`docker run -u app:app`) or another colon-bearing value after +`-u` is masked too, and calls that differ only there share one journal identity. A credential flag in value position may be the +previous flag's value (`--password -MySecret`) or a flag of its own (`--ask-password --password `), +so it is masked as a value and still masks the element after it: neither reading leaks, at the cost of the flag +name there. After a `-u`/`--user` with no pair (`unzip -u -P `) the element is read as any other. A +`--flag=value` spelling keeps an anchored name (`--password=[redacted]`), and a last-word name only where the +text rules keep it too; where they mask it with its value (`--db-token=`), the whole element is masked, +and a glued `-u` pair keeps its `-u` on the same terms. These rules only add masks to the anchored rule of +before 2026-11-04: an element is masked whenever the element before it is any credential flag, and a +`--flag=value` or glued spelling never shows text the text rules mask. +Short-option clusters (`curl -su alice:secret`), +`-U`/`--proxy-user` and flags whose last word is not a listed credential word (`--oauth2-bearer`, +`--pass`, openssl's `-passin`, keytool's `-storepass`) are outside the rule, a recorded limit. LF/TAB inside a recognized token-shaped credential extends the masked original span. An ambiguous token-alphabet continuation after LF/TAB is conservatively masked with that credential; an @@ -263,7 +296,8 @@ credential split by one, are masked as in raw text (`{"stdout":"keys:\nsk-…"}` `{"stdout":"keys:\n[redacted]"}`), while a header label split by one is not read; the journal projection reads no escape. Core does not read a credential or a scheme label glued to a terminal sequence's final byte (`ESC[1msk-…`), which leaves no word boundary there, a documented limit; the -backstop's terminal handling is in [error handling](../../standards/error-handling.md). Supported +backstop's terminal handling is in the +[diagnostic secret backstop](diagnostic-secret-backstop.md). Supported split shapes are recognized **before** complete-text one-line normalization, by that projection's full fixed point, followed by ordinary recognition and length clipping. This preserves multiline quoted-value recognition without exposing a split token's suffix. Public argument bytes are not diff --git a/docs/reference/shared-core/database-schema.md b/docs/reference/shared-core/database-schema.md index d75762a13..70d6e90bb 100644 --- a/docs/reference/shared-core/database-schema.md +++ b/docs/reference/shared-core/database-schema.md @@ -707,9 +707,12 @@ clobber it. A table is also queryable — by a human diagnosing a stuck run. | `created_at` | INTEGER | NOT NULL | | `updated_at` | INTEGER | NOT NULL | -The FK is why a fresh run's lease is created just **after** its `run:started` is folded rather than in the -same transaction as ADR-0079 §3 first described: the `runs` row must exist first. The window is uncontended -by construction — the `runId` came from `ids.newId()` moments earlier and no other process has seen it. +A fresh run's lease is acquired just **after** its `run:started` is folded, as a separate write, rather than in +the same transaction as ADR-0079 §3 first described. The FK needs only the `runs` row to exist first, so the same +transaction would work once that row is inserted; what shipped is the separate acquire. The window it leaves is +contended — `reconcile()` runs at `run`, `gate` and `budget resume` start and on Home open — and what bounds it is +that `reconcile()` leaves a run younger than one lease TTL alone (ADR-0079 §3's 2026-11-06 note), with the +terminal refusal below as the backstop. TTL **60 s**, heartbeat every **20 s** (`RUN_LEASE_TTL_MS` / `RUN_LEASE_HEARTBEAT_MS` in `@relavium/shared`): three missed beats permit a takeover — wide enough that a long provider call or disk @@ -1362,6 +1365,7 @@ operational SQLite provenance, even if the native error is storage-shaped. - **Reads that must be consistent across statements** use a read transaction: `sessionStore.loadFull` reads the session row and its transcript inside one deferred transaction so **both reads observe a single consistent DB snapshot** (never a two-`SELECT` straddle across a concurrent commit). This now composes with **turn atomicity** on the write side: `sessionStore.writeTurn` appends a turn's messages and flushes the session row in ONE `BEGIN IMMEDIATE` transaction (#228), so a snapshot can no longer observe messages ahead of their totals, and a failed write leaves neither a half-written turn nor an unanswered `user` row that resume cannot roll back. This discharges the per-turn-transaction follow-up this section previously tracked. - **The durable append is compare-and-append** ([ADR-0078](../../decisions/0078-ordered-durable-append-and-the-terminal-outbox.md) §2). `persistEvent` takes an optional `DurableWriteContext` carrying `expectedLastSequenceNumber` — the sequence the caller last *asked* to append, not the last it saw succeed. When present, the store reads `max(run_events.seq)` for that run **inside the same `BEGIN IMMEDIATE` transaction, through the transaction handle**, and refuses the append with a typed `AppendConflictError` when the two differ. `UNIQUE(run_id, seq)` bars a duplicate and says nothing about order or holes; this is what makes the committed log a *prefix* of what was asked rather than merely a set. Three properties are deliberate: the read is `max(seq)` rather than a denormalized `runs.last_event_seq` column, so there is no migration and no second source of truth that can drift from the rows; the check runs **before** the derived `runs`/`step_executions`/`run_costs` writes, so a refusal leaves nothing behind; and `AppendConflictError` is **not** in the retryable-code set above — a conflict is a stale belief, not lock contention, and retrying it five times would waste five transactions and report the wrong cause. The engine exempts the run TERMINAL from the guard, because exactly-one-terminal ([ADR-0036](../../decisions/0036-run-loop-substrate-event-bus-and-execution-host.md)) outranks it; a terminal the store will not take is ADR-0078 §4's outbox to own. - **The same write is fenced by run ownership** ([ADR-0079](../../decisions/0079-cross-process-run-ownership-lease-and-fencing-token.md) §2). `DurableWriteContext` carries a second, **independent** claim beside `expectedLastSequenceNumber`: a `fence` of `{ ownerId, generation }`. When present, the store reads the run's `run_leases` row **inside the same `BEGIN IMMEDIATE` transaction, through the transaction handle** — outside it, the read and the write would be two statements another process could interleave, which is the exact race the check exists to close — and refuses with a typed `LeaseFencedError` when the owner or generation differs, **or when the row is absent**: a writer that cannot prove ownership fails closed. It is checked *after* the append guard, because a stale belief about the log is the more specific diagnosis when a writer has both problems. The two fields are independent because the run TERMINAL carries the fence *without* the append guard — exempt from one, not the other. An **absent** `fence` is a pass rather than a refusal: a caller holding no ownership claim has nothing stale to catch. Like `AppendConflictError`, `LeaseFencedError` is **not** in the retryable set — a stale fence is an expected refusal, not lock contention. +- **A log holds exactly one terminal, and the store refuses a second** ([ADR-0036](../../decisions/0036-run-loop-substrate-event-bus-and-execution-host.md)'s exactly-one-terminal). When the event is `run:completed`, `run:failed` or `run:cancelled`, `persistEvent` looks for an existing terminal row for that run inside the same transaction, after the guard and the fence and before the derived writes, and refuses with a typed `RunAlreadyTerminalError` if one is there. It reads the event rows rather than `runs.status`, for the same no-second-source reason as `max(seq)`. Neither claim above can do this: the engine exempts its terminal from the append guard, and a writer that never acquired a lease carries no fence. The case it guards is a `reconcile()` (which the CLI calls at `run`/`gate` start and on Home open) that settles a run in the moment between its durable `run:started` and its lease acquire, then releases its claim. Reconcile leaves a run younger than one lease TTL alone, so that takes a reconciler that claims it anyway — a development build without that rule, or a clock more than a TTL ahead. The run's own acquire then succeeds, and its terminal would land behind the first. The refused terminal goes to ADR-0078 §4's outbox, the run reports exit `5`, and the next drain drops the entry because the run is no longer open. The in-memory reference store refuses the same way. - **Cross-platform:** `BEGIN IMMEDIATE` + the retry behave identically on every OS. The `0600`/`0700` at-rest guard below is a documented Windows no-op, so the concurrency test lane gates POSIX-permission assertions off Windows only. This realizes the concurrent-process write requirement recorded in the [ADR-0064](../../decisions/0064-live-model-catalog.md) §5 amendment note (2.5.I). diff --git a/docs/reference/shared-core/diagnostic-secret-backstop.md b/docs/reference/shared-core/diagnostic-secret-backstop.md index d4f40b314..9394b77bc 100644 --- a/docs/reference/shared-core/diagnostic-secret-backstop.md +++ b/docs/reference/shared-core/diagnostic-secret-backstop.md @@ -5,8 +5,9 @@ - **Related**: [error-handling.md](../../standards/error-handling.md#diagnostic-secret-backstop) (the rule: a defense-in-depth backstop behind an adapter's own redaction), [built-in-tools.md](built-in-tools.md#finite-diagnostic-credential-masking) (core's journal and display projection, which differs from this list by design), [llm-provider-seam.md](llm-provider-seam.md) (the error model `makeLlmError` belongs to), [../../standards/security-review.md](../../standards/security-review.md) Moved here from [error-handling.md](../../standards/error-handling.md#diagnostic-secret-backstop) on -2026-11-04, unchanged: a PR #96 review found an exact specification in a standard, against the rule that one -lives only in its `docs/reference/` home. The standard keeps the rule and links here. +2026-11-04, unchanged except the dated LF/TAB `Bearer` limit in **Split and normalized forms**: a PR #96 +review found an exact specification in a standard, against the rule that one lives only in its +`docs/reference/` home. The standard keeps the rule and links here. - **Shapes.** OpenAI/Anthropic `sk-…` and Google `AIza…` key prefixes; a finite list of literal secret query names, after `?`, `&` or `#` (an OAuth implicit-flow `#access_token=`); the exact diff --git a/docs/reference/shared-core/effect-journal.md b/docs/reference/shared-core/effect-journal.md index 474db2012..00a6e9391 100644 --- a/docs/reference/shared-core/effect-journal.md +++ b/docs/reference/shared-core/effect-journal.md @@ -232,6 +232,10 @@ shapes were weighed and two rejected: - a **typed `ErrorCode` on the existing terminal** — chosen. It reuses the closed error taxonomy every surface already switches on, and `run:failed` is honest: the run did not complete. +A run a crashed process left behind gets the same code when crash reconciliation settles it: `reconcile()` reads +the journal's resume half for the run, and a row §4 says blocks (or a journal it cannot read) makes the terminal +`effect_needs_attention` rather than `internal`, which a caller would read as an ordinary failure to run again. + **It does NOT report `durability: 'uncertain'`, and that is a correction to an earlier draft of this section.** Terminal durability and final local effect health are independent. After all admitted work has joined, the CLI selects the final disposition and remedy according to @@ -420,13 +424,20 @@ A recognized structural credential key masks its entire value before traversal. inner text match does not make the remaining value public. Only an anchored Bearer, Basic or Token scheme survives as its canonical label followed by `[redacted]`; no original credential remainder or casing is retained. Credential flags in an argument vector mask -the whole following element or the complete value in `--token=value` form. Only the +the following element or the complete value in `--token=value` form, with the exceptions the +[argv rule](built-in-tools.md#finite-diagnostic-credential-masking) states (a `-u`/`--user` value only as a +`user:password` pair; a credential flag in value position also masks the element after it). Only the ambiguous `-p` spelling retains the existing port-shaped exception; explicitly named credential flags have none. Since 2026-11-04 the credential flags include every flag whose last -word names a credential and curl's `-u`/`--user` pair, and a URL password's `,` or `/` no longer -ends its userinfo ([built-in tools](built-in-tools.md#finite-diagnostic-credential-masking)); as +word names a credential and curl's `-u`/`--user` pair +([built-in tools](built-in-tools.md#finite-diagnostic-credential-masking)); as with the PR95 tightening above, a `v5:` row whose arguments these project differently now refuses -matching prepare, and every other row keeps its digest. Unrecognized public fields and ordinary arguments still +matching prepare, and every other row keeps its digest. Both rules are program-agnostic, so a value a +boolean flag happens to precede is masked as well — a `name:group` after `-u` (`docker run -u app:app`), the +argument after `--ask-vault-password` — and calls that differ only in that element share one projection; on a +crash-resume where the model changes exactly that element, the slot replays the committed result instead of +refusing. It is a recorded limit of the finite rules ([deferred tasks](../../roadmap/deferred-tasks.md)), the same +cost recognized credentials always carry. Unrecognized public fields and ordinary arguments still contribute their complete surviving values to identity. These projections do not change the arguments dispatched to the host. diff --git a/docs/reference/shared-core/tool-registry.md b/docs/reference/shared-core/tool-registry.md index c3c24049a..e30b037c9 100644 --- a/docs/reference/shared-core/tool-registry.md +++ b/docs/reference/shared-core/tool-registry.md @@ -418,12 +418,8 @@ distinct from the *event* `outputSummary`, which the registry bounding stage pro The ceiling is a **byte/line** bound (no token count — that needs a provider-specific tokenizer, which would break engine purity); concrete defaults and the marker shape are canonical in [built-in-tools.md](built-in-tools.md#tool-result-bounding). `ToolResultLimits` carries them into `ctx`. -The bound counts the inline media a result still holds — a base64 `data:` URI, a canonical base64 -source, a binary buffer — at its full size, although the summary, spill and preview show only a -marker for it: a builtin's media rides the attachment rail ([ADR-0089](../../decisions/0089-media-correctness-four-boundaries.md) §1), -so media left in a dispatch result is text to the model and is settled into the journal. Over the -ceiling, the model gets the media-free preview and the marker reports the full byte count (2026-11-04; -a 30 MiB data URI had been measured as its marker). +The bound also counts the inline media a result still holds, although its summary, spill and preview show +only a marker for it ([built-in-tools.md](built-in-tools.md#tool-result-bounding), 2026-11-04). Observability and journal-argument projections distinguish shared siblings from ancestor cycles. Each walk removes a visited object when its branch finishes, including native/binary/media early diff --git a/docs/reference/vscode/extension-api.md b/docs/reference/vscode/extension-api.md index 0f4239080..27abd3611 100644 --- a/docs/reference/vscode/extension-api.md +++ b/docs/reference/vscode/extension-api.md @@ -41,7 +41,7 @@ The extension declares a minimal `activationEvents` set — it activates only wh |------------|--------------|-----------| | **Right-click run** | Run a workflow on the active file (editor/explorer context menu, plus a keybinding). Compatible workflows are filtered by their input schema and shown in a QuickPick. | `contributes.menus` (`editor/context`, `explorer/context`) → `relavium.runWorkflowOnFile` | | **Status-bar run monitor** | Passive awareness of background runs: idle (hidden), running (`$(loading~spin) N runs active`), awaiting-human (`$(bell)` amber), completed/failed. Clicking opens the Active Runs view. | `vscode.StatusBarItem` (right, priority 100) | -| **Sidebar** | A `relavium` view container with three TreeViews: **Workflows** (from `.relavium/*.relavium.yaml`, grouped by tag, last-run status), **Agents** (from `.relavium/agents/*.agent.yaml`), and **Active Runs** (live, expandable to per-node status with inline `[Approve]`/`[Reject]`). | `contributes.views` + `TreeDataProvider` | +| **Sidebar** | A `relavium` view container with three TreeViews: **Workflows** (from `.relavium/*.relavium.yaml`, grouped by tag, last-run status), **Agents** (from `.relavium/agents/*.agent.yaml`), and **Active Runs** (live, expandable to per-node status with inline `[Approve]`/`[Reject]`, or an input field and `[Reject]` on an `input` gate). | `contributes.views` + `TreeDataProvider` | | **Command palette** | All commands grouped under the "Relavium" category for discovery and keyboard-driven use. | `contributes.commands` | | **Chat panel (agent session)** | A full conversational coding assistant in a `WebviewPanel` (React/Preact UI, **not** the Copilot-specific `vscode.chat` API): a multi-turn [agent session](../contracts/agent-session-spec.md) bound to one agent + its fallback chain, driven by the bundled engine. Auto-detects the active file, selection, workspace root, and git branch as the session [`SessionContext`](../contracts/agent-session-spec.md#session-context). Conversations **auto-persist and resume** from the local `history.db` (no separate store), and any session can be exported to a `.relavium.yaml` scaffold for review — the **same** persistence + export contract as the [CLI `relavium chat`](../cli/commands.md) and the [desktop Chat tab](../desktop/routes-and-screens.md), so a session started in any surface continues in the editor. From the same panel a user can also invoke a workflow in natural language. | `WebviewPanel` + bundled `AgentSession` | | **Inline diff review** | Agent-proposed file changes (`file_patch` output) open in VS Code's native diff editor with per-hunk `[Accept]`/`[Reject]` CodeLens. **No file is written until explicitly accepted.** | `vscode.diff` + CodeLens + `WorkspaceEdit` | @@ -82,7 +82,7 @@ The chat panel is the editor projection of an `AgentSession`; the persistence an | `relavium.openWorkflowInDesktop` | Open a workflow by id in the desktop canvas. No-op (gracefully) if the desktop app is not running. | | `relavium.showRunHistory` | Open the run-history panel for a workflow id. | | `relavium.cancelRun` | Cancel a running workflow by run id. | -| `relavium.approveHumanGate` | Approve or reject a pending human gate, with an optional note. | +| `relavium.approveHumanGate` | Resolve a pending human gate: approve or reject it with an optional note, or give an `input` gate its input or reject it. | | `relavium.getActiveRuns` | Return an array of active-run objects for programmatic consumers. | | `relavium.refreshWorkflows` | Force-reload workflow/agent definitions from disk (after external edits). | | `relavium.exportRunTrace` | Export a run's full execution trace as JSON (secret references stripped). | @@ -152,7 +152,7 @@ The canonical schema these capabilities validate against is [workflow-yaml-spec. While a run executes, the active agent is shown through three simultaneous channels: a spinning codicon on the Active Runs tree node (auto-revealed), a gutter decoration on the file being processed, and a truncated status-bar label. Streaming output goes to a per-run `OutputChannel` for non-interactive runs and renders in the chat panel's React UI for interactive ones (tokens batched on a flush interval — `relavium.streamingBatchIntervalMs` — to avoid UI jank). -A pending [`human_gate:paused`](../contracts/sse-event-schema.md) surfaces in up to three places depending on context: inline `[Approve]`/`[Reject]` buttons on the Active Runs tree node, an amber status-bar item that opens an approval QuickPick, and (for chat-triggered runs) an inline approval card. All three dispatch to the same resume action; if the gate has a timeout, a draining progress indicator is shown and the node's `timeout_action` applies on expiry. +A pending [`human_gate:paused`](../contracts/sse-event-schema.md) surfaces in up to three places depending on context: inline `[Approve]`/`[Reject]` buttons on the Active Runs tree node, an amber status-bar item that opens an approval QuickPick, and (for chat-triggered runs) an inline approval card. An `input` gate takes the requested input or a rejection, never an approval, so it offers an input field and `[Reject]` in place of `[Approve]` ([decisions by `gate_type`](../contracts/workflow-yaml-spec.md#human_gate-node)). All three dispatch to the same resume action; if the gate has a timeout, a draining progress indicator is shown and the node's `timeout_action` applies on expiry. ## Security model diff --git a/docs/roadmap/current.md b/docs/roadmap/current.md index 083e9fd40..7b633ace9 100644 --- a/docs/roadmap/current.md +++ b/docs/roadmap/current.md @@ -1,7 +1,7 @@ # Current state > Status: Living -> Last updated: 2026-11-04 +> Last updated: 2026-11-06 - **Related**: [README.md](README.md), [phases/phase-2.6-conversational-authoring.md](phases/phase-2.6-conversational-authoring.md), [phases/phase-2.5.5-hardening-and-remediation.md](phases/phase-2.5.5-hardening-and-remediation.md), [phases/phase-2.6.5-core-reliability-remediation.md](phases/phase-2.6.5-core-reliability-remediation.md), [deferred-tasks.md](deferred-tasks.md), [../project-structure.md](../project-structure.md), [../tech-stack.md](../tech-stack.md) @@ -35,11 +35,11 @@ evidence summary, the recorded Wave 3 splits and the process amendments. | Phase 2 — CLI | Feature-complete; `relavium@0.1.1` published on npm 2026-06-28 (M3) | | Phase 2.5 — CLI Consolidation | Complete (M2.5-4, PR #69, 2026-07-08) | | Phase 2.6.5 — Core reliability | Complete with approved deferrals, 49/51 (PR #92, 2026-10-11; [merge checkpoint](phases/phase-2.6.5-core-reliability-remediation.md#w8-merge-checkpoint--2026-10-11)). CR-70, CR-93 and CR-95's long half stay deferred in [deferred-tasks.md](deferred-tasks.md) | -| Phase 2.5.5 — Hardening | Waves 0–2 merged (Wave 2: PR #94, 2026-10-15). Wave 3 Steps 1–8 merged (PR #95, 2026-10-18); PR95, 9a.1, 9a.2a, 9a.2b, NI, 9b.1 and 9b.2 accepted on `development`. **Steps 9c–19 paused** (25 of 32 increments). NI's Windows clause closed on 2026-10-26 | -| Phase 2.6 — Conversational authoring | 2.6.C done; 2.6.F done except its release clause; 2.6.Q P1–P5 landed; the other fourteen workstreams have not started in their core. The MVP takes its first slices (below) | +| Phase 2.5.5 — Hardening | Waves 0–2 merged (Wave 2: PR #94, 2026-10-15). Wave 3 Steps 1–8 merged (PR #95, 2026-10-18); PR95, 9a.1, 9a.2a, 9a.2b, NI, 9b.1 and 9b.2 accepted and merged to `main` with the D61 checkpoint (PR #96, 2026-11-04). **Steps 9c–19 paused** (25 of 32 increments). NI's Windows clause closed on 2026-10-26 | +| Phase 2.6 — Conversational authoring | 2.6.C done; 2.6.F done except its release clause; 2.6.Q P1–P5 landed; the other fourteen workstreams have not started in their core. The MVP takes its first slices (below); MVP-1 accepted 2026-11-06 | | Phases 3–7 | Not started — desktop, VS Code, then Product Phase 2 (managed inference, cloud) | -| Publication | npm serves `0.1.1`. Nothing newer can be published yet: Step 9a.1's interim guard on `development` refuses every artifact until D59's superseding release ADR is Accepted | -| Branches | `development` is ahead of `main` (`6d2a7372`). `main` lacks the PR95 scrubber fixes, NI and the interim guard; a scoped `development`→`main` checkpoint follows this reset (D61) | +| Publication | npm serves `0.1.1`. Nothing newer can be published yet: Step 9a.1's interim guard, on `main` since PR #96, refuses every artifact until D59's superseding release ADR is Accepted | +| Branches | `main` is at `b3cf117e`: the D61 `development`→`main` checkpoint ([PR #96](https://github.com/HodeTech/Relavium/pull/96), merged 2026-11-04; [checkpoint record](phases/phase-2.5.5-hardening-and-remediation.md#d61-checkpoint--pr-96-merged-2026-11-04)) brought the PR95 scrubber fixes, NI, the interim guard, this reset and its review correction to `main`, and `development` was fast-forwarded to the merge commit. `development` is ahead of `main` again with the D64 fixes to that correction (from `5a1006ea`); `main` keeps the redaction regressions they fix (a credential flag after a boolean flag, a credential key in a `,`/`/` URL password's userinfo or a nested URL after it, a masked name in a last-word `--flag=value` spelling) until the next checkpoint, MVP-10's unless the maintainer schedules one earlier. **The maintainer scheduled it earlier: PR #97** (opened 2026-11-06, after MVP-1). Its SonarCloud fixes (`a417a57d`, behaviour-preserving) and CodeRabbit's doc fixes (`077650a8`) are on `development`. Four reviews of it found eight medium findings: by the maintainer's rulings six are fixed before it merges and two are resolved in the record (the URL-password reversal below, and the ReDoS vector, which stays open), so it owes a D64 rigor pass, a new complete round and a separate security sitting ([deferred-tasks.md](deferred-tasks.md#pr-97-review--fixed-findings-and-recorded-lows-2026-11-06)). By the maintainer's ruling it also returns `main`'s URL-password projection to `64fb8772`: a `,` or `/` in a URL password no longer masks the userinfo `main` masks today ([A4](deferred-tasks.md)), until a superset-safe reading lands before v0.2.0 | ### What a CLI user meets today @@ -78,11 +78,15 @@ Each milestone ends green on `development` with its canonical docs updated. Item [CLI MVP additions](phases/phase-2.6-conversational-authoring.md#cli-mvp-additions--2026-11-04), or a recorded Wave 3 split as `row ` in the [increment table](phases/phase-2.5.5-hardening-and-remediation.md#proposed-implementation-increments). -0. **Gate 0 — decide and re-order (no product code).** This reset; the `development`→`main` checkpoint (D61); +0. **Gate 0 — decide and re-order (no product code).** This reset and the `development`→`main` checkpoint (D61) — + **both done:** PR #96 merged on 2026-11-04 ([checkpoint record](phases/phase-2.5.5-hardening-and-remediation.md#d61-checkpoint--pr-96-merged-2026-11-04)); its review + correction's D64 rounds 1–4 found five, one, one and one medium findings, all fixed since; by the maintainer's + ruling of 2026-11-05 one careful pass closed it instead of a fifth round (the `,`/`/` URL reading removed, A4 a + recorded limit again); drafts for the new ADRs: the pre-1.0 release path (D59), the toolbelt plus tool-render/approval-preview contract (phase 2.6 ADR row 9, split so `ask_user` comes later), the in-house markdown architecture (ADR row 11, part 1; no dependency), the ADR-0057 amendments (default grant and `default_mode`, D62; denial feedback, D67), the - keymap (D68), the `run_command` approval card (D63) and the ADR-0095 amendment for the CR-71 residual (D67). Maintainer + keymap (D68), the `run_command` approval card (D63) and the ADR-0095 amendment for the CR-71 residual (D67). **Drafted 2026-11-05, all Proposed and awaiting the maintainer:** [ADR-0114](../decisions/0114-in-house-markdown-rendering-for-the-chat-transcript.md) (markdown), [ADR-0115](../decisions/0115-one-interrupt-keymap-for-the-interactive-cli.md) (keymap), [ADR-0116](../decisions/0116-the-built-in-chat-agent-can-edit-and-the-starting-mode-is-configurable.md) (D62), [ADR-0117](../decisions/0117-a-denied-tool-call-returns-to-the-model.md) (D67 denials), [ADR-0118](../decisions/0118-a-command-outside-the-allowlist-asks-instead-of-failing.md) (D63), [ADR-0119](../decisions/0119-an-interrupted-turn-stays-as-a-content-free-marker.md) (the CR-71 residual), [ADR-0120](../decisions/0120-the-toolbelt-grows-and-a-tool-call-shows-what-it-does.md) (row 9, part 1) and [ADR-0121](../decisions/0121-pre-1-0-releases-publish-the-smoke-verified-package-test-tarball.md) (D59). Maintainer gates: CI provider keys for the live-nightly lane (D37), provider spend for a manual three-provider turn and a Windows machine for MVP-10. A stale-entry sweep of [deferred-tasks.md](deferred-tasks.md) and the phase 2.6 bullets that 2.6.5 and Wave 2–3 work already satisfied. @@ -91,7 +95,29 @@ Wave 3 split as `row ` in the [increment table](phases/phase-2.5.5-hardening- `run`/`gate` start; `status` stays read-only); 2.6.K · **Validate the gate decision shape against `gateType` before resume**; 2.6.A · **Run-path tool pre-flight for the unwired egress/os arms** (and no `web_search` in the `create` placeholder); `/export` into `.relavium/workflows/` (MVP-A3); the default model follows the stored - key (MVP-A4); the interactive output budget (MVP-A7). + key (MVP-A4); the interactive output budget (MVP-A7). **Sliced 2026-11-05 (maintainer):** **1a** (UX tier, reviewed + per milestone) — the run id, `list --runs` and `status `, the run-path pre-flight, the output budget; **1b** + (security tier under D64) — human run output, the gate decision check, `/export`, the default model and missing-key + error, and liveness. **Progress 2026-11-05:** 1a is on `development`. **1b is accepted** and on `development`. + Wiring liveness exposed a second-terminal race, so the run store now refuses a second terminal. After the rigor + pass, D64 rounds 1 (at `5f2a0eb8`), 2 (at `60bc9727`), 3 (at `224b8f69`) and 4 (at `b70f9def`) were not accepted, + with nine, two, two and one medium findings, all fixed. Round 5 (at `df685a52`) found no blocker, high or medium + finding, and the separate security sitting (at `ab5e46e1`) passed. Every round's lows and the sitting's are fixed + or recorded in [deferred-tasks.md](deferred-tasks.md); one of them, a 0.1.x run settled by a newer reconcile, + needs a ruling before the v0.2.0 release. The MVP-1 milestone review of 1a (at `624bb2e7`, which includes a + `relavium list` sink fix for round 5's export-1) was not accepted: it found four medium findings, all fixed. They + were a budget pause pointed at `relavium gate`, a finished run's open gate shown as pending, an unbounded tool + pre-flight, and the chat's catalog-ceiling cap running into the 120-second attempt deadline. The maintainer chose + a chat deadline that scales with the cap, recorded as ADR-0101's 2026-11-06 note. Round 2 (at `d8762a23`) was not + accepted either: the round-1 run-id latch let a resumed run's summary point a gate restored from its checkpoint at + `relavium gate`, and the summary now points such a gate at `relavium status`. Round 3 (at `32d1a8a7`) was not + accepted either: `status` printed the command that continues a gate only for a budget gate, so that pointer + promised a command it did not print for a human one; `status` now prints one for every pending gate. Round 4 (at + `8e7f41f2`) was not accepted either: `status ` printed an approved gate's step as `stopped` under its + completed run, because no `node:completed` closes a gate's step row; it now prints `completed`. **Round 5 (at + `3354dda1`) found no blocker, high or medium finding, so 1a is accepted and MVP-1 is done (2026-11-06).** Every + round's lows are fixed or recorded in [deferred-tasks.md](deferred-tasks.md), round 1's `officialEndpointOf` half + of budget-5 included. 2. **MVP-2 — First run, resume and a readable chat.** Onboarding offered by `relavium chat` (MVP-A5); standard provider env-var fallback (MVP-A6); 2.6.G's resume-in-place **with the prior transcript repainted** (the [deferred `chat-resume` entry](deferred-tasks.md)); picker-on-omitted-id and `chat --continue`; excess @@ -199,7 +225,8 @@ sub-stream D are satisfied on `main`; M2.5.5-2's I half stays open. **Paused on 2026-10-26 after Step 9b.2; 25 of 32 increments pending behind the MVP.** Steps 1–8 merged to `main` as [PR #95](https://github.com/HodeTech/Relavium/pull/95) on 2026-10-18 ([merge checkpoint](phases/phase-2.5.5-hardening-and-remediation.md#wave-3-steps-18-merge-checkpoint--2026-10-18)); -PR95, 9a.1, 9a.2a, 9a.2b, NI, 9b.1 and 9b.2 are accepted on `development`, each with its acceptance record in the +PR95, 9a.1, 9a.2a, 9a.2b, NI, 9b.1 and 9b.2 are accepted and on `main` since the D61 checkpoint (PR #96, 2026-11-04), +each with its acceptance record in the [register](phases/phase-2.5.5-hardening-and-remediation.md). The [increment table](phases/phase-2.5.5-hardening-and-remediation.md#proposed-implementation-increments) owns the remaining increments' scope and order. Whole-wave acceptance stays Step 19. @@ -358,7 +385,7 @@ unanswered**; the remainder sit inline in their own phase-file bullet. | D58 | MVP | MVP scope | ✅ **Ruled 2026-11-04 — the larger MVP:** v0.2.0 on npm (milestone M3.5) after MVP-1…MVP-10, including conversational authoring, web, the Home hub and MCP/provider management; refines D7 | | D59 | MVP | Release path under the interim guard | ✅ **Ruled 2026-11-04 — option B:** a superseding pre-1.0 release ADR publishes the smoke-verified package-test tarball from a trusted `v*` tag on `main` with a green `ci` at that commit; no archived encrypted maps for those releases; custody becomes mandatory again at 1.0 or when 9c/9d land. The guard stays in force until that ADR is Accepted and its guard change passes a security sitting | | D60 | MVP | `LICENSE` §2 grants no usage right without a written agreement | ✅ **Ruled 2026-11-04 — unchanged.** Recorded risk for the published package | -| D61 | Gate 0 | `development`→`main` checkpoint before the MVP | ✅ **Ruled 2026-11-04 — yes,** a scoped checkpoint PR right after this reset; merging is the maintainer's action | +| D61 | Gate 0 | `development`→`main` checkpoint before the MVP | ✅ **Ruled 2026-11-04 — yes,** a scoped checkpoint PR right after this reset; merging is the maintainer's action · **Done 2026-11-04:** PR #96 merged as `b3cf117e` ([checkpoint](phases/phase-2.5.5-hardening-and-remediation.md#d61-checkpoint--pr-96-merged-2026-11-04)) | | D62 | MVP-3 | Default chat agent posture | ✅ **Ruled 2026-11-04:** read/search/find plus `write_file`/`edit_file` (hidden in ask/plan, per-call diff approval in accept-edits) plus `run_command` under D63; egress stays opt-in; the default mode stays `ask` with a `[chat].default_mode` setting and a one-time hint. Needs an ADR-0057 amendment and a security review | | D63 | MVP-4 | `run_command` approval semantics | ✅ **Ruled 2026-11-04:** a typed card for any non-allowlisted command — once, session or remember; remembered grants live outside the repository in operator-owned config keyed by project root; accept-edits keeps prompting; headless stays fail-closed. New ADR (amends ADR-0029(a)/0061/0063) and a security sitting | | D64 | MVP | Acceptance bar for MVP increments | ✅ **Ruled 2026-11-04 — tiered** (see [Next work](#next-work--the-cli-mvp)) | @@ -404,9 +431,11 @@ places, each deliberate: and its push CI is observed. - `main` carries branch protection (PR required; `lint · typecheck · test`, the coverage floor and, since 2026-11-04, the Windows job required; no approval count, since GitHub refuses a self-approval — see - [code-review.md](../standards/code-review.md); force-push and deletion blocked) and a `v*` tag ruleset - limited to the maintain/admin roles; `release.yml` asserts its ref is reachable from `main` and publishes - from the `release` deployment environment. + [code-review.md](../standards/code-review.md); force-push and deletion blocked; administrators not enforced) and a `v*` tag ruleset + limited to the maintain/admin roles; `release.yml` asserts its ref is reachable from `main`, and its publish + job names the `release` deployment environment; MVP-A23 either gives it `NPM_TOKEN` under a protection policy or + replaces the token with npm trusted publishing, chosen in the D59 release ADR (until then the token is a + repository secret). ## History @@ -417,7 +446,8 @@ Finished work is recorded in its register; this page no longer restates it. - **Phase 2.5.5** — the [phase register](phases/phase-2.5.5-hardening-and-remediation.md): the 2026-07-19 review triage under Positioning and the traceability appendix, Waves 0–1 and the PR #81 closing list in the [moved execution record](phases/phase-2.5.5-hardening-and-remediation.md#execution-record-moved-from-currentmd--2026-11-04), - the Wave 2 and Wave 3 Steps 1–8 merge checkpoints, and every Wave 3 increment's checkpoint and acceptance. + the Wave 2 and Wave 3 Steps 1–8 merge checkpoints, the [D61 checkpoint](phases/phase-2.5.5-hardening-and-remediation.md#d61-checkpoint--pr-96-merged-2026-11-04) (PR #96), and every + Wave 3 increment's checkpoint and acceptance. - **Phase 2.6** — the 2.6.F and 2.6.C implementation records and open obligations, in their [workstream sections](phases/phase-2.6-conversational-authoring.md). - **Phases 1, 2 and 2.5** — [phase 1](phases/phase-1-engine-and-llm.md), [phase 2](phases/phase-2-cli.md) and diff --git a/docs/roadmap/deferred-tasks.md b/docs/roadmap/deferred-tasks.md index e74c90cc2..db0e3d863 100644 --- a/docs/roadmap/deferred-tasks.md +++ b/docs/roadmap/deferred-tasks.md @@ -2,7 +2,7 @@ > Status: Living -> Last updated: 2026-11-04 — the CLI MVP comes first (maintainer, 2026-10-26; decisions D58–D72 ruled 2026-11-04): Wave 3 is paused after Step 9b.2 and its Steps 9c–19 are pending, not declined; the order is in [current.md](current.md#next-work--the-cli-mvp). Wave 2 is merged in PR #94 and Wave 3 Steps 1–8 in PR #95; PR95, 9a.1, 9a.2a, 9a.2b, NI (its Windows clause closed on 2026-10-26), 9b.1 and 9b.2 are accepted on `development` with their recorded findings below. The independent PR #95 review's findings keep the maintainer's 2026-10-19 owner rulings, and the masker's edge cases the 2026-10-21 bar and the 2026-10-22 rulings. Existing approved deferrals remain; entries the MVP pulls forward say so. +> Last updated: 2026-11-04 — the CLI MVP comes first (maintainer, 2026-10-26; decisions D58–D72 ruled 2026-11-04): Wave 3 is paused after Step 9b.2 and its Steps 9c–19 are pending, not declined; the order is in [current.md](current.md#next-work--the-cli-mvp). Wave 2 is merged in PR #94 and Wave 3 Steps 1–8 in PR #95; PR95, 9a.1, 9a.2a, 9a.2b, NI (its Windows clause closed on 2026-10-26), 9b.1 and 9b.2 are accepted with their recorded findings below and reached `main` with the `development`→`main` checkpoint (PR #96, merged 2026-11-04). The independent PR #95 review's findings keep the maintainer's 2026-10-19 owner rulings, and the masker's edge cases the 2026-10-21 bar and the 2026-10-22 rulings. Existing approved deferrals remain; entries the MVP pulls forward say so. > The [merge checkpoint](phases/phase-2.5.5-hardening-and-remediation.md#wave-2-merge-checkpoint--2026-10-15) records completed prerequisites; full M/N lifecycle and other scheduled work remain with their owners. > > Earlier triage: the Phase-2.6 rewrite triaged every open item, the 2026-07-19 full-project @@ -393,12 +393,12 @@ measured or counted here. `ci.yml` and the Sonar backlog section below), the S4036 accepted as on the PR #95 branch. SonarCloud then reported zero open vulnerabilities and an OK gate for `main`. No rule, threshold or gate was changed; the per-issue record is local and Git-ignored. *(was medium · process)* -- **DONE 2026-10-22 — Wave 3 PR95 increment, closed under the maintainer's round cap. An `api-key` header label erases the `Bearer`/`Basic` label that follows it, so the token leaks.** *Low (security).* *(author)* `scrubSecrets('api-key:Bearer abcdefghijkl123456')` returns `api-key:[REDACTED] abcdefghijkl123456` — the `api-key` rule consumes the word `Bearer` as the header's value and the Bearer rule then has no label to anchor on. The pre-PR matcher returned `api-key:Bearer [REDACTED]`. Surfaced by the post-merge security sitting, reproduced independently; it comes from the PR's rule order, not from `a3e164b5`, and is **not fixed**. A plain `Authorization: Bearer …` is unaffected. Fix idea: let the header value skip a following `Bearer`/`Basic` label, or run those rules first. *(low · packages/llm/src/scrub-secrets.ts)* -- **DONE 2026-10-22 — Wave 3 PR95 increment, closed under the maintainer's round cap. URL userinfo containing a raw `?` or `#` before the first `:` (or with no `:`) is not masked.** *Ruled 2026-10-19:* mask unless the text before the `?`/`#` is host-shaped (a dotted hostname, `localhost` or a bracketed IP, with an optional `:port`) **and** an `=` assignment lies between the `?`/`#` and the authority `@`; a plain host-shaped exemption would still leave `https://my.user:1234?TOKEN@h/` open. The same predicate applies to core's twin in `packages/core/src/tools/bounding.ts` (its colonless short-handle rule and `]` delimiter stay, recorded as differences). *Low (security).* *(author, from two independent differential runs)* `https://us?er:TOKEN@h/`, `http://#TOKEN@h` and `https://my.user:1234?TOKEN@h/` pass through unchanged; the pre-PR matcher masked all of them. It follows from the deliberate host-query policy in `maskUrlUserinfo` (a raw `?`/`#` may begin public query text, e.g. `https://host?contact=a@b.c`) and the colon-first cases are pinned by tests; it is identical at `19c99968` and is not introduced by the follow-up. **A maintainer call** whether a username shaped that way is worth the false positives. Fix idea: exempt only when the text before the `?`/`#` is non-empty and host-shaped (contains `.`, a `:port`, `localhost` or a bracketed IPv6), otherwise mask. *(low · packages/llm/src/scrub-secrets.ts)* -- **DONE 2026-10-22 — Wave 3 PR95 increment, closed under the maintainer's round cap. Credentials in URLs of other schemes are outside the finite rule.** *Ruled 2026-10-19:* extend the finite list with postgres, postgresql, mysql, mariadb, redis, rediss, mongodb, mongodb+srv, amqp, amqps, ssh, sftp, ldap, ldaps, smtp and smtps, masking only colon-bearing userinfo for the new schemes; malformed `https:/u:p@h`, a raw `/` inside a password and invisible characters inside the scheme stay documented limits. *Low, informational.* `postgres://`, `redis://`, `mongodb(+srv)://`, `ssh://` userinfo, malformed `https:/u:p@h`, a raw `/` inside a password, and invisible characters inside the scheme are not masked, identically before and after the follow-up. The file states its shapes are finite; a database connection error is the realistic carrier, so the scheme list may deserve a decision. *(info · packages/llm/src/scrub-secrets.ts)* -- **DONE 2026-10-22 — Wave 3 PR95 increment, closed under the maintainer's round cap. The URL rule's split-credential scan costs 2.1–2.5× on letter-heavy input, and nothing times that loop.** *Low / nit.* With `NO_BOUNDARY` the scanner tests every ASCII letter start for the URL rule; at 8 MiB the worst case stays under about 1.1 s and linear (1–16 MiB scaling checked). A prefilter on `h`/`w`/`f` would recover most of it. The existing 8 MiB timing case contains no `@`, so it returns before the loop; a guard would use a leading `a\nhttps://u@h ` plus 8 MiB of letters, but see the load-sensitive timing item below before adding another wall-clock assertion. SonarCloud was not run on the never-matching `NO_BOUNDARY = /(?!)/`; check its next analysis. *(low · packages/llm/src/scrub-secrets.ts)* -- **DONE 2026-10-22 — Wave 3 PR95 increment, closed under the maintainer's round cap. Two latent invariants in `hasSplitCredential` are unchecked.** *Nit.* A boundary-bearing rule with neither `\b` nor a `joinedPattern` makes `scrubSecrets` throw a `SyntaxError` on LF/TAB input no earlier rule has redacted (the suite fails at once, so it is caught, but it would break `makeLlmError`'s no-throw claim); and `wordBlocksStart` tests `boundary.test('a')` as a proxy for "rejects every ASCII word character", true for all current boundaries. An assertion at module load would pin both. *(nit · packages/llm/src/scrub-secrets.ts)* -- **DONE 2026-10-22 — Wave 3 PR95 increment, closed under the maintainer's round cap. The userinfo control's joined-view half is not separately witnessed.** *Low (coverage).* *(author, from an independent run)* Mutating only `joinedPattern` of the URL rule leaves 111/111 tests green. The whitespace-stripped residual probe independently catches the class the existing cases use, so it may be largely an equivalent mutant; an added case that did not discriminate it was reverted rather than kept. No causal control pins the glued-scheme behaviour either — only ordinary tests do. *(low · tools/secrets-controls/mutations.mjs)* +- **DONE 2026-10-22 — Wave 3 PR95 increment, closed under the maintainer's round cap. An `api-key` header label erases the `Bearer`/`Basic` label that follows it, so the token leaks.** *Low (security).* *(author)* `scrubSecrets('api-key:Bearer abcdefghijkl123456')` returns `api-key:[REDACTED] abcdefghijkl123456` — the `api-key` rule consumes the word `Bearer` as the header's value and the Bearer rule then has no label to anchor on. The pre-PR matcher returned `api-key:Bearer [REDACTED]`. Surfaced by the post-merge security sitting, reproduced independently; it came from the PR's rule order, not from `a3e164b5`, and was not fixed when recorded. A plain `Authorization: Bearer …` was unaffected. **Closed by the PR95 increment:** a header value may begin with up to eight `Bearer`/`Basic` labels, and the example now returns `api-key:Bearer [REDACTED]` (re-checked at `b3cf117e`, 2026-11-04). *(low · packages/llm/src/scrub-secrets.ts)* +- **DONE 2026-10-22 — Wave 3 PR95 increment, closed under the maintainer's round cap. URL userinfo containing a raw `?` or `#` before the first `:` (or with no `:`) is not masked.** *Ruled 2026-10-19:* mask unless the text before the `?`/`#` is host-shaped (a dotted hostname, `localhost` or a bracketed IP, with an optional `:port`) **and** an `=` assignment lies between the `?`/`#` and the authority `@`; a plain host-shaped exemption would still leave `https://my.user:1234?TOKEN@h/` open. The same predicate applies to core's twin in `packages/core/src/tools/bounding.ts` (its colonless short-handle rule and `]` delimiter stay, recorded as differences). *Low (security).* *(author, from two independent differential runs)* When recorded, `https://us?er:TOKEN@h/`, `http://#TOKEN@h` and `https://my.user:1234?TOKEN@h/` passed through unchanged; the pre-PR matcher masked all of them. **Closed by the PR95 increment:** all three return masked userinfo (re-checked at `b3cf117e`, 2026-11-04). It follows from the deliberate host-query policy in `maskUrlUserinfo` (a raw `?`/`#` may begin public query text, e.g. `https://host?contact=a@b.c`) and the colon-first cases are pinned by tests; it is identical at `19c99968` and is not introduced by the follow-up. (As recorded, it asked for a maintainer call and proposed the host-shaped exemption the 2026-10-19 ruling above adopted.) *(low · packages/llm/src/scrub-secrets.ts)* +- **DONE 2026-10-22 — Wave 3 PR95 increment, closed under the maintainer's round cap. Credentials in URLs of other schemes are outside the finite rule.** *Ruled 2026-10-19:* extend the finite list with postgres, postgresql, mysql, mariadb, redis, rediss, mongodb, mongodb+srv, amqp, amqps, ssh, sftp, ldap, ldaps, smtp and smtps, masking only colon-bearing userinfo for the new schemes; malformed `https:/u:p@h`, a raw `/` inside a password and invisible characters inside the scheme stay documented limits. *Low, informational.* When recorded, `postgres://`, `redis://`, `mongodb(+srv)://`, `ssh://` userinfo, malformed `https:/u:p@h`, a raw `/` inside a password, and invisible characters inside the scheme were not masked, identically before and after the follow-up. **Closed by the PR95 increment** as ruled: the listed connection-string schemes mask colon-bearing userinfo (`postgres://u:p@db` → `postgres://[REDACTED]@db`; a bare `ssh://git@host` stays), and the other three stay documented limits (re-checked at `b3cf117e`, 2026-11-04). The file states its shapes are finite; a database connection error is the realistic carrier (as recorded, it asked for the decision the 2026-10-19 ruling above made). *(info · packages/llm/src/scrub-secrets.ts)* +- **DONE 2026-10-22 — Wave 3 PR95 increment, closed under the maintainer's round cap. The URL rule's split-credential scan costs 2.1–2.5× on letter-heavy input, and nothing times that loop.** *Low / nit.* When recorded, with `NO_BOUNDARY` the scanner tested every ASCII letter start for the URL rule; at 8 MiB the worst case stayed under about 1.1 s and linear (1–16 MiB scaling checked), and the 8 MiB timing case of the time contained no `@`, so it returned before the loop. **Closed by the PR95 increment:** the URL rule's split probe now runs only within 32 characters of a `://` (`anchor: { text: '://', window: URL_SCHEME_WINDOW }`), and `reaches the split URL scan over admitted-size letter-heavy text` drives 8 MiB of letters through it under the case deadline instead of a wall-clock assertion (re-checked at `b3cf117e`, 2026-11-04). *(low · packages/llm/src/scrub-secrets.ts)* +- **DONE 2026-10-22 — Wave 3 PR95 increment, closed under the maintainer's round cap. Two latent invariants in `hasSplitCredential` are unchecked.** *Nit.* A boundary-bearing rule with neither `\b` nor a `joinedPattern` makes `scrubSecrets` throw a `SyntaxError` on LF/TAB input no earlier rule has redacted (the suite fails at once, so it is caught, but it would break `makeLlmError`'s no-throw claim); and `wordBlocksStart` tests `boundary.test('a')` as a proxy for "rejects every ASCII word character", true for all current boundaries. An assertion at module load would pin both. **Closed by the PR95 increment:** `splitProbe` checks the scanner's two invariants at module load, so a misconfigured rule fails fast with a typed error (re-checked at `b3cf117e`, 2026-11-04). *(nit · packages/llm/src/scrub-secrets.ts)* +- **DONE 2026-10-22 — Wave 3 PR95 increment, closed under the maintainer's round cap. The userinfo control's joined-view half is not separately witnessed.** *Low (coverage).* *(author, from an independent run)* When recorded, mutating only `joinedPattern` of the URL rule left 111/111 tests green, and no causal control pinned the glued-scheme behaviour. **Closed by the PR95 increment:** the `diagnostic-joined-url-userinfo` control replaces the URL rule's `joinedPattern` with a never-matching pattern, and `diagnostic-url-glued-scheme` pins the glued-scheme behaviour (re-checked at `b3cf117e`, 2026-11-04). *(low · tools/secrets-controls/mutations.mjs)* - [ ] **Approved deferral (maintainer, 2026-10-19). `LOW-2` (the 1 s default `waitFor` on a real child) was examined and deliberately not changed.** *Low.* The only wait that observes a real child booting is `holdNativeToolProcess`'s marker wait, whose sole caller sits inside `drive-home.test.ts` waits (lines 633 and 637) that use the 1 s default and enclose the boot, so a longer inner bound cannot help; a retune must raise those outer waits together. No failure was reproduced (0 in dozens of loaded runs). `testing.md` also states that no readiness or case deadline is extended to pass the workspace gate. **Trigger: a recorded boot-time failure.** *(low · apps/cli/src/commands/native-forwarding.test-support.ts, apps/cli/src/home/drive-home.test.ts)* - [ ] **Scheduled → Wave 3 17b. Join-table test nits.** *Nit.* The `process-close.test.ts` gate margin is nominally 100 ms but `waitFor`'s 50 ms poll makes it 100–150 ms, so a publication more than about 150 ms late is invisible; the three rejection cases assert the message but not the `ProcessCapabilityError` class; the `mcp-orphan.e2e.test.ts` comment's statement that its cases have only ever run on POSIX is historical and unverifiable; the abort case's `await spawned.promise` is unbounded (a spawn that never happens fails only as the 5 s case timeout), its closed-child `waitFor` carries no tag, and the 100/300/3,000 ms margins are bare literals. *(nit · apps/cli/src/engine/tool-host/process-close.test.ts, apps/cli/src/harness/mcp-orphan.e2e.test.ts)* - [ ] **Approved deferral (maintainer, 2026-10-19). Native-lifetime watchdogs are fixed 5 s bounds inside 10 s cases (`LOW-1`).** *Low.* The watchdogs in @@ -414,15 +414,17 @@ measured or counted here. 5 s for two 8 MiB scrubs that take about 0.75 s in total, and it failed twice with the load average near 65 (6.4 s in the run that logged the duration). Measured alternately, the pre-change code and the corrected code take the same time, so the failures were load, not the correction. Two independent reviewers saw it too (5,590 ms and 3,453 ms at load averages near 88–97) and recorded that `tools/secrets-controls/README.md` says the fixture runs without a machine-speed threshold, which this assertion contradicts; six controls run this file, so a loaded runner can turn a fixed phase red. The test - guards against a quadratic regression, so any loosening must stay far below minutes. **Trigger: the next CI - failure of this case.** *(low · packages/llm/src/scrub-secrets.test.ts, `SECRETS_B2_HEADER_PREPARATION`)* + guards against a quadratic regression, so any loosening must stay far below minutes. **Closed by the PR95 + increment:** the eight-MiB cases no longer assert a duration; their bound is the case deadline + `ADMITTED_SIZE_CASE_TIMEOUT_MS` (15 s, the largest the secrets causal-control phases admit), far below the minutes a + quadratic scan would need (re-checked at `b3cf117e`, 2026-11-04). *(low · packages/llm/src/scrub-secrets.test.ts, `SECRETS_B2_HEADER_PREPARATION`)* - [ ] **Scheduled → Wave 3 17a (all four sites together). Raw exit-code literals where `EXIT_CODES` names exist.** *Nit.* *(reviewer)* reported `result === 5 || result === 7 || result === 8` in `apps/cli/src/chat/session-surface-lifetime.ts:54`; *(author)* the same literals occur in four places: that site, `commands/workflow-lifetime.ts:178`, `process/command-lifetime.ts:110` and `commands/drive.ts:225`. Behaviour-neutral, but it sits on the exit-code precedence that the review's red-team matrix held, so rerun the precedence tests with the change. *(nit · the four files above)* -- [ ] **Scheduled → Wave 3: `session-ink-input.ts` → 15c, `HomeDeps.exit` → 14a, the `lazy-dispatch.ts` cause-wrap → 10a. CLI lifetime and API-drift nits.** *Nit, (reviewer).* `chat/session-ink-input.ts:40-42,124-131` guards only +- [ ] **Scheduled → Wave 3: `session-ink-input.ts` → 15c, `HomeDeps.exit` → 14a, the `lazy-dispatch.ts` cause-wrap → 10a. CLI lifetime and API-drift nits.** **Not pulled into the CLI MVP (checked 2026-11-04):** D65 moved only row 15c's #63 part into MVP-3; the `session-ink-input.ts` half is 15c's "PR #95 input-listener fold-in", which stays with 15c's paused remainder, and the other two halves stay with 14a and 10a. *Nit, (reviewer).* `chat/session-ink-input.ts:40-42,124-131` guards only the `readable` listener (Ink's kitty `data` path is latent because `kittyKeyboard` is unused); `home/drive-home.tsx:165-166,1067,1115-1116` keeps `HomeDeps.exit` although the owned path never calls it; `commands/lazy-dispatch.ts:49` imports the media route without the cause-wrap the sibling routes carry. @@ -659,9 +661,12 @@ Observed in 9a.1's push CI and its preflight for 9a.2 (2026-10-24): runner the `exclusive` case took 7.7 s and failed. 9a.1 changed no database source. The advisory Windows job failed 6 times in the last 40 CI runs, each a default 5 s timeout on a different SQLite- or process-heavy test (`fs-workspace-admission`, `concurrency.e2e`, the runner-abort ownership case and this - one); the job is advisory and every required job passed. **Re-check:** give the case its siblings' `30_000` - bound, and review the other three tests' bounds against the Windows runner, when the file is next edited or - the advisory job next fails. *(low · packages/db/src/session-effect-turns.test.ts)* + one); the job was advisory then and every required job passed. **Since 2026-11-04 the Windows job is a required + check** (the PR #96 review ruling), so such a timeout now blocks a merge; it has been green for 14 consecutive runs + since 2026-10-25 (D64 round 2 on the PR #96 correction). **Re-check:** measure the failure's cause and rate before + changing a bound, then give the case its siblings' `30_000` and review the other three tests' bounds against the + Windows runner, when the file is next edited or the Windows job next fails on any run. *(low · + packages/db/src/session-effect-turns.test.ts)* - [ ] **`--verbose` never prints a real stack.** *Low, for Step 10a.* *(9a.2 preflight)* `apps/cli/src/process/render-user-facing.ts` (`ownErrorStack`) prints a stack only when `stack` is an own data property, but V8 defines a real error's `stack` as an accessor (get/set), so a thrown `Error` prints @@ -973,12 +978,12 @@ names, unless the entry names another. **Sonar code smells (gate-neutral; maintainability stays A):** - [ ] **Cognitive complexity (`S3776`) in fifteen functions.** *Low.* `packages/core/src/tools/bounding.ts` (five, - 25–38), `packages/llm/src/scrub-secrets.ts:411`, `tools/cli-package/source-map.mjs:47` (35), `map-controls-core.mjs`, + 18–38), `packages/llm/src/scrub-secrets.ts:411`, `tools/cli-package/source-map.mjs:47` (35), `map-controls-core.mjs`, `release-custody.mjs`, `candidate.mjs`, `release-binding.mjs`, `symbolicate.mjs`, `ustar.mjs`, `tools/bundle-closure/closure.mjs` and `inventory.mjs`. Each is security-critical redaction or release tooling, so a refactor needs its own mutation sweep and review. *(low · the files named)* -- [ ] **Regex complexity (`S5843`) in eight patterns.** *Low.* `bounding.ts`'s standalone and key rules and five - `scrub-secrets.ts` rules: their "complexity" is the length of a finite name or shape alternation (as the existing +- [ ] **Regex complexity (`S5843`): eight reports on seven patterns.** *Low.* `bounding.ts`'s scheme and key rules + (the key rule reported twice) and five `scrub-secrets.ts` rules: their "complexity" is the length of a finite name or shape alternation (as the existing `NOSONAR` notes say), and each is a causal-control anchor. The three `S5869` reports on `scrub-secrets.ts:138` are the `i` flag making `A-Z` and `a-z` overlap, not a duplicate. **Re-check:** split a rule only with its controls. *(low · packages/core/src/tools/bounding.ts, packages/llm/src/scrub-secrets.ts)* @@ -992,9 +997,21 @@ names, unless the entry names another. **Redaction:** -- [ ] **The LLM backstop leaves a `/` in a URL password.** *Low.* `scrubSecrets('https://user:pa/ss@host')` keeps the - password, because its userinfo pattern stops at `/`; core masks it since this correction. Matching core needs its - port guard (`host:8080/a@b` stays public). *(low · packages/llm/src/scrub-secrets.ts)* +- [ ] **A URL password that holds a `,` or `/` leaves the whole userinfo public (the PR #96 review's A4).** *Medium as + reviewed. Pre-existing against `64fb8772`, but a reversal against `main`, which masks it since `0826f30a`: merging + PR #97 returns the URL projection to `64fb8772` (maintainer ruling 2026-11-06).* Core's authority scan ends at + either, so `https://user:pa,ss@host` and `https://user:pa/ss@host` leave `user:pa,ss` and `user:pa/ss` visible in + `toolInput`, summaries and the journal digest input; the PR #97 review measured about thirty such shapes `main` + masks today, among them a comma in a `git clone` URL's password, `mysql://`, `postgres://`, `ftp://` and `redis://` + URLs with a `/` in the password, and `x-access-token` URLs. The LLM backstop masks the comma spelling but stops at + `/` too. A reading past the delimiter landed with the PR #96 correction and was removed on 2026-11-05 (maintainer: + one careful pass, then continue): the projection repeats until stable, so a reading's mask can change the text + other rules read in a later pass, and D64 rounds 1, 3 and 4 each found a credential it newly showed. A new reading + must keep a port and a path public (`host:8080/a@b`, pinned in `bounding.test.ts`). **Trigger:** before the v0.2.0 + release, as its own increment (maintainer 2026-11-06): a design that computes the reading's ranges once on the + original text and merges them with the base rules' after the fixed point, so it provably only adds masks, with a + superset oracle validated against both `64fb8772` and `b3cf117e`. *(medium as reviewed · + packages/core/src/tools/bounding.ts, packages/llm/src/scrub-secrets.ts)* - [ ] **A glued `-pSECRET` (`mysql -uroot -pSecret`) is not masked.** *Low.* A glued `-p` is ambiguous with `cp -pR` and `tar -pxf`, so only the separate and `=` spellings are read. *(low · packages/core/src/tools/bounding.ts)* - [ ] **The object-key rule masks count keys such as `max_tokens`.** *Low.* Its plural folding reads `tokens` as @@ -1030,14 +1047,1084 @@ names, unless the entry names another. 1-second default.** *(low · packages/mcp)* - [ ] **The case-insensitive custody tests run in no CI job.** *Medium as reviewed, low in practice.* Ubuntu skips them and no macOS or Windows job runs `custody.test.mjs`; the local `pnpm run ci` on macOS runs them. A CI lane - needs a macOS job inside `ci`'s success aggregation. *(low · tools/cli-package/custody.test.mjs:830, .github/workflows/ci.yml)* + needs a macOS job inside `ci`'s success aggregation. *(low · tools/cli-package/custody.test.mjs:855, .github/workflows/ci.yml)* +- [ ] **CI no longer runs the real-GnuPG AEAD refusal.** *Low.* *(2026-11-05)* `ubuntu-latest` moved to ubuntu-26.04, + whose GnuPG reports SEIPDv1 for the AEAD-advertising fixture, so the custody test now reads GnuPG's own status line + and expects admission there. The real OCB refusal runs only where GnuPG picks OCB (the local `pnpm run ci` on + macOS); the synthetic status rewrites still cover the refusal logic on every runner, and the test's diagnostic + says which branch ran. The status line is also the only evidence: a GnuPG that wrote AEAD but reported `2 9` would + pass both the product and the test. **Re-check:** with the macOS lane above, or a packet-level `gpg + --list-packets` check of the ciphertext. *(low · tools/cli-package/custody.test.mjs, tools/cli-package/custody.mjs)* - **DECLINED — declaring `RELAVIUM_CLI_CANDIDATE_STAGING` in `turbo.json`.** The candidate build never runs through Turbo (`pnpm exec tsc` and `pnpm pack`), and Turbo 2's strict environment mode withholds an undeclared variable, so a Turbo build is always map-free; declaring it would let one become a candidate build. -**Not yet recorded here:** the review's unverified lows (its section F) wait for the consolidated file from that -review session, and two roadmap lows ("DONE entries whose body still describes the open state", "MVP items without an -annotation") need the entries they name. +**D64 round 1 on this correction (2026-11-04, at `f25fb83d`).** A cumulative round of four scoped reviewers and a +separate security sitting, with one adversarial verifier per medium-or-higher finding, read the whole correction ([D61 +checkpoint](phases/phase-2.5.5-hardening-and-remediation.md#d61-checkpoint--pr-96-merged-2026-11-04)). Its five +confirmed medium findings and the lows in the correction's own code and documents were fixed in the follow-up; these +lows are recorded: + +- [ ] **The argv rules are program-agnostic, so a value a boolean flag precedes is masked and calls that differ only + there share one journal identity.** `-u`/`--user` masks any colon-bearing value (`docker run -u app:app`, `date -u + +%H:%M`, `python -u C:\a.py`), and a boolean flag whose last word names a credential (`--ask-vault-password + site.yml`) masks the next argument, and the one after it when that is itself a credential flag (round 3); on a crash-resume where the model changes exactly that element, the slot replays + the committed result instead of refusing. Fix ideas: exclude drive paths and `+` formats from the pair shape, or + verb-first flags (`ask`, `skip`, `with`, `generate`). Documented in built-in-tools.md and effect-journal.md §11. + *(low · packages/core/src/tools/bounding.ts · round-1 F3, SEC-5)* +- [ ] **Common credential spellings are outside the argv rules:** curl short-option clusters (`-su alice:pw`, `-sSu`, + `-kualice:pw`), `-U`/`--proxy-user`, `--oauth2-bearer`, `--pass`, `--proxy-pass`, `--tlspassword`, openssl's + `-passin pass:…` and keytool's `-storepass`/`-keypass`. None is a regression; documented as a limit in + built-in-tools.md. *(low · packages/core/src/tools/bounding.ts · round-1 F6, SEC-4)* +- **DECLINED 2026-11-05 — moot: the `,`/`/` reading was removed (see the A4 entry above).** **A raw `?` or `#` after a URL password's `,` or `/` keeps the password public** (`https://user:pa,ss?1@host`); + the LLM backstop masks the comma spelling. Admitting them needs a public-query test on the tail like + `isPublicQueryContact`. Documented in built-in-tools.md. *(low · packages/core/src/tools/bounding.ts · round-1 F5, + DOC-2)* +- **DECLINED 2026-11-05 — moot: the `,`/`/` reading was removed (see the A4 entry above).** **The `,`/`/` reading stops at the first `@`; the main userinfo path masks to the last.** + `https://user:pa/ss@word9@host.example/x` keeps `word9` visible. Extend the mask to the last `@` before the next + `/`, `?`, `#`, whitespace or quote. *(low · packages/core/src/tools/bounding.ts · round-1 SEC-6)* +- **DECLINED 2026-11-05 — moot: the `,`/`/` reading was removed (see the A4 entry above).** **The `,`/`/` tail scan runs once per scheme occurrence.** On dense `scheme://x:y,` input at the 1 MiB CLI + output cap the projection costs about six times more than before (linear: each scan stops at 512 characters, so no + ReDoS). Track the next `@` monotonically across matches and pin the cost with an admitted-size case. *(low · + packages/core/src/tools/bounding.ts · round-1 F7)* +- [ ] **A binary buffer is counted at its `byteLength`, while its JSON form (`{"0":200,…}`) is about nine times + larger.** The seam refuses the buffer in `tool_result.result`, so the model never receives it, but one under the + ceiling is settled into the journal at the larger size, and an `ArrayBuffer` (JSON `{}`) is previewed needlessly. + *(low · packages/core/src/tools/bounding.ts:114 · round-1 F4)* +- [ ] **A dispatch result that holds inline media under the ceiling fails the next request:** the seam refuses raw + media bytes in `tool_result.result` (ADR-0031), and only an over-ceiling result becomes a media-free preview. + Pre-existing; builtins use the attachment rail. *(low · packages/core/src/tools/registry.ts · round-1 DOC-3)* +- [ ] **The approval preview scrubs the joined command with the text rules only,** so an argv-rule credential masked + in `toolInput` (`curl -u alice:pw`, `--password pw`) shows verbatim in `agent:approval_requested` and the prompt. + Pre-existing for `--password`; the correction widened the gap to `-u` and the last-word flags. Build the process + preview from the argv projection; an approval-path change, so it keeps that path's security sitting. *(low · + packages/core/src/tools/registry.ts:963 · round-1 SEC-3)* +- **DECLINED 2026-11-04 (maintainer) — enabling `enforce_admins` on `main`.** The branch protection does not bind + administrators, and the sole maintainer is one; the maintainer keeps the green-CI rule by practice, as + code-review.md and current.md state. *(low · repository settings · round-1 DOC-9)* + +**D64 round 2 (2026-11-04, at `6155c6dd`).** A new cumulative round of the same shape confirmed one medium finding, a +boolean flag whose last word names a credential consuming the credential flag after it (`--pass-credentials --password +`), and it was fixed with the round's lows but these two recorded limits: + +- **DECLINED 2026-11-05 — moot: the `,`/`/` reading was removed (see the A4 entry above).** **A URL password that begins with its `,` or `/` stays public** (`postgres://app:/Xk9@db`, + `https://user:,pw@host`): the port test reads the empty text after the colon as a port. Treating only a non-empty + digit run as a port needs the scheme check to cover the authority's trailing colon + (`https://xhttps://user:pw@h.example/`). Documented in built-in-tools.md. *(low · + packages/core/src/tools/bounding.ts · round-2 RED-2)* +- **DECLINED 2026-11-05 — moot: the `,`/`/` reading was removed (see the A4 entry above).** **A non-numeric port with an `@` in its path reads as a password** (`http://localhost:$PORT/users/@me` → + `http://[redacted]@me`), masking benign text and merging the journal identities of calls that differ there. Fix + idea: leave a placeholder-shaped port (`$NAME`, `${…}`, `<…>`) after a host-shaped name public. Documented in + built-in-tools.md. *(low · packages/core/src/tools/bounding.ts · round-2 RED-3)* + +**D64 round 3 (2026-11-04, at `07f653e7`).** One medium finding (round 2's fix let a flag-shaped password show) and a +second claim downgraded to low were fixed by making both readings add masks only; the round's lows were fixed with +them but these: + +- **DECLINED 2026-11-05 — moot: the `,`/`/` reading was removed (see the A4 entry above).** **A URL password with an `@` before its `,` or `/` keeps its tail after that `@`** + (`https://user:p@ss/word@vault/x` → `https://[redacted]@ss/word@vault/x`): the authority scan finds the `@` first, + so the `,`/`/` reading never runs. Pre-existing; documented in built-in-tools.md. Fix idea: when the userinfo before + the first `@` holds a password and the next delimiter is `,` or `/`, read on through the next `@` with the port + guard. *(low · packages/core/src/tools/bounding.ts · round-3 SEC-R3-3)* +- [ ] **The hash-object answer check `names.length !== batch.paths.length` is equivalent to `<` within one batch:** an + extra line is compared with the next batch's first file, so only a crafted answer across batches tells the two + apart, and no test builds one. *(low · tools/cli-package/candidate.mjs:353 · round-3 RT3-1)* + +**D64 round 4 (2026-11-05, at `7a12ee6f`).** One medium finding (the `,`/`/` reading skipped a nested URL) and a +downgraded claim (a masked `--flag=value` name shown) were fixed, with the round's lows; recorded with them: + +- [ ] **Eleven more test cleanups check a process with `alive()` and then kill it**, the race that failed + `consumer-lifetime.test.mjs` once on the Linux CI (ESRCH when the process exits in between; fixed there in + `93a8b033`): `tools/cli-bootstrap/witness.test.mjs:488,552`, + `tools/cli-package/process.test.mjs:252,409,424,495,523,581`, `produce.test.mjs:593,806` and `check.test.mjs:218`. + Replace each with a kill that ignores ESRCH only. *(low · the files named · round-4 CI-R4-1)* + +**Resolution (maintainer, 2026-11-05).** No fifth round: one careful pass removed the `,`/`/` URL reading, which could +not be shown to only add masks under the repeated projection, and showed the argv rules to be a superset of the base +([D61 checkpoint](phases/phase-2.5.5-hardening-and-remediation.md#d61-checkpoint--pr-96-merged-2026-11-04)). The +entries about that reading above are closed as moot, and A4 is recorded under **Redaction**. + +**The review's unverified first-pass lows, triaged at `f25fb83d` (2026-11-04).** Two agents checked all 86 against the +merged head: 81 still hold (78 entries below once duplicates merge), R1a-06 was fixed by `e8121aa4`, R1a-04 and R9a-05 +are entries above, and R6-04 and R7-12 were refuted. Line numbers are at `f25fb83d`. The two roadmap lows the review +named without entries were R12b-03 (DONE entries whose bodies described the open state: four, and three more the D64 round 2 found) and R12b-04 (entries the +MVP pulled without an annotation); both were fixed on 2026-11-04. + +*Release, custody and packaging tooling:* + +- [ ] `gnupg.mjs`'s header says no gpg call does key discovery. Only encryptArgs passes `--no-encrypt-to`, + `--no-auto-key-locate` and `--no-auto-key-retrieve`. The version, `--show-keys` and recipient-listing calls use + `isolated()` without those flags, and `gnupg.test.mjs` asserts the three flags for encryption only. *(low · + tools/cli-package/gnupg.mjs:67 · first-pass R1a-01)* +- [ ] encryptOnce in `custody.mjs` publishes the bytes from its first read of `ciphertext.gpg`, but GnuPG lists the + recipients of the file at that path, and the later byte-equality check cannot prove the listed file was those bytes: + a same-user swap restored before the second read passes. Listing a private copy of the verified bytes, as the + recipient path does, closes it. *(low · tools/cli-package/custody.mjs:384 · first-pass R1a-03)* +- [ ] The end-to-end secret-key refusal in `custody.test.mjs` uses an armored secret export, which `custody.mjs` + refuses by a byte check before any GnuPG call. The sec/ssb colon branch is tested only with synthetic records, and + no test gives real GnuPG a standalone binary secret export. *(low · tools/cli-package/custody.test.mjs:380 · + first-pass R1a-05)* +- [ ] The production recipient loader in `release-recipient.mjs` reads `recipient.asc` only for size and file kind and + discards the bytes, so the loaded recipient is never checked against its pins. It also accepts a history entry whose + primary equals its subkey, which encryptOnce refuses later as RECIPIENT_UNUSABLE. Comment the read as shape-only or + check the pinned subkey against the key at load. *(low · tools/cli-package/release-recipient.mjs:169 · first-pass + R1b-05)* +- [ ] `release-scope.test.mjs`'s reach controls skip every file named `*.test.*`, `*-fixture.mjs` or under test/, + tests/ or `__tests__/`, so a product or tooling file with such a name or location is invisible to them. The register + records only the lint-fixtures case (PLAN-9B2-R4-2). *(low · tools/cli-package/release-scope.test.mjs:53 · + first-pass R1b-06)* +- [ ] `release-recipient.mjs` requires the recipient directory to list exactly `pins.json` and `recipient.asc`. An + extra entry such as a Finder .DS_Store refuses with PRODUCTION_RECIPIENT_INVALID, the same code as bad JSON or + non-canonical bytes, and no runbook mentions it. Give unexpected entries their own fixed code. *(low · + tools/cli-package/release-recipient.mjs:163 · first-pass R1b-07)* +- [ ] In `release-scope.test.mjs` only the `custody.mjs` control decodes escapes and ignores case. The release-module, + fixture and loader-seam controls match case-sensitive regexes on raw text, so '`./Release-Custody.mjs`' or + './release-custody%2Emjs' pass the release-module control. The scan also skips .jsx, .sh, .ps1 and non-package + .json, which the test name and `packaging.md` do not say. *(low · tools/cli-package/release-scope.test.mjs:61 · + first-pass R2-03)* +- [ ] verifyRetainedCandidate in `release-binding.mjs` lists the candidate directory without lstat-ing it, so a + symlinked candidate directory is accepted; only its entries are lstat-ed. `packaging.md`'s Reuse paragraph names no + linked-directory refusal, though the sealer refuses a linked destination. *(low · + tools/cli-package/release-binding.mjs:339 · first-pass R2-04)* +- [ ] `release-binding.test.mjs` asserts verifyRetainedCandidate.length === 1 as proof the reuse API takes no GnuPG + binary or run port, but function arity does not limit an options object's keys. The closure test in + `release-scope.test.mjs` is the real control. Delete the assertion or replace it with a comment that points there. + *(low · tools/cli-package/release-binding.test.mjs:955 · first-pass R2-05)* +- [ ] The v1 release binding receipt carries toolchain, target and inputs, which are checked only for format and never + bound, and mapCheck and origin, which are shape-only until Step 9d. These fields freeze when v1 is released. + `packaging.md`'s sentence "Every field is a claim until Step 9d binds the last two" also conflicts with its table, + which gives Step 9d trust to packageVersion and source as well. *(low · tools/cli-package/release-binding.mjs:4 · + first-pass R2-06)* +- [ ] `production-failure.mjs`'s filter forwards any printable-ASCII line of up to 240 characters that starts with '✗ + CLI ' from captured child output. The header's "at most a repository-relative path" is therefore enforced by the + producers, not the filter, and the PACKED_CLI_ code tail has no length bound. Reword the header or tighten the + pattern. *(low · tools/cli-package/production-failure.mjs:9 · first-pass R2-07)* +- [ ] `seal.mjs` writes `package-test-receipt.json` in place with the wx flag and no temporary file, fsync or rename. + A hard kill mid-write leaves a truncated receipt that fails closed but blocks every re-seal with ALREADY_SEALED. + Write a sibling, fsync it and link it to the final name. *(low · tools/cli-package/seal.mjs:187 · first-pass R3-01)* +- [ ] `container.mjs` checks `finalized.json`'s outputs only by path and SHA-256 and its upstreamMaps only as an + integer, so a recovered container can carry different outputs[].bytes or upstreamMaps and still pass. `packaging.md` + already states that the metafile has no digest but does not mention these `finalized.json` fields. *(low · + tools/cli-package/container.mjs:104 · first-pass R3-02)* +- [ ] buildPrivateContainer in `container.mjs` reads every staged record and map into memory, up to 16 MiB each for up + to 256 maps, before it checks the 128 MiB total. `packaging.md` says the total is "refused before the archive is + assembled", which is true for the archive but not for the reads. Sum the lstat sizes before reading. *(low · + tools/cli-package/container.mjs:189 · first-pass R3-03)* +- [ ] Five negative cases in `seal.test.mjs` pass undefined as the expected refusal: the empty graph, a missing chunk, + a missing hash record, a missing metafile and a missing public document. assert.throws then accepts any exception, + including a TypeError from a broken fixture. Assert ENOENT at minimum, or give a missing expected file a fixed + PACKED_CLI_ code. *(low · tools/cli-package/seal.test.mjs:127 · first-pass R3-04)* +- [ ] No test pins finalizeCandidate's behavior on a re-run or after a crash. By reading it fails closed, because the + first run unlinks `dist/metafile-esm.json` and a half-replaced dist is refused, but a reordering could let a re-run + succeed on mixed state without a test failing. Add a second-finalize test and a half-replaced-candidate test. *(low + · tools/cli-package/finalize.mjs:527 · first-pass R3-05)* +- [ ] `seal.mjs`'s `finalizedPublicFiles` turns backslashes in metafile output keys into slashes, while `finalize.mjs` + refuses any key that is not exactly `dist/.js`. The two sides can therefore accept different keys. Drop the + `replaceAll` and add a `dist\index.js` case to `seal.test.mjs`. *(low · tools/cli-package/seal.mjs:66 · first-pass + R3-06)* +- [ ] The same size and count bounds are written as separate literals in `finalize.mjs`, `seal.mjs`, `manifest.mjs`, + `container.mjs` and `symbolicate.mjs`: 256 outputs, 8 MiB JS, 16 MiB map, 4 MiB metafile, 1 MiB `finalized.json` and + 128 KiB documents. A change to one copy does not reach the others. Export one frozen limits object and import it + everywhere. *(low · tools/cli-package/seal.mjs:68 · first-pass R3-07)* +- [ ] ustarEntry in `ustar.mjs` silently truncates a path longer than 100 bytes and would write a size of 8 GiB or + more past its 12-byte field. The container's read-back catches the bad header only as CONTAINER_PATH or + CONTAINER_UTF8. Refuse both cases in the writer and add negative writer tests. *(low · + tools/cli-package/ustar.mjs:37 · first-pass R4a-02)* +- [ ] The USTAR reader is tested only against archives from the repository's own writer (`ustar.mjs`, also used by + `ustar-fixture.mjs`). The one platform-tar test runs in the other direction: it extracts the writer's output with + tar. A symmetric error in field offsets or the checksum would pass. Feed a platform `tar --format=ustar -c` archive + through readUstar and inspectPackageArchive. *(low · tools/cli-package/ustar.test.mjs:65 · first-pass R4a-03)* +- [ ] `archive.mjs`'s gzip check validates only ID1, ID2, CM and FLG, so MTIME, XFL and the OS byte vary by host and + are bound into the receipt's sha256. The comment suggests a canonical header that the code does not enforce. Reword + the comment, or pin MTIME and document the OS byte. *(low · tools/cli-package/archive.mjs:54 · first-pass R4a-04)* +- [ ] The public package mode rule (`dist/index.js` 0755, every other file 0644) is written twice: once in + `archive.mjs`'s inspector and once in `ustar-fixture.mjs`. The fixture claims to produce the exact shape the + inspector accepts, so the two copies can drift without a test failing. Export one function and import it in the + fixture. *(low · tools/cli-package/archive.mjs:199 · first-pass R4a-05)* +- [ ] On failure or interrupt, `produce.mjs`'s emptyArtifact removes every entry in the caller's artifact directory, + not only the ones the producer created, so a file another process writes there during the build is deleted. This is + related to the recorded PLAT-9 entry "Two producers can share one artifact directory". Remove only what the producer + created. *(low · tools/cli-package/produce.mjs:94 · first-pass R4a-06)* +- [ ] When the local package check in `check.mjs` fails, the developer sees Node's uncaught-error dump of the whole + runCommand result, including unbounded child stdout and stderr, not the fixed '✗ CLI' lines `production-failure.mjs` + prints. `check.test.mjs` asserts only the exit code. Print only the fixed lines and rethrow a fixed code. *(low · + tools/cli-package/check.mjs:61 · first-pass R4b-03)* +- [ ] `process.test.mjs`'s grace-validation test passes String(graceMs) as the second argument to assert.throws, which + makes it the failure message, not a matcher. Any synchronous throw passes, including an unrelated TypeError. Match + the AssertionError code instead. *(low · tools/cli-package/process.test.mjs:569 · first-pass R4b-04)* +- [ ] The case-insensitive placement assertions in `paths.test.mjs` run only if an uppercased path happens to exist, + so on case-sensitive Linux CI they are skipped silently and no skip is reported. Probe case sensitivity explicitly + and call t.skip with a reason. *(low · tools/cli-package/paths.test.mjs:35 · first-pass R4b-05)* +- [ ] resolvePnpm's comment in `process.mjs` says it resolves the action's standalone executable, but only Windows + accepts a .exe. On POSIX, a non-JS pnpm binary is skipped and PINNED_PNPM_NOT_FOUND is thrown. The function also + reads the whole target file only to check a 128-byte prefix. *(low · tools/cli-package/process.mjs:78 · first-pass + R4b-06)* +- [ ] In `check.mjs`'s finally block, rmSync runs before `interrupts.dispose()`. If the removal throws after an + interrupt, dispose is skipped and the process exits 1 instead of 128 plus the signal number. Nest the removal in its + own try/finally. *(low · tools/cli-package/check.mjs:64 · first-pass R4b-07)* +- [ ] The consumer's cold-store `pnpm add` in `consume.mjs`, which builds better-sqlite3 and esbuild, depends on + runCommand's implicit 300-second default timeout. The bound is not named, justified or tied to `check.mjs`'s + deadline. Pass an explicit, named timeoutMs. *(low · tools/cli-package/consume.mjs:88 · first-pass R4b-08)* +- [ ] publicPackage in `binding.mjs` admits any scripts object in the public manifest, including preinstall, install, + postinstall and prepare, which npm runs on users' machines. The current apps/cli scripts are benign. Refuse the + install-lifecycle keys. *(low · tools/cli-package/binding.mjs:201 · first-pass R4b-09)* +- [ ] `prepack.test.mjs`'s 'tsup: false' case expects an exact MODULE_NOT_FOUND, but its fixture symlinks the real + root node_modules, so the result depends on tsup not being hoisted there. If it ever were, the test would fail + loudly rather than pass vacuously. Give the fixture an isolated empty node_modules. *(low · + tools/cli-package/prepack.test.mjs:181 · first-pass R4b-10)* +- [ ] decodeMappings in `source-map.mjs` accepts non-canonical VLQ. 'B' (negative zero) and zero-padded continuation + digits ('gA', up to seven digits) decode to the same value as 'A', so two byte strings for one map both pass, and + `source-map.test.mjs` pins the padded form as accepted. Refuse them, or document that canonical form is not + enforced. *(low · tools/cli-package/source-map.mjs:120 · first-pass R5b-02)* +- [ ] decodeMappings in `source-map.mjs` bounds memory only by the 16 MiB input size, not by line, segment or field + count. Every ';' allocates an array: a probe measured 87 MiB of retained heap for 2 MiB of ';', so roughly 700 MiB + at the bound. A run of digits grows the field array until a separator. Refuse more than five fields inside the loop + and cap lines at the generated line count. *(low · tools/cli-package/source-map.mjs:97 · first-pass R5b-03)* +- [ ] mapsOf in `symbolicate.mjs` maps only SourceMapError and SyntaxError to MAP_DECODE. A staged map that lacks + sources or names, or is null, throws a raw TypeError, which is printed as UNEXPECTED. mapsOf also never runs the + generated or original range checks itself. Validate the map shape and run both range assertions inside the try. + *(low · tools/cli-package/symbolicate.mjs:332 · first-pass R5b-04)* +- [ ] `symbolicate.test.mjs` asserts that the host Node has module.registerHooks. The root `package.json` admits Node + 22.13, and `packaging.md` says the producer needs 22.15, so the file fails on 22.13 and 22.14 instead of skipping. + CI uses Node 24 and is unaffected. Skip the capture cases without registerHooks, or state the 22.15 floor for the + tools tests. *(low · tools/cli-package/symbolicate.test.mjs:991 · first-pass R5b-05)* +- [ ] `source-map.test.mjs` and `symbolicate.test.mjs` run only in Ubuntu jobs. The now-required Windows job runs + neither, so `symbolicate.mjs`'s realpath dirname containment is not exercised against file:///C:/ URLs or native + path casing. A mismatch fails closed as a spurious refusal. Run both files on windows-latest, or unit-test the + frame-to-dist mapping with win32 paths. *(low · tools/cli-package/symbolicate.mjs:348 · first-pass R5b-06)* +- [ ] The symbolication frame record is produced inside the candidate's own process, and `frame-capture.mjs` calls the + global Error.captureStackTrace, so code the candidate loads first could forge call sites. The anchors show that the + runtime agrees with the maps, not that the runtime is honest. `packaging.md`'s Symbolication paragraph should say + the record is self-reported. *(low · tools/cli-package/frame-capture.mjs:46 · first-pass R5b-07)* + +*Engine and redaction:* + +- [ ] projectInput in `registry.ts` falls back to the unprojected arguments when the projection is not a plain record. + On the journal path, that is the value the journal identity must never see. It is unreachable today because the walk + preserves plain objects, but it fails open. Fail closed on the journal path. *(low · + packages/core/src/tools/registry.ts:1137 · first-pass R8-06)* +- [ ] makeSummary in `bounding.ts` cuts the one-line summary at 500 UTF-16 code units, so it can leave a lone high + surrogate in outputSummary. The byte preview is code-point safe and the summary is not. Cut on a code-point + boundary. *(low · packages/core/src/tools/bounding.ts:1717 · first-pass R8-07)* +- [ ] Review history is written into code and test names, for example 'PR95 review round N' describe titles, + SECRETS_B7/B8 labels and 'a review caught…' or 'A PR #96 review found…' comments, in `bounding.ts`, its tests, + `registry.test.ts` and `archive.mjs`. The PR #96 correction added more. Name tests by the property they assert and + keep the history in the register; the labels are mutation-control ids, so rename them together with their controls. + *(low · packages/core/src/tools/bounding.test.ts:1453 · first-pass R8-08)* +- [ ] ToolResultLimits are never validated at the engine boundary. A NaN maxBytes makes every result count as + truncated, while `bounding.ts`'s byte slicers never stop, so the preview is the whole text, head and tail both, + bounded only by lines. Only the default limits are wired today. Validate the limits as finite non-negative integers + at dispatch, or fall back to the default. *(low · packages/core/src/tools/bounding.ts:1818 · first-pass R8-09)* +- [ ] The LLM diagnostic backstop in `scrub-secrets.ts` does not recognize JSON-escaped URL slashes + (`https:\/\/user:pass@host`) or percent-encoded separators (`Bearer%20TOKEN`, `https%3A%2F%2F…`), so those + credentials pass unmasked. The new `diagnostic-secret-backstop.md` Limits bullet does not list these forms. Accept + `\/` as a separator or document the limit. *(low · packages/llm/src/scrub-secrets.ts:82 · first-pass R9a-03)* +- [ ] The Bearer/Basic label fragment with its LF/TAB tolerance and the label-repeat groups are hand-copied into three + regexes in `scrub-secrets.ts`: the header, standalone-label and label-run rules. A policy change must be repeated in + each copy, and the comments do not name the others. Compose the fragments from named strings, as `terminal-safe.ts` + does, and move the secrets mutation anchors along with them. *(low · packages/llm/src/scrub-secrets.ts:140 · + first-pass R9a-06)* + +*Test and control harnesses:* + +- [ ] `tools/causal-controls/provenance.mjs`'s `exactReplacement` applies a control's `after` text with a string + replacement, so a future `after` containing a `$` replacement pattern (`$&`, `$$`, `$n`, or a dollar sign followed + by a backtick or quote) would be written as different bytes than declared. No anchor uses those sequences today; the + fix is a replacer function (`text.replace(control.before, () => control.after)`) plus a `run.test.mjs` case. R7-10 + reports the same defect. *(low · tools/causal-controls/provenance.mjs:195 · first-pass R6-02)* +- [ ] `tools/fs-policy-controls/mutations.mjs` sets each control's expectedTests from a file-keyed nested ternary + whose final default is 19, so a new control on an unlisted file silently receives 19. A wrong guess still fails + loudly as declared-workload drift. Make `control()` require expectedTests, as mediaControl already does, and drop + the table. *(low · tools/fs-policy-controls/mutations.mjs:248 · first-pass R6-03)* +- [ ] The `tools/overflow-capture/check.mjs` header claims the real stdin/file/signal boundary, but caller-abort emits + a synthetic SIGTERM. Only the POSIX 'hang' mode sends a real signal, and cleanup-race cannot reach its race branch + today, so it passes either way. State in the header that cleanup-race is a future-regression guard, or drive + caller-abort with a real child.kill('SIGTERM'). *(low · tools/overflow-capture/check.mjs:1 · first-pass R6-05)* +- [ ] `tools/fs-policy-controls/README.md:144` says 'The complete 19-case temporary regression' in the present tense. + That control (MEDIA_TEMP_NATIVE_IDENTITY_PRECISION) now declares 23 cases, and line 5 says the temporary suite grew + to 23. Change it to 23-case, or mark the paragraph as history. *(low · tools/fs-policy-controls/README.md:144 · + first-pass R6-06)* +- [ ] `tools/secrets-controls/mutations.mjs` pins whole-file case counts by hand (CORE_PROJECTION_CASES, + REGISTRY_CASES, LLM_SCRUB_CASES), so any new test in `bounding.test.ts` breaks every coreProjectionControl phase + until someone edits the number. Add a helper that prints the current per-file counts from the harness's own + collection, and document a refresh step in the README. *(low · tools/secrets-controls/mutations.mjs:339 · first-pass + R6-07)* +- [ ] `tools/secrets-controls/README.md` (581 lines, one heading) is a per-round changelog that names retired control + IDs inline, so finding the current inventory means reading the whole file. Keep one current table (id, path, + workload, witness count), move round history to a dated section, and list retired IDs once. *(low · + tools/secrets-controls/README.md:327 · first-pass R6-08)* +- [ ] The `tools/secrets-controls/mutations.mjs` header still says 'Steps 3–5 and 7', although the file also declares + the PR95 and Wave 2 controls. `README.md:556` names `deferred-tasks.md` as plain text, not as a relative link. + Update the header and link `../../docs/roadmap/deferred-tasks.md`. *(low · tools/secrets-controls/mutations.mjs:1 · + first-pass R6-09)* +- [ ] Two negative-control loops accept any refusal: `tools/bundle-closure/closure.test.mjs`'s escaping-specifier loop + accepts any BundleClosureError, and `tools/cli-package/smoke/native-process.test.mjs`'s observed-group loop accepts + any thrown error. A mutation can therefore pass for the wrong reason. Use the existing refuses(value, code) helper, + and assert AssertionError or a message for the native vectors. *(low · tools/bundle-closure/closure.test.mjs:230 · + first-pass R7-04)* +- [ ] The test 'output cap counts UTF-8 bytes' in `tools/cli-bootstrap/witness.test.mjs` cannot tell bytes from UTF-16 + units, because its emoji payload exceeds the 4,096 cap either way. A probe showed that a units-counting mutant of + `process.mjs:65` still passes, and that 'é'.repeat(3000) separates the two. Switch to that payload. *(low · + tools/cli-bootstrap/witness.test.mjs:407 · first-pass R7-05)* +- [ ] `tools/bundle-closure/closure.mjs` refuses loader and code-generation capabilities only by identifier name or + string-literal element access. Computed forms such as globalThis['req'+'uire'], a template-literal key, or + ({}).constructor.constructor pass, although the comment claims it refuses 'acquisition/aliasing'. Document the guard + as best-effort drift detection, or refuse computed access on globals and .constructor chains. *(low · + tools/bundle-closure/closure.mjs:202 · first-pass R7-06)* +- [ ] `tools/bundle-closure/check.mjs` reports every non-BundleClosureError failure, including a TypeError or EACCES, + as 'cannot read valid build inventory — build the CLI first', with no message, which hides the real cause. Print + error.code or error.message for non-closure errors, and keep the build hint for ENOENT. `prepack.test.mjs:164` + matches the prefix. *(low · tools/bundle-closure/check.mjs:30 · first-pass R7-07)* +- [ ] `tools/budget-replay-compat/system-tools.mjs` hard-codes C:`/Windows/System32/tar.exe` and C:/Program Files/Git + for Windows, while `tools/cli-package/process.mjs` derives taskkill from SystemRoot. A Windows install on another + drive fails with a generic assertion. Derive the paths from SystemRoot and ProgramFiles, and name the variable in + the error. *(low · tools/budget-replay-compat/system-tools.mjs:9 · first-pass R7-08)* +- [ ] On Windows, the timeout kill in `tools/causal-controls/process.mjs` and `tools/cli-bootstrap/process.mjs` + reaches only the direct child, and both resolve only on 'close'. A grandchild holding the pipe can therefore leave + the join unsettled after the timeout. Use taskkill /T on win32, or resolve on 'exit' after a bounded drain. CI runs + these families on Ubuntu only, and the README admits descendant ownership is not asserted. *(low · + tools/causal-controls/process.mjs:20 · first-pass R7-09)* +- [ ] `tools/causal-controls/provenance.mjs` gives filesystem phases the operator's whole environment, with the real + HOME and TMPDIR. Secrets phases keep TMPDIR and redirect only HOME, and the bootstrap harness redirects TMP, TEMP + and TMPDIR. The README says only that the filesystem environment is 'byte-for-byte equivalent to its predecessor'. + Either redirect `TMP*` (and HOME for filesystem), or state the isolation choice per family in the causal README. + *(low · tools/causal-controls/provenance.mjs:174 · first-pass R7-11)* +- [ ] `tools/cli-package/smoke/README.md:101` says 'Step 8d adds an eighth required case', but 4b1f6f6b later inserted + two cases before it, and REQUIRED_SMOKE_CASES now lists actual-native-bootstrap-lifecycle as the tenth. Name the + case IDs instead of ordinals. *(low · tools/cli-package/smoke/README.md:101 · first-pass R7-13)* +- [ ] The `provenance.test.mjs` test titled 'owned helpers and redirected root directories cannot be symlinked' + symlinks only a helper file, so root-directory refusal in captureOwnedTools is untested. In + `native-process.test.mjs` the sentinel loop's `mode` variable is unused. Add a root-directory symlink case and drop + or use `mode`. *(low · tools/causal-controls/provenance.test.mjs:67 · first-pass R7-14)* +- [ ] `tools/fs-policy-controls/outcome.test.mjs` and `process.test.mjs` are dead re-export shims that no script runs. + The `require-packed-cli.test.mjs` listing guard covers only five tools directories, so a new unlisted test elsewhere + would never run. Delete the shims and extend the guard to every `tools/*` directory with `*.test.mjs`, exempting + db-sync. This consolidates R6-10 and R7-01. Nits: `ci.yml:250-251` repeats the same job heading twice. (Consolidates + R6-10 and R7-01.) *(low · tools/ci/require-packed-cli.test.mjs:606 · first-pass R13-08)* + +*Documentation and roadmap (trigger: Gate 0's stale-entry sweep, or the next change to the file):* + +- [ ] The rule that refuses an unsettled or member-collapsing journal projection is still stated in three reference + docs: `built-in-tools.md:212-218`, `tool-registry.md:258-264` and `effect-journal.md:409-418`. Commit 0826f30a had + to add the member-name clause to all three. Make `tool-registry.md` the one home and have the other two link to it. + The `error-handling.md` half of the finding was fixed by 964994c7, which moved the backstop's shape list to + `diagnostic-secret-backstop.md`. *(low · docs/reference/shared-core/effect-journal.md:409 · first-pass R10-03)* +- [ ] `docs/reference/cli/packaging.md` keeps a 905-character status line and per-step 'implementation boundary — Step + 7a…9b.2' sections. `effect-journal.md:389` ('PR95 increment tightened') and `built-in-tools.md:257` ('a documented + relaxation') also carry history in contract text. Keep one status line per reference doc, write the contract in the + present tense, and move step history to the phase register. *(low · docs/reference/cli/packaging.md:3 · first-pass + R10-06)* +- [ ] Commit 964994c7 put only the 2026-10-25 corrections of ADR-0105 and ADR-0106 into the dated '> Amended' form, + and those still sit under their old ### headings. ADR-0106 keeps 21 other dated ### correction sections and ADR-0105 + keeps 14, none in the blockquote form that `documentation-style.md` requires. ADR-0106's Decision also repeats 256 + KiB, 25 MiB and the 24-hour age that it says have canonical homes elsewhere. Convert them or document the + correction-section convention. The D64 round 1 on the correction (DOC-10) found the same: either convert the + sections or accept a dated `### — YYYY-MM-DD` correction section in documentation-style.md §7. *(low · + docs/decisions/0106-media-publication-requires-bounded-verified-bytes.md:220 · first-pass R10-07)* +- [ ] The Step 9b preflight ruling in `phase-2.5.5-hardening-and-remediation.md:10105` still says 'Windows has no + GnuPG on its image'. Lines 10179-10181 and 10346-10347 record that the image ships Git for Windows' MSYS2 gpg. Add a + dated note to the ruling pointing to the correction. *(low · + docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md:10105 · first-pass R12a-02)* +- [ ] The NI row of the phase 2.5.5 increment table (line 3707) still says its Windows clause 'closes after three + green advisory Windows runs', although the clause closed on 2026-10-26 (run 3 of 3 at line 10068, restated at + 10696). Add the closure to the row. *(low · docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md:3707 · + first-pass R12a-03)* +- [ ] `phase-2.5.5-hardening-and-remediation.md:9274` says the merged PR95 head moved the scrub-secrets pin 89→103, + and :9374 says the follow-up moved it 89→111. Git history settles it: a3e164b5 set the pin to 103 but left the + README at 89, and c64f3c39 moved the pin 103→111 and the README 89→111. Line 9374 should say 'pin 103→111, README + 89→111'. *(low · docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md:9374 · first-pass R12a-04)* +- [ ] `phase-2.5.5-hardening-and-remediation.md:9435` labels correction commits '(round N)', meaning the correction + made after round N, while the round table at :9454 lists the same commit as round N+1's reviewed head. Add '(the + correction after round N)' so readers do not match the label to the wrong round. *(low · + docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md:9435 · first-pass R12a-05)* +- [ ] The entries in `docs/roadmap/deferred-tasks.md` carry only round-review tags (R10-SCRUB-1, TCA-9A1-R13-1, + SEC-9B1-PS-2, COR-R4-2), and the detailed reports they come from are Git-ignored, so the tags cannot be resolved + from the repository. Give entries a stable register ID and keep the round tag as provenance. *(low · + docs/roadmap/deferred-tasks.md:521 · first-pass R12b-07)* +- [ ] `docs/roadmap/deferred-tasks.md:374` says the PR95 independent review's medium and 'three of its four' lows were + corrected in a3e164b5. LOW-2's change was reverted in ebbec1c0, and the same paragraph lists LOW-1 and LOW-2 as + approved deferrals, so the net is two of four (LOW-3, LOW-4). Reword it. *(low · docs/roadmap/deferred-tasks.md:374 + · first-pass R12b-08)* +- [ ] In `docs/roadmap/deferred-tasks.md`, the CR-70 entry is 'Scheduled (D67): v0.2.1' with 'the trigger above is + unchanged', but the trigger needs ADR-0096's recovery observed in a release that is not yet published. State the + observation the schedule waits for. `phase-2.6-conversational-authoring.md:353` still says 'three already-live bugs + gate it', which is stale since W7/CR-98, and links `deferred-tasks.md` without an anchor; point it at the CR-70 + entry. *(low · docs/roadmap/deferred-tasks.md:1143 · first-pass R12b-09)* +- [ ] `phase-2.6-conversational-authoring.md:8` says 'The phase scope, exit criteria and milestones are unchanged' + right after introducing the CLI MVP additions table, which adds MVP-A items. The M2.6-1 row and Required-ADR rows 9 + and 11 were also edited. Reword to 'the scope lines and exit criteria are unchanged; the CLI MVP additions table + adds items'. *(low · docs/roadmap/phases/phase-2.6-conversational-authoring.md:8 · first-pass R12b-10)* +- [ ] `phase-2.6-conversational-authoring.md:357` still lists 'The CLI e2e suite migrates the developer's real + ~/.relavium/history.db' as an open obligation tracked in `deferred-tasks.md`, but `deferred-tasks.md:2572` marks it + DONE 2026-07-13 (PR #76 review fold). Mark it closed in the phase record. *(low · + docs/roadmap/phases/phase-2.6-conversational-authoring.md:357 · first-pass R12b-11)* +- [ ] The 'CLI render-layer (ink component) test harness' entry in `docs/roadmap/deferred-tasks.md` is still '[ ] + Scheduled → 2.6.F', although 2.6.F is done and apps/cli now uses ink-testing-library; close it or re-point it. The + '[ ]' tooling-nits entry at :437 records its accepted tooling half inline but stays open for 19b's headings; split + it into a closed and an open half. *(low · docs/roadmap/deferred-tasks.md:2552 · first-pass R12b-12)* +- [ ] `docs/roadmap/current.md:34` dates Phase 1's M2 to 2026-06-21, but `phase-1-engine-and-llm.md:795` records M2 as + reached on 2026-06-16 (PR #27). 2026-06-21 is the last workstream merge. Write 'Complete (M2 2026-06-16; last + workstream 2026-06-21)'. *(low · docs/roadmap/current.md:34 · first-pass R11-03)* +- [ ] `docs/roadmap/current.md:69-74` states D64's security-tier bar without 'and no regression', which the register's + D64 amendment includes. `current.md:365` lists D65 as six splits plus #61 and omits G30's rows 13a–13c, although + `current.md:207-209` and the register's split table include them. The register's own summary at :10660 repeats 'six + recorded splits plus #61'. Copy the register's D64 wording and count seven splits. *(low · + docs/roadmap/current.md:69 · first-pass R11-04)* +- [ ] `docs/roadmap/current.md` still carries history that D66 says belongs to the registers: the D8 row's 'Recorded + here because the shipped state contradicted this table for four days', the D3 row's execution detail, the Wave 2 + merged block with commit SHA, and the Departures section's 'Discharged 2026-07-26; kept here because…'. Keep one + status line per closed decision with a register link. *(low · docs/roadmap/current.md:338 · first-pass R11-05)* +- **DONE 2026-11-04 (D64 round 3 follow-up).** Four in-page links in the record 964994c7 moved into the phase 2.5.5 + register pointed at `current.md`'s live sections instead of their moved copies: 'Execution order — Phase 2.5.5 + + Phase 2.6 … below', two 'Active now' links and the 'Wave 3 queue' link. They now point in-page + (`#execution-order--phase-255--phase-26-temporary`, `#what-is-active-now`, `#wave-3--clear-the-ground`). *(low · + docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md · first-pass R11-06, round-3 DOC-R3-4)* +- [ ] The repository has no mechanical check behind its 'moved verbatim' claims, such as the one in + `phase-2.5.5-hardening-and-remediation.md`'s 'Moved by the PR #96 review correction' note. The earlier D66 move + silently dropped 983 lines. Add a small tools/ script that normalizes link text and whitespace and requires every + moved line to appear in the destination. *(low · docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md:11125 + · first-pass R11-07)* + +## MVP-1 1b — recorded low findings (2026-11-05) + +D64 rounds 1 (at `5f2a0eb8`), 2 (at `60bc9727`), 3 (at `224b8f69`) and 4 (at `b70f9def`) of MVP-1's security +increment found nine, two, two and one medium findings, all fixed before the next round, and the lows below that +were not fixed with them. Round 5 (at `df685a52`) found no blocker, high or medium finding; its nine lows are the +next group below, and the separate security sitting's two lows (at `ab5e46e1`) are the last. +**Trigger for every entry:** the next change to the file it names. + +- [ ] **On the checkpoint path the engine checks a gate decision's fit only after passive admission.** *Low.* + *(round 1, gate-2)* A direct engine caller (the coming desktop and VS Code `resume_run`, or any embedder of + `@relavium/core`) that sends `input_provided` to an approval gate while a `context:` source is briefly missing, or + an unresolved effect row blocks the resume, gets the run written `run:failed` instead of `invalid_decision` with + the run left parked. The CLI refuses a misfit before any engine work, so it is not affected. **Re-check:** do the + fit check in `#prepareCheckpointGate` right after the pending gate is found, before `prepareResumeAdmission`, and + add a core test with a failing context. *(low · packages/core/src/engine/engine.ts)* +- [ ] **A long output value row soft-wraps to column 0.** *Low.* *(round 1, outputs-5)* Hard line breaks in an + output value are indented under its key, but a single row wider than the terminal wraps to column 0 of the next + visual row, where padded text can look like a summary row. The comment now says so. **Re-check:** wrap value rows + at the terminal width with the continuation indented (the TUI path knows the column count), or bound a row's + width. *(low · apps/cli/src/render/run-outputs.ts)* +- [ ] **One damaged row in any non-terminal run stops every reconcile and outbox drain.** *Low.* *(round 1, + liveness-2)* `listInterruptedRuns` throws `CorruptRunEventError` for the whole listing, so `reconcile()` rejects, + and the CLI's best-effort catch hides it: a crashed run stays `running` on every Home open and `run`/`gate` start, + and a held terminal is never drained. `status` already isolates per run (`readPerRunOrDegrade`). The dependency + predates 1b; 1b's liveness now relies on it. **Re-check:** isolate failures per run in the interruption listing + and name an unreadable run once. *(low · packages/db/src/run-history-store.ts)* +- [ ] **The compiled-CLI MCP orphan probe reads the developer's real keychain.** *Low; test-side.* *(round 1, + keys-6)* Since the key pre-flight, `agent run` in `tools/cli-smoke/check.mjs` asks the keychain before the env + var, so a stored Relavium Anthropic key shadows the synthetic one, and on macOS a keychain prompt could hold the + probe until its step timeout. Nothing is sent: consent refuses the first call and the server never answers the + second. **Re-check:** run the probe's bundle under the keyring-denied module guard the package smoke uses, so the + synthetic env key is the only one resolved. *(low · tools/cli-smoke/check.mjs)* +- [ ] **Under the home directory, `/export` with no project writes into the global `~/.relavium/workflows/`.** + *Low.* *(round 1, export-6)* Project discovery walks up to the first `.relavium/`, and under `$HOME` that is the + global data directory, so the scaffold and its transcript land there and appear as a project workflow from every + other non-project directory under home. The reference now states this. **Re-check:** exclude the global + configuration directory from project discovery for `/export` (writing `<cwd>/.relavium/workflows/` instead), or + ask before writing there. *(low · apps/cli/src/chat/export.ts, apps/cli/src/config/paths.ts)* + +- [ ] **A `/clear` after a `/models` rescue rebuilds on the agent the session left.** *Low.* *(round 2, keys-1)* A + `chat-resume` deferred for a missing key, rescued with `/models`, then `/clear`ed, rebuilds on the resumed + snapshot's agent: the `/clear` rebuild closure captures `built.agent` once, and a reseat does not update it. The new + pre-flight then ends the REPL with a refusal naming the abandoned provider. Nothing is lost; the printed + `chat-resume` reopens on the rescued provider. The root cause predates 1b: ADR-0059's ADR-0112 amendment rules that + an explicit model choice survives a rebuild, and that is not implemented yet. **Re-check:** with ADR-0112's rebuild + rule, have the `/clear` rebuild take the agent of the session being cleared. *(low · apps/cli/src/commands/chat.ts)* +- [ ] **A direct engine caller can send a payload with `approved` or `rejected`.** *Low.* *(round 2, gate-1)* The fit + check reads the decision kind only, and the gate's output is `decision.payload ?? { decision }`, so an embedder that + approves an approval gate with `payload: { decision: 'rejected' }` routes downstream on the payload. The CLI cannot + send one (only `--input` carries a payload), and the spec now says the check reads the kind only. **Re-check:** + refuse a payload on an `approved`/`rejected` human-gate decision in `#assertDecisionFitsGate`, as budget gates + already do. *(low · packages/core/src/engine/engine.ts)* +- [ ] **Run outputs no longer join a query name split by whitespace.** *Low.* *(round 2, outputs-3)* Dropping the CLI's + diagnostics-only residual (round 1, outputs-2) also dropped its split-query-name probe, so an output holding + `?access_ token=<value>` (a hard-wrapped URL, say) prints the value; round 1's head masked it. The shared scrubber + does not join query names by design, and its query rule is private, so a per-leaf probe would be a second copy of a + security rule. commands.md states the limit. **Re-check:** export a query-only probe from the shared scrubber and + apply it per leaf. *(low · apps/cli/src/render/run-outputs.ts, packages/llm/src/scrub-secrets.ts)* +- [x] **On a resumed run the outputs note names `<runId>`.** *Low; cosmetic.* *(round 2, outputs-5)* **Fixed + 2026-11-06** (the MVP-1 1a milestone review, runid-2): the run view takes the run id from a resumed stream's first + event, so the footer, the summary's id line and this note all name it. The original entry follows. The TUI final + summary passes `state.runId`, which only `run:started` sets, so after `relavium gate` or `budget resume` a shortened + or masked block says `relavium logs <runId> --json`; the plain renderer names the id. **Re-check:** fold the + terminal's `runId` into the view state (or carry it on the summary) and pass that. *(low · + apps/cli/src/render/tui/final-summary.ts, apps/cli/src/render/tui/run-view-model.ts)* + +- [ ] **A held terminal the log has already moved past is never written, and its run is never settled.** *Low.* + *(round 3, liveness-1)* The drain retries an outbox entry whose sequence number is at or below the run's durable + last one, the store's append guard refuses it every time, and the run counts as held, so the repair skips it on + every start. It takes three rare events in a row: a held terminal from a gate deadline, a drain that cannot land + it during the next `relavium gate`, and that resume then dying after writing past it. The run stays `running`, and + `status` keeps calling it held. **Re-check:** in `#drainTerminalOutbox`, drop an entry whose sequence number is at + or below the log's last one (it can never land) instead of holding its run, and add a CLI reconcile test. *(low · + packages/core/src/engine/engine.ts)* +- [ ] **Only `relavium status` says the terminal outbox cannot be read.** *Low.* *(round 3, liveness-2)* An outbox + that exists but cannot be read (root-owned after a `sudo` run, a directory at its path) holds every run, so no + start or Home open settles anything until it can be read. Holding is the safe choice, and the reference now says + so, but `run`, `gate`, `budget resume` and the Home swallow the reconcile result and print nothing. **Re-check:** + have `reconcile()` report an unreadable outbox, and print one fixed line on stderr and in the Home naming the + file. *(low · packages/core/src/engine/engine.ts, apps/cli/src/history/reconcile.ts)* +- [ ] **An export larger than the parser accepts is never recognized as the session's own.** *Low.* *(round 3, + export-2)* Past the parser's 2,097,152-character bound, a later `/export` of the same session writes another + file (`<slug>-2`, …) rather than rewriting the first, and the run hint it prints names a file `relavium run` + refuses. It needs a session whose attachments or shell output reach that size. **Re-check:** refuse to write a + scaffold over the bound, pointing at `relavium chat-export <sessionId> --out <path>`. *(low · + apps/cli/src/chat/export.ts)* +- [ ] **An `/export` before the first completed exchange is not found by a later one.** *Low.* *(round 3, + export-4)* With no title yet, the export's id is `exported-session-<sessionId>`; once a title exists, a later + `/export` searches only the title's slug, so it writes a second file and the empty first one stays in the catalog. + **Re-check:** also search the fallback id for the session's own export (renaming it on rewrite), or refuse + `/export` while there is nothing to export. *(low · apps/cli/src/chat/export.ts)* + +- [ ] **A 0.1.x run still executing when a newer start reconciles is settled as crashed.** *Low.* *(round 4, + liveness-1)* `relavium@0.1.1` takes no run lease, so a run it is still executing has no lease row, and the first + `run`, `gate`, `budget resume` or Home open of a version with 2.6.G reconcile claims it at once and writes + `run:failed{internal}`. The 0.1.1 process then hits the sequence it expected, aborts, and its own `run:failed` + lands behind the first, because 0.1.1's store has no terminal refusal. It needs an older executable still + running on the upgraded `~/.relavium`, which [database-schema.md](../reference/shared-core/database-schema.md) + calls unsupported ("active downgrade execution"). **Narrowed 2026-11-06 (maintainer ruling, PR #97 review):** + reconcile now leaves a run younger than one lease TTL alone (ADR-0079 §3's 2026-11-06 note), so a 0.1.x run is + safe for its first minute; one still executing after that is settled as before. **Re-check before the v0.2.0 + release:** the release notes tell users to finish or cancel 0.1.x runs before upgrading. *(low · + packages/core/src/engine/engine.ts)* +- [ ] **A project config key with a line break can fake a line under an `/export` refusal.** *Low.* *(round 4, + export-1)* A config error names the keys it refused, and a REPL command's output keeps its line breaks (only + terminal controls are stripped), so a quoted `project.toml` key holding a newline and a made-up `exported session + to …` line prints that line under `export failed:`. It needs a hostile config swapped in after the chat started. + `doctor` and the top-level error renderer already print such a message on one line. **Re-check:** print the + refusal through `sanitizeInline`, or name a config error by its code only, and check the other REPL commands + that show config text. *(low · apps/cli/src/commands/chat.ts)* +- [ ] **Two kinds of output name the name probe does not read as an API-key header.** *Low.* *(round 4, + outputs-2)* The probe reads a name as serialized JSON, where a tab or line break inside the key becomes `\t` or + `\n`: the escape's letter hides the header name's boundary, so an opaque value under `"\nx-api-key"` or + `"x-api-key\t"` is shown (it was before round 4 too). And a name the scrubber masks up to its end is not a label + by design, so an opaque value under `Bearer x-api-key` is shown, as it was before the member view. Both need a + key no real header block produces cleanly. **Re-check:** turn control characters in the key into spaces before + the probe, and pin both escapes. *(low · apps/cli/src/render/run-outputs.ts)* +- [ ] **The VS Code gate event cannot tell an input gate from an approval gate.** *Low; reference only.* *(round 4, + gate-1)* `relavium.onHumanGatePending` carries `{ runId, nodeId, promptText, timeoutMs }`: no `gateType` to + choose between Approve and an input field, and no `gateId` to resume a run with more than one gate pending. + Nothing implements it yet. **Re-check:** add both when the VS Code surface is built. *(low · + docs/reference/vscode/extension-api.md)* + +- [ ] **The exit-code table says every exit 5 is retried, but the next start drops a raced one.** *Low; reference + only.* *(round 5, liveness-1)* The table row and the note on what 5 and 6 mean say a held terminal is retried on + the next start. When a concurrent reconcile has already written the run's terminal, the next start's drain finds + the run closed and drops the entry instead. The exit-5 note in the same section states this, and the remedy the + two lines give (re-check after the next invocation) still gives the right answer. **Re-check:** qualify both + lines ("…or drops it when the log already ends in a terminal"). *(low · docs/reference/cli/commands.md)* +- [ ] **The `reconcile()` docstring leaves out two kinds of run it does not settle.** *Low; comment only.* *(round + 5, liveness-2)* A run whose repair write throws is skipped and retried on the next reconcile, and a run parked on + a media job is resumable and left alone like one parked at a gate. The docstring names neither; nothing it states + is false. **Re-check:** add "a run whose repair write failed" to the runs left for a later reconcile, and say + "parked at a gate or on a media job". *(low · packages/core/src/engine/engine.ts)* +- [ ] **Before a `/models` rescue, `/clear` in a resumed session without a key ends the REPL.** *Low.* *(round 5, + keys-1)* A message and `/compact` consult the send guard and are refused with a pointer to `/models`; `/clear` + does not, so it rebuilds on the resumed session's keyless agent, the key pre-flight refuses that, and the REPL + ends with a remedy naming only the missing key and the same `chat-resume`. Nothing is sent or lost, and + chat-session.md says a `/clear` whose build fails ends the REPL. Round 2's keys-1 is the same `/clear` after the + rescue. **Re-check:** in `clearSession`, check the send guard first, as `compactHistory` does. *(low · + apps/cli/src/commands/chat.ts)* +- [ ] **The desktop gate overlay reference gives an input gate no Reject.** *Low; reference only.* *(round 5, + gate-1)* routes-and-screens.md gives an `input` gate an input field only, while the engine accepts `rejected` there + and the VS Code reference and `relavium gate --reject` offer it. `apps/desktop` holds only a README. **Re-check:** + with round 4's gate-1, when the desktop surface is built: "an input field and Reject for an `input` gate". *(low · + docs/reference/desktop/routes-and-screens.md)* +- [ ] **An output name that is a secret shape still masks its value when a delimiter or control character follows + the shape.** *Low; fails closed.* *(round 5, outputs-1)* Round 4's check keeps the value only when the name's own + scrub ends in `[REDACTED]`. The scrubber's Basic/Bearer token cannot end on `,` `;` `}` `"` `'` or `\`, and a + trailing tab or line break is written as `\t` or `\n` in the serialized view, so a value under `Basic plan,`, + `"Basic auth"` or `Basic plan\r` (the last header of a naively split CRLF file) is masked whole, with the masked + note. **Re-check:** strip trailing delimiters and control characters from the name's own scrub before the end + check, rerun the round-4 oracle with these names, and pin `Basic plan,` and `Basic plan\n`. *(low · + apps/cli/src/render/run-outputs.ts)* +- [ ] **An output name that already ends in `[REDACTED]` masks its value, through an unpinned clause.** *Low; fails + closed.* *(round 5, outputs-2)* The `own !== key` clause in `namesCredential` makes `Bearer [REDACTED]` or + `…?token=[REDACTED]` (an already-redacted log line used as a key) a label, while `Bearer token` keeps its value. + Dropping the clause passes every test. **Re-check:** decide what the clause is for, and pin the choice with a + `{ v: { 'Bearer [REDACTED]': 'x' } }` case. *(low · apps/cli/src/render/run-outputs.ts)* +- [ ] **Two output names that mask to the same text show only the later value.** *Low.* *(round 5, docs-1; extends + round 2's key collision)* Since round 4 a name that is a secret shape keeps its value, so `Basic plan` and `Basic + support` in one object both become `Basic [REDACTED]` and only `Basic support`'s value is shown, in the first + member's position; commands.md says such a name's value is kept. The masked note points at the full record. + **Re-check:** suffix a colliding name (`Basic [REDACTED] (2)`, as core's bounding walk reports collisions) or mask + every colliding value, and pin a `Basic plan` + `Basic support` case. *(low · apps/cli/src/render/run-outputs.ts, + docs/reference/cli/commands.md)* +- [x] **`relavium list` prints a workflow's name without stripping terminal controls, and `/export` now feeds it + session titles.** *Low for 1b.* *(round 5, export-1)* **Fixed 2026-11-06** (maintainer: fix it before the + MVP-1 milestone review): `list` passes every catalog field it prints (slug, name, tag; the parse reason already + did) through `sanitizeUntrustedInline`, pinned by a test whose name, tag, file-name slug and agent name carry + `ESC ] … BEL`, `ESC [2J` and U+202E. The original entry follows. The scaffold's `name` is the raw session title (only its + whitespace is collapsed, so ESC and BEL survive), and since MVP-A3 `/export` writes into `.relavium/workflows/`, + which `list` scans and prints raw; a piped first message holding an OSC or CSI sequence then reaches the terminal + on `relavium list`. The sink predates 1b: any committed workflow name, a tag and an invalid file's stem print raw + the same way, and a cloned repository reaches it more easily. **Re-check:** pass `entry.name`, each tag and an + invalid entry's slug through `sanitizeUntrustedInline`, as `list.ts` already does for `entry.error`, and pin it + with an `ESC ] … BEL` name. *(low · apps/cli/src/commands/list.ts)* +- [ ] **The canonical export mapping still says the export file is named by session id and that ids are not + unique.** *Low; reference only.* *(round 5, export-2)* The live `/export` names its file after the first free + `<slug>`, `<slug>-2`, … and writes that id into the file, and commands.md's chat-export section now links to this + mapping. chat-session.md's `/export` row states the live behaviour correctly. **Re-check:** qualify the sentence: + `relavium chat-export`'s default filename uses the session id; the live `/export` names its file after the id it + takes. *(low · docs/reference/contracts/agent-session-spec.md)* + +- [ ] **A live run whose process is stopped for longer than the lease TTL is settled by the next start.** *Low; + liveness only.* *(security sitting, sec-1)* A run whose owner is suspended for more than 60 seconds (SIGSTOP, or + Ctrl+Z on the plain driver) has an expired lease, so the next `run`, `gate`, `budget resume` or Home open takes + it over and writes `run:failed{internal}`. On Linux, timers do not count suspend, so after a laptop resume the + owner's heartbeat can lag by up to 20 seconds, and a scheduled run that starts at wake can be the trigger. On + macOS the monotonic clock counts sleep, so an overdue heartbeat fires on wake. Integrity holds: the takeover + bumps the generation, so the woken owner is fenced (exit 6) and there is one terminal; a tool call between wake + and the fence is the window ADR-0079 §6 accepts. **Re-check:** store the owner's pid and boot id on the lease row + and skip a same-host takeover while that pid is alive, as an ADR-0079 amendment. *(low · + packages/core/src/engine/engine.ts, packages/db)* +- [ ] **A `.relavium` that is a symlink sends the `/export` transcript to the link's target.** *Low.* *(security + sitting, sec-2; extends round 1's export-6)* Project discovery follows the link, and the catalog's containment + check compares against the link's own target, so `/export` creates `<target>/workflows/<slug>.relavium.yaml` with + the full transcript, `@`-file and `!`-shell bodies included. A cloned repository whose `.relavium` points at a + world-writable directory another local account prepared would receive it. Nothing is overwritten: a new file is + written exclusively, and the rewrite replaces only a regular file carrying this session's marker. The same + discovered directory already receives `create`'s writes. **Re-check:** refuse a project config directory whose + real path is outside the directory that contains it, or ask before writing through a linked `.relavium`. *(low · + apps/cli/src/chat/export.ts, apps/cli/src/config/paths.ts)* + +## MVP-1 1a milestone review — recorded low findings (2026-11-06) + +The milestone review of MVP-1's UX increment 1a (at `624bb2e7`, the `relavium list` sink fix included) found four +medium findings, all fixed: a budget pause's summary pointed at `relavium gate`, `status <runId>` showed a finished +run's open gate as pending, the run-path tool pre-flight grew with nodes times grant length before the node ceiling, +and the chat's catalog-ceiling output cap ran into ADR-0082's 120-second absolute deadline. Round 2 (at `d8762a23`) +found one more medium, also fixed: the round-1 run-id latch let a resumed run's summary point a gate restored from its +checkpoint at `relavium gate`, although such a gate can be a budget gate `gate` declines. Round 2's wording and +`status` test lows (history-9/docs-3, history-10 in part, history-11, security-2/docs-4) were fixed with it, except +that the pending gate's node-id sanitizer stayed unpinned. Round 3 (at `32d1a8a7`) found one more medium, also fixed: +round 2's fix sent such a gate to `relavium status` for the command that continues it, which `status` printed only for +a budget gate; it now prints one for every pending human gate too, and the node-id sanitizer is pinned with it +(history-14, docs-2, security-1). Round 4 (at `8e7f41f2`) found one more medium, also fixed: `status <runId>` printed +an approved gate's step as `stopped` under its completed run, because no `node:completed` closes a gate's step row; +it now reads the decision from the log and prints `completed`. Round 5 (at `3354dda1`) found no blocker, high or +medium finding, so 1a is accepted; its docs-1 low, two entries below that still called `status`'s commands complete, +is fixed in this register. The lows below were not fixed. **Trigger for every entry:** the next change to the file it +names. + +- [ ] **A fenced run's summary can still point at `relavium gate`.** *Low.* *(runid-3)* When an inline gate prompt + delivers a stale `run:paused` after the run was fenced (exit `6`), the TUI summary takes the paused branch while + stderr says another process owns the run. Following either is harmless (`gate` is idempotent or refused). + **Re-check:** pass the exit disposition to the summary and point a fenced run at `relavium logs`. *(low · + apps/cli/src/render/tui/final-summary.ts)* +- [ ] **`list --runs` loads every column of every run.** *Low.* *(history-2)* The listing reads full run rows, + inputs and outputs included, to print five fields, and offers no bound; `| head` instead exits `9` under + `pipefail`. **Re-check:** a projection-only recency query, and a `--limit N` option. *(low · + apps/cli/src/commands/list.ts, packages/db)* +- [ ] **No dispatch-level test passes `--runs` or a `<runId>` through.** *Low; test-side.* *(history-6)* A refactor + that dropped either argument in `dispatch.ts` would turn `status <runId>` back into the active listing, or + `list --runs` into the catalog, with every test green. **Re-check:** one `executeCommand` case per argument. + *(low · apps/cli/src/commands/dispatch.ts)* +- [ ] **Commit `e4783366` credits the 85th `run.test.ts` case to the run-id test.** *Low; history only.* + *(docs-2)* The case that moved the secrets workload from 84 to 85 is the 2.6.A pre-flight test from `97714f9d`. + Commits are immutable; the pins themselves are right. **Re-check:** if the workload pins gain a provenance note, + credit `97714f9d`. *(low · tools/secrets-controls/mutations.mjs)* +- [ ] **`mcp_call` with no declared server passes the run-path pre-flight.** *Low.* *(preflight-2)* `relavium run` + wires the MCP arm only when an inline agent declares `mcp_servers`, so an agent granting `mcp_call` without one + passes and fails with `tool_unavailable` at its first call. built-in-tools.md now says so. **Re-check:** give the + pre-flight a conditional `mcp` arm and refuse `mcp_call` when no server is declared. *(low · + packages/core/src/validate-catalog.ts, apps/cli/src/commands/run.ts)* +- [ ] **`gate` and `budget resume` do not re-run the tool pre-flight.** *Low.* *(preflight-3)* Only a run started + before 1a, by `relavium@0.1.x`, can carry a grant the pre-flight would refuse; resuming it fails with + `tool_unavailable` at the tool's first call, after the user decided the gate. built-in-tools.md now says so. + **Re-check:** call the pre-flight beside the catalog check in `gate.ts`, before the MCP connect and the engine + build. *(low · apps/cli/src/commands/gate.ts)* +- [ ] **`/export`'s run hint names a scaffold the pre-flight refuses.** *Low.* *(preflight-6)* A chat that used an + egress or os tool exports a workflow granting it, and the printed `relavium run <id>` exits `2` naming the tool. + **Re-check:** check the exported workflow against the run profile and replace the hint with a note naming the + tools, or say so in chat-session.md's `/export` row. *(low · apps/cli/src/commands/chat.ts)* +- [ ] **The larger chat cap moves auto-compaction earlier and grows budget-shaped thinking.** *Low; recorded in + ADR-0101's 2026-11-06 note.* *(budget-3, budget-4)* Pre-send compaction reserves the effective cap, so on a + 200k-window model with a 64,000-token ceiling it starts at about 68% of the window instead of 80%; and on a model + whose effort tiers are budget-shaped, the thinking budgets derive from the effective cap and grow about 8–16× on the + 64,000-ceiling models from low to max (51,200 tokens at max), less on `claude-opus-4-1`. + **Re-check:** reserve the configured estimate rather than the inserted default for the compaction decision, and + derive budget-shaped tiers from a fixed reference cap. *(low · packages/core/src/engine/agent-session.ts, + packages/llm/src/adapters/anthropic.ts)* + +- [ ] **The summary's budget command has no decision flag.** *Low.* *(runid-6)* The TUI summary prints + `relavium budget resume <runId> --gate <gateId>`, and copied as printed it exits `2` asking for `--approve-amount` + or `--abort`. The plain renderer and `status` print a command for each decision (history-16 covers the `secret` + input case). **Re-check:** keep the allowance context on + the view's budget notice and print `budgetResumeHints`. *(low · apps/cli/src/render/tui/final-summary.ts)* +- [ ] **The summary embeds a run id in a command without the shell-inert check.** *Low.* *(security-3)* Only the + budget command is checked; the `logs`, `gate` and `status` lines put the sanitized id in backticks, so a non-UUID id + from a tampered `history.db`, resumed by quoting it, prints a command that differs from the id or parses as a flag. + **Re-check:** apply `resumeCommandBase`'s test to the run id and fall back to display text. *(low · + apps/cli/src/render/tui/final-summary.ts)* +- [ ] **A run whose terminal is held still lists its open gates.** *Low.* *(history-8)* The finished-run guard reads + the projection, so a run held at exit `5` while paused shows the FINISHED banner beside a pending gate and a + copyable `budget resume`, and `--json` pairs `terminalHeld: true` with non-empty gates. Acting on them answers + `nothing to resume`. **Re-check:** treat `terminalHeld` as finished for the gate guard and the `stopped` mapping. + *(low · apps/cli/src/commands/status.ts)* +- [x] **Run and step status strings print raw.** *Low.* *(history-12, security-1)* `status` and `list --runs` + sanitize every history identifier but not the status column; only the SQLite CHECK keeps it clean, and a file + written with `ignore_check_constraints` gets past it. **Fixed in the PR #97 review correction (2026-11-06):** the + run and step status in `status`, the `--runs` row and the catalog line's `[last: …]` in `list`, and the run id and + status in `logs`'s header now get the one-line floor, each pinned by a forged-status or forged-id test. *(low · + apps/cli/src/commands/status.ts, apps/cli/src/commands/list.ts, apps/cli/src/commands/logs.ts)* +- [ ] **`status`'s own `completed` terminal entry is unpinned.** *Low; test-side.* *(history-10, remainder)* Removing + `'completed'` from `TERMINAL_STATUSES` in `status.ts` passes every test, while a completed run with a stale outbox + entry would then read held. **Re-check:** a completed run with a stale outbox entry expecting `terminalHeld: false`. + *(low · apps/cli/src/commands/status.test.ts)* +- [ ] **The CLI's issue count stops at ten.** *Low.* *(preflight-10)* The pre-flight reports ten nodes and one summary + issue, and the CLI prints the first issue with `(and 10 more issues)`, so the summary holding the real count never + shows. **Re-check:** render the summary issue, or put the total in the first message. *(low · + apps/cli/src/commands/drive.ts, packages/core/src/validate-catalog.ts)* +- [ ] **The pre-flight still scans the agent list once per node.** *Low.* *(preflight-11)* The per-grant memo is + consulted after `inlineAgent`'s linear find, so an adversarial 2 MiB file with tens of thousands of agents and nodes + stalls about 2.3 s before the node ceiling, roughly doubling the time to its exit-`2` refusal. **Re-check:** build an + agent map once per call. *(low · packages/core/src/validate-catalog.ts)* +- [ ] **The pre-flight's bounded test passes without the memo.** *Low; test-side.* *(preflight-12)* Dropping the + per-grant memo keeps every test green while round 1's 944 KB file then takes about 3 s in the pre-flight. + **Re-check:** count iterations of a grant across many nodes rather than measuring output. *(low · + packages/core/src/validate-catalog.test.ts)* +- [ ] **Nothing pins `agent run`'s `oneShot`.** *Low; test-side.* *(budget-6)* Deleting `oneShot: true` from + `agent-run.ts`, or the builder's guard on the deadline, keeps every test green, though a one-shot would then send + the catalog ceiling with a long deadline. **Re-check:** an `agent run` case asserting no cap on the wire and the + default deadline, and a builder case with `oneShot` and an authored `max_tokens`. *(low · + apps/cli/src/commands/agent-run.test.ts, apps/cli/src/chat/session-host.test.ts)* +- [ ] **`officialEndpointOf` is untested.** *Low; test-side.* *(budget-7, docs-2; round-1 budget-5's other half)* + Making it report a custom endpoint, or a throwing resolver, as official keeps every test green; unreachable for + Anthropic today, which refuses a custom base URL. **Re-check:** the two `buildChatSession` cases expecting no cap + on the wire. *(low · apps/cli/src/chat/session-host.test.ts)* +- [ ] **A chat reply longer than 30 minutes still times out.** *Low.* *(budget-8)* The 30-minute cap is below 25 + tokens per second for every ceiling above 45,000, so a healthy reply that streams past it fails as a billed, + rolled-back timeout; ADR-0101's 2026-11-06 note states both bounds without the trade. **Re-check:** state the + residual, or cap the chat's `max_tokens` at what the deadline can stream so the reply ends as `length`. *(low · + apps/cli/src/chat/session-host.ts, docs/decisions/0101-configured-output-estimates-apply-only-when-the-wire-is-uncapped.md)* +- [ ] **The scaled deadline also bounds a stall, and the stall is retried.** *Low.* *(budget-9, security-4)* A + pre-content timeout is retryable and the session primary gets two attempts, so a piped `chat --json` on an endpoint + that keeps the stream open without content waits about 60 minutes per call (each fallback entry adds its own), instead + of about 4. Each attempt stays bounded and SIGINT cancels it. **Re-check:** keep the default deadline until + the first content chunk, or for non-interactive drivers, and say so in the note. *(low · + apps/cli/src/chat/session-host.ts, apps/cli/src/commands/chat.ts)* +- [ ] **The deadline is sized from the requested cap, not the effective one.** *Low.* *(budget-11)* + `chatAttemptDeadline` reads `agent.max_tokens` before the wire clamp, so `claude-opus-4-1` with a configured + 128,000 gets 30 minutes for a 32,000-token wire, and a cost-capped chat sending the implicit 4,096 keeps 120 s + although 4,096 at 25 tokens per second needs 164 s. The note says "effective cap". **Re-check:** size it from the + planned wire cap, or reword the note and config-spec. *(low · apps/cli/src/chat/session-host.ts)* + +- [ ] **The summary's `relavium gate <runId>` asks for `--gate` when two human gates wait.** *Low.* *(runid-8, + docs-3)* A fan-out paused with no inline prompter (a piped stdin, `--secret-inputs-stdin`) announces both gates, and + the summary prints one `relavium gate <runId>` with no `--gate`; copied as printed it exits `2` naming the pending + gates. The same holds beside a gate restored from the checkpoint that is a human one. `relavium status <runId>` + prints a command for each gate. **Re-check:** print one `relavium gate <runId> --gate <gateId>` per gate through + `resumeCommandBase`, or point at `status` when more than one human gate may be pending. *(low · + apps/cli/src/render/tui/final-summary.ts)* +- [ ] **`list --runs --json`'s record shape is only partly pinned.** *Low; test-side.* *(history-15)* Dropping + `workflowId`, or replacing the documented `null` for a deleted workflow's slug or a missing start time with a + fallback, keeps every test green. **Re-check:** assert one whole record with `toEqual`, plus a soft-deleted + workflow's run and a never-started run. *(low · apps/cli/src/commands/list.test.ts)* +- [ ] **The 30-minute chat cap and the `max_cost_microcents = 0` mapping are unpinned.** *Low; test-side.* + *(budget-12)* The deadline tests read `MAX_CHAT_ATTEMPT_TIMEOUT_MS` rather than its value, so 10 or 60 minutes + passes; and treating any defined `max_cost_microcents` as a cap, the documented unbounded `0` included, would + restore the 4,096-token default with every test green. **Re-check:** assert the cap by value, and a + `buildChatSession` case with `maxCostMicrocents: 0` expecting the catalog ceiling on the wire. *(low · + apps/cli/src/chat/session-host.test.ts)* + +- [ ] **The summary's human-gate line has no decision flag.** *Low.* *(runid-10)* The human-gate twin of runid-6: + `relavium gate <runId>` copied as printed exits `2` asking for `--approve`, `--reject` or `--input`. `status` prints + a command for each decision (history-16 covers the `secret` input case). **Re-check:** keep the gate type on the view's human-gate entry and print + `humanGateResumeHints`, together with runid-6 and runid-8. *(low · apps/cli/src/render/tui/final-summary.ts)* +- [ ] **`status`'s gate commands omit `--secret-stdin` for a run with a `secret` input.** *Low.* *(history-16, + docs-1)* `gate` and `budget resume` refuse such a run without `--secret-stdin`, so the `approve:` and `reject:` + lines `status` prints exit `2` as printed; the refusal names the full remedy. **Re-check:** read the masked secret + names from `run:started` and print the piped `--secret-stdin` form `gate.ts`'s `secretPipeHint` builds, for human + and budget hints alike. *(low · apps/cli/src/commands/status.ts, apps/cli/src/gate/pending.ts)* +- [ ] **A run id of `list` turns a printed `gate` command into `gate list`.** *Low.* *(security-1)* `list` passes + `resumeCommandBase`'s shell-inert check, so `status` prints `relavium gate list --gate <id> --approve`, which runs + the listing and exits `0` with nothing resolved. Engine run ids are UUIDs; only a crafted `history.db` holds this. + **Re-check:** refuse a run id that names a `gate` subcommand in `resumeCommandBase`, and make `gate list` reject + decision flags. *(low · apps/cli/src/gate/budget.ts, apps/cli/src/commands/descriptors.ts)* +- [ ] **No test has a shell-inert run id beside a hostile gate id at the `status` call site.** *Low; test-side.* + *(docs-2)* The identifier test makes both ids hostile, so a call site that sanitized one of them before building + the command would keep every test green and print an invented gate id. **Re-check:** a case per id, each expecting + the flags-only sentence. *(low · apps/cli/src/commands/status.test.ts)* +- [ ] **No pre-flight test has two inline agents.** *Low; test-side.* *(preflight-13)* A lookup that ignores + `agent_ref`, or reads the first agent's grant for every node, passes every test. **Re-check:** two agents, one + granting `web_search` and one `read_file`, a node bound to each, in both list orders; land it with preflight-11's + agent map. *(low · packages/core/src/validate-catalog.test.ts)* +- [ ] **A reply that fills a 200k window fails where the 4,096 cap ended it as `length`.** *Low.* *(budget-13)* On a + 64,000-ceiling model with a 200k window, a first message of about 180k tokens with nothing foldable skips + compaction, suppresses overflow recovery and is sent with the catalog ceiling; the provider stops with + `model_context_window_exceeded`, which fails and rolls back the exchange. **Re-check:** cap the default at the room + the window leaves, or map that stop with committed content to `length`; or record it in ADR-0101's note beside + budget-3 and budget-8. *(low · apps/cli/src/chat/session-host.ts, packages/llm/src/adapters/anthropic.ts)* + +- [ ] **The decided-gate relabel is tested only on a finished run with an approval.** *Low; test-side.* *(runid-11, + history-17, docs-2)* Every `status` test that decides a gate seeds `decideAfterPause`, which approves and then + completes the run, and the paused-run test checks the substring `g [human_in_the_loop]`, not its `running` label. + Limiting the relabel to finished runs, recording only approvals in `decidedGateNodes`, or relabelling a still-pending + gate's step under a paused run as `completed` each keeps every test that calls `statusCommand` green. The code at + `3354dda1` is correct; real-engine probes print `completed` for a decided gate and `running` for the waiting one + under a paused run, and `completed` after a rejection. **Re-check:** a paused two-gate case, one gate decided and one + pending, expecting `completed` and `running` plus the pending gate's hints; a rejected-decision case through a + decision option on `decideAfterPause`; and the paused-run test asserting ` running g [human_in_the_loop]\n`. + *(low · apps/cli/src/commands/status.test.ts, apps/cli/src/test-support.ts)* +- [ ] **An approved budget gate lists its agent node twice, each row with the node's full cost.** *Low; + pre-existing.* *(runid-12)* A budget approval re-dispatches the node at attempt 1, so a second `node:started` + inserts a second step row, and the one `node:completed` closes both, since `stepMatch` matches on run, node and + attempt 1, with the settled cost on each. `status <runId>` prints `completed agent [agent]` twice, and a `--json` consumer that sums + step costs gets twice the run's spend. The projection predates 1a; `status <runId>` now shows it for finished runs + too. **Re-check:** close the gated attempt's row when its budget gate resolves, or give the re-dispatch its own + attempt number. *(low · packages/db/src/run-history-store.ts)* +- [ ] **An empty `fallback_chain: []` misses the chat's catalog-ceiling default.** *Low.* *(budget-14)* + `withChatOutputDefault` checks whether the key is present, not whether the chain has entries, and the schema + accepts an empty chain. An Anthropic chat agent that authors `fallback_chain: []` keeps the adapter's 4,096-token + cap and the 120-second deadline instead of the ceiling and the scaled deadline that config-spec.md and ADR-0101's + notes promise. No Relavium generator writes an empty chain; removing the key or setting `[chat].max_tokens` works + around it. **Re-check:** treat an empty chain as no chain, with a unit case expecting the ceiling; land it with + budget-6 and budget-7. *(low · apps/cli/src/chat/session-host.ts)* +- [ ] **Two comments overstate since round 4.** *Low; comment-only.* *(docs-3)* `ActiveRunStatus.gatesUnavailable`'s + comment says it is `true` whenever the run's log could not be read, but a finished run's damaged log leaves it + unset. `decidedGateNodes`'s comment says the projection never closes a human gate's step row, but a gate that times + out without approval is settled with `node:failed`, which closes it. **Re-check:** say "an active run's" in the + first and "a decided human gate's step row" in the second. *(low · apps/cli/src/commands/status.ts, + apps/cli/src/gate/pending.ts)* +- [ ] **A gate message can soft-wrap into a fake command row above `status`'s real ones.** *Low.* *(security-1)* + `sanitizeInline` collapses tabs and line feeds but keeps runs of spaces, and the gate message has no length bound. + A message padded to a guessed terminal width can place a row that looks like ` approve: relavium gate …` with an + attacker-chosen suffix directly above the real `approve:` and `reject:` lines round 3 added. It needs a guessed + width, leaves a stray `"` row visible, and a gate message can already carry a plain-prose instruction; the same + class as round 1's outputs-5 in 1b's register. **Re-check:** bound the printed message's width, wrap it with an + indented continuation, or put a fixed label row between it and the commands; land it with outputs-5. *(low · + apps/cli/src/commands/status.ts)* + +## PR #97 review — fixed findings and recorded lows (2026-11-06) + +Four reviews of the `development` → `main` checkpoint PR #97 (at `077650a8`) reported about sixty findings, several +rated high. Six verification agents checked each against the code at that commit, with probes, and none held as +high: the gate-type bypass is unreachable from the CLI, the live-run start race was recorded and fails closed, and +the `/export` race needs a concurrent writer. Eight held as medium. By the maintainer's rulings of 2026-11-06 six are +fixed before the PR merges and two are resolved in the record, so the correction owes a D64 rigor pass, a new complete +round and a separate security sitting. The verified register is `docs/analysis/private/2026-11-06-pr97-review-triage.md` +(not tracked). + +**Fixed (medium):** reconcile's repair could relabel a completed run failed when its owner held its terminal in +the outbox meanwhile (it now reads the outbox again under its claim); a bare Enter on an approval, review or +budget-amount card approved; a card whose gate a deadline decided stayed open and dropped the later answer silently; +the Windows readiness budget; the ADR-0119 and ADR-0120 drafts. **Resolved in the record (medium):** the URL-password +reversal against `main` is stated (the PR #96 review's A4, above); the ReDoS entry names the cloned-repository vector +and stays open (M8). **Fixed (low):** the drain now reads the outbox before the open runs (A2); reconcile leaves a run +younger than one lease TTL alone, which narrows 1b's liveness-1 (A1); a crashed run with an unresolved external effect +is settled as `effect_needs_attention` (A4); Esc at the rejection reason cancelled the run (Ctrl-C there still +cancels it: the card watches its input for the key, since Clack reports both as one cancel); the card promised +`auto-reject` where the run fails; `/export`'s own-export rewrite re-checks the file before it replaces it; the +ADR-0118 draft (HMAC grants); history-12; the doc and comment items. The register's plan also listed B3's `?? []` and +B2's missing-vertex fail-closed as one-line fixes; they were recorded below instead. The lows below were not fixed. +**Trigger for every entry:** the next change to the file it names. + +- [ ] **A corrupt or schema-mismatched outbox line lets reconcile settle its run failed.** *Low.* *(A3)* The + outbox skips a line it cannot parse, so its run holds no terminal and the repair settles it. A truncated line + is ADR-0078's accepted floor; a complete line a newer binary wrote is not. **Re-check:** a JSON object with a + string `runId` that fails the schema holds its run; count unreadable lines and print one fixed diagnostic; never + hold every run on one bad line, since the file is never compacted. *(low · apps/cli/src/engine/terminal-outbox.ts, + packages/core/src/engine/engine.ts)* +- [ ] **Isolating an unreadable run would drop its outbox entry.** *Low.* *(A5)* The round-1 liveness-2 fix must also + make the listing report the unreadable run and the drain hold its entry; a run simply left out of the list has its + entry dropped as already landed. *(low · packages/db/src/run-history-store.ts, packages/core/src/engine/engine.ts)* +- [ ] **Reconcile's result is discarded.** *Low.* *(A6)* `reconcileInterruptedRuns` and the `run`/`gate` calls + swallow everything, which hides A3, A5 and a wiring regression. **Re-check:** return a structured outcome and print + one fixed, secret-free line. *(low · apps/cli/src/history/reconcile.ts)* +- [ ] **The run-terminal type set is repeated across packages.** *Low; nit.* *(A8)* **Re-check:** one exported set in + `@relavium/shared`. *(low · packages/core, packages/db)* +- [ ] **The gate-type fit checks skip a missing or non-gate node.** *Low.* *(B1, B2, B3)* Unreachable from the CLI + (the snapshot schema and the gate producers rule it out), but an embedder's executor can pause an `agent` vertex as + an approval gate and resume it with `input_provided`; a missing vertex may stall `#resume`; an unknown `gateType` + throws a `TypeError`. **Re-check:** carry `gateType` on the pending gate and check against it, throw + `invalid_decision` for a missing vertex, and treat an unknown type as accepting nothing. *(low · + packages/core/src/engine/engine.ts, packages/core/src/engine/gate-decision-fit.ts, apps/cli/src/gate/decision.ts)* +- [ ] **An input gate records an empty value on a bare Enter and offers no Reject.** *Low; product call.* *(B8)* + *(low · apps/cli/src/gate/clack-prompter.ts)* +- [ ] **`gate list` hides the gates of a run a crash left `running`.** *Low.* *(B9)* `status` and `gate` still reach + them, against commands.md's "the listing and the resume can never disagree". **Re-check:** read every non-terminal + run. *(low · apps/cli/src/commands/gate-list.ts)* +- [ ] **`chat-export --force` writes through a link at `--out`.** *Low; pre-existing.* *(C2)* **Re-check:** under + `--force`, write a temp and rename it over the target. *(low · apps/cli/src/process/owned-storage.ts)* +- [ ] **The own-export rewrite does not fsync the directory.** *Low.* *(C3)* A power loss can bring the old version + back; the old-or-new promise holds. *(low · apps/cli/src/process/owned-storage.ts)* +- [ ] **A locked keychain reads as "no API key".** *Low.* *(C6)* MVP-A4 shows it before the first turn now, with a + "store one" remedy. **Re-check:** a tri-state key status and an "unlock the keychain" wording. *(low · + apps/cli/src/engine/providers.ts, apps/cli/src/chat/session-host.ts)* +- [ ] **The chat output default looks the catalog up by model id alone.** *Low.* *(C7)* An Anthropic agent with an + OpenAI model id gets that model's ceiling; harmless, since Anthropic refuses the id. **Re-check:** require the + entry's provider to match. *(low · apps/cli/src/chat/session-host.ts)* +- [ ] **The session builders fall back to an env-only key resolver.** *Low; latent.* *(C5)* Every production caller + passes a keychain-aware one. **Re-check:** make `providers` required. *(low · apps/cli/src/chat/session-host.ts)* +- [ ] **Run outputs show a credential nested under a credential's name.** *Low.* *(D3)* An object or array under + `x-api-key` or `api-key` (a name the scrubber reads as a credential's label) is shown, a `null` under such a name + prints `[REDACTED]`, and a value that cannot be represented prints no note. **Re-check:** mask any non-null value + under a name the scrubber reads as a credential, keep `null`, carry `masked` out of the catch. *(low · + apps/cli/src/render/run-outputs.ts)* +- [ ] **The secret walk and the media walk have no depth bound.** *Low; fails closed.* *(D5)* About 3,000 levels + make the summary `[unserializable]`. **Re-check:** a depth cap with a fixed marker. *(low · + packages/core/src/tools/bounding.ts)* +- [ ] **The `run_command` allowlist compares the space-joined argv.** *Low; pre-existing.* *(D11)* `["cat a b"]` + admits `["cat", "a b"]` and `["cat a", "b"]`. ADR-0118's remembered grants match element-wise; this must be fixed + before that ships. **Re-check:** compare tokenized entries element by element. *(low · + packages/core/src/tools/registry.ts, packages/core/src/tools/builtins.ts)* +- [ ] **The release tooling refuses semver build metadata and some legal package names.** *Low; fails closed.* + *(F3)* *(low · tools/cli-package/archive.mjs)* +- [ ] **The MCP-orphan guarantee is not tested on Windows.** *Low.* *(F6)* `check.mjs` uses `/bin/ps` and the e2e + suite skips win32. *(low · tools/cli-smoke/check.mjs, apps/cli/src/harness/mcp-orphan.e2e.test.ts)* +- [ ] **The "required checks cannot be skipped" test pins 7 of the `ci` job's 29 steps.** *Low.* *(F7)* A + `continue-on-error` on Format check passes it; `f213d2a8`'s subject overstates it. **Re-check:** assert no + `continue-on-error` on any `ci` step, with the two known `if`/`shell` exceptions. *(low · + tools/ci/workspace-scheduling.test.mjs)* +- [ ] **A workflow input's `min` or `max` of `.inf` makes its run unresumable.** *Low; pre-existing.* *(F8)* JSON + turns it into `null` in the snapshot, which the snapshot schema refuses. **Re-check:** `.finite()`, with a + round-trip test. `[chat].max_tokens` is unbounded too, though capped downstream. *(low · + packages/shared/src/workflow.ts, packages/shared/src/config.ts)* +- [ ] **Five more Windows tests sit on Vitest's 5 s default.** *Low.* *(F1)* `concurrency.e2e` (twice), + `fs-workspace-admission` (twice) and `session-effect-turns` timed out in the same slow-runner cohort over 58 runs. + **Re-check:** watch at least 20 Windows runs after the readiness budget, then give these their own budget if they + recur. *(low · packages/db, apps/cli)* +- [ ] **The pre-flight's exit change needs a release note.** *Low.* *(E8)* `relavium run` now refuses (exit `2`) a + workflow granting `http_request`, `web_search`, `read_clipboard` or `notify`; before, it ran and failed (exit `1`) + at the first such call, or completed if none was made. **Trigger:** the v0.2.0 CHANGELOG (MVP-A23). *(low · + CHANGELOG.md)* + +**D64 round 1 of the correction (2026-11-06, at `13979d60`)** found one medium, ADR-0118's grant label storing +`argv[0]` verbatim, and fixed it with the lows below it in the correction's own records and tests (the own-run +guard pinned again, the TTL rule bounded on both sides, the event contract's reconcile codes, a logs run-id test, +an own-export identity that also compares `size`, `mtime` and the bytes' SHA-256, so a same-length rewrite inside one +tick of a coarse filesystem clock is caught and its tests no longer depend on the clock, ADR-0118's Session answer and its +"what a grant does not bind" limit, ADR-0120's escape order). These were recorded: + +- [ ] **A late answer can still be dropped without a notice.** *Low.* *(gates-2, security-7)* The card closes + when the deadline's `human_gate:resumed` reaches the run view. An answer that lands after the deadline claimed + the gate but before that event arrives — the timer's few milliseconds, or seconds while the deadline's write + waits on a busy `history.db` — reaches `engine.resume`, which ignores a decided gate in silence. **Re-check:** keep + the card registered until its gate's `human_gate:resumed` is seen, or have `resume` report whether it applied the + decision. *(low · apps/cli/src/commands/drive.ts, packages/core/src/engine/engine.ts)* +- [x] **The notice says "your answer" when no card was drawn.** *Low; cosmetic.* *(gates-3)* A deadline that fires + while the view unmounts for the card prints it anyway; so does an idle card a deadline closes (round 2's gates-3). + **Fixed 2026-11-06 (D64 round 2):** the notice now says no answer from this terminal was recorded, which holds in + every case. *(low · apps/cli/src/commands/drive.ts)* +- [ ] **A paste or a hotkey can still answer a gate card.** *Low; pre-existing.* *(security-2)* Clack's confirm + takes `y`/`n` and `h`/`j`/`k`/`l`/space as answers, so a stray paste such as "The deploy looks wrong" approves an + approval or budget-amount card; only a bare Enter is now safe. The MCP consent prompt shares it. The chat + approvals have a paste floor (ADR-0057); the gate card has none. **Re-check:** filter the card's input to one + unmodified keypress and drop bracketed paste, with a real-Clack paste test. *(low · + apps/cli/src/gate/clack-prompter.ts, apps/cli/src/mcp/consent-prompt.ts)* +- [ ] **A raced own-export rewrite can leave two exports of one session.** *Low.* *(export-2)* When the file + changed but still names this session (a second terminal's `/export`, an editor's atomic save), the export + moves to the next id, and later `/export`s update the lower one. **Re-check:** prevent the duplicate without + writing over the changed file: an editor's save keeps the session's marker, so a file that changed after it was + recognized stays as it is, as the changed-file contract says. *(low · apps/cli/src/chat/export.ts)* +- [ ] **No Windows job runs the own-export identity tests.** *Low.* *(export-3)* **Re-check:** add + `owned-storage-replace.test.ts` and `export-project.test.ts` to the Windows filesystem step. *(low · + .github/workflows/ci.yml)* +- [ ] **The Home and `chat list` print `history.db` status and slugs raw.** *Low.* *(sinks-2)* history-12's class on + surfaces its fix did not cover. **Re-check:** `sanitizeInline` in `home-projection.ts`'s labels and + `chat-list.ts`'s rows, with forged-status tests. *(low · apps/cli/src/render/tui/home-projection.ts, + apps/cli/src/commands/chat-list.ts)* +- [ ] **The reconcile guard against an in-flight resume, and the drain's own-run guard, are unpinned.** *Low; + test-side; pre-existing.* *(reconcile-1)* The own-run test now pins `#runs` again; the `#resumingRuns` half and the + drain's claim never had a test. *(low · packages/core/src/engine/run-lease.test.ts)* + +**D64 round 2 of the correction (2026-11-06, at `82db354e`)** found two mediums, both in Proposed drafts. ADR-0118's +label took `argv[0]`'s base name before the grammar check, so the secret last segment of a webhook URL passed whole +as `command`, or the tail of an assigned secret, could become the label; the label now names only an `argv[0]` that +is a bare name resolving on the `PATH`. ADR-0120's approval escape covered only controls and bidi characters, so a +zero-width or tag-character run could hide content in an approved diff; it now covers every default-ignorable and +format code point. Fixed with them: the stale gate contracts in `drive.ts` and `prompter.ts`, the decided-elsewhere +notice (above), the hollow directory test and the forged-hash test, the own-export re-check reading the file before +comparing its stat fields (it now reads only the same regular file, no-follow and non-blocking, as `/export`'s own +read does, so a FIFO swapped in no longer blocks and a permission change moves the export on), the `logs` +skipped-row note printing `history.db`'s `seq` raw, and three doc sentences (D3's credential names, the ReDoS +entry's commands, the glossary's and the vision's auto-reject timeout). Recorded: + +- [ ] **A Reject chosen before an approve deadline still loses to it while the optional reason is typed.** *Low; + pre-existing.* *(gates-2)* The rejection reaches the engine only when the reason prompt settles, so an `approve` + deadline that elapses meanwhile approves the gate and the card closes with the notice. **Re-check:** on a card with + an approve deadline, submit the rejection as soon as Reject is chosen, or show the time left at the reason prompt. + *(low · apps/cli/src/gate/clack-prompter.ts)* + +**D64 round 3 of the correction (2026-11-07, at `4f259890`)** found two mediums. Closing a card when a deadline +decided its gate opened the run's next card at once, so the keys the user was still typing for the closed card +answered it (Clack's `y` approves); after such a close the run's later gates are now left for the command its +summary names. The card also tells its own decision by what it returned, no longer by a `decidedBy` of `cli`, which +every CLI decision shares. ADR-0120's escape still let U+2028 and U+2029 through, which JavaScript reads as line breaks and a +terminal draws as nothing; the escape is now a closed set (every `\p{C}` code point, every separator but the ASCII +space, every default-ignorable code point and U+2800, with TAB shown as a tab mark). Fixed with them: tests for the +reconcile re-read's per-run check and its claim release, for a deadline deciding another gate while a card is open +and for the legacy budget card's Enter; tests for a swap between the own-export check's last `lstat` and its open; +`gate list`'s ids and `status`'s attempt number on one line; the card-close comments; and ADR-0118's masked spans and +Positive consequence, and ADR-0120's plain drawing, against ADR-0114 §5. Recorded: + +- [ ] **No test drives a FIFO swapped in between the own-export check's `lstat` and its open.** *Low; test gap.* + *(export-1)* `O_NONBLOCK` is what keeps that open from blocking; a test would hang its worker on a regression + rather than fail. **Re-check:** run the case in a child process with a `spawnSync` timeout. *(low · + apps/cli/src/process/owned-storage.ts)* +- [ ] **The MCP consent card's argument display passes U+2028 and U+2029.** *Low; pre-existing.* *(security-1's + sibling)* `safe()` in the consent gate strips controls and zero-width characters but not the line separators, so a + repository-declared server's argument can look like a comment. **Re-check:** apply ADR-0120's approval escape when + it lands. *(low · apps/cli/src/engine/mcp-consent-gate.ts)* + +**D64 round 4 of the correction (2026-11-07, at `2d7f0714`)** found two mediums. Round 3's own fix left the pause +that parked a gate in place after the gate resumed, so once a card closed under the user every later event attempted a +departure and re-mounted the live view, which then stacked one frame per event and drew no streamed text; a resumed +gate now clears it. ADR-0120's closed escape still drew a trailing ASCII space as nothing and dropped a line's +ending from the comparison; whitespace at a line's end now shows a mark per character, and a changed or mixed line +ending shows as `<CR LF>` or `<LF>`, so a whitespace-only or ending-only change never renders as an unchanged line. +Fixed with them: a test that the TTL check runs before the claim, tests that something already at the own export's +path is refused before it is opened, a test for a legitimate attempt number, `gate list`'s empty-result line and +`gate`'s two idempotent notices on one line, ADR-0120's blank-drawing list (U+16FE4, U+1D159) and its plain drawing, +ADR-0118's card and Positive wording, and three comments. Recorded: + +- [ ] **Keys typed into a card closed under the user reach the shell when the CLI exits.** *Low.* *(security-3)* + Nothing reads the input once the card closes, so the cooked line discipline keeps the rest of a typed reason, and + the shell reads it when the run parks and the CLI exits `3`. **Re-check:** keep a raw-mode reader that discards + input until the view re-mounts, or drain pending input before exit. *(low · apps/cli/src/commands/drive.ts)* + +**D64 round 5 of the correction (2026-11-07, at `882a2ff8`)** found no medium in the code; reconcile came back clean. +It found two in ADR-0120. A new file written entirely in CR LF still looked like its LF twin, since only a differing +or mixed ending was marked; the card now states each side's endings whenever they are not all LF. And secret masking +could hide code a model chose: `Bearer $(curl …|sh)` is masked like `Bearer sk-…`. A masked span holding anything but +credential characters is now flagged, and approving waits until the user reveals it with a deliberate key. Fixed with +them: the escape notation escapes a literal `<U+`, visible right-to-left text and the hand-kept blank list are stated, +ADR-0118 draws each argument on its own line, a test that a chmod after recognition is refused, a test for `gate`'s +resolved-id notice, `/export`'s "next free id" wording, and the card-ownership comments and test names. Recorded: + +- [ ] **No test pins `gate`'s "already settled" notice sanitizer.** *Low; test gap.* *(sinks-1)* Reaching it needs a + paused run whose stored id carries controls. **Re-check:** seed one through the run store and resume it with a + closed handle. *(low · apps/cli/src/commands/gate.ts)* +- [ ] **ADR-0120's approval escape is a starting set; its completeness is for MVP-3's security review.** + *Maintainer ruling 2026-11-07.* Rounds 2 to 5 each found a further way an approval preview could hide content + (zero-width and tag runs, U+2028 and U+2029, line-end whitespace and endings, code inside a mask), each rated medium + against the draft's claim that a preview never hides what it approves. The draft now states its rules as a starting + set and claims only them; the mandatory security review confirms or extends them before the card is built, and the + correction's later rounds do not treat a further gap as a finding against the draft. **Trigger:** MVP-3's approval + card. *(docs/decisions/0120-the-toolbelt-grows-and-a-tool-call-shows-what-it-does.md)* + +**D64 round 6 of the correction (2026-11-07, at `2cc865c7`)** found one medium in the correction: commands.md still +said a losing rejection does not stop later gates, which cbe39064 changed; it now says the run parks on them. It also +found a medium that predates the correction (`d5ed56f4`, already on `main`): the whole gate card ran under the 250 ms +cleanup timer, so every card a person took longer than that to answer printed a false "Run cleanup is still pending" +under it and threw off its redraws. By the maintainer's ruling it is fixed here: the card's timer starts only when the +card is aborted. Fixed with them: the own export's stat fields pinned one at a time, independent of the filesystem +clock (round 5's chmod test needed a fine-grained ctime), the export docblock's wording, the reconcile comment that +called the drain order the protection, the race test's name, and ADR-0118's and ADR-0120's wording after the +narrowing. Recorded: + +- [ ] **Two CR-92 "not relabelled failed" tests now pass because of the TTL rule.** *Low; test erosion.* + *(reconcile-1)* They reconcile a run younger than one lease TTL, so the repair never reaches it; the property stays + pinned by engine.test.ts and history/reconcile.test.ts. **Re-check:** reconcile on a host clock past start + TTL. + *(low · packages/core/src/engine/m2-e2e-harness.e2e.test.ts, apps/cli/src/harness/durability.e2e.test.ts)* +- [ ] **A deadline that fires while the live view unmounts stops later inline prompts though no card was drawn.** + *Low.* *(gates-3)* The notice prints and the run parks on its next gate. **Re-check:** set the refusal only when a + card was drawn. *(low · apps/cli/src/commands/drive.ts)* +- [ ] **No test pins that `/export` moves past a changed own export to another of the session's own exports.** + *Low; test gap.* *(export-2)* **Re-check:** two own exports, the first changed during the replace. *(low · + apps/cli/src/chat/export.ts)* ## Wave 3 NI — recorded low findings (2026-10-25) @@ -1752,10 +2839,10 @@ open — an unstated residual reads as an oversight, and the review that found t so directly: these "should remain visible rather than disappear behind the green test suite." - [ ] **Terminal-outbox drain, callable from any surface ([ADR-0078](../decisions/0078-ordered-durable-append-and-the-terminal-outbox.md) §4/§5).** - `drainTerminalOutbox` is a `WorkflowEngine` method, and `run`/`gate` are the only commands that construct - one — `chat`, `agent run` and the bare-invocation Home run on `AgentSession` and have no engine to drain - with. `PR83-06` corrected the documentation and made `relavium status` NAME a run whose terminal is held - (`terminalHeld` in `--json`), but the drain itself is still reachable from two commands only. The real fix + `drainTerminalOutbox` is a `WorkflowEngine` method. **Narrowed 2026-11-05 (MVP-1 1b):** `run`, `gate`, + `budget resume` and Home open now drain through `reconcile()` (the Home through a reconcile-only engine, + `apps/cli/src/history/reconcile.ts`). What stays open: `chat` and `agent run` run on `AgentSession` and have + no engine to drain with, and `status` NAMES a held run (`terminalHeld` in `--json`) without draining it. The real fix lifts it off the engine onto its host ports (`store` + `terminalOutbox` + `runLeases` + `mediaReferences`), so any surface can call it at startup. **Acceptance:** a held terminal is retried before `status` reads the run; a drain failure stays non-fatal and preserves the entry; the entry is removed only once the exact @@ -1784,7 +2871,13 @@ so directly: these "should remain visible rather than disappear behind the green - [ ] **An authored `pattern` with no `max_length` can still stall the process ([ADR-0083](../decisions/0083-input-admission-and-a-resume-that-verifies-its-own-identity.md) §4).** `max_length` bounds the input a catastrophic regex chews on, and it is checked first — but only when the author supplied it. ADR-0083 accepts this floor explicitly. A stronger contract would require `max_length` - whenever `pattern` is present, or admit only a demonstrably safe regex subset. *(packages/shared/src/workflow.ts; ADR-0083 §4)* + whenever `pattern` is present, or admit only a demonstrably safe regex subset. **Reframed by the PR #97 review + (2026-11-06): the author need not be the user.** An input's `default` is checked against its `pattern` when the + file is PARSED, and `relavium list`, chat's `/workflows` and `/export`, and `relavium create`/`import`/`export` + parse every workflow in the project's catalog. A workflow in a cloned repository with `pattern: '(a+)+'`, a default of `'a'` × 28 + `'!'` and no `max_length` stalled + `parseWorkflow` for between about 1 s and 7.6 s in two measurements that day (machine and load differ), and the + cost doubles with each character, so a repository can hang those commands with no input from the user. *(medium for that vector · + packages/shared/src/workflow.ts; ADR-0083 §4)* - [ ] **Two SonarCloud CRITICALs are declines, not debt — mark them Accepted in the UI.** `WorkflowEngine#emitDurable`'s closure (23) and `dispatch` (22) are both over the cognitive-complexity @@ -2256,17 +3349,20 @@ model/provider/cost. If it's deliberately left out, that should be a stated deci highest-trust surface — the approval consent line's target (`formatApprovalTarget` → `sanitizeInline`) — still passes NON-reordering zero-width chars (ZWSP U+200B / word-joiner U+2060 / BOM U+FEFF), which the provider-URL echo already strips. Running the stricter zero-width superset on the consent-line target would be defense-in-depth. Not - a CVE gap (those chars cannot reorder a command to masquerade). **Scheduled → 2.6.M** (with tool-render v2). *(low · apps/cli/src/render/tui/chat-projection.ts)* + a CVE gap (those chars cannot reorder a command to masquerade). **2026-11-06:** they can still hide content in an + approved diff or argument (a tag-character run, a zero-width space in a literal); ADR-0120 (Proposed) makes the + approval preview escape every default-ignorable and format code point, which covers this target when it lands. + **Scheduled → 2.6.M** (with tool-render v2). **Not pulled into the CLI MVP (checked 2026-11-04):** MVP-3 takes only render v2's two named behaviours, so this lands with render v2's remainder. *(low · apps/cli/src/render/tui/chat-projection.ts)* - [ ] **Extract the `[c]` reason-capture to a shared pure reducer (2.5-close Step 14 review, test-parity).** The reason-capture keystroke glue is duplicated inline in `ChatApp` (chat-ink.tsx) and the Home controller; the shared primitives (`reduceApprovalKey`, `sanitizeApprovalReason`, `reduceEditorMotion`) are unit-tested and the Home path has an integration test, but the ChatApp inline copy has no direct test (matching the existing no-ChatApp-integration boundary). Extracting the capture step to one pure reducer (like the mention/effort submodes) would let both - surfaces test the same function. **Scheduled → 2.6.M** (with tool-render v2). *(low · apps/cli/src/render/tui/chat-ink.tsx + home-controller.ts)* + surfaces test the same function. **Scheduled → 2.6.M** (with tool-render v2). **Not pulled into the CLI MVP (checked 2026-11-04):** MVP-3 takes only render v2's two named behaviours, and paused Step 14b owns the shared reducer's call-site swap. *(low · apps/cli/src/render/tui/chat-ink.tsx + home-controller.ts)* - [ ] **Live `web_search` / `http_request` egress credential resolver.** `assembleToolEnv` accepts an `egressCredentialResolver` and the egress arm attaches it host-side as a Bearer, but the chat/Home session-host does not yet wire it to the keychain — so a `web_search` needing a provider key currently 401s (surfaced, never a crash). - Wire the provider-key resolver through when the chat surface needs authenticated egress. **Scheduled → 2.6.M** (the `web_search` activation). *(low · apps/cli/src/chat/session-host.ts)* + Wire the provider-key resolver through when the chat surface needs authenticated egress. **Scheduled → 2.6.M** (the `web_search` activation). **Pulled into the CLI MVP 2026-11-04 (MVP-7, `web_search` activation).** *(low · apps/cli/src/chat/session-host.ts)* - [ ] **Session-level budget pause/resume (rides the EA4 machine).** The EA4 pause/resume state landed for mid-turn abort + approval; the ADR-0028 session budget `pause_for_approval` can now ride the same machine (today a chat cost-cap trip settles the turn loudly as `budget_exceeded` — the REPL is the approval gate). See also the 1.V diff --git a/docs/roadmap/phases/phase-2-cli.md b/docs/roadmap/phases/phase-2-cli.md index 1121f33c5..fc5d11e17 100644 --- a/docs/roadmap/phases/phase-2-cli.md +++ b/docs/roadmap/phases/phase-2-cli.md @@ -1006,7 +1006,7 @@ validated copy-in with **project-global** id uniqueness), and `relavium export < **Phase 2 — CLI is feature-complete.** Every in-phase workstream (2.A–2.S) is merged and the published CLI is cut as **v0.1.1**; M3 was reached at 2.K and the Phase-3 go/no-go held from 2.L. See the -[Phase 2 workstreams]() and the +[Phase 2 workstreams](phase-2-cli.md) and the [sequencing plan](#sequencing--parallelization). The full status-aware history is the [Remaining build order](#remaining-build-order) section (its queue is now empty). diff --git a/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md b/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md index abc4dacc5..0eafa7929 100644 --- a/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md +++ b/docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md @@ -9402,7 +9402,7 @@ plan amendment (row PR95) and the recorded splits; ID coverage is unchanged apar | Area | Ruling | | --- | --- | | Acceptance | Each increment keeps the existing rule: implement, author gates, commit, then NEW independent complete cumulative review rounds until two consecutive rounds are clean after the last material fix. Comment- or documentation-only nits do not reset the count. Security-touching increments add a separate scoped sitting: PR95, 9a.1, 9a.2, 9b, 9c, 9d (whole Step 9 custody), 10a, 10c, 11b, 13c, 14b, 15c, 16a, 16b, 16c (whole Step 16), 18 and 19b (whole wave). PR95 instead follows the maintainer's 2026-10-21 masker acceptance bar and round cap ([checkpoint](#wave-3-pr95-increment-implementation-checkpoint-and-review-round-corrections--2026-10-20)). **Amended 2026-10-23 (maintainer, after Step 9a.1's twelfth round):** from Step 9a.1's round 13 on, every increment is accepted after one NEW independent complete cumulative round that reports no blocker, high or medium finding and no regression; that round's low findings are recorded in [deferred-tasks.md](../deferred-tasks.md) with their file, a minimal example and a re-check note rather than fixed in the loop, and nits are optional. A high or medium finding is still corrected and followed by a new round, and the separate security sittings above are unchanged. **Amended 2026-11-04 (D64):** tiered for the CLI MVP — see the [MVP priority reset](#mvp-priority-reset--2026-11-04). | -| Publication | `development` is pushed after each accepted increment, so its actual CI (including the four same-tarball platform consumers) is observed per increment. The Wave 3 PR follows Step 19. **Amended 2026-11-04 (D61):** a scoped `development`→`main` checkpoint follows the [MVP priority reset](#mvp-priority-reset--2026-11-04); the Wave 3 PR still follows Step 19. | +| Publication | `development` is pushed after each accepted increment, so its actual CI (including the four same-tarball platform consumers) is observed per increment. The Wave 3 PR follows Step 19. **Amended 2026-11-04 (D61):** a scoped `development`→`main` checkpoint follows the [MVP priority reset](#mvp-priority-reset--2026-11-04); the Wave 3 PR still follows Step 19. **Done 2026-11-04:** PR #96 merged ([D61 checkpoint](#d61-checkpoint--pr-96-merged-2026-11-04)). | | Plan shape | PR95 + 9a.1, 9a.2, 9b, 9c, 9d + 10a–c + 11a–b + 12a–b + 13a–c + 14a–b + 15a–c + 16a–c + 17a–b + 18 + 19a–b (29 increments). Each split is recorded before its step starts. **Amended 2026-10-24 (maintainer):** 9a.2 is split into 9a.2a and 9a.2b, so the plan has 30 increments ([9a.2 preflight decisions](#wave-3-step-9a2-preflight-decisions--2026-10-24)). **Amended 2026-10-24 (maintainer, E6):** the native-identity increment NI is added after 9a.2b, so the plan has 31 increments ([9a.2a acceptance](#wave-3-step-9a2a-acceptance--2026-10-24)). **Amended 2026-10-25 (maintainer, L8):** 9b is split into 9b.1 and 9b.2, so the plan has 32 increments ([9b preflight decisions](#wave-3-step-9b-preflight-decisions--2026-10-25)). | | Reassignments | G54 moves from row 17 to 11a; #311's implementation moves to 10b and its certification stays in 17a; G36/G46 are re-scoped in 10a to the branch that replaces the raw-stack branch and are certified in 17b; promotion of the Steps 6–10 commands.md sections moves from Step 19 to 10c. | | Interim release guard | 9a.1 adds a fail-closed check to the `release.yml` publish job that refuses any non-release candidate. Consequence, acknowledged: no npm publication, including a v0.1.x patch, until 9d and Wave 4b production custody exist. **Amended 2026-11-04 (D59):** to be superseded by a pre-1.0 release ADR (option B, sunset at 1.0 or when 9c/9d land); the guard stays in force until that ADR is Accepted and its guard change passes a security sitting ([MVP priority reset](#mvp-priority-reset--2026-11-04)). | @@ -10685,7 +10685,7 @@ sitting, and lands in the named MVP milestone: reviewed per MVP milestone: author self-review, `pnpm run ci` plus `pnpm coverage:enforced`, and one independent scoped round with no high or medium finding; lows go to [deferred-tasks.md](../deferred-tasks.md). - *Publication (D61):* a scoped `development`→`main` checkpoint follows this reset; the Wave 3 PR still follows - Step 19 when the wave resumes. + Step 19 when the wave resumes. **Done 2026-11-04:** PR #96 merged ([D61 checkpoint](#d61-checkpoint--pr-96-merged-2026-11-04)). - *Interim release guard (D59):* to be superseded by a new pre-1.0 release ADR (option B) with a sunset at 1.0 or when 9c/9d land, whichever comes first. The guard stays in force, in code and procedurally, until that ADR is Accepted and its guard change passes a security sitting. @@ -10696,6 +10696,129 @@ unsanitized `relavium run` output; the maintainer accepted it because the produc **NI's Windows clause is closed** (2026-10-26): run 3 of 3 is recorded in the [NI acceptance](#wave-3-ni-acceptance--2026-10-25). +## D61 checkpoint — PR #96 merged, 2026-11-04 + +[PR #96](https://github.com/HodeTech/Relavium/pull/96) merged `development` into `main` at 16:58:27 UTC on +2026-11-04, merge commit `b3cf117ea1dae4e534f17c4806f2a30eb300e317` (parents `6d2a7372` and `f25fb83d`). The merge +tree (`c7af0bc3`) is byte-identical to the merged head `f25fb83da17583c6e51ee72c7d5bfdbdfc7ad5a7`; GitHub records 97 +commits, 179 changed files, +34,184 / −3,264. `development` was fast-forwarded to the merge commit. It is the scoped +checkpoint [D61](#mvp-priority-reset--2026-11-04) ruled and claims no wave acceptance: Wave 3 stays paused after +Step 9b.2, and whole-wave acceptance remains Step 19. The review correction reached `main` with this merge before any of its D64 rounds, +by the maintainer's decision; `main` keeps the regressions those rounds found until the next checkpoint +([current.md](../current.md#status-at-a-glance) names them). + +**What reached `main`.** The accepted increments PR95, 9a.1, 9a.2a, 9a.2b, NI, 9b.1 and 9b.2, each under its own +acceptance register (none is widened by the merge), including Step 9a.1's interim release guard; the +[MVP priority reset](#mvp-priority-reset--2026-11-04) (`64fb8772`); and the PR #96 review correction. + +**The review correction.** A read-only review of the PR at `64fb8772` (eighteen scoped reviewers, four verifiers) +reported two high and several medium findings, among them argv credential flags and URL passwords left unmasked, a +30 MiB data URI settled whole into the effect journal, and current.md records dropped by the reset. The maintainer +ruled on its governance items on 2026-11-04. Nine commits fixed the verified findings and SonarCloud's 27 bugs and 10 +vulnerabilities: `0826f30a` (core masking and the omitted-media bound), `b4ef9374` (the run failure summary), +`8445a700` (the build configuration typechecked), `e8121aa4` (release tooling: the runtime-dependency allowlist, the +candidate copy check through Git's own blob hashes, code-unit sorts), `002ff6d7` (CI: the Windows job required, the +publish job naming the `release` environment, skipped provider lanes reported), `964994c7` (the dropped records +restored, [moved here](#moved-by-the-pr-96-review-correction--2026-11-04) and to the other phase files), `eb2cce48` +(MVP-A23's publish-credential prerequisite), and `062564f0`/`f25fb83d` (complexity). The lows and the Sonar smells +the maintainer chose to record are in +[deferred-tasks.md](../deferred-tasks.md#pr-96-review--recorded-findings-and-sonar-dispositions-2026-11-04). + +**Evidence.** Local `pnpm run ci` passed at `964994c7`, three commits before the merged head (secrets controls +130/130, acceptance tools 1,027/1,027), and `pnpm coverage:enforced` passed there (753 files, 16,244 tests; 94.75% +lines, 92.83% branches). GitHub CI at the merged head `f25fb83d`: push run 41848319834 and pull-request run +41848332524, 18 of 18 jobs green each. SonarCloud: the PR's gate OK at `f25fb83d` (0 bugs, 0 vulnerabilities, 42 +recorded smells) and `main`'s gate OK at `b3cf117e`. `main`'s own push run at `b3cf117e` (41872891482) failed +on its first attempt: its macOS consumer was cancelled before any step ran, for want of a runner, so the two +aggregates that need it failed; its re-run (attempt 2, 2026-11-05) passed, so the commit is green. + +**Repository settings changed with it (maintainer, 2026-11-04).** `main`'s branch protection requires +`windows · db concurrency + filesystem + headless smoke` beside `lint · typecheck · test` and the coverage floor; +it does not bind administrators. The `release` deployment environment that `release.yml`'s publish job names does +not exist yet: creating it with a `v*`-tag deployment policy and a required reviewer and moving `NPM_TOKEN` into it, +or adopting npm trusted publishing, is MVP-A23's prerequisite, chosen in the D59 release ADR. + +**D64 round 1 (2026-11-04, at `f25fb83d`).** The correction touched secrets redaction and release tooling, so +[D64](#mvp-priority-reset--2026-11-04) asks for a NEW complete cumulative round and a separate security sitting. +Four scoped reviewers (redaction, release tooling, CI and build, documents and governance) and a security sitting +read the whole range `64fb8772..f25fb83d`; one adversarial verifier per medium-or-higher claim confirmed seven +reports, five distinct medium findings: + +| Finding | Kind | Disposition | +| --- | --- | --- | +| A boolean `-u`/`--user` swallowed the credential flag after it (`unzip -u -P <pw>`), a regression introduced by `0826f30a` | Redaction | Fixed: a value that is no `user:password` pair is processed as any element | +| The `,`/`/` password helper gave the username the text before the first `:` even after a raw `?`/`#`, so the tail of a credential query value stayed visible, a regression for the `/` spelling | Redaction | Fixed: the username owns that text only as in the authority scan | +| packaging.md and `release.yml` said the `release` environment holds `NPM_TOKEN` (three reports) | Documents | Corrected to the current state | +| built-in-tools.md omitted that a raw `?`/`#` after the `,`/`/` keeps a password public | Documents | Stated as a recorded limit | +| "Media left in a dispatch result is text to the model" was false: the seam refuses it | Documents | Corrected; the rule moved to built-in-tools.md | + +The fixes are `5a1006ea` (core), `3cb9712c` (tooling and CI) and the documentation commit that records them. Of +25 lows, those in the correction's own code and documents were fixed with them (the requirement allowlist's +prerelease grammar and tests, a stranded JSDoc, the fixture generator's temporary home, the Windows job's comment and +a pin that no required job can be skipped, stale pointers, move notes, empty links, the argv rule's scope, the 2.6.Q +item narrowed to what W8 left); the rest are in +[deferred-tasks.md](../deferred-tasks.md#pr-96-review--recorded-findings-and-sonar-dispositions-2026-11-04), with the +review's 86 unverified first-pass lows triaged against `f25fb83d`. **After round 1 D64's bar was not met:** it found +medium findings, so acceptance needs one NEW complete cumulative round over the corrected head with no blocker, high +or medium finding. + +**D64 round 2 (2026-11-04, at `6155c6dd`).** A new cumulative round of the same shape (four reviewers, a security +sitting, a verifier per medium-or-higher claim) over `64fb8772..6155c6dd` confirmed one medium finding: a boolean flag +whose last word names a credential (`--pass-credentials`, `--with-token`, `--ask-password`) consumed the credential +flag after it, so that flag's value showed in `toolInput` and the journal digest, a regression introduced by +`0826f30a`. A credential flag in value position is now read as a flag. Of 17 lows, all but two recorded limits were +fixed with it: an inline flag name read by the text rules, two surviving mutants pinned, the requirement allowlist's +length bound and npm's name rules with a test per guard, the required-check pin extended to `needs`, the Build step, +the coverage steps and `ci`'s name, and eight documentation corrections. Before round 3, a differential oracle over +365,640 argument vectors (every vector of up to three items from a 40-item pool, and 300,000 random ones of four to +seven) found no credential that `64fb8772` masked and the fixed head shows, in either projection; its value pool held +no flag-shaped secret, so it could not see the class round 3 then found. **D64's bar was still not met:** acceptance +needed a NEW round 3 with no blocker, high or medium finding. + +**D64 round 3 (2026-11-04, at `07f653e7`).** The same shape over `64fb8772..07f653e7` confirmed one medium finding: +round 2's fix read a credential flag in value position as a flag, so a password spelled like one (`--password +-MySecret`) showed in `toolInput` and the journal digest, which `64fb8772` masked (introduced by `2f7224ec`). A +verifier downgraded a second claim to low: the `,`/`/` reading gave a credential-word username (`x-access-token:`) its +text, so a password's tail after a raw `@` showed (introduced by `0826f30a`). Both readings now only add masks: a +credential flag in value position is masked as a value and still masks the element after it, and the `,`/`/` reading's +username owns no text, so the key rules mask as they did before. A superset oracle checks that property directly, with +markers in values, flag-shaped values, credential-word usernames, passwords and hosts (not, as round 4 found, in a +`--flag=value` name or a nested URL): over 200,000 generated URL texts +and argument vectors, in the display, value and journal projections (one million comparisons), no marker that +`64fb8772` hid is visible at the fixed head, while the same oracle finds 60,299 violations at `07f653e7`. The round's +lows were fixed with it (the password tail's five stops and `-u-` pinned, the hash-object batch bound and an extra +answer line tested, the allowlist on optional and peer dependencies, a 40-hex blob id, default and step shells pinned, +eight documentation corrections) except one recorded limit and one equivalent mutant. **D64's bar was still not met:** +acceptance needed a NEW round 4 with no blocker, high or medium finding. + +**D64 round 4 (2026-11-05, at `7a12ee6f`).** The redaction reviewer and the security sitting each found the same +medium regression independently: when an authority ends in `scheme:` and the `/` delimiter begins a nested `//`, the +`,`/`/` reading masked to the nested URL's first `@` and skipped the scheme scan past it, so that URL's password tail +after a raw `@` showed, which `64fb8772` masked (introduced by `0826f30a`). A verifier downgraded a second claim to +low: round 3's oracle had placed no marker in a `--flag=value` name, and there the head showed a last-word name the +text rules masked with its value. The reading now leaves a straddling `://` and a backslash in its authority or tail +to the other rules, and a last-word `--flag=value` spelling keeps its name only where the text rules keep it. Two +independent oracles, each first shown to catch the head, find no violation at the fixed head: the reviewer's (about +2.2 million comparisons over four seeds; 686 at `7a12ee6f`) and an extended one of ours with flag-name, nested-URL and +escape shapes (one million; 70,280 at `7a12ee6f`). The round's lows were fixed with it (relative candidate roots +refused, batch packing pinned, the `ci` aggregate's guard steps pinned to their keys, the W7 summary moved back beside +its copy, the release runbook and `code-review.md` notes, and smaller corrections). A cleanup race in a test failed +the usage controls job once at `7a12ee6f` (ESRCH between a liveness check and a kill) and was fixed in `93a8b033`. +**D64's bar was still not met.** + +**Resolution (maintainer, 2026-11-05).** Four rounds each found one medium regression in the correction's two +heuristic readings, each fix exposing the next case. The maintainer ruled against a fifth round: one careful pass by +the author, then continue. That pass separated what can be shown from what cannot. The argv rules provably only add +masks: at the base an element was masked when the element before it was an anchored credential flag that was not +itself masked, and now whenever the element before it is any credential flag, a set that contains the anchored names; +a `--flag=value` or glued `-u` spelling keeps text only where the text rules keep it. The `,`/`/` URL reading cannot +be shown so, because the projection repeats until stable and a reading's masks can change the text other rules read in +a later pass, so it was removed: the text projection is again the base's (no difference over 200,000 generated texts), +and the review's A4 is a recorded limit again +([deferred-tasks.md](../deferred-tasks.md#pr-96-review--recorded-findings-and-sonar-dispositions-2026-11-04)). Both +superset oracles find no violation at the resulting head. D64's bar of a clean NEW round is waived for this correction +by that ruling, a recorded deviation. + ## Execution record moved from current.md — 2026-11-04 > Moved verbatim from `docs/roadmap/current.md` on 2026-11-04 (decision D66) when that page was reduced to live @@ -11128,7 +11251,11 @@ the review of `#W15-16`, fixed with it, and now the signal that test drives. > found that the rest of its unique records had left the docs, against D66's ruling to relocate them, so they are > moved here verbatim too: the Wave 2 and Wave 3 sections of that plan, the rows the reset edited in place (as they > read before), and current.md's, CLAUDE.md's and AGENTS.md's status blocks. Only relative links were rewritten and -> heading levels lowered by one; a heading in brackets is added. The Phase 2.6.5 interlude and the W7/W8 records are +> heading levels lowered by one; headings in brackets are added. Four old lines are deliberately not repeated: the +> page's own `Last updated` and **Related** lines and the 2.6.F and 2.6.C status headings (their records are the +> workstream sections in [Phase 2.6](phase-2.6-conversational-authoring.md)); the 2.6.Q usage-capture line was +> extended in place in Phase 2.6. current.md's W7 round-1 review summary, first left out as a duplicate, holds facts +> its Phase 2.6.5 copy lacks and now sits verbatim beside that copy (D64 round 4). The Phase 2.6.5 interlude and the W7/W8 records are > in [Phase 2.6.5's record](phase-2.6.5-core-reliability-remediation.md#execution-record-moved-from-currentmd--2026-11-04), > Phase 2's status in [Phase 2's](phase-2-cli.md#status-record-moved-from-currentmd--2026-11-04) and the > "Not started yet" summary in [Phase 2.6's](phase-2.6-conversational-authoring.md#status-record-moved-from-currentmd--2026-11-04). @@ -11145,7 +11272,7 @@ the review of `#W15-16`, fixed with it, and now the signal that test drives. (#35, G32, #205) → transport/discovery/result size bounds against a hostile server (G33, #201, #209, #288) → structured `serverId`/`reason` discriminants (#203, #204) → the two fail-loud violations (#206, #207) → per-file transport-adapter tests (#297) → `CLIENT_INFO.version` (#208). These finding ids keep their home in - [phases/phase-2.5.5-hardening-and-remediation.md]() and + [phases/phase-2.5.5-hardening-and-remediation.md](phase-2.5.5-hardening-and-remediation.md) and certify from there in step 6 below; the *work* happens in 2.6.5 alongside `CR-16` (consent before a stdio spawn) and `CR-41` (the DNS/redirect SSRF hole), because they are the same code and the same security sitting. Splitting them across two phases would book the hostile-MCP reviewer twice. @@ -11270,14 +11397,14 @@ breakdown, now historical, is in A 2026-07-19 full-project code review then opened **Phase 2.5.5 (Hardening and Remediation)**, an interstitial phase holding the review's verified findings against the pre-2.6 codebase; it runs **concurrently with Phase 2.6, not before it** — see -[phases/phase-2.5.5-hardening-and-remediation.md]() -and [Active now](../current.md#what-is-active-now). +[phases/phase-2.5.5-hardening-and-remediation.md](phase-2.5.5-hardening-and-remediation.md) +and [Active now](#what-is-active-now). **Phase 2.6 — Conversational Authoring and the First-Class CLI** is **in progress** (re-scoped 2026-07-08); its plan is in [phases/phase-2.6-conversational-authoring.md](phase-2.6-conversational-authoring.md). Workstream **2.6.F (platform floor + the full-screen TUI renderer)** is **merged to `main`** (PR #74, 2026-07-11), and so is **2.6.C** (the reseat transcript-carry + the `/cost` per-model breakdown, -PR #75, 2026-07-13) — see [Active now](../current.md#what-is-active-now). **2.6.Q** (dynamic model-catalog +PR #75, 2026-07-13) — see [Active now](#what-is-active-now). **2.6.Q** (dynamic model-catalog enrichment) is **no longer blocked**: [ADR-0071](../../decisions/0071-models-dev-as-the-model-metadata-source.md) and [ADR-0072](../../decisions/0072-model-metadata-in-the-db-behind-a-generated-offline-floor.md) are both **Accepted**, resolving the six open maintainer questions the phase file had cited, and the P1–P5 @@ -11286,7 +11413,7 @@ visibility toggle remain open; the bundled offline snapshot was **regenerated** price ruling. **The unified build order across both phases is in -[Execution order — Phase 2.5.5 + Phase 2.6](../current.md#execution-order--phase-255--phase-26-temporary) below** — a +[Execution order — Phase 2.5.5 + Phase 2.6](#execution-order--phase-255--phase-26-temporary) below** — a temporary section, deleted when both phases close. Its baseline step is discharged (PRs #76 and #77 merged) and Wave 0 is merged (PR #82), as is Wave 1 (PR #83). The interlude's `W2` (core reliability, six items) is merged too (PR #85, 2026-08-28). @@ -11366,7 +11493,7 @@ are recorded in 9a.1–9d increments carry a dedicated archive/crypto security sitting. Steps 10–19 follow the [increment table](#proposed-implementation-increments) and end with whole-wave acceptance at Step 19. `development` is pushed after each accepted increment; PR #95 no longer bounds the -review scope of new commits. See the [Wave 3 queue](../current.md#wave-3--clear-the-ground). +review scope of new commits. See the [Wave 3 queue](#wave-3--clear-the-ground). ### Where we are @@ -11412,7 +11539,7 @@ writer but the three oldest, cancel-wins implemented in four node handlers and o fifth). That makes the remediation cheap and low-risk: the reference implementation is already in-tree. -Disposition: 176 work items opened the new **[Phase 2.5.5 — Hardening and Remediation]()** +Disposition: 176 work items opened the new **[Phase 2.5.5 — Hardening and Remediation](phase-2.5.5-hardening-and-remediation.md)** (engine/LLM/persistence hardening, MCP/secrets, the CLI operator-safety net, TUI terminal safety, docs accuracy, CI/test-infrastructure, and hygiene), running **concurrently with Phase 2.6, not before it** (327 findings); 27 findings (16 work items) were folded directly into the charter of an diff --git a/docs/roadmap/phases/phase-2.6-conversational-authoring.md b/docs/roadmap/phases/phase-2.6-conversational-authoring.md index 67882d268..8b4f04a07 100644 --- a/docs/roadmap/phases/phase-2.6-conversational-authoring.md +++ b/docs/roadmap/phases/phase-2.6-conversational-authoring.md @@ -194,7 +194,12 @@ the same core. [`built-in-tools.md`](../../reference/shared-core/built-in-tools.md) / [`workflow-yaml-spec.md`](../../reference/contracts/workflow-yaml-spec.md), which currently document these tools as unconditionally available. Does **not** wire the arms themselves — that stays out of - scope per the recorded 2.5.E design boundary (Phase 3+). + scope per the recorded 2.5.E design boundary (Phase 3+). **Ruled 2026-11-05 (maintainer, MVP-1):** the + pre-flight refuses such a workflow before the run starts (exit `2`), naming the tool; MCP-discovered tools are + exempt. Until MVP-6 back-ports `validateAuthoredWorkflow`, the check is `validateWorkflowToolArms`, beside + `validateWorkflowWithCatalog`, and `relavium run` calls it before any key is read. **Landed 2026-11-05 (MVP-1 + 1a)**; its limits ($ref grants, `mcp_call` without a server, resumes) are in + [built-in-tools.md](../../reference/shared-core/built-in-tools.md#the-built-in-tools). *(M · `apps/cli/src/engine/build-engine.ts`, `apps/cli/src/engine/tool-host/assemble.ts`; #37, G29)* - **Catalog-aware `max_tokens` pre-flight** *(manual-test finding 2026-07-11):* extend `validateWorkflowWithCatalog` so `create`/`import`/`export` reject-or-clamp a `max_tokens` that exceeds the @@ -631,9 +636,16 @@ last drill-down position. settle per their timeout policy via 2.6.K's re-arm. Confirmed live by the review — `engine.reconcile()` is tested in `packages/core` but called from nowhere in `apps/cli` today, so a killed/crashed run stays `'running'` forever. *(S · `packages/core/src/engine/engine.ts` (`reconcile`), `apps/cli`; #229)* + **Wired 2026-11-05 (MVP-1 1b):** `run`, `gate` and `budget resume` call `engine.reconcile()` at start, and the + Home calls it on open, before its first read of the runs; `status` stays read-only, and the browser reads come + with the browsers. Wiring it exposed a second-terminal race (a reconcile between a run's `run:started` and its + lease acquire), so the store now refuses a second terminal + ([database-schema.md](../../reference/shared-core/database-schema.md#concurrency--transaction-behavior)). + Expired gates settling through 2.6.K's re-arm remain open. - Non-interactive parity: extend the read *commands* minimally (`relavium list --runs`, `status <runId>` for finished runs, `logs --follow/--failed`) so scripting keeps pace with the TUI — - [ADR-0049](../../decisions/0049-cli-machine-output-contract.md)-conformant. + [ADR-0049](../../decisions/0049-cli-machine-output-contract.md)-conformant. **`list --runs` and `status <runId>` + landed 2026-11-05 (MVP-1 1a)**; `logs --follow/--failed` remain open. - **Detached-run notification**: when a run is started as "Run detached" from the Home and completes (or fails), surface a notification on the Home's next idle tick — an `Attention required` row with the outcome, linked to the run detail. For a chat that remains open with a detached run in the @@ -798,6 +810,9 @@ security-sensitive `gate.ts` cross-process resume path, so they land together wi no error. Add `assertDecisionMatchesGateType` in `gate/decision.ts`, with `WorkflowEngine#resume` gaining the same check as defense-in-depth, as part of the shared resume core this task extracts. *(M · `apps/cli/src/gate/decision.ts`, `packages/core/src/engine/engine.ts` (`resume`); #0)* + **Ruled 2026-11-05 (maintainer, MVP-1):** an `approval` or `review` gate accepts `approved` or `rejected`; an + `input` gate accepts `input_provided` or `rejected`. The engine reads the gate type from the workflow + definition's `gate_type`, since the checkpoint does not carry it. - **`relavium budget resume`** — **MOVED to Phase 2.6.5 `W7` on 2026-09-18.** [ADR-0097](../../decisions/0097-a-budget-approval-is-an-allowance-not-an-exemption.md) makes an approval a shown, frozen allowance, and the inline `run` prompter is the only way to approve a budget gate today — so @@ -1306,11 +1321,16 @@ models the four shipped adapters can **already** call and to ADR-0065 custom end untrusted-community-pricing-into-a-safety-control is a co-gate. - **Usage capture for streams that end without usage** *(added 2026-11-04, after the MVP; 2.6.C's open obligation, [ADR-0070](../../decisions/0070-durable-per-model-session-cost-attribution.md) §3 exceptions - 1–2).* An egress that streamed content but ended without a usage chunk, and a mid-stream failure, are - recorded as 0 on both sides of the reconciliation invariant, so reported spend under-estimates the - provider's invoice. Capture or estimate their usage at the seam and mark it as such, or show the - under-report on the money surfaces; ADR-0070 §3's exceptions narrow with it. A PR #96 review found 2.6.C's - "filed against 2.6.Q" with no item here; the maintainer ruled this phase owns it. + 1–2).* Since W8, an egress that streamed content but ended without a usage chunk, and a mid-stream + failure, carry `missing` or `partial` usage evidence + ([ADR-0104](../../decisions/0104-unknown-provider-usage-is-durable-cost-uncertainty.md)): their realized + money stays 0, and the money surfaces already qualify the total ("usage unknown for N calls") without + inventing a quantity. What remains is capture: where a provider does report usage that an adapter still + drops, if any, read it at the seam so the realized total stops under-reporting, and ADR-0070 §3's + exceptions narrow with it. An estimate is out of scope here: it would need an ADR amending ADR-0104's + rule that missing evidence contains no invented quantities. A PR #96 review found 2.6.C's "filed against + 2.6.Q" with no item here; the maintainer ruled this phase owns it, and the D64 round-1 review narrowed it + to what W8 left (2026-11-04). - **Tests:** an over-ceiling `max_tokens` is clamped per model; an OpenAI reasoning id uses `max_completion_tokens`; an out-of-range effort never reaches the wire and the picker offers only accepted tiers; a reachable-but-unpriced id gets a models-dev price so the cap applies (or is refused under @@ -1346,7 +1366,7 @@ workstream named in its row and follows that workstream's ADR and security gates | MVP-A4 | 2.6.J | **The default model follows the stored key.** With no configured default, the chat model comes from a provider that has a key, and a missing key yields the actionable "no API key … `provider set-key`" error instead of an opaque credential failure. | | MVP-A5 | 2.6.J | **`relavium chat` offers onboarding** when no key exists, without re-running the wizard on every launch when the keychain is unavailable (#55's loop stays fixed in its own row). | | MVP-A6 | 2.6.J | **Standard provider environment variables** (`ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, `GEMINI_API_KEY`, `DEEPSEEK_API_KEY`) are read after the `RELAVIUM_*` variables, never logged or echoed; [config-spec.md](../../reference/contracts/config-spec.md) is updated with the code. Key-custody review. | -| MVP-A7 | 2.6.Q | **Interactive output budget.** No implicit 4,096-token cap on interactive Anthropic chat: the default is the catalog ceiling, clamped, with a `[chat].max_tokens` key; the budget estimate's interplay (ADR-0101) is checked. | +| MVP-A7 | 2.6.Q | **Interactive output budget.** No implicit 4,096-token cap on interactive Anthropic chat: the default is the catalog ceiling (not clamped: the chat's per-attempt deadline scales with it instead, ADR-0101 2026-11-06; chat surfaces only), with a `[chat].max_tokens` key; the budget estimate's interplay (ADR-0101) is checked. Recorded as a dated amendment to ADR-0101 (maintainer, 2026-11-05). | | MVP-A8 | 2.6.M | **Truthful tool rows.** A failed tool call shows a failure mark, not ✓, and the assistant's text between tool calls stays visible. | | MVP-A9 | 2.6.M | **Ranged reads.** `read_file` takes line or byte ranges and bounded output spills to a wired `ToolHost.outputStore`; its model-visible description matches the CLI's binary refusal. | | MVP-A10 | 2.6.M | **Denials reach the model.** A user or guardrail denial returns to the model as a `tool_result` instead of ending the turn (ADR-0057 amendment, D67). | @@ -1689,7 +1709,7 @@ Part of [roadmap/](../README.md). Carry-over hardening lives in [../deferred-tas ### Not started yet The rest of **Phase 2.6 — Conversational Authoring and the First-Class CLI** -([phase-2.6-conversational-authoring.md](), in progress — +([phase-2.6-conversational-authoring.md](phase-2.6-conversational-authoring.md), in progress — **2.6.F is done**, unblocked by the 2.5 close and **re-scoped 2026-07-08** from maintainer UX findings + a competitor research pass + the deferred-tasks triage): a full-screen, Home-managed CLI (browsers for diff --git a/docs/roadmap/phases/phase-2.6.5-core-reliability-remediation.md b/docs/roadmap/phases/phase-2.6.5-core-reliability-remediation.md index b3c1bbd3e..8965fe745 100644 --- a/docs/roadmap/phases/phase-2.6.5-core-reliability-remediation.md +++ b/docs/roadmap/phases/phase-2.6.5-core-reliability-remediation.md @@ -5827,6 +5827,11 @@ Two complete independent reviews of `c6c55b3b..bbe01091` verify one new MCP-cons [Round 1](https://github.com/HodeTech/Relavium/blob/f267bb0d4e9a4b4e2b51cb2c44ff744284d5228a/docs/reviews/2026-10-08T18-21-45-w7-adr-0102-0103-maintainer-round-1-review.md) checks the eight maintainer items and verifies a reachable Gemini SDK prototype-key merge conflict. The corrected Proposed ADR-0102 explicitly narrows non-cap data to refuse own `__proto__` keys while retaining cap authority and the unchanged measurement subset. Proposed ADR-0103 specifies actual primary-delivery observation, gap/abandonment refusal and the local terminal-invariant qualification. [Two fresh complete round-2 reviews](https://github.com/HodeTech/Relavium/blob/f267bb0d4e9a4b4e2b51cb2c44ff744284d5228a/docs/reviews/2026-10-08T18-25-00-w7-adr-0102-0103-maintainer-round-2-review.md) find no new material decision-level issue. **Both proposals still require maintainer approval before dependent implementation.** The review records the supplied Gemini Free Tier quota without claiming a new capture or authorising billing/spend. Runtime ownership/lifecycle acceptance, missing provider artifacts, Steps 7–8/12, Sonar and final W7/PR acceptance remain open. +> current.md's version of this summary, moved here verbatim on 2026-11-04 because it holds facts the summary above +> lacks (the D64 round 4 on the PR #96 correction found it dropped): +> +> [Round 1](https://github.com/HodeTech/Relavium/blob/f267bb0d4e9a4b4e2b51cb2c44ff744284d5228a/docs/reviews/2026-10-08T18-21-45-w7-adr-0102-0103-maintainer-round-1-review.md) checks the eight maintainer items and verifies a reachable Gemini SDK prototype-key merge conflict. The corrected Proposed ADR-0102 explicitly narrows non-cap data to refuse own `__proto__` keys; it preserves the cap exception and unchanged measured subset. Proposed ADR-0103 now specifies real primary publication/delivery observation, gap/abandonment refusal and the terminal-free local-stream qualification. [Two fresh complete round-2 reviews](https://github.com/HodeTech/Relavium/blob/f267bb0d4e9a4b4e2b51cb2c44ff744284d5228a/docs/reviews/2026-10-08T18-25-00-w7-adr-0102-0103-maintainer-round-2-review.md) find no further material decision-level issue. **Both ADRs remain Proposed; the additional compatibility narrowing and lifecycle/CLI decision require maintainer approval before dependent code.** No runtime acceptance or new provider result is claimed. The supplied Gemini 3.1 Flash Lite Free Tier quota is 250K input TPM, below its 1,048,576-token window; the capture access/authorization gate remains open. Ownership/lifecycle implementation Highs, Steps 7–8/12, Sonar and W7/PR acceptance remain open. + ### W7 latest decisions approved and completion sequence — 2026-10-08 @@ -7096,7 +7101,7 @@ remains canonical for execution order. W8 implementation is not started by this > [PR #96 review correction](phase-2.5.5-hardening-and-remediation.md#moved-by-the-pr-96-review-correction--2026-11-04): > the D66 reset had kept only current.md's live status and order and recorded its 2026-07-26 plan in the Phase 2.5.5 > register, and these Phase 2.6.5 records had left the docs. Only relative links were rewritten for this directory -> and heading levels lowered by one; a heading in brackets is added. The `W4` start-gate paragraph is kept in +> and heading levels lowered by one; headings in brackets are added. The `W4` start-gate paragraph is kept in > [its earlier section](#w4-start-gate--maintainer-ruling-moved-from-currentmd-on-2026-11-04). ### [current.md's header notes, as of 2026-10-25] @@ -7499,7 +7504,7 @@ gaps**: effect journal, stdio MCP consent-before-spawn, run lease, compaction tr input admission, event-log ordering. Three separate reviews landing on the same seven points is not opinion. The full, self-contained work list is -[phase-2.6.5-core-reliability-remediation.md]() — **51 items** (`CR-01`…`CR-98`; `CR-96` and `CR-97` were opened 2026-09-14 from the `W7` ADR reviews, `CR-98` on 2026-09-18) with evidence, fix, acceptance criteria and a decision/ADR/gate register, written so the work +[phase-2.6.5-core-reliability-remediation.md](phase-2.6.5-core-reliability-remediation.md) — **51 items** (`CR-01`…`CR-98`; `CR-96` and `CR-97` were opened 2026-09-14 from the `W7` ADR reviews, `CR-98` on 2026-09-18) with evidence, fix, acceptance criteria and a decision/ADR/gate register, written so the work can be done from that document alone. An adversarial plan review on 2026-08-10 corrected the phase boundary, the exit rule and the execution order, and added two items (`CR-17` resume identity, `CR-63` `input_schema` docs-only); `CR-64` came from the Batch 1 triage, `CR-21b` from ADR-0082 §10 and `CR-21c` from the `W2` diff --git a/docs/runbooks/release-a-surface.md b/docs/runbooks/release-a-surface.md index 8b8da656b..f4f064a06 100644 --- a/docs/runbooks/release-a-surface.md +++ b/docs/runbooks/release-a-surface.md @@ -88,6 +88,11 @@ and the bundle boundary are [ADR-0051](../decisions/0051-cli-distribution-thin-b - Add the **`NPM_TOKEN`** repo secret (an npm automation token for the `relavium` package); enable **2FA** on the npm account. The publish job is otherwise maintainer-gated by design — like the `ci` branch-protection obligation. + + > Amended 2026-11-04: before the next publication, MVP-A23 moves this credential out of the repository: into + > the protected `release` deployment environment that `release.yml`'s publish job names (a `v*`-tag deployment + > policy and a required reviewer, with the repository-level secret deleted), or replaces it with npm trusted + > publishing, chosen in the D59 release ADR ([phase 2.6](../roadmap/phases/phase-2.6-conversational-authoring.md#cli-mvp-additions--2026-11-04)). - A `workflow_dispatch` run executes `pack` + the cross-OS `smoke` **without** publishing — use it to verify a release candidate before tagging. diff --git a/docs/standards/code-review.md b/docs/standards/code-review.md index ee1a9650a..b828534a1 100644 --- a/docs/standards/code-review.md +++ b/docs/standards/code-review.md @@ -15,12 +15,17 @@ are actually enforced — automation catches format and types; a human catches t (typecheck, lint, unit tests, fixture-mode conformance — see [testing.md](testing.md)). While the project has one maintainer, GitHub cannot enforce an approval (it refuses a self-approval), so branch protection requires the CI checks and the review is the maintainer's own plus an independent review - round recorded for the change under the roadmap's acceptance bar. A GitHub approval becomes a required + round recorded for the change under the roadmap's acceptance bar. The protection does not bind + administrators, and the maintainer is one, so their own merges keep the green-CI rule by practice. A GitHub approval becomes a required check when a second maintainer joins. > Amended 2026-11-04: "a review approval" read as a GitHub approval that `main`'s protection has never > required (0 approvals, no CODEOWNERS); a PR #96 review found the mismatch and the maintainer ruled > the rule should state the practice. + + > Amended 2026-11-04 (D64 round 4 on the PR #96 correction): a maintainer-scheduled checkpoint may carry a + > correction whose acceptance round is still open only when the phase register records that decision and + > `current.md` names what `main` keeps until the next checkpoint, as the D61 checkpoint did. - The author runs lint, format, and tests **before** requesting review; review is not a substitute for the toolchain. - Keep PRs small and scoped to one concern; a reviewable diff gets a real review. Large diff --git a/docs/vision.md b/docs/vision.md index da3fd2747..716f92109 100644 --- a/docs/vision.md +++ b/docs/vision.md @@ -162,7 +162,7 @@ The full positioning and competitor matrix lives in [uvp.md](uvp.md). - **Multi-model fallback chains per agent** — `[claude → gpt-4o → gemini]`; runs survive provider outages. - **Human gate with timeout policy** — pause any workflow for a real human decision, - with an auto-approve / auto-reject timeout fallback; makes Relavium viable for + with a timeout that either auto-approves or fails the run; makes Relavium viable for compliance-sensitive work. - **Per-node cost waterfall** — token and dollar attribution per node, per model. - **Zero-install VS Code right-click trigger** — install-to-value under 3 minutes. diff --git a/packages/core/src/engine/budget-rejection-reconcile.test.ts b/packages/core/src/engine/budget-rejection-reconcile.test.ts index ddf58996c..71cf3e3b9 100644 --- a/packages/core/src/engine/budget-rejection-reconcile.test.ts +++ b/packages/core/src/engine/budget-rejection-reconcile.test.ts @@ -8,7 +8,7 @@ import type { NodeOutcome } from './node-executor.js'; const event = (sequenceNumber: number, fields: Record<string, unknown>): RunEvent => RunEventSchema.parse({ runId: 'interrupted-budget', - timestamp: '2026-10-04T00:00:00.000Z', + timestamp: '2025-12-01T00:00:00.000Z', // older than one lease TTL by the in-memory host's clock sequenceNumber, ...fields, }); diff --git a/packages/core/src/engine/engine.test.ts b/packages/core/src/engine/engine.test.ts index 999513e8a..c15df2154 100644 --- a/packages/core/src/engine/engine.test.ts +++ b/packages/core/src/engine/engine.test.ts @@ -1,8 +1,14 @@ import { prepareOutputCapPlan, type MediaJobStatus } from '@relavium/llm'; import { describe, expect, it } from 'vitest'; -import { collectDurableMediaHandles } from '@relavium/shared'; -import type { MediaReferencePort, MediaStore, MediaWritePort, RunEvent } from '@relavium/shared'; +import { collectDurableMediaHandles, RUN_LEASE_TTL_MS } from '@relavium/shared'; +import type { + MediaReferencePort, + MediaStore, + MediaWritePort, + RunEvent, + UnresolvedEffect, +} from '@relavium/shared'; import { ADMISSION_CEILINGS, DEFAULT_MAX_PARALLEL } from '../limits.js'; import { parseWorkflow, type WorkflowDefinition } from '../parser.js'; @@ -155,7 +161,9 @@ function seedStarted( * refused with `workflow_mismatch`. */ workflowId = '00000000-0000-4000-8000-000000000099', ): Promise<void> { - const startedAt = '2026-06-13T00:00:00.000Z'; + // Long before the in-memory host's clock (2026-01-01), as a crashed run is: reconcile leaves a run younger + // than one lease TTL alone. + const startedAt = '2025-12-01T00:00:00.000Z'; const events: RunEvent[] = [ { type: 'run:started', @@ -1966,6 +1974,30 @@ describe('WorkflowEngine — human gate suspend/resume', () => { expect(events.some((e) => e.type === 'node:started' && e.nodeId === 'out')).toBe(true); }); + it("refuses an in-process decision its gate's type does not accept, claims nothing, then takes a fitting one (2.6.K)", async () => { + const engine = engineWith({ + g: () => ({ kind: 'paused', gate: { gateType: 'approval', message: 'approve?' } }), + }); + const handle = engine.start({ workflow: workflow(GATED) }); + const events: RunEvent[] = []; + for await (const event of handle.events) { + events.push(event); + if (event.type === 'run:paused') { + const gateId = event.gateIds[0] ?? ''; + await expect( + engine.resume(handle.runId, gateId, { + decision: 'input_provided', + decidedBy: 'tester', + payload: { text: 'x' }, + }), + ).rejects.toMatchObject({ code: 'invalid_decision' }); + await engine.resume(handle.runId, gateId, { decision: 'approved', decidedBy: 'tester' }); + } + } + expect(events.filter((e) => e.type === 'human_gate:resumed')).toHaveLength(1); + expect(terminalsIn(events)[0]?.type).toBe('run:completed'); + }); + it('de-inlines a media-bearing gate decision.payload to a handle on human_gate:resumed (no base64)', async () => { // The resume() decision.payload (z.unknown()) is the one #emitDurable de-inline caller a gate exercises; // it must flow through the SAME I3 choke point, so the delivered + persisted human_gate:resumed carries a @@ -2196,6 +2228,30 @@ describe('WorkflowEngine — human gate suspend/resume', () => { assertGapFreeSeq(events); }); + it('fails an INPUT gate whose deadline would approve it with run_timeout, never resolving it approved', async () => { + // Admission refuses `timeout_action: approve` on an input gate; a run parked before that rule still carries it + // in its frozen snapshot. An input gate takes its input or is rejected, so the deadline cannot approve it. + const host = createInMemoryHost(); + const engine = engineWith( + { g: () => gate({ gateType: 'input', timeoutMs: 1000, timeoutAction: 'approve' }) }, + host, + ); + // The fit check reads the gate type from the plan, so the node itself is an input gate. + const handle = engine.start({ + workflow: workflow(GATED.replace('gate_type: approval', 'gate_type: input')), + }); + const events: RunEvent[] = []; + for await (const event of handle.events) { + events.push(event); + if (event.type === 'run:paused') host.fireTimers(); + } + expect(events.some((e) => e.type === 'human_gate:resumed')).toBe(false); + const terminal = terminalsIn(events)[0]; + expect(terminal?.type).toBe('run:failed'); + if (terminal?.type === 'run:failed') expect(terminal.error.code).toBe('run_timeout'); + assertGapFreeSeq(events); + }); + it('disarms the gate timer when a human decision arrives first (no timeout fires, single resolution)', async () => { const host = createInMemoryHost(); const engine = engineWith( @@ -2585,6 +2641,33 @@ ${chain eventsB.forEach((event, index) => expect(event.sequenceNumber).toBe(lastSeq + 1 + index)); }); + it("refuses a checkpoint decision its gate's type does not accept before writing, and releases the run (2.6.K)", async () => { + const store = new InMemoryRunStore(); + const { runId, gateId } = await runToGate(store); + const persisted = store.eventsFor(runId).length; + const engineB = engineWith({}, createInMemoryHost({ store })); + await expect( + engineB.resumeFromCheckpoint({ + runId, + workflow: workflow(GATED), + gateId, + decision: { decision: 'input_provided', decidedBy: 'tester', payload: { text: 'x' } }, + }), + ).rejects.toMatchObject({ code: 'invalid_decision' }); + expect(store.eventsFor(runId)).toHaveLength(persisted); + // The refused resume left the run parked and unowned: a fitting decision resumes it to completion. + const engineC = engineWith({}, createInMemoryHost({ store })); + const events = await drain( + await engineC.resumeFromCheckpoint({ + runId, + workflow: workflow(GATED), + gateId, + decision: { decision: 'rejected', decidedBy: 'tester' }, + }), + ); + expect(events.some((e) => e.type === 'human_gate:resumed')).toBe(true); + }); + // H2 (governor re-seed) is pinned at the unit level: the checkpoint fold restores the cumulative from the // durable budget:paused.spentMicrocents (checkpoint.test.ts) and #seedFromCheckpoint feeds it to the // governor. A full engine resume-then-block test is not added here because cost:updated is streamed (not @@ -6054,7 +6137,7 @@ describe('WorkflowEngine — internal failures and handle-side controls', () => it('reconcile() skips a run whose terminal write fails and still reconciles the others', async () => { const store = new InMemoryRunStore(); - const startedAt = '2026-06-13T00:00:00.000Z'; + const startedAt = '2025-12-01T00:00:00.000Z'; // older than one lease TTL by the host's clock for (const runId of ['crash-a', 'crash-b']) { await store.persistEvent({ type: 'run:started', @@ -6083,6 +6166,466 @@ describe('WorkflowEngine — internal failures and handle-side controls', () => // crash-a's write failed and is skipped; crash-b still reconciled — one fault doesn't abandon the rest. expect(reconciled.map((e) => e.runId)).toEqual(['crash-b']); }); + + it('never relabels a run failed while its held terminal is still unwritten, and lands it on a later start (ADR-0078 §4)', async () => { + // The crashed process held `run:completed` in the outbox because the store refused it (a full disk, say). If + // the store still refuses the large terminal on the next start but takes the small repair, a reconcile that + // repaired after a failed drain wrote `run:failed`, and the drain after that dropped the held completion. + const store = new InMemoryRunStore(); + await seedStarted(store, 'held-1'); + let refuseCompletion = true; + const base = createInMemoryHost(); + const host: ExecutionHost = { + ...base, + store: { + resolveWorkflowId: (slug) => store.resolveWorkflowId(slug), + persistEvent: (event, options) => + refuseCompletion && event.type === 'run:completed' + ? Promise.reject(new Error('SQLITE_FULL')) + : store.persistEvent(event, options), + listInterruptedRuns: () => store.listInterruptedRuns(), + readWorkflowSnapshot: (runId: string) => store.readWorkflowSnapshot(runId), + }, + }; + await host.terminalOutbox.put({ + type: 'run:completed', + runId: 'held-1', + timestamp: host.clock.now(), + sequenceNumber: 1, + outputs: { answer: 42 }, + totalTokensUsed: { input: 0, output: 0 }, + totalCostMicrocents: 0, + durationMs: 1, + }); + const engine = new WorkflowEngine({ host, executor: new StubExecutor() }); + + expect(await engine.reconcile()).toEqual([]); + expect(store.eventsFor('held-1').map((e) => e.type)).toEqual(['run:started']); + expect((await host.terminalOutbox.list()).map((e) => e.type)).toEqual(['run:completed']); + + refuseCompletion = false; + expect((await engine.reconcile()).map((e) => e.type)).toEqual(['run:completed']); + expect(store.eventsFor('held-1').map((e) => e.type)).toEqual(['run:started', 'run:completed']); + expect(await host.terminalOutbox.list()).toEqual([]); + }); + + it('keeps a run held when the drain could not claim it, so the repair never writes past its held terminal', async () => { + // The drain's claim fails (the lease port throws under contention) but the repair's claim a moment later + // succeeds. Unless the declined drain claim holds the run, the repair writes `run:failed` and the next drain + // drops the held completion. + const store = new InMemoryRunStore(); + await seedStarted(store, 'declined-1'); + const base = createInMemoryHost({ store }); + let acquires = 0; + const host: ExecutionHost = { + ...base, + runLeases: { + ...base.runLeases, + acquire: (runId, ownerId, ttlMs) => { + acquires += 1; + return acquires === 1 + ? Promise.reject(new Error('SQLITE_BUSY')) + : base.runLeases.acquire(runId, ownerId, ttlMs); + }, + }, + }; + await host.terminalOutbox.put({ + type: 'run:completed', + runId: 'declined-1', + timestamp: host.clock.now(), + sequenceNumber: 1, + outputs: { answer: 42 }, + totalTokensUsed: { input: 0, output: 0 }, + totalCostMicrocents: 0, + durationMs: 1, + }); + const engine = new WorkflowEngine({ host, executor: new StubExecutor() }); + + expect(await engine.reconcile()).toEqual([]); + expect(acquires).toBe(1); + expect(store.eventsFor('declined-1').map((e) => e.type)).toEqual(['run:started']); + expect((await host.terminalOutbox.list()).map((e) => e.type)).toEqual(['run:completed']); + + expect((await engine.reconcile()).map((e) => e.type)).toEqual(['run:completed']); + expect(store.eventsFor('declined-1').map((e) => e.type)).toEqual([ + 'run:started', + 'run:completed', + ]); + }); + + it('repairs nothing when the outbox cannot be read, since any run might hold a terminal there', async () => { + const store = new InMemoryRunStore(); + await seedStarted(store, 'unknown-1'); + const base = createInMemoryHost({ store }); + const host: ExecutionHost = { + ...base, + terminalOutbox: { + ...base.terminalOutbox, + list: () => Promise.reject(new Error('unreadable')), + }, + }; + expect(await new WorkflowEngine({ host, executor: new StubExecutor() }).reconcile()).toEqual( + [], + ); + expect(store.eventsFor('unknown-1').map((e) => e.type)).toEqual(['run:started']); + }); + + it('leaves a run that started less than one lease TTL ago alone, and settles it once it is older', async () => { + // Its owner persists `run:started` before it can take its lease (the lease row needs the run row), so a run + // with no lease yet may have started a moment ago in another process (ADR-0079 §3). + const store = new InMemoryRunStore(); + const startedMs = Date.parse('2026-03-01T00:00:00.000Z'); + await store.persistEvent({ + type: 'run:started', + runId: 'young-1', + timestamp: new Date(startedMs).toISOString(), + sequenceNumber: 0, + workflowId: '00000000-0000-4000-8000-000000000099', + inputs: {}, + executionMode: 'local', + }); + const young = createInMemoryHost({ store, baseEpochMs: startedMs + RUN_LEASE_TTL_MS - 1_000 }); + // Left alone before any claim: a claim held even briefly could refuse the owner's own first acquire. + const claimed: string[] = []; + const watched: ExecutionHost = { + ...young, + runLeases: { + ...young.runLeases, + acquire: (runId, ownerId, ttlMs) => { + claimed.push(runId); + return young.runLeases.acquire(runId, ownerId, ttlMs); + }, + }, + }; + expect( + await new WorkflowEngine({ host: watched, executor: new StubExecutor() }).reconcile(), + ).toEqual([]); + expect(claimed).toEqual([]); + expect(store.eventsFor('young-1').map((e) => e.type)).toEqual(['run:started']); + + const old = createInMemoryHost({ store, baseEpochMs: startedMs + RUN_LEASE_TTL_MS + 1 }); + const settled = await new WorkflowEngine({ + host: old, + executor: new StubExecutor(), + }).reconcile(); + expect(settled.map((e) => e.type)).toEqual(['run:failed']); + }); + + it('counts a start just ahead of the host clock as recent, but settles one more than a TTL ahead', async () => { + // A small skew between processes must not settle a run that just started; a clock moved back by more than a + // TTL must not keep a crashed run unsettled until the clock catches up. + const baseMs = Date.parse('2026-03-01T00:00:00.000Z'); + for (const [aheadMs, settles] of [ + [1_000, false], + [RUN_LEASE_TTL_MS - 1_000, false], + [RUN_LEASE_TTL_MS + 1_000, true], + ] as const) { + const store = new InMemoryRunStore(); + await store.persistEvent({ + type: 'run:started', + runId: 'ahead-1', + timestamp: new Date(baseMs + aheadMs).toISOString(), + sequenceNumber: 0, + workflowId: '00000000-0000-4000-8000-000000000099', + inputs: {}, + executionMode: 'local', + }); + const host = createInMemoryHost({ store, baseEpochMs: baseMs }); + const settled = await new WorkflowEngine({ host, executor: new StubExecutor() }).reconcile(); + expect(settled.map((e) => e.type)).toEqual(settles ? ['run:failed'] : []); + } + }); + + it('gives no TTL grace to a run whose store does not say when it started', async () => { + const store = new InMemoryRunStore(); + const host = createInMemoryHost({ store }); + await store.persistEvent({ + type: 'run:started', + runId: 'unknown-start-1', + timestamp: host.clock.now(), // just started by the host's clock + sequenceNumber: 0, + workflowId: '00000000-0000-4000-8000-000000000099', + inputs: {}, + executionMode: 'local', + }); + const withoutStart: ExecutionHost = { + ...host, + store: { + resolveWorkflowId: (slug) => store.resolveWorkflowId(slug), + persistEvent: (event, options) => store.persistEvent(event, options), + listInterruptedRuns: async () => + (await store.listInterruptedRuns()).map((run) => ({ + runId: run.runId, + workflowId: run.workflowId, + resumable: run.resumable, + lastSequenceNumber: run.lastSequenceNumber, + })), + readWorkflowSnapshot: (runId: string) => store.readWorkflowSnapshot(runId), + }, + }; + const settled = await new WorkflowEngine({ + host: withoutStart, + executor: new StubExecutor(), + }).reconcile(); + expect(settled.map((e) => e.type)).toEqual(['run:failed']); + }); + + it('settles a budget-rejected run with an unresolved effect as effect_needs_attention, else budget_exceeded', async () => { + for (const [rows, code] of [ + [1, 'effect_needs_attention'], + [0, 'budget_exceeded'], + ] as const) { + const store = new InMemoryRunStore(); + await seedStarted(store, 'rejected-1'); + const host = createInMemoryHost({ store }); + const rejected: ExecutionHost = { + ...host, + store: { + resolveWorkflowId: (slug) => store.resolveWorkflowId(slug), + persistEvent: (event, options) => store.persistEvent(event, options), + listInterruptedRuns: async () => + (await store.listInterruptedRuns()).map((run) => ({ ...run, budgetRejected: true })), + readWorkflowSnapshot: (runId: string) => store.readWorkflowSnapshot(runId), + }, + }; + const unresolved: UnresolvedEffect = { + identity: { scope: 'run:rejected-1:a', slot: 0, toolId: 'run_command' }, + state: 'prepared', + tier: 3, + nodeId: 'a', + }; + const engine = engineWith(undefined, rejected, { + effectResume: { + unresolvedForRun: () => Promise.resolve(rows === 1 ? [unresolved] : []), + }, + }); + const settled = await engine.reconcile(); + expect(settled.map((e) => (e.type === 'run:failed' ? e.error.code : e.type))).toEqual([code]); + } + }); + + it('reads the outbox before listing the open runs, so a run that finishes in between keeps its held terminal', async () => { + // Listing first let another process start a run, finish it and have its terminal refused into the outbox + // between the two reads: the drain then found a held terminal for a run missing from its stale list and + // dropped it as already landed. + const store = new InMemoryRunStore(); + const base = createInMemoryHost({ store }); + const late: RunEvent = { + type: 'run:completed', + runId: 'late-1', + timestamp: base.clock.now(), + sequenceNumber: 1, + outputs: { answer: 42 }, + totalTokensUsed: { input: 0, output: 0 }, + totalCostMicrocents: 0, + durationMs: 1, + }; + let raced = false; + const host: ExecutionHost = { + ...base, + store: { + resolveWorkflowId: (slug) => store.resolveWorkflowId(slug), + persistEvent: (event, options) => store.persistEvent(event, options), + listInterruptedRuns: async () => { + const listed = await store.listInterruptedRuns(); + if (!raced) { + raced = true; + await store.persistEvent({ + type: 'run:started', + runId: 'late-1', + timestamp: '2025-12-01T00:00:00.000Z', + sequenceNumber: 0, + workflowId: '00000000-0000-4000-8000-000000000099', + inputs: {}, + executionMode: 'local', + }); + await base.terminalOutbox.put(late); + } + return listed; + }, + readWorkflowSnapshot: (runId: string) => store.readWorkflowSnapshot(runId), + }, + }; + const engine = new WorkflowEngine({ host, executor: new StubExecutor() }); + + expect(await engine.reconcile()).toEqual([]); + expect(await host.terminalOutbox.list()).toEqual([late]); + expect((await engine.reconcile()).map((e) => e.type)).toEqual(['run:completed']); + expect(store.eventsFor('late-1').map((e) => e.type)).toEqual(['run:started', 'run:completed']); + }); + + it('looks at the outbox again under its claim, so it never relabels a run whose owner just held its terminal', async () => { + // The owner was still holding its lease, writing a terminal the store then refused: it puts that terminal in + // the outbox and only then releases the lease. A reconcile that read the outbox before the put and claimed + // after the release wrote `run:failed` past it, and its next drain dropped the held outputs. + const store = new InMemoryRunStore(); + await seedStarted(store, 'owner-1'); + const base = createInMemoryHost({ store }); + const held: RunEvent = { + type: 'run:completed', + runId: 'owner-1', + timestamp: base.clock.now(), + sequenceNumber: 1, + outputs: { answer: 42 }, + totalTokensUsed: { input: 0, output: 0 }, + totalCostMicrocents: 0, + durationMs: 1, + }; + let ownerFinished = false; + const host: ExecutionHost = { + ...base, + runLeases: { + ...base.runLeases, + acquire: async (runId, ownerId, ttlMs) => { + if (runId === 'owner-1' && !ownerFinished) { + ownerFinished = true; + await base.terminalOutbox.put(held); // the owner's refused terminal, put before its release + } + return base.runLeases.acquire(runId, ownerId, ttlMs); + }, + }, + }; + const engine = new WorkflowEngine({ host, executor: new StubExecutor() }); + + expect(await engine.reconcile()).toEqual([]); + expect(store.eventsFor('owner-1').map((e) => e.type)).toEqual(['run:started']); + expect(await host.terminalOutbox.list()).toEqual([held]); + // Skipping the run releases the claim it took (ADR-0079 §4): another process's drain must be able to land it. + expect(await base.runLeases.read('owner-1')).toBeUndefined(); + expect((await engine.reconcile()).map((e) => e.type)).toEqual(['run:completed']); + expect(store.eventsFor('owner-1').map((e) => e.type)).toEqual(['run:started', 'run:completed']); + }); + + it('skips only the run whose terminal the outbox holds when it looks again under its claim', async () => { + // Another run's held entry, which this drain cannot land while its lease is live, must not keep a crashed run + // from being settled. + const store = new InMemoryRunStore(); + await seedStarted(store, 'crashed-x'); + await seedStarted(store, 'held-y'); + const host = createInMemoryHost({ store }); + await host.terminalOutbox.put({ + type: 'run:completed', + runId: 'held-y', + timestamp: host.clock.now(), + sequenceNumber: 1, + outputs: {}, + totalTokensUsed: { input: 0, output: 0 }, + totalCostMicrocents: 0, + durationMs: 1, + }); + await host.runLeases.acquire('held-y', 'another-process', RUN_LEASE_TTL_MS); + const engine = new WorkflowEngine({ host, executor: new StubExecutor() }); + + const settled = await engine.reconcile(); + expect(settled.map((e) => [e.runId, e.type])).toEqual([['crashed-x', 'run:failed']]); + expect(store.eventsFor('held-y').map((e) => e.type)).toEqual(['run:started']); + }); + + it('repairs nothing when the outbox cannot be read again under its claim', async () => { + const store = new InMemoryRunStore(); + await seedStarted(store, 'reread-1'); + const base = createInMemoryHost({ store }); + let reads = 0; + const host: ExecutionHost = { + ...base, + terminalOutbox: { + ...base.terminalOutbox, + list: () => { + reads += 1; + return reads === 1 ? base.terminalOutbox.list() : Promise.reject(new Error('unreadable')); + }, + }, + }; + expect(await new WorkflowEngine({ host, executor: new StubExecutor() }).reconcile()).toEqual( + [], + ); + expect(reads).toBe(2); + expect(store.eventsFor('reread-1').map((e) => e.type)).toEqual(['run:started']); + }); + + it('drainTerminalOutbox also reads the outbox before listing the open runs', async () => { + const store = new InMemoryRunStore(); + const base = createInMemoryHost({ store }); + const late: RunEvent = { + type: 'run:completed', + runId: 'late-2', + timestamp: base.clock.now(), + sequenceNumber: 1, + outputs: {}, + totalTokensUsed: { input: 0, output: 0 }, + totalCostMicrocents: 0, + durationMs: 1, + }; + const host: ExecutionHost = { + ...base, + store: { + resolveWorkflowId: (slug) => store.resolveWorkflowId(slug), + persistEvent: (event, options) => store.persistEvent(event, options), + listInterruptedRuns: async () => { + const listed = await store.listInterruptedRuns(); + await store.persistEvent({ + type: 'run:started', + runId: 'late-2', + timestamp: '2025-12-01T00:00:00.000Z', + sequenceNumber: 0, + workflowId: '00000000-0000-4000-8000-000000000099', + inputs: {}, + executionMode: 'local', + }); + await base.terminalOutbox.put(late); + return listed; + }, + readWorkflowSnapshot: (runId: string) => store.readWorkflowSnapshot(runId), + }, + }; + expect( + await new WorkflowEngine({ host, executor: new StubExecutor() }).drainTerminalOutbox(), + ).toEqual([]); + expect(await host.terminalOutbox.list()).toEqual([late]); + }); + + it('settles a crashed run with unresolved external effects as effect_needs_attention, not internal', async () => { + // effect-journal.md §8: an unresolved effect is surfaced and never retried automatically. `internal` reads + // as an ordinary failure a caller may simply run again. + const store = new InMemoryRunStore(); + await seedStarted(store, 'effects-1'); + await seedStarted(store, 'clean-1'); + const unresolved: UnresolvedEffect = { + identity: { scope: 'run:effects-1:a', slot: 0, toolId: 'run_command' }, + state: 'prepared', + tier: 3, + nodeId: 'a', + }; + const engine = engineWith(undefined, createInMemoryHost({ store }), { + effectResume: { + unresolvedForRun: (runId) => Promise.resolve(runId === 'effects-1' ? [unresolved] : []), + }, + }); + const settled = await engine.reconcile(); + const codes = new Map<string | undefined, string>( + settled.map((e) => [e.runId, e.type === 'run:failed' ? e.error.code : e.type]), + ); + expect(Object.fromEntries(codes)).toEqual({ + 'effects-1': 'effect_needs_attention', + 'clean-1': 'internal', + }); + const effects = settled.find((e) => e.runId === 'effects-1'); + expect(effects?.type === 'run:failed' ? effects.error.message : '').toContain( + 'a/run_command (prepared, tier 3)', + ); + }); + + it('settles a crashed run as effect_needs_attention when its effect journal cannot be read', async () => { + const store = new InMemoryRunStore(); + await seedStarted(store, 'unreadable-1'); + const engine = engineWith(undefined, createInMemoryHost({ store }), { + effectResume: { unresolvedForRun: () => Promise.reject(new Error('journal locked')) }, + }); + const settled = await engine.reconcile(); + expect(settled.map((e) => (e.type === 'run:failed' ? e.error.code : e.type))).toEqual([ + 'effect_needs_attention', + ]); + }); }); // --- ADR-0074 §3: the frozen money basis on a parked media job -------------------------------- diff --git a/packages/core/src/engine/engine.ts b/packages/core/src/engine/engine.ts index ab0671142..981368f2a 100644 --- a/packages/core/src/engine/engine.ts +++ b/packages/core/src/engine/engine.ts @@ -46,6 +46,7 @@ import { MediaAdmissionError, deInlineMedia, type DurableMediaMeta, + type ErrorCode, type ExecutionMode, type GateDecision, type LlmProviderId, @@ -125,6 +126,7 @@ import type { BudgetDispatchPreparation, NodePreparationContext, } from './node-executor.js'; +import { decisionsForGateType, gateAcceptsDecision } from './gate-decision-fit.js'; import { sameBudgetAllowanceQuote } from './budget-allowance.js'; import type { DispatchAllowanceToken } from './dispatch-allowance.js'; import { NodeMediaPinError } from './media-pin-error.js'; @@ -1076,21 +1078,30 @@ class RunExecution { // once `run:started` has folded, so an acquire before the first event fails the foreign key. Doing it // here keeps the store free of any lease coupling. // - // The window it opens is a run that is durable and momentarily unowned. It is not reachable in - // practice — the `runId` came from `ids.newId()` in the same tick and no other process has yet had a - // chance to see it — and it is not harmful even if it were: a racing process would acquire first, our - // acquire would be refused, and this run would fail before executing a single node. Refused, never - // duplicated. + // The window it opens is a run that is durable and momentarily unowned. The only process that can see + // the run in it is one calling `reconcile()` (the CLI does at every `run`/`gate` start and Home open, + // 2.6.G), and `reconcile()` leaves a run younger than one lease TTL alone, so a reconciler with that rule + // never settles it (the CLI's `history/reconcile.test.ts` drives one into this window and the run + // completes). A reconciler without it — a development build before the rule, or a clock more than a TTL + // ahead — can: it writes `run:failed{internal}` and releases its claim, so the acquire below usually + // SUCCEEDS; our next guarded write then conflicts with that terminal, no node + // runs, and the store refuses our own terminal behind it (`RunAlreadyTerminalError`), which is held in + // the outbox and reported `uncertain`. If the reconciler still holds its claim, the acquire is refused and + // our terminal takes the reconciler's sequence number: the store's terminal refusal stops it, with + // `UNIQUE(run_id, seq)` as the fallback behind that refusal. If our terminal commits before the + // reconciler's, the reconciler's guarded write is the one refused, and ours stands. One terminal either + // way, never two. if (!(await this.#acquireLease())) { // Name it. Without a `#failure` this settles on the generic default — `internal: "the run failed"` — // so a user whose `history.db` is locked, unmigrated or read-only saw every `relavium run` die with a - // message pointing at nothing. The docblock above already diagnoses this case; the event should say - // it too. Secret-free: no path, no store detail, just what could not be established. + // message pointing at nothing. The comment above names the other cause, a reconcile that claimed the + // run in the window; the event names both. Secret-free: no path, no store detail, just what could not + // be established. this.#failure = { error: { code: 'internal', message: - 'the run could not take ownership of its own id — the host run-lease port refused a fresh run', + 'the run could not take ownership of its own id — another relavium process claimed it first, or the host run-lease port refused it', retryable: false, }, }; @@ -1714,7 +1725,10 @@ class RunExecution { decision: GateDecision, gateAlreadyResolved: boolean, ): Promise<void> { - if (!gateAlreadyResolved) this.#assertGatePending(gateId); + // Before any write of this resume: a decision the gate's type does not accept is refused here, and the + // caller's catch retires the execution and releases its fence (2.6.K). + if (!gateAlreadyResolved) + this.#assertDecisionFitsGate(this.#assertGatePending(gateId), gateId, decision); await this.#startUsageCoverage('legacy_unverified'); if (!this.#resumeAdmitted && !(await this.#resolveContextOrFail())) { await this.#settle(this.#cancelling ? 'run:cancelled' : 'run:failed'); @@ -1868,6 +1882,24 @@ class RunExecution { return gate; } + /** + * A human gate's decision must fit its authored `gate_type` (2.6.K, ruled 2026-11-05): `relavium gate --input` + * against an approval gate used to write the wrong decision shape with no error. A budget gate keeps its own + * rules. Throws {@link EngineStateError} (`invalid_decision`) naming the type and the kinds it accepts. + */ + #assertDecisionFitsGate(gate: PendingGate, gateId: string, decision: GateDecision): void { + if (gate.isBudgetGate) return; + const config = this.#plan.vertices.get(gate.vertexId)?.config; + if (config?.kind !== 'human_in_the_loop') return; + const gateType = config.node.gate_type; + if (gateAcceptsDecision(gateType, decision.decision)) return; + throw new EngineStateError( + 'invalid_decision', + `this ${gateType} gate accepts ${decisionsForGateType(gateType).join(' or ')}, not ${decision.decision}`, + { runId: this.runId, gateId }, + ); + } + resume(gateId: string, decision: GateDecision, alreadyGated = false): Promise<void> { return this.#hostWork.invoke(() => this.#resume(gateId, decision, alreadyGated)); } @@ -1902,6 +1934,8 @@ class RunExecution { await this.#resumeBudgetGate(gateId, gate, decision, alreadyGated); return; } + // Synchronous, before the claim: a decision the gate's type does not accept is refused with nothing claimed. + this.#assertDecisionFitsGate(gate, gateId, decision); this.#resolvedGates.add(gateId); // …and only now, holding the claim, the effect gate. **`#pendingGates.delete` stays BELOW this await**, // and that ordering is load-bearing in the other direction: with the gate already removed, the idle @@ -4013,7 +4047,8 @@ class RunExecution { /** * A gate's timeout elapsed with no decision (1.Q). Idempotent: a no-op once the gate resolved (a human * beat the timer — resume disarmed it, but a fired-and-queued callback still guards here) or the run - * settled. `approve` auto-resolves the gate as approved (`decidedBy: 'timeout'`); `reject` fails the run. + * settled. `approve` auto-resolves the gate as approved (`decidedBy: 'timeout'`) — on an `input` gate that + * decision does not fit, so the run fails with `run_timeout`; `reject` fails the run. */ async #onGateTimeout( gateId: string, @@ -4134,10 +4169,12 @@ class RunExecution { /** * Acquire this run's lease and start its heartbeat (ADR-0079 §3, §6). * - * For a FRESH run this is uncontended by construction — the `runId` came from `ids.newId()` moments ago — - * so a refusal here means the host's lease port is broken rather than that someone else owns the run. It - * still fails the run rather than proceeding unowned: an unfenced run is exactly what CR-11 exists to - * prevent, and silently continuing would make the guarantee optional in practice. + * For a FRESH run no other run can own the `runId` — it came from `ids.newId()` moments ago — so a refusal + * here means either that a reconcile in another process claimed the run between its `run:started` and this + * acquire (the window the caller's comment describes, reachable since the CLI reconciles at every start, + * 2.6.G), or that the host's lease port is broken. Either way it fails the run rather than proceeding + * unowned: an unfenced run is exactly what CR-11 exists to prevent, and silently continuing would make the + * guarantee optional in practice. * * A RESUMED run already holds a fence, acquired before the checkpoint was read, and passes it in. */ @@ -6307,21 +6344,40 @@ export class WorkflowEngine { } /** - * Crash reconciliation (startup). For every run the store reports as interrupted-and-not-resumable - * (started, no terminal event, not parked at a gate), persist a terminal `run:failed{internal}`, - * or `budget_exceeded` when ordered gate history already records a budget rejection, - * continuing that run's `sequenceNumber` — so a crashed run never lingers as a stuck `run:started`. - * Returns the reconciled events. Resumable runs (parked at a gate) are left for `resume`. + * Crash reconciliation (startup). First drains the terminal outbox; then, for each run the store reports as + * interrupted-and-not-resumable (started, no terminal event, not parked at a gate), persists a terminal + * `run:failed`, continuing that run's `sequenceNumber`: `effect_needs_attention` when the run left an external + * effect unresolved or its effect journal cannot be read, else `budget_exceeded` when ordered gate history + * records a budget rejection, else `internal`. Returns the reconciled events. Resumable runs (parked at a gate) + * are left for `resume`. Also left alone, for a later reconcile: a run that started within one lease TTL of now + * (its owner may not hold its lease yet), a run whose lease this pass could not take (another process holds a + * live one, or the lease port failed), a run whose held terminal this pass could not write or whose outbox + * entry appeared before the repair (or the outbox could not be read again), and every run while the outbox + * exists but cannot be read — so in those cases a crashed run can linger until a later start. */ async reconcile(): Promise<readonly RunEvent[]> { - // **Drain the outbox FIRST (ADR-0078 §4), and the order is load-bearing.** A crashed process leaves its - // terminal here; if reconciliation ran first it would see a run with no durable terminal, conclude it - // needs repair, and write `run:failed{internal}` for a run that actually COMPLETED — the exact - // divergence the outbox exists to close, reintroduced by ordering. §3's "no terminal present" condition - // makes the two writers safe within one process; across processes only this order does. + // **Drain the outbox FIRST (ADR-0078 §4).** A crashed process leaves its terminal here, and draining first + // lands it in this pass, before the repair loop looks at that run. What keeps the repair off a run whose + // terminal is held is the look at the outbox again under the repair's claim (below): without it a repair + // would write `run:failed{internal}` for a run that actually COMPLETED, the divergence the outbox exists to + // close. + // + // **The outbox is READ before the open runs are listed**, for the same reason. The drain drops an entry + // whose run is not in the list, which is right only if the list is no older than the entry: an owner puts + // its terminal only after its own `run:started` is durable, so an entry read first whose run is missing + // from a later list has a terminal in the log by then. Listing first let a run that started, finished and + // had its terminal refused between the two reads lose that terminal as "already landed". + const outbox = await this.#readOutbox(); const interrupted = await this.#host.store.listInterruptedRuns(); - const reconciled: RunEvent[] = [...(await this.#drainTerminalOutbox(interrupted))]; + const drained = await this.#drainTerminalOutbox(interrupted, outbox); + const reconciled: RunEvent[] = [...drained.written]; for (const run of interrupted) { + // **A run whose held terminal this pass could not write is left for the next start.** Draining first only + // protects a completed run if the repair below then stays away from it: repairing now would write + // `run:failed` past the held `run:completed`, and the next drain would drop that entry as already-terminal + // — the completed run relabelled failed and its outputs gone, which is exactly what the outbox exists to + // prevent (ADR-0078 §4). It keeps reporting `uncertain` until a drain can write what it holds. + if (drained.holds(run.runId)) continue; if (run.resumable) { // A run parked at a gate is intentionally left for the checkpoint/resume path (1.R): // rehydrating its full RunExecution from the persisted step_executions + run_events is the @@ -6330,6 +6386,11 @@ export class WorkflowEngine { // unknown_run by design — never silently, and never a corrupted half-run. continue; } + // **A run younger than one lease TTL is left alone.** Its owner persists `run:started` before it can take + // the lease (the lease row needs the run row, ADR-0079 §3), so a run with no lease yet may be one that + // started a moment ago in another process; and a run started by a version that takes no lease at all + // looks the same. Past one TTL, a run with no live lease is treated as crashed, as before. + if (this.#startedWithinLeaseTtl(run)) continue; // **Never terminate a run another process is working on (ADR-0079 §7).** `reconcile()` writes a // terminal for every non-resumable interrupted run, and it runs from a process that may not own any of // them. A run holding a LIVE lease is mid-execution somewhere else — leaving it interrupted is correct, @@ -6340,23 +6401,24 @@ export class WorkflowEngine { // under the old generation would leave a zombie able to append past the terminal written here. const fence = await this.#claimForReconcile(run.runId); if (fence === undefined) continue; - const event = RunEventSchema.parse({ - type: 'run:failed', - runId: run.runId, - timestamp: this.#host.clock.now(), - sequenceNumber: run.lastSequenceNumber + 1, - error: { - code: run.budgetRejected === true ? 'budget_exceeded' : 'internal', - message: - run.budgetRejected === true - ? 'the recorded budget rejection was reconciled before completion' - : 'the run was interrupted before completion and reconciled on restart', - retryable: false, - correlationId: this.#host.ids.newId(), // matches the #settle / node:failed live-failure paths - }, - partialOutputs: {}, - }); try { + // **Look at the outbox again, under the claim.** The read above may predate this run's owner putting + // its terminal there: the owner was still holding its lease, writing a terminal the store then refused. + // The owner puts before it releases its lease, so with the claim taken any such entry is visible now — + // and repairing past it would relabel a completed run failed and let the next drain drop its outputs. + if (await this.#outboxMayHold(run.runId)) continue; + const event = RunEventSchema.parse({ + type: 'run:failed', + runId: run.runId, + timestamp: this.#host.clock.now(), + sequenceNumber: run.lastSequenceNumber + 1, + error: { + ...(await this.#reconciledFailure(run)), + retryable: false, + correlationId: this.#host.ids.newId(), // matches the #settle / node:failed live-failure paths + }, + partialOutputs: {}, + }); // **`reconcile()` is a SECOND durable write path** — it bypasses `#emitDurable` entirely, so every // property established at that choke point has to be re-established here or it holds for one of two // writers (ADR-0078 §3). It passes the guard with the belief it actually has: `listInterruptedRuns` @@ -6507,30 +6569,117 @@ export class WorkflowEngine { * Retry every terminal a prior process could not write — the PUBLIC entry point, and it exists because * without one the mechanism was unreachable. * - * `reconcile()` drains as its first step, but `reconcile()` has no shipping caller: it also REPAIRS every - * interrupted run, which is a much larger behaviour to switch on, so no surface has ever called it. That - * left `CR-92`'s outbox written, tested, certified — and dead. A user who saw the `uncertain` exit code had - * no command that would ever move their run to `durable`, while the exit code's own documentation said one - * would. Draining is the narrow half a surface can call at start with no other consequence: it writes only - * terminals the engine itself already produced, and only for runs whose log still lacks one. + * `reconcile()` drains as its first step, but when this was added `reconcile()` had no shipping caller: it + * also REPAIRS every interrupted run, which is a much larger behaviour to switch on. That left `CR-92`'s + * outbox written, tested, certified — and dead. A user who saw the `uncertain` exit code had no command + * that would ever move their run to `durable`, while the exit code's own documentation said one would. + * Draining is the narrow half a surface can call with no other consequence: it writes only terminals the + * engine itself already produced, and only for runs whose log still lacks one. The CLI now calls + * `reconcile()` itself (at `run`/`gate` start and on Home open, 2.6.G); this entry point stays for a + * surface that wants the drain without the repair. */ async drainTerminalOutbox(): Promise<readonly RunEvent[]> { + // Outbox before list, as in `reconcile()`. + const outbox = await this.#readOutbox(); const interrupted = await this.#host.store.listInterruptedRuns(); - return this.#drainTerminalOutbox(interrupted); + return (await this.#drainTerminalOutbox(interrupted, outbox)).written; } - async #drainTerminalOutbox(interrupted: readonly InterruptedRun[]): Promise<readonly RunEvent[]> { - let held: readonly RunEvent[]; + /** The outbox's entries, or `undefined` when it cannot be read. */ + async #readOutbox(): Promise<readonly RunEvent[] | undefined> { try { - held = await this.#host.terminalOutbox.list(); + return await this.#host.terminalOutbox.list(); } catch { - return []; + return undefined; } + } + + /** Whether the outbox holds a terminal for `runId` now — `true` when it cannot be read, since nothing then proves it does not. */ + async #outboxMayHold(runId: string): Promise<boolean> { + const held = await this.#readOutbox(); + return held === undefined || held.some((event) => event.runId === runId); + } + + /** + * Whether `run` started within one lease TTL of now by this host's clock, on either side: a start slightly in the + * future (skew between processes) counts as recent, but one more than a TTL ahead means the clock moved, and the + * run falls back to the lease rule rather than waiting for the clock to catch up. + */ + #startedWithinLeaseTtl(run: InterruptedRun): boolean { + if (run.startedAtMs === undefined) return false; + const age = this.#nowMs() - run.startedAtMs; + return age > -RUN_LEASE_TTL_MS && age < RUN_LEASE_TTL_MS; + } + + /** + * The error a reconciled run is settled with. An unresolved external effect comes first: such a run must not + * read as an ordinary `internal` failure, because a caller re-runs those, and §8 of effect-journal.md says an + * unresolved effect is surfaced and never retried automatically. A journal that cannot be read counts as + * unresolved, as it does at the resume gate. + */ + async #reconciledFailure( + run: InterruptedRun, + ): Promise<{ readonly code: ErrorCode; readonly message: string }> { + if (this.#effectResume !== undefined) { + let unresolved: readonly UnresolvedEffect[] | undefined; + try { + unresolved = await this.#effectResume.unresolvedForRun(run.runId); + } catch { + unresolved = undefined; + } + if (unresolved === undefined) { + return { + code: 'effect_needs_attention', + message: + 'the run was interrupted before completion and its effect journal could not be read; check its ' + + 'external effects before running it again', + }; + } + if (unresolved.length > 0) { + return { + code: 'effect_needs_attention', + message: + `the run was interrupted before completion with ${String(unresolved.length)} external effect(s) ` + + 'unresolved: ' + + unresolved + .map((u) => `${u.nodeId}/${u.identity.toolId} (${u.state}, tier ${String(u.tier)})`) + .join(', ') + + '. Check the target before running it again.', + }; + } + } + return run.budgetRejected === true + ? { + code: 'budget_exceeded', + message: 'the recorded budget rejection was reconciled before completion', + } + : { + code: 'internal', + message: 'the run was interrupted before completion and reconciled on restart', + }; + } + + /** + * Write every held terminal it can, and say which runs still hold one: `written` are the terminals that landed, + * `holds(runId)` is true for a run whose held terminal stayed in the outbox this pass (its claim was declined or + * its write failed). An outbox that could not be read (`held` is `undefined`) holds every run, because nothing + * can then prove a run has no terminal waiting — {@link reconcile} repairs none of them rather than risk + * relabelling a completed run. `held` must be read before `interrupted` is listed (see {@link reconcile}). + */ + async #drainTerminalOutbox( + interrupted: readonly InterruptedRun[], + held: readonly RunEvent[] | undefined, + ): Promise<{ + readonly written: readonly RunEvent[]; + readonly holds: (runId: string) => boolean; + }> { + if (held === undefined) return { written: [], holds: () => true }; // `listInterruptedRuns` is the port's own answer to "does this run still lack a terminal" — a run that // has one is not in it. Using it rather than adding a read method keeps the drain inside the three // methods `RunStore` already declares, which is what lets a Phase-2 cloud store implement this at all. const stillOpen = new Map(interrupted.map((r) => [r.runId, r])); const written: RunEvent[] = []; + const stillHeld = new Set<string>(); for (const event of held) { const runId = event.runId; if (runId === undefined) continue; @@ -6548,7 +6697,10 @@ export class WorkflowEngine { // writing this would durably contradict the run it is finishing right now. Left in the outbox and // re-evaluated on the next start, when it will almost always be dropped as already-terminal. const fence = await this.#claimForReconcile(runId); - if (fence === undefined) continue; + if (fence === undefined) { + stillHeld.add(runId); + continue; + } try { await this.#host.store.persistEvent(event, { expectedLastSequenceNumber: open.lastSequenceNumber, @@ -6562,12 +6714,13 @@ export class WorkflowEngine { written.push(event); } catch { // Still unwritable, or another process moved the log first. The entry stays for the next start; the - // run keeps reporting `uncertain`. + // run keeps reporting `uncertain`, and `reconcile()` leaves it unrepaired. + stillHeld.add(runId); } finally { await this.#releaseReconcileClaim(runId, fence); } } - return written; + return { written, holds: (runId) => stillHeld.has(runId) }; } /** Drop an outbox entry, swallowing a failure — a stale entry costs one read next start, never a wrong terminal. */ diff --git a/packages/core/src/engine/execution-host.test.ts b/packages/core/src/engine/execution-host.test.ts index cad0fe39a..a882fc3cd 100644 --- a/packages/core/src/engine/execution-host.test.ts +++ b/packages/core/src/engine/execution-host.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it, vi } from 'vitest'; import { isAppendConflictError, isCorruptRunEventError, + RunAlreadyTerminalError, RunEventSchema, type RunEvent, } from '@relavium/shared'; @@ -529,4 +530,27 @@ describe('InMemoryRunStore — the compare-and-append guard (CR-10, ADR-0078 §2 store.persistEvent(skipped(1), { expectedLastSequenceNumber: 0 }), ).resolves.toBeUndefined(); }); + + const failed = (seq: number): RunEvent => ({ + type: 'run:failed', + runId: 'r1', + sequenceNumber: seq, + timestamp: TS, + error: { code: 'internal', message: 'reconciled', retryable: false }, + partialOutputs: {}, + }); + + it('mirrors the exactly-one-terminal refusal — guarded or not, and per run (ADR-0036)', async () => { + const store = new InMemoryRunStore(); + await store.persistEvent(started(0), { expectedLastSequenceNumber: -1 }); + await store.persistEvent(failed(1), { expectedLastSequenceNumber: 0 }); + await expect(store.persistEvent(failed(2))).rejects.toBeInstanceOf(RunAlreadyTerminalError); + await expect( + store.persistEvent(failed(2), { expectedLastSequenceNumber: 1 }), + ).rejects.toBeInstanceOf(RunAlreadyTerminalError); + expect(store.eventsFor('r1').map((e) => e.type)).toEqual(['run:started', 'run:failed']); + // Another run's terminal does not close this one. + await store.persistEvent({ ...started(0), runId: 'r2' }); + await expect(store.persistEvent({ ...failed(1), runId: 'r2' })).resolves.toBeUndefined(); + }); }); diff --git a/packages/core/src/engine/execution-host.ts b/packages/core/src/engine/execution-host.ts index ba96015f2..dfefc83e9 100644 --- a/packages/core/src/engine/execution-host.ts +++ b/packages/core/src/engine/execution-host.ts @@ -38,6 +38,7 @@ import { type MediaStore, type MediaWritePort, nodeIdFromRunScope, + RunAlreadyTerminalError, type RunEvent, RunSuspensionReducer, type RunLeaseInfo, @@ -124,6 +125,12 @@ export interface InterruptedRun { readonly budgetRejected?: boolean; /** The highest `sequenceNumber` already persisted for this run — the reconcile event continues from it. */ readonly lastSequenceNumber: number; + /** + * When the run started (its `run:started` timestamp, epoch ms). `reconcile()` leaves a run younger than one + * lease TTL alone: its owner may not have taken its lease yet (ADR-0079 §3) or may be a version that takes + * none. Absent ⇒ the store does not know, and the run gets no such grace. + */ + readonly startedAtMs?: number; } /** @@ -156,7 +163,11 @@ export interface RunStore { * The residual risk is real and named: a future production caller that forgets `ctx` writes unguarded and * nothing fails. What closes it is that the engine has exactly two `persistEvent` call sites — `#emitDurable` * and `reconcile()` — both of which pass it, both of which are pinned by tests. A store may refuse an - * unguarded write outright; none does today. + * unguarded write outright; none does today, apart from the second-terminal refusal below. + * + * **A store refuses a second terminal**, guarded or not, with `RunAlreadyTerminalError` (ADR-0036's + * exactly-one-terminal). The engine's terminal is exempt from the append guard and an unowned writer carries + * no fence, so this refusal is the only thing that holds the invariant across writers. */ persistEvent: (event: RunEvent, ctx?: DurableWriteContext) => Promise<void>; /** Runs with a `run:started` but no terminal event — for startup crash reconciliation. */ @@ -426,6 +437,11 @@ export class InMemoryRunStore implements RunStore { ); } } + // …and the SAME exactly-one-terminal refusal (ADR-0036), mirrored for the reason above: neither check + // stops an unguarded, unfenced terminal behind one already in the log. + if (TERMINAL_TYPES.has(event.type) && (bucket ?? []).some((e) => TERMINAL_TYPES.has(e.type))) { + return Promise.reject(new RunAlreadyTerminalError(event.runId)); + } if (bucket === undefined) { this.#events.set(event.runId, [event]); } else { @@ -480,6 +496,7 @@ export class InMemoryRunStore implements RunStore { resumable: suspension.isResumable(runId), ...(suspension.budgetRejections(runId).length === 0 ? {} : { budgetRejected: true }), lastSequenceNumber: events.reduce((max, e) => Math.max(max, e.sequenceNumber), -1), + startedAtMs: Date.parse(started.timestamp), }); } return Promise.resolve(interrupted); diff --git a/packages/core/src/engine/gate-decision-fit.test.ts b/packages/core/src/engine/gate-decision-fit.test.ts new file mode 100644 index 000000000..576142ffa --- /dev/null +++ b/packages/core/src/engine/gate-decision-fit.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from 'vitest'; + +import { decisionsForGateType, gateAcceptsDecision } from './gate-decision-fit.js'; + +describe('the decisions each human gate type accepts (2.6.K, ruled 2026-11-05)', () => { + it('approval and review take approved or rejected, never an input', () => { + for (const gateType of ['approval', 'review'] as const) { + expect(gateAcceptsDecision(gateType, 'approved')).toBe(true); + expect(gateAcceptsDecision(gateType, 'rejected')).toBe(true); + expect(gateAcceptsDecision(gateType, 'input_provided')).toBe(false); + } + }); + + it('input takes its input or a rejection, never an approval', () => { + expect(gateAcceptsDecision('input', 'input_provided')).toBe(true); + expect(gateAcceptsDecision('input', 'rejected')).toBe(true); + expect(gateAcceptsDecision('input', 'approved')).toBe(false); + expect(decisionsForGateType('input')).toEqual(['input_provided', 'rejected']); + }); +}); diff --git a/packages/core/src/engine/gate-decision-fit.ts b/packages/core/src/engine/gate-decision-fit.ts new file mode 100644 index 000000000..7157226df --- /dev/null +++ b/packages/core/src/engine/gate-decision-fit.ts @@ -0,0 +1,27 @@ +import type { GateDecision } from '@relavium/shared'; + +import type { GateType } from './node-executor.js'; + +/** + * The decisions a human gate of each type accepts (2.6.K, ruled by the maintainer on 2026-11-05): an `approval` + * or `review` gate is approved or rejected; an `input` gate takes its input or is rejected. A budget gate has its + * own rules (`budgetApprovalAmount`). + */ +const DECISIONS_BY_GATE_TYPE: Readonly<Record<GateType, readonly GateDecision['decision'][]>> = { + approval: ['approved', 'rejected'], + review: ['approved', 'rejected'], + input: ['input_provided', 'rejected'], +}; + +/** Whether a human gate of `gateType` accepts a decision of this kind. */ +export function gateAcceptsDecision( + gateType: GateType, + decision: GateDecision['decision'], +): boolean { + return DECISIONS_BY_GATE_TYPE[gateType].includes(decision); +} + +/** The decision kinds a human gate of `gateType` accepts, for an actionable refusal. */ +export function decisionsForGateType(gateType: GateType): readonly GateDecision['decision'][] { + return DECISIONS_BY_GATE_TYPE[gateType]; +} diff --git a/packages/core/src/engine/m2-e2e-harness.e2e.test.ts b/packages/core/src/engine/m2-e2e-harness.e2e.test.ts index 8b0760007..ba964b284 100644 --- a/packages/core/src/engine/m2-e2e-harness.e2e.test.ts +++ b/packages/core/src/engine/m2-e2e-harness.e2e.test.ts @@ -2605,7 +2605,8 @@ workflow: const engine = buildEngine(host, () => scriptedProvider([textTurn('x')])); const runId = 'r-interrupted'; - const base = { runId, timestamp: '2026-01-01T00:00:00.000Z' } as const; + // Older than one lease TTL by the host's clock: reconcile leaves a younger run alone. + const base = { runId, timestamp: '2025-12-01T00:00:00.000Z' } as const; await store.persistEvent({ type: 'run:started', ...base, diff --git a/packages/core/src/engine/node-executor.ts b/packages/core/src/engine/node-executor.ts index d8121c6e0..20bdb98a4 100644 --- a/packages/core/src/engine/node-executor.ts +++ b/packages/core/src/engine/node-executor.ts @@ -84,8 +84,9 @@ export interface GateRequest { readonly timeoutMs?: number; /** * What the engine does if the gate's `timeoutMs` elapses with no decision (1.Q): `approve` auto-resolves - * the gate as approved (`decidedBy: 'timeout'`, the run continues); `reject` fails the run with - * `run_timeout` (execution-model.md `AwaitingGate → Failed`). The handler supplies it from the node's + * the gate as approved (`decidedBy: 'timeout'`, the run continues) — except on an `input` gate, which + * cannot be approved, so the refused auto-approval fails the run with `run_timeout`; `reject` fails the + * run with `run_timeout` (execution-model.md `AwaitingGate → Failed`). The handler supplies it from the node's * `timeout_action` (defaulting to the safe `reject`); it is only acted on when `timeoutMs` is set. */ readonly timeoutAction?: 'approve' | 'reject'; diff --git a/packages/core/src/engine/run-lease.test.ts b/packages/core/src/engine/run-lease.test.ts index b833f4f5d..d9a7cb30b 100644 --- a/packages/core/src/engine/run-lease.test.ts +++ b/packages/core/src/engine/run-lease.test.ts @@ -335,7 +335,7 @@ describe('ADR-0079 §7 — reconcile() never terminates a run somebody else is r type: 'run:started', runId, sequenceNumber: 0, - timestamp: '2026-01-01T00:00:00.000Z', + timestamp: '2025-12-01T00:00:00.000Z', // older than one lease TTL by the host's clock workflowId, inputs: {}, executionMode: 'local', @@ -543,13 +543,21 @@ describe('ADR-0079 §4/§7 — a claim that leads nowhere is never leaked', () = // generation — fencing the live execution out at its next write and then deleting its row in the // caller's `finally`. The lease cannot tell the two apart; the in-memory run table can. const store = new InMemoryRunStore(); - const host = createInMemoryHost({ store }); + const base = createInMemoryHost({ store }); + // The run must look older than one lease TTL to reconcile, or the TTL rule would skip it before the own-run + // guard is reached, and this test would pass with the guard gone. + let skew = 0; + const host = { + ...base, + clock: { now: () => new Date(Date.parse(base.clock.now()) + skew).toISOString() }, + }; const engine = new WorkflowEngine({ host, executor: new Stub({ a: inFlight }), }); const handle = engine.start({ workflow: LINEAR }); await until(() => store.eventsFor(handle.runId).length > 0, 'the run started'); + skew = RUN_LEASE_TTL_MS + 1; const repaired = await engine.reconcile(); diff --git a/packages/core/src/export/session-marker.test.ts b/packages/core/src/export/session-marker.test.ts new file mode 100644 index 000000000..19d7040e3 --- /dev/null +++ b/packages/core/src/export/session-marker.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from 'vitest'; + +import { serializeWorkflow, sessionToWorkflow } from './serializer.js'; +import { readSessionExportMarker } from './session-marker.js'; + +const ISO = '2026-11-05T00:00:00.000Z'; + +describe('readSessionExportMarker', () => { + it('reads back the session a scaffold was exported from', () => { + const yaml = serializeWorkflow( + sessionToWorkflow( + { + id: 'sess-42', + agentSlug: 'coder', + title: 'My Session', + context: { workingDir: '/w', fsScopeTier: 'sandboxed' }, + status: 'active', + totalInputTokens: 0, + totalOutputTokens: 0, + totalCostMicrocents: 0, + totalConservativeMicrocents: 0, + createdAt: ISO, + updatedAt: ISO, + }, + [], + ), + ); + expect(readSessionExportMarker(yaml)).toBe('sess-42'); + }); + + it('is undefined for a hand-authored workflow, another marker source, a non-string id, or undecodable text', () => { + const doc = (relaviumExport: string): string => + `schema_version: '1.0'\nworkflow:\n id: w\n metadata:\n relaviumExport: ${relaviumExport}\n nodes: []\n`; + expect(readSessionExportMarker("schema_version: '1.0'\nworkflow:\n id: w\n")).toBeUndefined(); + expect(readSessionExportMarker(doc('{ source: other, sessionId: s }'))).toBeUndefined(); + expect(readSessionExportMarker(doc('{ source: session, sessionId: 7 }'))).toBeUndefined(); + expect(readSessionExportMarker(doc('{ source: session, sessionId: s }'))).toBe('s'); + expect(readSessionExportMarker('workflow: [unclosed')).toBeUndefined(); + // The hardened profile refuses an alias, so a marker cannot be smuggled through one. + expect( + readSessionExportMarker( + `x: &m { source: session, sessionId: s }\nworkflow:\n metadata:\n relaviumExport: *m\n`, + ), + ).toBeUndefined(); + }); +}); diff --git a/packages/core/src/export/session-marker.ts b/packages/core/src/export/session-marker.ts new file mode 100644 index 000000000..59bc730ca --- /dev/null +++ b/packages/core/src/export/session-marker.ts @@ -0,0 +1,36 @@ +import { LineCounter } from 'yaml'; + +import { decodeHardenedYaml } from '../yaml-decode.js'; + +/** + * The session a workflow file was exported from, read back from the marker {@link sessionToWorkflow} writes: + * `workflow.metadata.relaviumExport` with `source: 'session'` and a string `sessionId`. Anything else — a file that + * does not decode, a hand-authored workflow, a marker of another shape — is `undefined`. + * + * It decodes with the one hardened profile every authored-YAML parser shares (ADR-0035) and does NOT validate the + * workflow, on purpose: an export whose historical agent snapshot no longer passes validation is still that + * session's export. The caller bounds the text's size before calling. Pure: no I/O. + * + * [sessionToWorkflow]: ./serializer.ts + */ +export function readSessionExportMarker(yamlText: string): string | undefined { + let document: unknown; + try { + document = decodeHardenedYaml(yamlText, new LineCounter()); + } catch { + return undefined; + } + const marker = field(field(field(document, 'workflow'), 'metadata'), 'relaviumExport'); + if (field(marker, 'source') !== 'session') return undefined; + const sessionId = field(marker, 'sessionId'); + return typeof sessionId === 'string' ? sessionId : undefined; +} + +/** `value[key]` when `value` is a plain mapping with `key` as an own data property; `undefined` otherwise. */ +function field(value: unknown, key: string): unknown { + if (typeof value !== 'object' || value === null || Array.isArray(value)) return undefined; + const descriptor = Object.getOwnPropertyDescriptor(value, key); + if (descriptor === undefined || !('value' in descriptor)) return undefined; + const found: unknown = descriptor.value; + return found; +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 4a21698bd..ab516e559 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -16,7 +16,12 @@ export type { AgentDefinition, ParseAgentOptions } from './agent-parser.js'; // Catalog-aware load-check (1.AF/D15) — validates each agent node's `output_modalities` against its // resolved model's `media.outputCombinations`, using a host-provided model→capabilities catalog. -export { validateWorkflowWithCatalog, type WorkflowModelCatalog } from './validate-catalog.js'; +export { + validateWorkflowToolArms, + validateWorkflowWithCatalog, + type ToolHostArm, + type WorkflowModelCatalog, +} from './validate-catalog.js'; // Typed, field-named, secret-free parse/validation errors — narrow on `code`, never on `message`. export { @@ -200,6 +205,7 @@ export type { GateRequest, GateType, } from './engine/node-executor.js'; +export { decisionsForGateType, gateAcceptsDecision } from './engine/gate-decision-fit.js'; // Typed run-loop API-boundary errors (1.N) — narrow on `code`, never on `message`. export { @@ -355,6 +361,7 @@ export type { SessionHandle, SessionStreamHandleEvent } from './engine/session-h // counterpart (the CLI authoring 2.J `create`/`import`/`export` path); `sessionToWorkflow` maps a persisted // session + transcript into a linear-chain scaffold (ADR-0026; agent-session-spec.md §"Export to workflow"). export { serializeWorkflow, serializeAgent, sessionToWorkflow } from './export/serializer.js'; +export { readSessionExportMarker } from './export/session-marker.js'; // Node-type handlers (1.P) — the six non-agent NodeExecutor arms (condition / transform / fan_out / // fan_in / input / output) plus the dispatcher that composes them (and the 1.O agent arm) into the one diff --git a/packages/core/src/tools/bounding.test.ts b/packages/core/src/tools/bounding.test.ts index edcfd70c1..a305adc8e 100644 --- a/packages/core/src/tools/bounding.test.ts +++ b/packages/core/src/tools/bounding.test.ts @@ -885,25 +885,27 @@ describe('redactSecretShapedValue — the KEY decides, whatever the value looks }); }); + // The `=` spelling keeps a name the text rules keep (`--pwd`, an anchored `--apiKey`) and hides one they + // mask as a key/value pair, as 64fb8772 did. it.each([ - '--auth-token', - '--access-token', - '--pwd', - '--db-password', - '--x-api-key', - '--apiKey', - '--credentials', - '--refresh_token', + ['--auth-token', '[redacted]'], + ['--access-token', '[redacted]'], + ['--pwd', '--pwd=[redacted]'], + ['--db-password', '[redacted]'], + ['--x-api-key', '[redacted]'], + ['--apiKey', '--apiKey=[redacted]'], + ['--credentials', '--credentials=[redacted]'], + ['--refresh_token', '[redacted]'], ])( 'STRUCTURAL_ARGV_WORDS masks the value after %s, whose last word names a credential', - (flag) => { + (flag, inline) => { // A PR #96 review: the object-key rule masked these names as keys while the anchored flag list passed // them verbatim into `agent:tool_call` and the journal projection. expect(redactSecretShapedValue({ args: [flag, 'Qz7flagValue', '--count=4'] })).toEqual({ args: [flag, '[redacted]', '--count=4'], }); expect(redactSecretShapedValue({ args: [`${flag}=Qz7flagValue`] })).toEqual({ - args: [`${flag}=[redacted]`], + args: [inline], }); expect(projectSecretShapedValue({ args: [flag, 'Qz7flagValue'] })).toEqual({ value: { args: [flag, '[redacted]'] }, @@ -923,7 +925,7 @@ describe('redactSecretShapedValue — the KEY decides, whatever the value looks expect(redactSecretShapedValue(benign)).toEqual(benign); }); - it('STRUCTURAL_ARGV_USERINFO masks a curl user:password in every spelling, and only that', () => { + it('STRUCTURAL_ARGV_USERINFO masks a curl user:password in its separate, `=` and glued spellings, and only that', () => { expect( redactSecretShapedValue({ args: ['-u', 'alice:Qz7pw', '-ualice:Qz7pw', '--user=bob:Qz7pw', '--user', 'carol:Qz7pw'], @@ -933,7 +935,18 @@ describe('redactSecretShapedValue — the KEY decides, whatever the value looks }); const benign = { args: [ - ...['-u', 'alice', '--user', 'carol', '-u', '1000:1000', '--user=dave', '-uroot'], + ...[ + '-u', + 'alice', + '--user', + 'carol', + '-u', + '1000:1000', + '--user=dave', + '-uroot', + '-u=1000:1000', + '-u-x:y', + ], ...['--user', 'git+https://example.invalid/r.git'], ], }; @@ -943,6 +956,100 @@ describe('redactSecretShapedValue — the KEY decides, whatever the value looks expect( JSON.stringify(redactSecretShapedValue({ args: ['--user', key, '-u', key] })), ).not.toContain(key); + // A credential flag in value position is masked as the previous flag's value AND masks the element + // after it: a boolean last-word flag before a credential flag, and a flag-shaped password, both stay + // masked, in both projections; a flag-shaped name keeps no secret shape in its `=` spelling. + const ghp = `ghp_${'Q'.repeat(36)}`; + for (const [args, expected] of [ + [ + [ + 'helm', + '--pass-credentials', + '--password', + 'Qz7pw', + 'r', + '--with-token', + '--token', + 'Qz7pw', + ], + [ + 'helm', + '--pass-credentials', + '[redacted]', + '[redacted]', + 'r', + '--with-token', + '[redacted]', + '[redacted]', + ], + ], + [ + ['--ask-password', '--passphrase', '--secret', '-p', 'Qz7pw'], + ['--ask-password', '[redacted]', '[redacted]', '[redacted]', '[redacted]'], + ], + [ + ['mysql', '--password', '-Qz7Secret', 'db', '--token', '--Qz7-password'], + ['mysql', '--password', '[redacted]', '[redacted]', '--token', '[redacted]'], + ], + // A last-word name the text rules mask in context hides the whole `=` spelling; an anchored one stays. + [[`--github-${ghp}-token=Qz7pw`], ['[redacted]']], + ] as const) { + expect(redactSecretShapedValue({ args })).toEqual({ args: expected }); + expect(projectSecretShapedValue({ args }).value).toEqual({ args: expected }); + } + // A last-word name stays where the text rules keep it (a value too short for their key rule) and is + // hidden where they mask it with its value; an anchored name always stays. + const deploy = [ + 'gh', + '--deploy-token=Qz7pw', + '--deploy-token=Qz7flagValue', + '--password=Qz7flagValue', + ]; + for (const projected of [ + redactSecretShapedValue({ args: deploy }), + projectSecretShapedValue({ args: deploy }).value, + ]) { + expect(projected).toEqual({ + args: ['gh', '--deploy-token=[redacted]', '[redacted]', '--password=[redacted]'], + }); + } + // A glued `-u` pair keeps its `-u` only where the text rules keep it (`-utoken:` reads as a key). + for (const projected of [ + redactSecretShapedValue({ args: ['-utoken:Qz7flagValue', '-ualice:Qz7pw'] }), + projectSecretShapedValue({ args: ['-utoken:Qz7flagValue', '-ualice:Qz7pw'] }).value, + ]) { + expect(projected).toEqual({ args: ['[redacted]', '-u[redacted]'] }); + } + // A boolean -u/--user (unzip's update flag) leaves the credential flag after it in force. + expect( + redactSecretShapedValue({ + args: [ + 'unzip', + '-u', + '-P', + 'Qz7pw', + 'a.zip', + '--user', + '--api-key', + 'Qz7pw', + '-u', + '--token=Qz7pw', + ], + }), + ).toEqual({ + args: [ + 'unzip', + '-u', + '-P', + '[redacted]', + 'a.zip', + '--user', + '--api-key', + '[redacted]', + '-u', + '--token=[redacted]', + ], + }); }); it('leaves ordinary structured args alone — the negative control', () => { @@ -1351,23 +1458,28 @@ describe('PR95 raw ?/# userinfo in the journal projection', () => { } }); - it('reads a `,` or `/` in a password through its `@`, never past a port, query or other delimiter', () => { - // A PR #96 review found the authority scan leaving `ssword` and `ss` public here. + it('keeps the authority scan at a `,` or `/` in a password, as before PR #96 (a recorded limit)', () => { + // A PR #96 review found these tails public (its A4). A reading past the delimiter was tried and removed + // on 2026-11-05: under the repeated projection its masks could hide context other rules read, so it could + // not be shown to only add masks. Each input projects exactly as at 64fb8772. for (const [input, expected] of [ - ['https://user:pa,ssword@host.example/', 'https://[redacted]@host.example/'], - ['https://user:pa/ss@host.example/x', 'https://[redacted]@host.example/x'], - ['{"u":"https://user:p,w/x@h.example/v1"}', '{"u":"https://[redacted]@h.example/v1"}'], - ['https://user:pa/ss@host.example/@scope/pkg', 'https://[redacted]@host.example/@scope/pkg'], - // A raw `?` that is not a host's public query stays userinfo, as the authority scan reads it. - ['https://u:p?x/y@h.example/', 'https://[redacted]@h.example/'], - // A key in the username names the user, so its value stops at the `@` and the host stays. - ['https://x-access-token:pa,ss@github.com/o/r', 'https://[redacted]@github.com/o/r'], - // With no `@` to read on to, the scan resumes inside the authority and finds the later scheme. + ['https://user:pa,ssword@host.example/', 'https://user:pa,ssword@host.example/'], + ['https://user:pa/ss@host.example/x', 'https://user:pa/ss@host.example/x'], + ['https://u:p?x/y@h.example/', 'https://u:p?x/y@h.example/'], + // Keys in the userinfo are still read by the key/value rules. + ['https://x-access-token:Ab/cd@Ef9Gh2Jk@github.com/o/r.git', 'https://[redacted]'], + ['http://svc;token=a:b/c@d', 'http://svc;[redacted]'], + ['http://svc?token=abc:de/fgh@ijk9', 'http://svc?[redacted]'], + // A later or nested scheme is read by the scheme scan on its own. + [ + 'curl https://proxy.example:https://user:p@ss@target.example/', + 'curl https://proxy.example:https://[redacted]@target.example/', + ], ['https://xhttps://user:pw@h.example/', 'https://xhttps://[redacted]@h.example/'], ] as const) { expect(redactSecretShapedText(input), input).toBe(expected); } - // The backstop already reads a comma; both now mask it. + // The backstop reads a comma in a password; core does not (a recorded difference). expect(scrubSecrets('https://user:pa,ssword@host.example/')).toBe( 'https://[REDACTED]@host.example/', ); @@ -1381,6 +1493,13 @@ describe('PR95 raw ?/# userinfo in the journal projection', () => { "'https://u:pw/x', 'a@b.example'", 'https://a:b/https://c@d.example', `https://user:pa/${'s'.repeat(600)}@host.example/`, + 'https://user:pa,ss?1@host.example/', + 'https://user:pa/ss#1@host.example/', + '{"a":"https://h.example:x/p","b":"c@d.example"}', + "https://u:pw/x'@y.example", + 'https://u:pw/x}@y.example', + 'https://u:pw/x]@y.example', + 'https://u:pw/x @y.example', ]) { expect(redactSecretShapedText(input), input).toBe(input); } @@ -2907,6 +3026,17 @@ describe('PR95 review round 8, sixth pre-review: escapes under nested masks, PEM 'bbbbbbbb', ], [{ stdout: 'AKIAABCDEFGH\nIJKLMNOP' }, '{"stdout":"[redacted]"}', 'IJKLMNOP'], + // A `,`/`/` URL password never reads across an escape the key rule needs to see. + [ + { stdout: 'password="https://u:pw/x\nnext@h', stderr: 'Zq9SecondLine"' }, + '{"stdout":"[redacted]"}', + 'Zq9SecondLine', + ], + [ + { stdout: 'password="https://u:p\nw/x@h', stderr: 'Zq9Second"' }, + '{"stdout":"[redacted]"}', + 'Zq9Second', + ], [ { stdout: 'progress 50%\rsk-proj-abcdefghijklmnopqrstuvwxyz' }, '{"stdout":"progress 50%\\r[redacted]"}', diff --git a/packages/core/src/tools/bounding.ts b/packages/core/src/tools/bounding.ts index 5ce628157..70c6776dc 100644 --- a/packages/core/src/tools/bounding.ts +++ b/packages/core/src/tools/bounding.ts @@ -81,9 +81,10 @@ export function redactInlineMedia(value: unknown): unknown { /** * The size of the inline media a text projection replaced with a marker. The model-facing value still holds - * those bytes — a builtin's media rides `attachMedia` instead (ADR-0089 §1), so media inside a dispatch - * result is ordinary text to the model and to the effect journal. A PR #96 review found a 30 MiB data URI - * measured as its 25-character marker, passed whole to the model and settled into `history.db`. + * those bytes — a builtin's media rides `attachMedia` instead (ADR-0089 §1). The seam refuses raw media bytes + * in `tool_result.result` (ADR-0031), while the effect journal settles the value as it is. A PR #96 review + * found a 30 MiB data URI measured as its 25-character marker and settled whole into `history.db`; counting + * the bytes turns such a result into a media-free preview. */ interface OmittedMedia { bytes: number; @@ -1140,12 +1141,11 @@ const HOST_SHAPED_MAX_LENGTH = 260; * its last `@`. This projection masks the userinfo and reads every key and parameter in it but its * username's; their values keep their own extent and the rules' ranges merge * (`https://gateway?password=P@ss.word1` projects to `https://[redacted]`). - * The projection differs from that backstop by design: its authority also ends at `"`, `'`, - * `}` and `]`, the delimiters of the JSON and quoted texts it reads, so userinfo containing one of - * them (an already-masked `[REDACTED]` password among them) is left as it is; it recognizes any - * scheme of up to 32 characters rather than a finite list; and a userinfo with no password is a - * vanity handle below 16 characters and masked from 16 on, under every scheme. A `,` or `/` in a - * password is read on to its `@` ({@link passwordPastDelimiter}). + * The projection differs from that backstop by design: its authority also ends at `"`, `'`, `,`, + * `}` and `]`, so userinfo containing one of them (an already-masked `[REDACTED]` password among + * them) is left as it is; it recognizes any scheme of up to 32 characters rather than a finite list; + * and a userinfo with no password is a vanity handle below 16 characters and masked from 16 on, + * under every scheme. */ function isPublicQueryContact(userinfo: string, query: number): boolean { // A longer prefix cannot be a hostname, and an unbounded dotted run would exhaust V8's RegExp @@ -1154,49 +1154,6 @@ function isPublicQueryContact(userinfo: string, query: number): boolean { return HOST_SHAPED_AUTHORITY.test(userinfo.slice(0, query)) && userinfo.includes('=', query + 1); } -/** - * The `@` of a password that holds a `,` or `/`, or -1. The authority scan stops at either, so - * `https://user:pa,ss@host` and `https://user:pa/ss@host` left the password's tail public (a PR #96 - * review finding; RFC 3986 admits a raw `,` in userinfo, and a raw `/` is a malformed URL a - * credential can still sit in). Reads on past them only, to the first `@` within 512 characters. A - * port (`host:8080/a@b`, `host:8080,bob@x.io`), a public query contact as the authority scan reads - * one ({@link isPublicQueryContact}), another scheme, and the delimiters the authority keeps - * (whitespace, quotes, `}`, `]`) keep the text public. - */ -function passwordPastDelimiter(text: string, start: number, end: number): number { - if (text[end] !== ',' && text[end] !== '/') return -1; - const authority = text.slice(start, end); - const colon = authority.indexOf(':'); - if (colon < 0 || /^\d*$/.test(authority.slice(colon + 1))) return -1; - const query = authority.search(/[?#]/); - if (query >= 0 && isPublicQueryContact(authority, query)) return -1; - PASSWORD_TAIL.lastIndex = end; - const tail = PASSWORD_TAIL.exec(text)?.[0]; - if (tail === undefined || tail.includes('://')) return -1; - return end + tail.length - 1; -} - -/** - * Mask a userinfo whose password holds a `,` or `/` ({@link passwordPastDelimiter}); its username owns the - * text before the first `:`, as in {@link visitUrlUserinfo}. Returns where the scheme scan resumes: after - * the `@` it masked through, or at `start` when there is none. - */ -function maskPasswordPastDelimiter( - text: string, - schemeStart: number, - start: number, - end: number, - mask: SecretSpanVisitor, -): number { - const passwordAt = passwordPastDelimiter(text, start, end); - if (passwordAt < 0) return start; - mask(start, passwordAt, '[redacted]', { schemeStart, ownsUntil: text.indexOf(':', start) }); - return passwordAt + 1; -} - -/** A password's tail past a `,` or `/`, through its `@`; bounded so a long run is never rescanned. */ -const PASSWORD_TAIL = /[^\s"'}\]?#@]{0,512}@/y; - function visitUrlUserinfo(text: string, mask: SecretSpanVisitor): void { if (!text.includes('://')) return; const scheme = /[a-z][\w+.-]{0,31}:\/\//gi; @@ -1209,10 +1166,7 @@ function visitUrlUserinfo(text: string, mask: SecretSpanVisitor): void { const firstQuery = authority.search(/[?#]/); const authorityAt = firstQuery < 0 ? -1 : authority.lastIndexOf('@', firstQuery - 1); const at = authorityAt >= 0 ? authorityAt : authority.lastIndexOf('@'); - if (at < 0) { - scheme.lastIndex = maskPasswordPastDelimiter(text, match.index, start, end, mask); - continue; - } + if (at < 0) continue; const userinfo = authority.slice(0, at); const colon = userinfo.indexOf(':'); const query = userinfo.search(/[?#]/); @@ -1453,18 +1407,26 @@ function redactArgVector( const out: unknown[] = []; let redactNext: string | undefined; for (const item of items) { - if (redactNext !== undefined) { - const masked = redactCredentialArgument(redactNext, item, whole); - out.push(masked ?? redactSecretShapedWalk(item, seen, project, collide, whole)); - redactNext = undefined; + // A credential flag in value position may be the previous flag's value (`--password -MySecret`) or a flag + // of its own (`--ask-password --password <pw>`): it is masked as a value and still masks the element after + // it, so neither reading leaks. A `-u`/`--user` value that is no user:password pair is processed as any + // element (D64 round-1 to round-3 findings on the PR #96 correction). + const flag = typeof item === 'string' && isCredentialFlag(item); + const masked = + redactNext === undefined ? undefined : redactCredentialArgument(redactNext, item, whole); + redactNext = undefined; + if (masked !== undefined) { + out.push(masked); + if (flag) redactNext = item; continue; } - const inline = typeof item === 'string' ? redactInlineCredential(item, whole) : undefined; + const inline = + typeof item === 'string' ? redactInlineCredential(item, project, whole) : undefined; if (inline !== undefined) { out.push(inline); continue; } - if (typeof item === 'string' && isCredentialFlag(item)) redactNext = item; + if (flag) redactNext = item; out.push(redactSecretShapedWalk(item, seen, project, collide, whole)); } return out; @@ -1474,15 +1436,34 @@ function redactArgVector( * One argument that carries its own credential, masked: a credential flag's `--flag=value` spelling or curl's * glued `-ualice:secret`. `undefined` when the argument is neither, so the vector walks it as usual. */ -function redactInlineCredential(item: string, whole: WholeMask): string | undefined { +function redactInlineCredential( + item: string, + project: TextProjection, + whole: WholeMask, +): string | undefined { const separator = item.indexOf('='); const flag = separator > 0 ? item.slice(0, separator) : ''; if (isCredentialFlag(flag)) { const masked = redactCredentialArgument(flag, item.slice(separator + 1), whole); - if (masked !== undefined) return `${flag}=${masked}`; + if (masked !== undefined) { + return keepsFlagName(item, flag, project) ? `${flag}=${masked}` : whole(item, '[redacted]'); + } } const glued = gluedUserPassword(item); - return glued === undefined ? undefined : `-u${whole(glued, '[redacted]')}`; + if (glued === undefined) return undefined; + return project(item).startsWith('-u') + ? `-u${whole(glued, '[redacted]')}` + : whole(item, '[redacted]'); +} + +/** + * Whether a credential flag's `--flag=value` spelling keeps its name visible: an anchored name always has; a + * last-word name only where the text rules leave it in place, so this spelling never shows a name they mask + * (`--db-token=…` reads as a key/value pair, a secret shape may sit in the name). Otherwise the element is + * masked whole. + */ +function keepsFlagName(item: string, flag: string, project: TextProjection): boolean { + return SECRETISH_FLAG.test(flag) || project(item).startsWith(`${flag}=`); } /** diff --git a/packages/core/src/validate-catalog.test.ts b/packages/core/src/validate-catalog.test.ts index 91f9bb29e..eef49a529 100644 --- a/packages/core/src/validate-catalog.test.ts +++ b/packages/core/src/validate-catalog.test.ts @@ -4,7 +4,12 @@ import { describe, expect, it } from 'vitest'; import { WorkflowValidationError } from './errors.js'; import { parseWorkflow, type WorkflowDefinition } from './parser.js'; -import { validateWorkflowWithCatalog, type WorkflowModelCatalog } from './validate-catalog.js'; +import { + MAX_REPORTED_TOOL_ARM_NODES, + validateWorkflowToolArms, + validateWorkflowWithCatalog, + type WorkflowModelCatalog, +} from './validate-catalog.js'; /** Capabilities with the given output combinations (input flags off; vision=input.image per the refine). */ function caps(outputCombinations: readonly (readonly OutputModality[])[]): CapabilityFlags { @@ -255,3 +260,83 @@ describe('validateWorkflowWithCatalog (1.AF/D15 — output_modalities load-check ).toThrow(WorkflowValidationError); }); }); + +describe('run-path tool pre-flight for unwired host arms (2.6.A, MVP-1)', () => { + function workflowGranting(agentTools: string, nodeTools = ''): WorkflowDefinition { + return parseWorkflow( + `schema_version: '1.0'\nworkflow:\n id: wf\n agents:\n - { id: writer, model: gpt-4o, provider: openai, system_prompt: hi, tools: ${agentTools} }\n nodes:\n - { id: n, type: agent, agent_ref: writer${nodeTools} }\n edges: []`, + ); + } + const runPath = { unwired: ['egress', 'os'] as const }; + + function issuesOf(workflow: WorkflowDefinition, unwired: readonly ('egress' | 'os')[]): string[] { + try { + validateWorkflowToolArms(workflow, { unwired }); + return []; + } catch (error) { + if (!(error instanceof WorkflowValidationError)) throw error; + return error.issues.map((issue) => `${issue.field}: ${issue.message}`); + } + } + + it('refuses each egress and os built-in, naming the tools, never a payload', () => { + const issues = issuesOf( + workflowGranting("['read_file', 'web_search', 'http_request', 'read_clipboard', 'notify']"), + runPath.unwired, + ); + expect(issues).toHaveLength(1); + expect(issues[0]).toContain('node `n`.tools'); + expect(issues[0]).toContain( + 'web_search, http_request, read_clipboard, notify need a host capability', + ); + expect(issues[0]).not.toContain('read_file'); + }); + + it('passes filesystem, process and MCP tools, and refuses only the arm left unwired', () => { + expect( + issuesOf(workflowGranting("['read_file', 'run_command', 'mcp_call']"), runPath.unwired), + ).toEqual([]); + expect(issuesOf(workflowGranting("['web_search']"), ['os'])).toEqual([]); + expect(issuesOf(workflowGranting("['notify']"), ['os'])).toHaveLength(1); + }); + + it('names every offending node once each, after a node that grants nothing', () => { + const workflow = parseWorkflow( + `schema_version: '1.0'\nworkflow:\n id: wf\n agents:\n - { id: writer, model: gpt-4o, provider: openai, system_prompt: hi, tools: ['web_search'] }\n nodes:\n - { id: start, type: input }\n - { id: a, type: agent, agent_ref: writer }\n - { id: b, type: agent, agent_ref: writer, tools: ['notify', 'read_file'] }\n edges: []`, + ); + const issues = issuesOf(workflow, runPath.unwired); + expect(issues).toEqual([ + 'node `a`.tools: web_search needs a host capability this run path does not provide — remove the grant to run this workflow here', + 'node `b`.tools: notify needs a host capability this run path does not provide — remove the grant to run this workflow here', + ]); + }); + + it('stays bounded on repeated grants across many nodes: each tool once, ten nodes named, the rest counted', () => { + const grant = `[${Array.from({ length: 2_000 }, () => "'web_search', 'notify'").join(', ')}]`; + const nodes = Array.from( + { length: 600 }, + (_, index) => ` - { id: n${String(index)}, type: agent, agent_ref: writer }`, + ).join('\n'); + const workflow = parseWorkflow( + `schema_version: '1.0'\nworkflow:\n id: wf\n agents:\n - { id: writer, model: gpt-4o, provider: openai, system_prompt: hi, tools: ${grant} }\n nodes:\n${nodes}\n edges: []`, + ); + const issues = issuesOf(workflow, runPath.unwired); + expect(issues).toHaveLength(MAX_REPORTED_TOOL_ARM_NODES + 1); + expect(issues[0]).toBe( + 'node `n0`.tools: web_search, notify need a host capability this run path does not provide — remove the grant to run this workflow here', + ); + expect(issues.at(-1)).toBe( + 'workflow.nodes: 590 more agent nodes grant a tool this run path does not provide', + ); + expect(issues.join('\n').length).toBeLessThan(2_000); + }); + + it("reads the node's narrowing `tools:` over its agent's", () => { + expect( + issuesOf( + workflowGranting("['read_file', 'web_search']", ", tools: ['read_file']"), + runPath.unwired, + ), + ).toEqual([]); + }); +}); diff --git a/packages/core/src/validate-catalog.ts b/packages/core/src/validate-catalog.ts index 273673754..6f18708b3 100644 --- a/packages/core/src/validate-catalog.ts +++ b/packages/core/src/validate-catalog.ts @@ -7,6 +7,8 @@ import { import { WorkflowValidationError, type WorkflowIssue } from './errors.js'; import type { WorkflowDefinition } from './parser.js'; +import { BUILTIN_TOOLS } from './tools/builtins.js'; +import type { ToolDef } from './tools/types.js'; /** Type guard: a media-billed output modality (image | audio | video) — no `text`. Avoids an `as` cast when * narrowing a node's `output_modalities` to the billed subset (the generative one-modality load-check). */ @@ -100,9 +102,13 @@ function candidateModels(node: WorkflowNode, workflow: WorkflowDefinition): read * Returned as a count because the load-check only needs "does this node use tools at all". */ function grantedToolCount(node: WorkflowNode, workflow: WorkflowDefinition): number { - if (node.type !== 'agent') return 0; - if (node.tools !== undefined) return node.tools.length; - return inlineAgent(node, workflow)?.tools?.length ?? 0; + return grantedTools(node, workflow).length; +} + +/** The authored tool names a node would dispatch with: the node's `tools:` when present, else its inline agent's. */ +function grantedTools(node: WorkflowNode, workflow: WorkflowDefinition): readonly string[] { + if (node.type !== 'agent') return []; + return node.tools ?? inlineAgent(node, workflow)?.tools ?? []; } /** @@ -214,3 +220,81 @@ export function validateWorkflowWithCatalog( throw new WorkflowValidationError(issues); } } + +/** A host capability a built-in tool reaches through, which a surface may leave unwired on one path. */ +export type ToolHostArm = 'egress' | 'os'; + +/** The host arm a built-in needs: `os` for the clipboard and notifications, `egress` for HTTP and search. */ +function toolHostArm(tool: ToolDef): ToolHostArm | undefined { + if (tool.policy.os === true) return 'os'; + // MCP reaches through its own host arm (`host.mcp`), so it needs neither. Any other egress kind, a future one + // included, needs the egress arm: an unknown kind is refused rather than admitted. + if (tool.policy.egress !== undefined && tool.policy.egress !== 'mcp') return 'egress'; + return undefined; +} + +const TOOL_HOST_ARMS: ReadonlyMap<string, ToolHostArm> = new Map( + BUILTIN_TOOLS.flatMap((tool): [string, ToolHostArm][] => { + const arm = toolHostArm(tool); + return arm === undefined ? [] : [[tool.id, arm]]; + }), +); + +/** Offending nodes named one by one before the rest are counted (as `MAX_REPORTED_WIDENINGS` bounds dag.ts). */ +export const MAX_REPORTED_TOOL_ARM_NODES = 10; + +/** + * The run-path tool pre-flight (2.6.A, MVP-1): refuse a workflow whose agent node grants a built-in that needs a + * host arm this path does not wire. The CLI's `relavium run` wires neither egress nor os, so `web_search`, + * `http_request`, `read_clipboard` or `notify` there cannot succeed: each call fails, with `tool_unavailable` once it + * passes policy, partway through a run that may already have spent money; refusing before the run starts is the + * louder signal. A `$ref`'d + * agent with no node `tools:` is deferred, as the catalog checks defer it. Throws a {@link WorkflowValidationError} + * naming each offending node and its distinct offending tools, never a payload. + * + * **Bounded.** It runs before the plan's node ceiling (ADR-0086), on a file that may hold thousands of nodes and + * grants with thousands of entries. Each grant list is read once (nodes that bind one inline agent share its + * list), a node names each offending tool once, and only the first {@link MAX_REPORTED_TOOL_ARM_NODES} nodes are + * named before the rest are counted. + */ +export function validateWorkflowToolArms( + workflow: WorkflowDefinition, + opts: { readonly unwired: readonly ToolHostArm[] }, +): void { + const offendingByGrant = new Map<readonly string[], readonly string[]>(); + const offendingIn = (grant: readonly string[]): readonly string[] => { + const known = offendingByGrant.get(grant); + if (known !== undefined) return known; + const found = new Set<string>(); + for (const tool of grant) { + const arm = TOOL_HOST_ARMS.get(tool); + if (arm !== undefined && opts.unwired.includes(arm)) found.add(tool); + } + const offending = [...found]; + offendingByGrant.set(grant, offending); + return offending; + }; + const issues: WorkflowIssue[] = []; + let unreported = 0; + for (const node of workflow.workflow.nodes) { + const tools = offendingIn(grantedTools(node, workflow)); + if (tools.length === 0) continue; + if (issues.length >= MAX_REPORTED_TOOL_ARM_NODES) { + unreported += 1; + continue; + } + issues.push({ + field: `node \`${node.id}\`.tools`, + message: `${tools.join(', ')} ${tools.length === 1 ? 'needs' : 'need'} a host capability this run path does not provide — remove the grant to run this workflow here`, + }); + } + if (unreported > 0) { + issues.push({ + field: 'workflow.nodes', + message: `${String(unreported)} more agent ${unreported === 1 ? 'node grants' : 'nodes grant'} a tool this run path does not provide`, + }); + } + if (issues.length > 0) { + throw new WorkflowValidationError(issues); + } +} diff --git a/packages/db/src/media-store-temporaries.test.ts b/packages/db/src/media-store-temporaries.test.ts index 038ad89ae..7369d5302 100644 --- a/packages/db/src/media-store-temporaries.test.ts +++ b/packages/db/src/media-store-temporaries.test.ts @@ -35,6 +35,13 @@ interface Worker { release: () => void; stop: () => Promise<void>; } +/** + * A fresh fork boots Node and the TypeScript loader before it reports its first stage. On a loaded Windows runner + * that alone has taken over four seconds (CI, 2026-11-06: 3.3 s to the loader, 4.4 s to the worker), so each + * worker's first stage gets a cold-start budget, under the case's 15 s; the IPC barriers after it keep 5 s. + */ +const COLD_START_STAGE_MS = 12_000; +const STAGE_MS = 5_000; let base: string; let root: string; const workers: Worker[] = []; @@ -109,11 +116,14 @@ function worker(mode: string, stage?: string): Worker { resolve(); }); }); + let firstStageAsked = false; const result: Worker = { child, closed, wait: (stage) => new Promise((resolve, reject) => { + const budgetMs = firstStageAsked ? STAGE_MS : COLD_START_STAGE_MS; + firstStageAsked = true; const timer = setTimeout(() => { pending.delete(check); reject( @@ -121,7 +131,7 @@ function worker(mode: string, stage?: string): Worker { `native fixture did not reach ${stage}: ${JSON.stringify({ phases, exitCode, exitSignal, closed: ended, faulted })}: ${diagnostic}`, ), ); - }, 5000); + }, budgetMs); const check = (): void => { const match = messages.find((message) => stage === 'done' ? message['done'] === true : message['stage'] === stage, diff --git a/packages/db/src/run-history-store.test.ts b/packages/db/src/run-history-store.test.ts index 45127e526..b28e9a73a 100644 --- a/packages/db/src/run-history-store.test.ts +++ b/packages/db/src/run-history-store.test.ts @@ -2,6 +2,7 @@ import { AllowanceQuoteResultSchema, isAppendConflictError, isLeaseFencedError, + RunAlreadyTerminalError, RunEventSchema, type RunEvent, } from '@relavium/shared'; @@ -288,6 +289,67 @@ describe('createRunHistoryStore', () => { ).resolves.toBeUndefined(); }); + // --- exactly one terminal (ADR-0036), held by the store for every writer ------------------------------- + + const failed = (seq: number) => + ev('run:failed', seq, { + error: { code: 'internal', message: 'reconciled', retryable: false }, + partialOutputs: {}, + }); + const completed = (seq: number) => + ev('run:completed', seq, { + outputs: {}, + totalTokensUsed: { input: 0, output: 0 }, + totalCostMicrocents: 0, + durationMs: 0, + }); + + it('REFUSES a second terminal, guarded or not, and writes nothing', async () => { + // The reachable case: a reconcile settles a run between its `run:started` and its lease acquire, and the + // run's own terminal follows. The engine's terminal is unguarded (ADR-0078 §2) and, for a writer that + // never acquired, unfenced — so this refusal is the only thing between that terminal and the log. + const workflowId = await store.resolveWorkflowId('demo'); + await store.persistEvent( + ev('run:started', 0, { workflowId, inputs: {}, executionMode: 'local' }), + { expectedLastSequenceNumber: -1 }, + ); + await store.persistEvent(failed(1), { expectedLastSequenceNumber: 0 }); + + await expect(store.persistEvent(completed(2))).rejects.toBeInstanceOf(RunAlreadyTerminalError); + // A guarded terminal whose belief matches the log is still a SECOND terminal. + await expect( + store.persistEvent(ev('run:cancelled', 2, {}), { + expectedLastSequenceNumber: 1, + }), + ).rejects.toBeInstanceOf(RunAlreadyTerminalError); + + expect( + store.loadRunEvents('run-1').map((e) => `${String(e.sequenceNumber)}:${e.type}`), + ).toEqual(['0:run:started', '1:run:failed']); + // The derived row was not relabelled by the refused write either. + expect( + client.db.select({ status: runs.status }).from(runs).where(eq(runs.id, 'run-1')).get(), + ).toEqual({ status: 'failed' }); + }); + + it('scopes the terminal refusal PER RUN — another run`s terminal does not close this one', async () => { + const workflowId = await store.resolveWorkflowId('demo'); + await store.persistEvent( + ev('run:started', 0, { workflowId, inputs: {}, executionMode: 'local' }), + ); + await store.persistEvent(failed(1)); + await store.persistEvent({ + type: 'run:started', + runId: 'run-2', + timestamp: TS, + sequenceNumber: 0, + workflowId, + inputs: {}, + executionMode: 'local', + }); + await expect(store.persistEvent({ ...completed(1), runId: 'run-2' })).resolves.toBeUndefined(); + }); + // --- the fence on the durable write (CR-11, ADR-0079 §2) -------------------------------------------- it('ACCEPTS a write carrying the lease the store actually holds', async () => { @@ -779,6 +841,8 @@ describe('createRunHistoryStore', () => { expect(byId.get('run-p')?.resumable).toBe(true); expect(byId.get('run-p')?.lastSequenceNumber).toBe(1); expect(byId.get('run-m')?.resumable).toBe(false); + // When each run started, so reconcile can leave a run younger than one lease TTL alone. + expect(byId.get('run-m')?.startedAtMs).toBe(Date.parse(TS)); }); it.each(['gate_conflict', 'invalid_json'] as const)( diff --git a/packages/db/src/run-history-store.ts b/packages/db/src/run-history-store.ts index d4c2227c9..64da1f519 100644 --- a/packages/db/src/run-history-store.ts +++ b/packages/db/src/run-history-store.ts @@ -4,6 +4,7 @@ import { CorruptRunEventError, LeaseFencedError, parseStoredRunEvent, + RunAlreadyTerminalError, RunEventSchema, RunSuspensionReducer, type DurableWriteContext, @@ -109,6 +110,8 @@ export interface InterruptedRunInfo { readonly budgetRejected?: boolean; /** The highest `sequenceNumber` already persisted for this run. */ readonly lastSequenceNumber: number; + /** The run's `started_at` (its `run:started` timestamp, epoch ms); absent for a row that has none. */ + readonly startedAtMs?: number; } /** A run summary for `relavium list` / `status` (2.I) — row-derived, with ISO timestamps. */ @@ -396,6 +399,13 @@ export interface RunLeaseState extends RunLease { const NON_TERMINAL_STATUSES = ['pending', 'running', 'paused'] as const; +/** The run terminals — a log holds at most one, and {@link createRunHistoryStore}'s fold refuses a second. */ +const TERMINAL_EVENT_TYPES: ReadonlySet<string> = new Set<RunEvent['type']>([ + 'run:completed', + 'run:failed', + 'run:cancelled', +]); + /** The per-token/tool streaming firehose — the highest-volume events, which checkpoint reconstruction and gate * detection ignore. {@link RunHistoryReader.loadRunStateEvents} validates every stored row first, then excludes * these from its returned fold so a corrupted discriminator cannot hide a durable suspension. @@ -508,6 +518,7 @@ function readInterruptedRuns(db: Db): InterruptedRunInfo[] { .select({ id: runs.id, workflowId: runs.workflowId, + startedAt: runs.startedAt, seq: runEvents.seq, eventType: runEvents.eventType, payloadJson: runEvents.payloadJson, @@ -526,6 +537,7 @@ function readInterruptedRuns(db: Db): InterruptedRunInfo[] { workflowId: row.workflowId, resumable: false, lastSequenceNumber: Math.max(prior?.lastSequenceNumber ?? 0, row.seq ?? 0), + ...(row.startedAt === null ? {} : { startedAtMs: row.startedAt }), }); if (row.seq === null || row.eventType === null || row.payloadJson === null) continue; const event = readStoredEventRow(row.id, { @@ -1031,6 +1043,22 @@ export function createRunHistoryStore(db: Db, deps: RunHistoryStoreDeps): RunHis ); } } + // **Exactly one terminal (ADR-0036), held HERE for every writer.** Neither check above stops a second + // one: the engine exempts its terminal from the append guard (ADR-0078 §2), and a writer that never + // acquired a lease carries no fence. The reachable case is a `reconcile()` in another process settling a + // run in the moment between its durable `run:started` and its lease acquire, then releasing its claim — + // the run's own acquire then succeeds and its terminal would land behind the first. Read from the event + // rows, not `runs.status`, for the same no-second-source reason as `max(seq)` above. + if (TERMINAL_EVENT_TYPES.has(event.type)) { + const prior = tx + .select({ seq: runEvents.seq }) + .from(runEvents) + .where( + and(eq(runEvents.runId, runId), inArray(runEvents.eventType, [...TERMINAL_EVENT_TYPES])), + ) + .get(); + if (prior !== undefined) throw new RunAlreadyTerminalError(runId); + } applyDerived(tx, event, runId, ts); const eventRow: NewRunEventRow = { id: deps.uuid(), diff --git a/packages/shared/src/config.test.ts b/packages/shared/src/config.test.ts index ffdc16c1a..3228d7b86 100644 --- a/packages/shared/src/config.test.ts +++ b/packages/shared/src/config.test.ts @@ -304,6 +304,9 @@ describe('config schemas', () => { expect(ProjectConfigSchema.safeParse({ chat: { on_exceed: 'explode' } }).success).toBe(false); // 0 = unbounded here (nonNegativeInt) — deliberately unlike the workflow budget's positiveInt. expect(ProjectConfigSchema.safeParse({ chat: { max_cost_microcents: 0 } }).success).toBe(true); + // [chat].max_tokens (MVP-A7) is a positive cap: 0 would mean nothing. + expect(ProjectConfigSchema.safeParse({ chat: { max_tokens: 4000 } }).success).toBe(true); + expect(ProjectConfigSchema.safeParse({ chat: { max_tokens: 0 } }).success).toBe(false); }); it('preserves absent and empty filesystem selectors and admits relative selectors (ADR-0105)', () => { diff --git a/packages/shared/src/config.ts b/packages/shared/src/config.ts index 33bcaaa0f..18a02626f 100644 --- a/packages/shared/src/config.ts +++ b/packages/shared/src/config.ts @@ -266,6 +266,9 @@ export const ChatConfigSchema = z auto_compact: z.boolean().optional(), compact_threshold: z.number().gt(0).lte(1).optional(), max_cost_microcents: nonNegativeInt.optional(), // 0/absent = unbounded; >0 = per-session cap + // The output cap for a chat whose bound agent authors no `max_tokens` (MVP-A7, ADR-0101 amendment of + // 2026-11-05). Absent ⇒ an Anthropic chat on the official endpoint uses the model's catalog ceiling. + max_tokens: positiveInt.optional(), on_exceed: z.enum(ON_EXCEED_ACTIONS).optional(), // Refuse a turn on a model we cannot PRICE (ADR-0071 §K7). Default false. The chat counterpart of a workflow // `budget.strict_cost_cap`: an unpriced model is a hole in the cap, and a user who set `max_cost_microcents` diff --git a/packages/shared/src/node.test.ts b/packages/shared/src/node.test.ts index 51b49f342..44b64e11e 100644 --- a/packages/shared/src/node.test.ts +++ b/packages/shared/src/node.test.ts @@ -5,6 +5,7 @@ import { ConditionNodeSchema, HumanGateNodeSchema, NodeSchema, + NodeSnapshotSchema, ParallelNodeSchema, } from './node.js'; @@ -122,6 +123,45 @@ describe.each([ ); }); +describe.each([ + ['human gate leaf', HumanGateNodeSchema], + ['node union', NodeSchema], +] as const)('%s input-gate timeout action', (_label, schema) => { + const input = { id: 'ask', type: 'human_gate', gate_type: 'input', timeout_ms: 1 }; + + it('refuses timeout_action=approve on an input gate, at the action field', () => { + const result = schema.safeParse({ ...input, timeout_action: 'approve' }); + expect(result.success).toBe(false); + if (!result.success) + expect(result.error.issues).toEqual([ + expect.objectContaining({ + path: ['timeout_action'], + message: + 'an input gate cannot approve on timeout — it takes its input or is rejected; use timeout_action: reject', + }), + ]); + }); + + it('permits an input gate that rejects on timeout or names no action', () => { + expect(schema.parse({ ...input, timeout_action: 'reject' })).toEqual({ + ...input, + timeout_action: 'reject', + }); + expect(schema.parse(input)).toEqual(input); + }); +}); + +it('the frozen snapshot reader still loads an input gate that approves on timeout', () => { + const parked = { + id: 'ask', + type: 'human_gate', + gate_type: 'input', + timeout_ms: 1, + timeout_action: 'approve', + }; + expect(NodeSnapshotSchema.parse(parked)).toEqual(parked); +}); + describe('NodeSchema', () => { it('the union has exactly the eight authored node types', () => { // Each type's acceptance is proven by "accepts a minimal valid node of each type" diff --git a/packages/shared/src/node.ts b/packages/shared/src/node.ts index 827feb8be..ce9f695b7 100644 --- a/packages/shared/src/node.ts +++ b/packages/shared/src/node.ts @@ -155,7 +155,11 @@ const HumanGateNodeFields = z }) .strict(); -/** Keep the cross-field rule identical on the exported leaf and the discriminated union. */ +/** + * Keep the cross-field rules identical on the exported leaf and the discriminated union. Authoring only: the + * frozen snapshot reader ({@link NodeSnapshotSchema}) applies neither, so a run parked before a rule existed still + * loads. + */ function validateHumanGateTimeout( node: z.infer<typeof HumanGateNodeFields>, ctx: z.RefinementCtx, @@ -167,6 +171,16 @@ function validateHumanGateTimeout( message: 'timeout_action requires timeout_ms', }); } + // An input gate takes its input or is rejected (2.6.K, ruled 2026-11-05); there is nothing for a deadline to + // approve, so the engine refuses the approval and the run fails with `run_timeout`. Say so at admission instead. + if (node.gate_type === 'input' && node.timeout_action === 'approve') { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['timeout_action'], + message: + 'an input gate cannot approve on timeout — it takes its input or is rejected; use timeout_action: reject', + }); + } } export const HumanGateNodeSchema = HumanGateNodeFields.superRefine(validateHumanGateTimeout); diff --git a/packages/shared/src/run.ts b/packages/shared/src/run.ts index 6f4ef9796..d25dc24d8 100644 --- a/packages/shared/src/run.ts +++ b/packages/shared/src/run.ts @@ -259,6 +259,29 @@ export function isLeaseFencedError(value: unknown): value is LeaseFencedError { return value instanceof LeaseFencedError; } +/** + * A terminal refused because the run's log already ends in one (ADR-0036's exactly-one-terminal, held by + * the store for every writer). + * + * The engine exempts its terminal from the append guard (ADR-0078 §2), and a writer that never acquired a + * lease carries no fence, so neither check stops a second terminal. This one does. The reachable case is a + * run whose `run:started` is durable but whose lease is not yet acquired: a `reconcile()` in another process + * can settle it in that window and release its claim, after which the run's own acquire succeeds and its + * terminal would land behind the first. + */ +export class RunAlreadyTerminalError extends RelaviumError { + override readonly name = 'RunAlreadyTerminalError'; + readonly runId: string; + + constructor(runId: string) { + super( + `durable append refused for run ${runId}: the log already ends with a terminal event, and a run ` + + `has exactly one`, + ); + this.runId = runId; + } +} + /** * A terminal the store would not accept, held OUTSIDE the store so it can be retried * ([ADR-0078](../../../docs/decisions/0078-ordered-durable-append-and-the-terminal-outbox.md) §4). diff --git a/packages/shared/src/workflow.ts b/packages/shared/src/workflow.ts index 1e7f38921..acdb785df 100644 --- a/packages/shared/src/workflow.ts +++ b/packages/shared/src/workflow.ts @@ -725,7 +725,8 @@ export const WorkflowSchema = WorkflowDocumentFields.superRefine(validateWorkflo /** * Frozen workflow reader: preserve prior structural and whole-document invariants without - * applying new author-only fallback, branch-match or orphan gate-action refusals to old runs. + * applying new author-only fallback, branch-match, orphan gate-action or input-gate approve-on-timeout + * refusals to old runs. * Never use this historical contract to admit a newly authored document. */ export const WorkflowSnapshotSchema = WorkflowDocumentFields.extend({ diff --git a/tools/causal-controls/outcome.test.mjs b/tools/causal-controls/outcome.test.mjs index 348fcd986..c45568480 100644 --- a/tools/causal-controls/outcome.test.mjs +++ b/tools/causal-controls/outcome.test.mjs @@ -99,7 +99,7 @@ test('the complete reader workload still requires both native and structural rem const { data, child } = sample(); data.testResults[0].name = `/graph/${control.files[0]}`; data.testResults[0].assertionResults = [ - ...Array.from({ length: 82 }, (_, index) => ({ + ...Array.from({ length: 83 }, (_, index) => ({ fullName: `preserved reader workload ${index}`, status: 'passed', failureMessages: [], @@ -113,25 +113,25 @@ test('the complete reader workload still requires both native and structural rem failureMessages: ['AssertionError: SECRETS_CAUSAL_RUN_DEFAULT_READER expected one read'], })), ]; - data.numTotalTests = 84; - data.numPassedTests = 82; + data.numTotalTests = 85; + data.numPassedTests = 83; data.numFailedTests = 2; const check = (candidate = data, declaration = control) => checkOutcome(candidate, child, declaration, 'mutated', '/graph'); assert.doesNotThrow(() => check()); const missing = globalThis.structuredClone(data); missing.testResults[0].assertionResults.shift(); - missing.numTotalTests = 83; - missing.numPassedTests = 81; + missing.numTotalTests = 84; + missing.numPassedTests = 82; assert.throws(() => check(missing), /Declared workload drift/); - assert.throws(() => check(data, { ...control, expectedTests: 83 }), /Declared workload drift/); + assert.throws(() => check(data, { ...control, expectedTests: 84 }), /Declared workload drift/); const altered = globalThis.structuredClone(data); - altered.testResults[0].assertionResults[83].fullName = 'unrelated failure'; + altered.testResults[0].assertionResults[84].fullName = 'unrelated failure'; assert.throws(() => check(altered), /missing additional named assertion/); const passing = globalThis.structuredClone(data); - passing.testResults[0].assertionResults[83].status = 'passed'; - passing.testResults[0].assertionResults[83].failureMessages = []; - passing.numPassedTests = 83; + passing.testResults[0].assertionResults[84].status = 'passed'; + passing.testResults[0].assertionResults[84].failureMessages = []; + passing.numPassedTests = 84; passing.numFailedTests = 1; assert.throws(() => check(passing), /missing additional named assertion/); }); diff --git a/tools/causal-controls/provenance.test.mjs b/tools/causal-controls/provenance.test.mjs index f07879d78..f3d8bfb5c 100644 --- a/tools/causal-controls/provenance.test.mjs +++ b/tools/causal-controls/provenance.test.mjs @@ -288,9 +288,9 @@ test('secrets workload corrections retain independent complete-count and reader- 513, ); for (const [id, file, expectedTests] of [ - ['run-default-secret-reader', 'apps/cli/src/commands/run.test.ts', 84], - ['snapshot-run-warning', 'apps/cli/src/commands/run.test.ts', 84], - ['gate-consumed-stdin-prompt-authority', 'apps/cli/src/commands/gate.test.ts', 73], + ['run-default-secret-reader', 'apps/cli/src/commands/run.test.ts', 85], + ['snapshot-run-warning', 'apps/cli/src/commands/run.test.ts', 85], + ['gate-consumed-stdin-prompt-authority', 'apps/cli/src/commands/gate.test.ts', 76], ['export-command-review-notice', 'apps/cli/src/commands/chat-export.test.ts', 18], ]) { const control = secretsControls.find((entry) => entry.id === id); diff --git a/tools/ci/windows-headless-smoke.test.mjs b/tools/ci/windows-headless-smoke.test.mjs index ddef2d30f..55d65b42a 100644 --- a/tools/ci/windows-headless-smoke.test.mjs +++ b/tools/ci/windows-headless-smoke.test.mjs @@ -22,7 +22,7 @@ assert.match( ); const fakeNode = `#!/usr/bin/env bash -printf '%s\\n' "$2" >> "$HEADLESS_FAKE_CALLS" +printf '%s\\n' "$2${'$'}{RELAVIUM_ANTHROPIC_API_KEY:+ with-key}" >> "$HEADLESS_FAKE_CALLS" case "$2" in --help) exit "${'$'}{HEADLESS_HELP_EXIT:-0}" ;; chat) @@ -58,6 +58,8 @@ function exercise({ HEADLESS_CHAT_OUTPUT: chatOutput, HEADLESS_CHAT_STREAM: chatStream, HEADLESS_HELP_EXIT: String(helpExit), + // Only the smoke itself may give `chat` its key: a key in the developer's shell would hide its loss. + RELAVIUM_ANTHROPIC_API_KEY: undefined, }, encoding: 'utf8', timeout: 10_000, @@ -79,7 +81,7 @@ test('the actual Windows smoke accepts the canonical chat EOF exit 4', () => { const result = exercise(); assert.equal(result.status, 0, result.stderr); assert.match(result.stdout, /windows headless no-TTY smoke passed/); - assert.deepEqual(result.calls, ['--help', 'chat']); + assert.deepEqual(result.calls, ['--help', 'chat with-key']); }); for (const chatExit of [0, 1, 2, 130]) { @@ -88,7 +90,7 @@ for (const chatExit of [0, 1, 2, 130]) { assert.equal(result.status, 1, result.stderr); assert.match(result.stdout, /expected 4/); assert.doesNotMatch(`${result.stdout}${result.stderr}`, /PRIVATE_CHILD_OUTPUT/); - assert.deepEqual(result.calls, ['--help', 'chat']); + assert.deepEqual(result.calls, ['--help', 'chat with-key']); }); } @@ -97,7 +99,7 @@ for (const chatStream of ['stdout', 'stderr']) { const result = exercise({ chatStream, chatOutput: 'setRawMode failed' }); assert.equal(result.status, 1, result.stderr); assert.match(result.stdout, /entered raw mode without a TTY/); - assert.deepEqual(result.calls, ['--help', 'chat']); + assert.deepEqual(result.calls, ['--help', 'chat with-key']); }); } diff --git a/tools/ci/workspace-scheduling.test.mjs b/tools/ci/workspace-scheduling.test.mjs index 94ce27a2c..60fa80fba 100644 --- a/tools/ci/workspace-scheduling.test.mjs +++ b/tools/ci/workspace-scheduling.test.mjs @@ -203,6 +203,63 @@ test('required and local CI share all workspace tasks and uncached per-invocatio assert.equal(floorCommand['continue-on-error'], undefined); }); +test('required checks keep their branch-protection names and cannot be skipped', () => { + // A job-level `if`, or a `needs` whose dependency fails, skips the job, and GitHub counts a skipped required + // check as passing; a default or step shell can turn a step into a no-op. A rename leaves the required + // context unreported and blocks merges, so the name pin keeps the job and branch protection in step + // (D64 round-1 and round-3 findings on the PR #96 correction). + const require = createRequire(new URL('apps/cli/package.json', root)); + const { parse } = require('yaml'); + const workflow = parse(readFileSync(new URL('.github/workflows/ci.yml', root), 'utf8')); + for (const [id, name] of [ + ['coverage', 'engine coverage floor (llm, mcp)'], + ['windows-concurrency', 'windows · db concurrency + filesystem + headless smoke'], + ]) { + const job = workflow.jobs[id]; + assert.equal(job.name, name, id); + assert.equal(job.if, undefined, id); + assert.equal(job['continue-on-error'], undefined, id); + assert.equal(job.needs, undefined, id); + assert.equal(job.defaults, undefined, id); + } + // The aggregate keeps its required context name (its `if: always()` is pinned with its needs). + assert.equal(workflow.jobs.ci.name, 'lint · typecheck · test'); + // Its guard steps keep exactly their keys: no `if`, `continue-on-error` or `shell` can soften one. + assert.equal(workflow.jobs.ci.defaults, undefined); + const guards = workflow.jobs.ci.steps.filter((step) => step.name?.startsWith('Require ')); + assert.ok(guards.length >= 6); + for (const guard of guards) { + const keys = guard.env === undefined ? ['name', 'run'] : ['env', 'name', 'run']; + assert.deepEqual(Object.keys(guard).sort(), keys, guard.name); + } + for (const step of workflow.jobs.coverage.steps) { + assert.equal(step.if, undefined, step.name ?? step.uses); + assert.equal(step['continue-on-error'], undefined, step.name ?? step.uses); + assert.equal(step.shell, undefined, step.name ?? step.uses); + } + const steps = workflow.jobs['windows-concurrency'].steps; + const build = steps.findIndex((step) => step.id === 'windows-build'); + assert.ok(build > 0); + assert.ok(steps.length > build + 1); + for (const step of steps.slice(0, build + 1)) { + assert.equal(step.if, undefined, step.name ?? step.uses); + assert.equal(step['continue-on-error'], undefined, step.name ?? step.uses); + } + for (const step of steps) { + // Only the headless smoke names a shell, and only bash. + const smoke = step.name?.startsWith('Headless no-TTY smoke') === true; + assert.equal(step.shell, smoke ? 'bash' : undefined, step.name ?? step.uses); + } + for (const step of steps.slice(build + 1)) { + assert.equal( + step.if, + "${{ !cancelled() && steps.windows-build.outcome == 'success' }}", + step.name, + ); + assert.equal(step['continue-on-error'], undefined, step.name); + } +}); + test('Windows cohorts retain twelve serial filesystem files and media cases', () => { const require = createRequire(new URL('apps/cli/package.json', root)); const { parse } = require('yaml'); diff --git a/tools/cli-package/archive.mjs b/tools/cli-package/archive.mjs index b2e89ca56..aeac97fd3 100644 --- a/tools/cli-package/archive.mjs +++ b/tools/cli-package/archive.mjs @@ -79,33 +79,44 @@ function unpack(bytes) { } } -/** @param {string} path */ /** * A runtime dependency requirement the public manifest may carry: an exact, caret or tilde registry * version, the form pnpm publishes for a `catalog:` entry. An allowlist rather than a list of refused * protocols, so `npm:` aliases, `github:`, git and tarball URLs, `file:`/`link:`/`workspace:`/`catalog:` - * specifiers, `*` and open ranges never reach a consumer's install (a PR #96 review finding). + * specifiers, `*` and open ranges never reach a consumer's install (a PR #96 review finding). The + * prerelease takes semver's dot-separated identifiers, since npm reads an empty one (`1.2.3-.`, + * `1.2.3-a..b`) as a dist-tag; leading zeros are admitted, as npm's and pnpm's loose reading accepts them + * as versions. */ -const REGISTRY_REQUIREMENT = /^[~^]?\d{1,9}\.\d{1,9}\.\d{1,9}(?:-[0-9A-Za-z.-]{1,64})?$/u; +const REGISTRY_REQUIREMENT = + /^[~^]?\d{1,9}\.\d{1,9}\.\d{1,9}(?:-[0-9A-Za-z-]{1,64}(?:\.[0-9A-Za-z-]{1,64}){0,15})?$/u; + +/** A lowercase npm package name: no part begins with `.` or `_`, as npm requires. */ +const PACKAGE_NAME = /^(?:@[a-z0-9-][a-z0-9_.-]*\/)?[a-z0-9-][a-z0-9_.-]*$/u; /** * Whether one public runtime dependency entry is admitted: an npm package name outside the private - * `@relavium/` scope, required at a registry version. Shared by the archive inspector and the package - * receipt binding so the two cannot drift. + * `@relavium/` scope (and not npm's reserved `node_modules` or `favicon.ico`), required at a registry + * version of at most 100 characters, well under semver's 256, past which npm reads a version as a + * dist-tag. Shared by the archive inspector and the package receipt binding so the two cannot drift. * @param {string} name * @param {unknown} requirement * @returns {boolean} */ export function isPublicRuntimeDependency(name, requirement) { return ( - /^(?:@[a-z0-9_.-]+\/)?[a-z0-9_.-]+$/u.test(name) && + PACKAGE_NAME.test(name) && name.length <= 200 && + name !== 'node_modules' && + name !== 'favicon.ico' && !name.startsWith('@relavium/') && typeof requirement === 'string' && + requirement.length <= 100 && REGISTRY_REQUIREMENT.test(requirement) ); } +/** @param {string} path */ export function assertPublicPackagePath(path) { if ( !/^package\/(?:dist\/[A-Za-z0-9_-]+\.js|drizzle\/[0-9]{4}_[a-z0-9_]+\.sql|drizzle\/meta\/(?:[0-9]{4}_snapshot|_journal)\.json|package\.json|README\.md|CHANGELOG\.md|LICENSE)$/u.test( diff --git a/tools/cli-package/archive.test.mjs b/tools/cli-package/archive.test.mjs index ce9bf97ba..963bf2d28 100644 --- a/tools/cli-package/archive.test.mjs +++ b/tools/cli-package/archive.test.mjs @@ -3,7 +3,12 @@ import { Buffer } from 'node:buffer'; import { createHash } from 'node:crypto'; import { test } from 'node:test'; import { gzipSync } from 'node:zlib'; -import { ARCHIVE_LIMITS, inspectPackageArchive, PackageArchiveError } from './archive.mjs'; +import { + ARCHIVE_LIMITS, + inspectPackageArchive, + isPublicRuntimeDependency, + PackageArchiveError, +} from './archive.mjs'; import { checksum, entry, ustar } from './ustar-fixture.mjs'; const expectedPackage = { @@ -304,8 +309,21 @@ test('manifest rejects runtime drift and unresolved/private runtime dependencies { commander: '*' }, { commander: '>=12.0.0' }, { commander: 'latest' }, + // npm reads an empty prerelease identifier as a dist-tag; a version past the allowlist's 100-character + // bound is refused too. + { commander: '1.2.3-.' }, + { commander: '1.2.3-a..b' }, + { commander: `1.2.3-${'a'.repeat(60)}.${'a'.repeat(34)}` }, { commander: '' }, { commander: 12 }, + { commander: ['12.1.0'] }, + // Names npm refuses, and the private scope. + { Commander: '12.1.0' }, + { _commander: '12.1.0' }, + { '.commander': '12.1.0' }, + { '@scope/.x': '12.1.0' }, + { node_modules: '12.1.0' }, + { [`c${'o'.repeat(200)}`]: '12.1.0' }, ]) { const badPackage = { ...expectedPackage, dependencies }; const files = inventory(); @@ -321,6 +339,60 @@ test('manifest rejects runtime drift and unresolved/private runtime dependencies } }); +test('the allowlist also guards optional and peer dependencies', () => { + for (const [field, requirement] of [ + ['optionalDependencies', 'github:attacker/commander'], + ['peerDependencies', '*'], + ]) { + const badPackage = { ...expectedPackage, [field]: { commander: requirement } }; + const files = inventory(); + files.set('package/package.json', Buffer.from(JSON.stringify(badPackage))); + assert.throws( + () => + inspectPackageArchive(gzipSync(tar(files)), { + expectedFiles: files, + expectedPackage: badPackage, + }), + (error) => error instanceof PackageArchiveError && error.code === 'MANIFEST_RUNTIME', + field, + ); + } +}); + +test('a runtime dependency is admitted at an exact, caret, tilde or prerelease registry version', () => { + for (const requirement of ['12.1.0', '^14.0.0', '~12.1.0', '^1.0.0-rc.1', '1.2.3-alpha.01']) { + assert.equal(isPublicRuntimeDependency('commander', requirement), true, requirement); + const goodPackage = { ...expectedPackage, dependencies: { commander: requirement } }; + const files = inventory(); + files.set('package/package.json', Buffer.from(JSON.stringify(goodPackage))); + assert.doesNotThrow( + () => + inspectPackageArchive(gzipSync(tar(files)), { + expectedFiles: files, + expectedPackage: goodPackage, + }), + requirement, + ); + } + for (const requirement of ['1.2.3-.', '1.2.3-a.', '1.2.3-..', 'latest', '>=1.0.0', ['12.1.0']]) { + assert.equal(isPublicRuntimeDependency('commander', requirement), false, String(requirement)); + } + assert.equal( + isPublicRuntimeDependency('commander', `1.2.3-${'a'.repeat(60)}.${'a'.repeat(33)}`), + true, + ); + assert.equal( + isPublicRuntimeDependency('commander', `1.2.3-${'a'.repeat(60)}.${'a'.repeat(34)}`), + false, + ); + for (const name of ['@scope/pkg', 'a-b.c_d', `c${'o'.repeat(199)}`]) { + assert.equal(isPublicRuntimeDependency(name, '1.0.0'), true, name); + } + for (const name of ['favicon.ico', '@relavium/core', '_x', '.x', '@_s/x']) { + assert.equal(isPublicRuntimeDependency(name, '1.0.0'), false, name); + } +}); + for (const [changed, code] of [ ...[ undefined, diff --git a/tools/cli-package/binding.test.mjs b/tools/cli-package/binding.test.mjs index 066607617..22c1830d2 100644 --- a/tools/cli-package/binding.test.mjs +++ b/tools/cli-package/binding.test.mjs @@ -186,9 +186,15 @@ test('package metadata is public-only and keeps private packages out of runtime' { dependencies: { '@relavium/core': '0.0.0' } }, { dependencies: { commander: 'workspace:*' } }, { dependencies: { commander: 'catalog:' } }, - ...['npm:evil@1.0.0', 'github:attacker/commander', 'https://example.invalid/c.tgz', '*'].map( - (requirement) => ({ dependencies: { commander: requirement } }), - ), + { dependencies: { _commander: '12.1.0' } }, + ...[ + 'npm:evil@1.0.0', + 'github:attacker/commander', + 'https://example.invalid/c.tgz', + '*', + '1.2.3-a..b', + `1.2.3-${'a'.repeat(60)}.${'a'.repeat(34)}`, + ].map((requirement) => ({ dependencies: { commander: requirement } })), ]) refuses(() => validatePackageReceipt({ ...pack, package: { ...pack.package, ...changed } }, expected), diff --git a/tools/cli-package/candidate.mjs b/tools/cli-package/candidate.mjs index c0a70db3e..e4501f8b2 100644 --- a/tools/cli-package/candidate.mjs +++ b/tools/cli-package/candidate.mjs @@ -320,8 +320,8 @@ export async function dependencyStore({ repository, environment, home, pnpm = ru * line-ending conversion (`core.autocrlf`, the Git for Windows default) and Git's stat cache can report * converted working-tree bytes as unchanged, and the candidate is built from those bytes. Each copy, * an ordinary file, must have the commit tree's blob id for its path, as Git itself names its raw bytes - * (`hash-object --no-filters`): Git's collision-detecting SHA-1 in the repository's object format, not a - * plain hash of ours. + * (`hash-object --no-filters`): Git's collision-detecting SHA-1, not a plain hash of ours (`sourceIdentity` + * admits only a SHA-1 repository). * @param {{repository: string, candidate: string, source: string, files: readonly string[], * run?: typeof runCommand}} options */ @@ -332,6 +332,8 @@ export async function copyMatchesCommit({ files, run = runCommand, }) { + // Both roots are absolute: the ordinary-file proof reads against this process and Git against the checkout. + assert.ok(isAbsolute(repository) && isAbsolute(candidate), 'PACKED_CLI_CANDIDATE_ROOT'); const git = async (args) => ( await run('git', ['--no-replace-objects', ...args], { @@ -341,7 +343,7 @@ export async function copyMatchesCommit({ ).stdout; const blobs = new Map(); for (const entry of gitPaths(await git(['ls-tree', '-r', '-z', '--full-tree', source]))) { - const match = /^[0-7]{6} blob ([0-9a-f]{40,64})\t(.+)$/su.exec(entry); + const match = /^[0-7]{6} blob ([0-9a-f]{40})\t(.+)$/su.exec(entry); if (match !== null) blobs.set(match[2], match[1]); } const copies = files.map((path) => join(candidate, ...path.split('/'))); diff --git a/tools/cli-package/candidate.test.mjs b/tools/cli-package/candidate.test.mjs index 4f73f0af0..542e46a03 100644 --- a/tools/cli-package/candidate.test.mjs +++ b/tools/cli-package/candidate.test.mjs @@ -1071,13 +1071,23 @@ test('every hash-object batch is compared, the last included', async () => { copyCandidateTree({ repository, destination: candidate, files }); const calls = []; const run = (command, args, options) => { - if (args[1] === 'hash-object') calls.push(args.length - 4); + if (args[1] === 'hash-object') { + calls.push(args.length - 4); + // Each command line stays well inside Windows's 32,767-character limit. + assert.ok(args.slice(4).join(' ').length <= 16 * 1024, 'a batch exceeds 16 KiB of paths'); + } return runCommand(command, args, options); }; const matches = () => copyMatchesCommit({ repository, candidate, source: identity.source, files, run }); assert.equal(await matches(), true); assert.ok(calls.length >= 2, `expected several batches, saw ${calls.length}`); + // Batches are packed, not one path apiece: the paths fill no more than one batch beyond their total. + const characters = files.reduce( + (sum, path) => sum + join(candidate, ...path.split('/')).length + 1, + 0, + ); + assert.ok(calls.length <= Math.ceil(characters / (16 * 1024)) + 1, `saw ${calls.length} batches`); assert.equal( calls.reduce((sum, count) => sum + count, 0), files.length, @@ -1106,25 +1116,39 @@ test( }, ); -test('a hash-object answer missing a line is not a match', async () => { +test('a relative candidate or repository root is refused', async () => { + for (const [repository, candidate] of [ + ['relative-repository', fresh('absolute-candidate')], + [fresh('absolute-repository'), 'relative-candidate'], + ]) { + await assert.rejects( + copyMatchesCommit({ repository, candidate, source: 'a'.repeat(40), files: ['kept.ts'] }), + /PACKED_CLI_CANDIDATE_ROOT/, + ); + } +}); + +test('a hash-object answer missing a line or carrying an extra one is not a match', async () => { const { repository } = await committedRepository('short-answer'); const identity = await sourceIdentity(repository); const candidate = fresh('short-answer-candidate'); copyCandidateTree({ repository, destination: candidate, files: ['kept.ts'] }); - const run = async (command, args, options) => { - const result = await runCommand(command, args, options); - return args[1] === 'hash-object' ? { ...result, stdout: '' } : result; - }; - assert.equal( - await copyMatchesCommit({ - repository, - candidate, - source: identity.source, - files: ['kept.ts'], - run, - }), - false, - ); + for (const answer of [() => '', (stdout) => `${stdout}${stdout}`]) { + const run = async (command, args, options) => { + const result = await runCommand(command, args, options); + return args[1] === 'hash-object' ? { ...result, stdout: answer(result.stdout) } : result; + }; + assert.equal( + await copyMatchesCommit({ + repository, + candidate, + source: identity.source, + files: ['kept.ts'], + run, + }), + false, + ); + } }); test('a store the caller names reaches the install, and pnpm is never asked to discover one', async () => { diff --git a/tools/cli-package/consumer-lifetime.test.mjs b/tools/cli-package/consumer-lifetime.test.mjs index 91f9ffb06..74636d6ab 100644 --- a/tools/cli-package/consumer-lifetime.test.mjs +++ b/tools/cli-package/consumer-lifetime.test.mjs @@ -281,6 +281,19 @@ test('a consumer without an absolute support directory refuses before it exercis } }); +/** + * Kill a process that may already have exited. Checking `alive` first races its exit, and `kill` then + * throws ESRCH (seen once on the Linux CI, 2026-11-04); a process that is gone needs no cleanup. + * @param {number} pid + */ +function killIfAlive(pid) { + try { + process.kill(pid, 'SIGKILL'); + } catch (error) { + if (error?.code !== 'ESRCH') throw error; + } +} + function alive(pid) { try { process.kill(pid, 0); @@ -403,7 +416,7 @@ await completeConsumer({ }; } finally { if (consumer.exitCode === null && consumer.signalCode === null) consumer.kill('SIGKILL'); - for (const pid of children) if (alive(pid)) process.kill(pid, 'SIGKILL'); + for (const pid of children) killIfAlive(pid); } } @@ -478,7 +491,7 @@ await completeConsumer({ assert.equal(readFileSync(log, 'utf8'), 'cleanup\ncleanupFailed\n'); } finally { if (consumer.exitCode === null && consumer.signalCode === null) consumer.kill('SIGKILL'); - if (child !== undefined && alive(child)) process.kill(child, 'SIGKILL'); + if (child !== undefined) killIfAlive(child); } }, ); diff --git a/tools/cli-package/custody-fixtures/generate.mjs b/tools/cli-package/custody-fixtures/generate.mjs index 38ac5f590..7534d36e4 100644 --- a/tools/cli-package/custody-fixtures/generate.mjs +++ b/tools/cli-package/custody-fixtures/generate.mjs @@ -16,10 +16,11 @@ import { fileURLToPath } from 'node:url'; import { resolveGpg } from '../gnupg.mjs'; const here = fileURLToPath(new URL('.', import.meta.url)); +// The installation from absolute PATH entries only, as the release path resolves it; resolved before the +// home exists, so a refusal leaves no temporary directory behind. +const installation = resolveGpg(); // A short home: the agent's socket path must fit the platform's limit (104 bytes on macOS). const home = mkdtempSync(join(tmpdir(), 'rg-')); -// The installation from absolute PATH entries only, as the release path resolves it. -const installation = resolveGpg(); const PAST = '20200101T000000!'; const NO_AEAD = 'AES256 AES192 AES SHA512 SHA384 SHA256 ZLIB ZIP Uncompressed'; diff --git a/tools/cli-package/custody.test.mjs b/tools/cli-package/custody.test.mjs index 6126516cd..662635618 100644 --- a/tools/cli-package/custody.test.mjs +++ b/tools/cli-package/custody.test.mjs @@ -385,7 +385,7 @@ test('an ephemeral key decrypts the ciphertext to the container, and tar extract assert.equal(existsSync(ephemeral.home), false); }); -test('every committed refusal fixture is refused before encryption, and AEAD after it', async () => { +test('every committed refusal fixture is refused before encryption, and AEAD after it', async (context) => { for (const [name, code] of [ ['expired-primary', 'RECIPIENT_EXPIRED'], ['expired-subkey', 'RECIPIENT_EXPIRED'], @@ -399,14 +399,39 @@ test('every committed refusal fixture is refused before encryption, and AEAD aft refused(await attempt({ run, recipient: { file: fixture(name), pins: PINS[name] } }), code); assert.deepEqual(calls, ['version', 'config', 'list'], name); } - // GnuPG 2.3 and later pick OCB for a key that advertises AEAD, a profile that is refused; GnuPG 2.2 - // has no AEAD and encrypts SEIPDv1, which the profile check admits. - const [major, minor] = (await installedVersion()).split('.').map(Number); + // For a key that advertises AEAD, GnuPG 2.5.21 (Homebrew) chose OCB, a profile that is refused; + // GnuPG 2.2, and the GnuPG on ubuntu-26.04 on 2026-11-05, report SEIPDv1, which is admitted. The version does not say which, so the test reads the choice from the real GnuPG's own + // status line, with its own pattern rather than the product's parser, expects the matching verdict, and + // says which branch ran. + /** @type {string[]} */ + const chosen = []; + const { run } = recording({ + encrypt: { + after: (result) => { + chosen.push( + ...String(result.stdout) + .split('\n') + .filter((line) => line.startsWith('[GNUPG:] BEGIN_ENCRYPTION ')), + ); + return result; + }, + }, + }); const aead = await attempt({ + run, recipient: { file: fixture('good-aead'), pins: PINS['good-aead'] }, }); - if (major > 2 || (major === 2 && minor >= 3)) refused(aead, 'PROFILE'); - else assert.equal(aead.error, undefined, String(aead.error)); + assert.equal( + chosen.length, + 1, + 'no single real encryption status line was captured for the AEAD key', + ); + context.diagnostic(`GnuPG ${await installedVersion()} chose for the AEAD key: ${chosen[0]}`); + if (chosen[0] === '[GNUPG:] BEGIN_ENCRYPTION 2 9') { + assert.equal(aead.error, undefined, String(aead.error)); + } else { + refused(aead, 'PROFILE'); + } }); test("GnuPG's own refusal is a second layer behind the pre-check", async () => { diff --git a/tools/cli-package/smoke/chat.mjs b/tools/cli-package/smoke/chat.mjs index 2a9b35e38..32a6ed6d8 100644 --- a/tools/cli-package/smoke/chat.mjs +++ b/tools/cli-package/smoke/chat.mjs @@ -127,8 +127,10 @@ export async function assertInstalledChat(context) { (message) => message.role === 'assistant' && message.content === replies[0], ), ); + // Two keychain reads per process: the session pre-flight's yes/no `hasKey` (MVP-A4, before any spawn or + // egress) and the turn's `keyFor`. Both are the same denied account, so the env key is what serves the turn. const attempts = observed.filter((call) => call.operation === 'keyring.Entry'); - assert.equal(attempts.length, 2); + assert.equal(attempts.length, 4); for (const attempt of attempts) assert.deepEqual( [attempt.service, attempt.account, attempt.denied], @@ -153,7 +155,7 @@ export async function assertInstalledChat(context) { sessionId: id, runs, dispatches: 2, - keyringDeniedAttempts: 2, + keyringDeniedAttempts: 4, forbiddenNetworkOrProcessCalls: 0, transcript: final.messages, usage: session, diff --git a/tools/cli-smoke/check.mjs b/tools/cli-smoke/check.mjs index 7d86660e4..7c9ea0bed 100644 --- a/tools/cli-smoke/check.mjs +++ b/tools/cli-smoke/check.mjs @@ -130,6 +130,15 @@ async function assertNoMcpOrphanOnSignal(home) { .filter((pid) => Number.isInteger(pid)); const before = new Set(survivors()); + // A synthetic key, so the session's key pre-flight (MVP-A4) passes and the run reaches the MCP consent gate + // and the connect this check is about. It never reaches a provider: the first call is refused at consent, and + // the second waits on a server that never completes `initialize` until it is signalled. + const env = { + ...process.env, + HOME: home, + USERPROFILE: home, + RELAVIUM_ANTHROPIC_API_KEY: 'sk-relavium-synthetic-orphan-probe', + }; // The consent gate (ADR-0084) refuses a non-interactive spawn and PRINTS the declaration digest; feed it // back rather than hard-coding a hash that changes with this machine's node path. const refusal = spawnSync(process.execPath, [BUNDLE, 'agent', 'run', agentPath], { @@ -137,7 +146,7 @@ async function assertNoMcpOrphanOnSignal(home) { encoding: 'utf8', input: 'hi\n', timeout: STEP_TIMEOUT_MS, - env: { ...process.env, HOME: home, USERPROFILE: home }, + env, }); const digest = /\bv1:[0-9a-f]{64}\b/.exec(`${refusal.stdout}${refusal.stderr}`)?.[0]; if (digest === undefined) { @@ -152,7 +161,7 @@ async function assertNoMcpOrphanOnSignal(home) { { cwd: home, stdio: ['pipe', 'ignore', 'ignore'], - env: { ...process.env, HOME: home, USERPROFILE: home }, + env, }, ); host.stdin.end('hi\n'); diff --git a/tools/secrets-controls/README.md b/tools/secrets-controls/README.md index 14f8440a6..d26ced3ff 100644 --- a/tools/secrets-controls/README.md +++ b/tools/secrets-controls/README.md @@ -72,7 +72,7 @@ The eleven Step 4 controls remove separate production mechanisms: The Step 4 declarations require 52 additional named assertion witnesses. Every selected workload remains a complete test file: 33 input cases, 14 action-forwarding -cases, 32 dispatch cases, 84 run-command cases, three run-consent cases, 73 gate cases, +cases, 32 dispatch cases, 85 run-command cases, three run-consent cases, 76 gate cases, 28 top-level parser cases, or 131 native integration cases, as declared per control. The coordinator independently enforces exact case counts, unique complete case identities and identical fixed/removal/restored inventories. It accepts only a @@ -129,7 +129,7 @@ permanent negative cases; no notice is produced before a successful export. Four snapshot controls remove the finite scrubber detection site and the actual `create`, `import`, and `run` warning calls. Each has a named value-free assertion in -its complete 11-case detector, 11-case create, 11-case import or 84-case run file. +its complete 11-case detector, 11-case create, 11-case import or 85-case run file. The adjacent tests check preserved authored files and admitted run snapshots, JSON stdout, and scan-budget/incomplete notices. These controls certify warning delivery for the planted supported shapes at these entry points, not universal secret diff --git a/tools/secrets-controls/mutations.mjs b/tools/secrets-controls/mutations.mjs index c7008a2f2..4191d695a 100644 --- a/tools/secrets-controls/mutations.mjs +++ b/tools/secrets-controls/mutations.mjs @@ -672,7 +672,7 @@ export const mutations = [ ' const raw = await read();', " const raw = await (deps.readSecretInput?.() ?? Promise.resolve(''));", ['apps/cli/src/commands/run.test.ts'], - 84, + 85, 'uses the real native default reader once and suppresses an injected gate selector', 'SECRETS_CAUSAL_RUN_DEFAULT_READER', [ @@ -699,7 +699,7 @@ export const mutations = [ ' const mayPrompt = args.secretStdin !== true;', ' const mayPrompt = true;', ['apps/cli/src/commands/gate.test.ts'], - 73, + 76, 'refuses unapproved stdio after a real read despite stale TTY, without prompting or writing a grant', 'SECRETS_CAUSAL_RESUME_CONSUMED_STDIN_CONSENT', ), @@ -913,7 +913,7 @@ export const mutations = [ ' warnSnapshotLiterals(def, deps.io);\n', '', ['apps/cli/src/commands/run.test.ts'], - 84, + 85, 'warns after admission before services and preserves the actual augmented frozen workflow', 'SNAPSHOT_CAUSAL_RUN_CALLER', ), diff --git a/tools/usage-coverage-controls/README.md b/tools/usage-coverage-controls/README.md index a8e285bb1..db8a1371a 100644 --- a/tools/usage-coverage-controls/README.md +++ b/tools/usage-coverage-controls/README.md @@ -70,9 +70,10 @@ shared 100,000-case scale files run separately from repeated small mutation coho crash, lock, receipt and scale tests remain part of ordinary package CI. Each ordinary phase retains its five-minute outer watchdog. The one complete permanent -cohort pins 446 cases and 71 joined native children, including Wave 2's two DB readiness +cohort pins 449 cases and 71 joined native children, including Wave 2's two DB readiness controls. Six additional CLI chat cases exercise live export notices, refusals and transcript -rendering; they add no native children. Its outer watchdog is calculated from the existing five-minute setup/general +rendering, two `logs` cases pin its header's one-line floor and one its skipped-row note's +(PR #97 review); they add no native children. Its outer watchdog is calculated from the existing five-minute setup/general allowance, 71 existing 20-second native-case bounds and the existing ten-minute DB scale bound (38 minutes 40 seconds total). Individual test/readiness bounds, both full 100,000-case scale controls and process-group cleanup remain in force. diff --git a/tools/usage-coverage-controls/workload.mjs b/tools/usage-coverage-controls/workload.mjs index d4b5bed2e..930a29107 100644 --- a/tools/usage-coverage-controls/workload.mjs +++ b/tools/usage-coverage-controls/workload.mjs @@ -3,7 +3,7 @@ import assert from 'node:assert/strict'; export const ordinaryPhaseTimeoutMs = 300_000; // Pinned complete cohort: 57 CLI crash, four causal crash and ten DB process children. // Each native case already has a 20s bound; the full DB scale test permits 600s. -export const permanentWorkload = Object.freeze({ cases: 446, nativeChildren: 71 }); +export const permanentWorkload = Object.freeze({ cases: 449, nativeChildren: 71 }); export const permanentPhaseTimeoutMs = ordinaryPhaseTimeoutMs + permanentWorkload.nativeChildren * 20_000 + 600_000; diff --git a/tools/usage-coverage-controls/workload.test.mjs b/tools/usage-coverage-controls/workload.test.mjs index d1f5f3faf..067027e73 100644 --- a/tools/usage-coverage-controls/workload.test.mjs +++ b/tools/usage-coverage-controls/workload.test.mjs @@ -65,11 +65,11 @@ test('the complete native/scale workload has its own calculated bound and exact assert.equal(ordinaryPhaseTimeoutMs, 300_000); assert.equal(permanentPhaseTimeoutMs, 300_000 + 71 * 20_000 + 600_000); const result = { - tests: Array.from({ length: 446 }), + tests: Array.from({ length: 449 }), runtime: { nativeChildren: 71, readinessTimeoutChildren: 1 }, }; assert.doesNotThrow(() => assertPermanentWorkload(result)); - for (const count of [0, 440, 445, 447]) { + for (const count of [0, 440, 448, 450]) { assert.throws( () => assertPermanentWorkload({ ...result, tests: Array.from({ length: count }) }), /Permanent case inventory drift/,