From 5a1006ea0a93507a801e7ef08e999a14d69d4784 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Wed, 4 Nov 2026 21:25:32 +0300 Subject: [PATCH 001/148] fix(core): keep credential flags after a boolean -u and query keys in URL passwords masked The D64 round 1 on the PR #96 review correction confirmed two regressions from 0826f30a in the journal and display projections: - A `-u`/`--user` whose value is no user:password pair consumed the next element, so a credential flag there (`unzip -u -P `, `--user --api-key `) never masked its own value. Such a value is now processed as any element. - The `,`/`/` password helper gave the username the text before the first `:` even after a raw `?`/`#`, so the key/value rules never saw a credential key there and its value's tail after the `@` stayed visible (`http://svc?token=a:b/c@d`). The username now owns that text only when no raw `?`/`#` comes first, as in the authority scan. Each fix is pinned by new cases that fail when it is reverted. Comments now state that a raw `?`/`#` after the delimiter keeps a password public (a recorded limit) and that the seam refuses inline media in a tool result, and the userinfo test no longer claims every spelling. Co-Authored-By: Claude Opus 5.5 --- packages/core/src/tools/bounding.test.ts | 39 +++++++++++++++++++++++- packages/core/src/tools/bounding.ts | 35 +++++++++++++-------- 2 files changed, 61 insertions(+), 13 deletions(-) diff --git a/packages/core/src/tools/bounding.test.ts b/packages/core/src/tools/bounding.test.ts index edcfd70c..8191edac 100644 --- a/packages/core/src/tools/bounding.test.ts +++ b/packages/core/src/tools/bounding.test.ts @@ -923,7 +923,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'], @@ -943,6 +943,36 @@ describe('redactSecretShapedValue — the KEY decides, whatever the value looks expect( JSON.stringify(redactSecretShapedValue({ args: ['--user', key, '-u', key] })), ).not.toContain(key); + // 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', () => { @@ -1364,6 +1394,10 @@ describe('PR95 raw ?/# userinfo in the journal projection', () => { ['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://xhttps://user:pw@h.example/', 'https://xhttps://[redacted]@h.example/'], + // After a raw `?`/`#` there is no username: the key there masks its value to its own end. + ['http://svc?token=abc:de/fgh@ijk9', 'http://[redacted]'], + ['http://svc#token=abc:de,fgh@ijk9', 'http://[redacted]'], + ['https://gw?token=ab:cd/ef@gh@host.example/', 'https://[redacted]'], ] as const) { expect(redactSecretShapedText(input), input).toBe(expected); } @@ -1381,6 +1415,9 @@ 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/`, + // A raw `?`/`#` after the delimiter may begin a path's query: a recorded limit. + 'https://user:pa,ss?1@host.example/', + 'https://user:pa/ss#1@host.example/', ]) { expect(redactSecretShapedText(input), input).toBe(input); } diff --git a/packages/core/src/tools/bounding.ts b/packages/core/src/tools/bounding.ts index 5ce62815..0bf365b0 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; @@ -1161,7 +1162,9 @@ function isPublicQueryContact(userinfo: string, query: number): boolean { * 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. + * (whitespace, quotes, `}`, `]`) keep the text public, and so does any raw `?` or `#` after the `,` or + * `/`, which may begin a path's query (`host:abc/p?e=a@b`): `https://user:pa,ss?1@host` stays public, a + * recorded limit (the LLM backstop masks that comma spelling, and neither masks its `/` twin). */ function passwordPastDelimiter(text: string, start: number, end: number): number { if (text[end] !== ',' && text[end] !== '/') return -1; @@ -1177,9 +1180,11 @@ function passwordPastDelimiter(text: string, start: number, end: number): number } /** - * 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. + * Mask a userinfo whose password holds a `,` or `/` ({@link passwordPastDelimiter}). As in + * {@link visitUrlUserinfo}, its username owns the text before the first `:` only when no raw `?`/`#` comes + * first; otherwise a key there (`http://svc?token=a:b/c@d`) is read by the key/value rules, which mask its + * value to its own end. Returns where the scheme scan resumes: after the `@` it masked through, or at + * `start` when there is none. */ function maskPasswordPastDelimiter( text: string, @@ -1190,7 +1195,10 @@ function maskPasswordPastDelimiter( ): number { const passwordAt = passwordPastDelimiter(text, start, end); if (passwordAt < 0) return start; - mask(start, passwordAt, '[redacted]', { schemeStart, ownsUntil: text.indexOf(':', start) }); + const colon = text.indexOf(':', start); + const query = text.slice(start, end).search(/[?#]/); + const usernameEnd = query < 0 || colon - start < query ? colon : start; + mask(start, passwordAt, '[redacted]', { schemeStart, ownsUntil: usernameEnd }); return passwordAt + 1; } @@ -1453,10 +1461,13 @@ 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 `-u`/`--user` value that is no user:password pair is processed as any element, so a credential flag + // there (`unzip -u -P `) still masks its own value (a D64 round-1 finding on the PR #96 correction). + const masked = + redactNext === undefined ? undefined : redactCredentialArgument(redactNext, item, whole); + redactNext = undefined; + if (masked !== undefined) { + out.push(masked); continue; } const inline = typeof item === 'string' ? redactInlineCredential(item, whole) : undefined; From 3cb9712c9a688afe733a819910288be7178a4ea2 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Wed, 4 Nov 2026 21:25:32 +0300 Subject: [PATCH 002/148] fix(repo): tighten the dependency allowlist and pin the required checks Lows from the D64 round 1 on the PR #96 review correction: - The runtime-dependency allowlist follows semver's dot-separated prerelease identifiers, since npm reads `1.2.3-.` or `1.2.3-a..b` as a dist-tag; exact, caret, tilde and prerelease forms are now tested. - A stranded JSDoc moves back to `assertPublicPackagePath`, and the custody fixture generator resolves GnuPG before it creates its temporary home. - A test pins that the required coverage and Windows jobs keep their branch-protection names and carry no job-level `if` or `continue-on-error` (GitHub counts a skipped required check as passing). - ci.yml no longer calls the Windows job advisory, and release.yml says the `release` environment is meant to hold NPM_TOKEN (MVP-A23), not that it does. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 8 ++--- .github/workflows/release.yml | 5 ++-- tools/ci/workspace-scheduling.test.mjs | 29 ++++++++++++++++++ tools/cli-package/archive.mjs | 9 ++++-- tools/cli-package/archive.test.mjs | 30 ++++++++++++++++++- tools/cli-package/binding.test.mjs | 10 +++++-- .../cli-package/custody-fixtures/generate.mjs | 5 ++-- 7 files changed, 81 insertions(+), 15 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6e224e37..6e65b823 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`. @@ -537,8 +537,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: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0e1d17a9..8ef218ec 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 / @@ -227,7 +228,7 @@ 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 + # 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 sets no job environment. environment: release timeout-minutes: 10 diff --git a/tools/ci/workspace-scheduling.test.mjs b/tools/ci/workspace-scheduling.test.mjs index 94ce27a2..80ba640c 100644 --- a/tools/ci/workspace-scheduling.test.mjs +++ b/tools/ci/workspace-scheduling.test.mjs @@ -203,6 +203,35 @@ 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', () => { + // GitHub counts a skipped required check as passing, so a job-level `if`, `continue-on-error` or rename + // would silently drop a required gate (a D64 round-1 finding 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); + } + 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(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 b2e89ca5..cecb4902 100644 --- a/tools/cli-package/archive.mjs +++ b/tools/cli-package/archive.mjs @@ -79,14 +79,16 @@ 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 follows semver's dot-separated identifiers, since npm reads an empty one (`1.2.3-.`, + * `1.2.3-a..b`) as a dist-tag. */ -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; /** * Whether one public runtime dependency entry is admitted: an npm package name outside the private @@ -106,6 +108,7 @@ export function isPublicRuntimeDependency(name, 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 ce9bf97b..5856753a 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,6 +309,9 @@ 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. + { commander: '1.2.3-.' }, + { commander: '1.2.3-a..b' }, { commander: '' }, { commander: 12 }, ]) { @@ -321,6 +329,26 @@ test('manifest rejects runtime drift and unresolved/private runtime dependencies } }); +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']) { + assert.equal(isPublicRuntimeDependency('commander', requirement), false, requirement); + } +}); + for (const [changed, code] of [ ...[ undefined, diff --git a/tools/cli-package/binding.test.mjs b/tools/cli-package/binding.test.mjs index 06660761..b0f67b63 100644 --- a/tools/cli-package/binding.test.mjs +++ b/tools/cli-package/binding.test.mjs @@ -186,9 +186,13 @@ 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 } }), - ), + ...[ + 'npm:evil@1.0.0', + 'github:attacker/commander', + 'https://example.invalid/c.tgz', + '*', + '1.2.3-a..b', + ].map((requirement) => ({ dependencies: { commander: requirement } })), ]) refuses(() => validatePackageReceipt({ ...pack, package: { ...pack.package, ...changed } }, expected), diff --git a/tools/cli-package/custody-fixtures/generate.mjs b/tools/cli-package/custody-fixtures/generate.mjs index 38ac5f59..7534d36e 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'; From 6155c6dd18d84ff509b997ddb77c94f7304a688a Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Wed, 4 Nov 2026 21:25:55 +0300 Subject: [PATCH 003/148] docs(repo): record the PR #96 merge, the D64 round 1 and the triaged review lows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Mark the D61 development→main checkpoint done: PR #96 merged as b3cf117e on 2026-11-04. current.md's status table, Gate 0, D61 row and history, the CLAUDE.md, AGENTS.md and README status lines, and a new D61 checkpoint record in the phase 2.5.5 register with the merge facts, the evidence and the repository settings. - Record the D64 round 1 on the review correction: five confirmed medium findings, all fixed (5a1006ea, 3cb9712c and this commit). D64's bar still needs a clean round 2 over the corrected head. - Correct the documents the round found false: the `release` environment does not hold NPM_TOKEN yet (packaging.md, current.md); a raw `?`/`#` after a URL password's `,`/`/` keeps it public; the seam refuses inline media in a tool result. The media bound now lives in built-in-tools.md, the argv rule states that it does not know the program, and effect-journal.md states the identity cost. - Fix the round's documentation lows: a stale backstop pointer, the move notes (the five omitted lines named), six empty self-links in moved text, the Windows job's required-check basis, the admin bypass in code-review.md, and the 2.6.Q usage item narrowed to what W8 left. - deferred-tasks.md: rewrite four PR95 DONE bodies to their closure state and annotate the entries the MVP pulled (the PR #96 review's R12b-03 and R12b-04); record the round's remaining lows and the review's 86 unverified first-pass lows, triaged at f25fb83d (78 entries). Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 3 +- CLAUDE.md | 4 +- README.md | 11 +- docs/reference/cli/packaging.md | 6 +- docs/reference/cli/regression-harness.md | 6 +- docs/reference/shared-core/built-in-tools.md | 32 +- .../shared-core/diagnostic-secret-backstop.md | 5 +- docs/reference/shared-core/effect-journal.md | 7 +- docs/reference/shared-core/tool-registry.md | 8 +- docs/roadmap/current.md | 22 +- docs/roadmap/deferred-tasks.md | 410 +++++++++++++++++- docs/roadmap/phases/phase-2-cli.md | 2 +- .../phase-2.5.5-hardening-and-remediation.md | 77 +++- .../phase-2.6-conversational-authoring.md | 17 +- ...hase-2.6.5-core-reliability-remediation.md | 4 +- docs/standards/code-review.md | 3 +- 16 files changed, 552 insertions(+), 65 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 8ecf7add..590862f3 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 de059aaa..1adcd4e2 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 77123328..1ad25b04 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 diff --git a/docs/reference/cli/packaging.md b/docs/reference/cli/packaging.md index b991138c..67a8bbe4 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 diff --git a/docs/reference/cli/regression-harness.md b/docs/reference/cli/regression-harness.md index 3d056e02..750b1aff 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/shared-core/built-in-tools.md b/docs/reference/shared-core/built-in-tools.md index 8dc999ad..9642117e 100644 --- a/docs/reference/shared-core/built-in-tools.md +++ b/docs/reference/shared-core/built-in-tools.md @@ -77,9 +77,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). @@ -129,8 +139,11 @@ below 16 characters and masked from 16 on, under every scheme. Since 2026-11-04 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). +scheme, a quote, `}`, `]` or whitespace, or any raw `?` or `#` after the `,` or `/` (which may begin a path's +query, as in `https://host:abc/p?e=a@b`) keeps the text public: `https://user:pa,ss?1@host` stays as it is, +a recorded limit the backstop does not share for the comma spelling. After a raw `?` or `#` before the first +`:` there is no username, as in the authority scan, so a key there masks its value to its own end +(`http://svc?token=a:b/c@d` → `http://[redacted]`). The backstop still stops at a `/` (a recorded low). 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 +151,14 @@ 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 `-u`/`--user` whose value is no +such pair is an ordinary flag: the element after it is read as any other, so a credential flag there +(`unzip -u -P `) still masks its own value. 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 +282,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/diagnostic-secret-backstop.md b/docs/reference/shared-core/diagnostic-secret-backstop.md index d4f40b31..9394b77b 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 474db201..704debb9 100644 --- a/docs/reference/shared-core/effect-journal.md +++ b/docs/reference/shared-core/effect-journal.md @@ -426,7 +426,12 @@ credential flags have none. Since 2026-11-04 the credential flags include every 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 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 c3c24049..e30b037c 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/roadmap/current.md b/docs/roadmap/current.md index 083e9fd4..8dabb96c 100644 --- a/docs/roadmap/current.md +++ b/docs/roadmap/current.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.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) | | 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. The next planned checkpoint is MVP-10's | ### What a CLI user meets today @@ -78,7 +78,9 @@ 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 round 1 found five medium findings, fixed since, and a clean round 2 is pending; 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 @@ -358,7 +360,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 +406,10 @@ 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, which MVP-A23 creates and gives `NPM_TOKEN` (until then the + token is a repository secret). ## History @@ -417,7 +420,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 e74c90cc..71e25902 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. 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)* `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. 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. *(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. 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. @@ -1035,9 +1037,391 @@ names, unless the entry names another. 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; 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)* +- [ ] **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)* +- [ ] **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)* +- [ ] **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)* +- [ ] **`main`'s branch protection does not bind administrators** (`enforce_admins` off), and the sole maintainer is + one, so the required checks bind only non-admins. code-review.md and current.md now say so. Maintainer call: enable + it, or keep the practice. *(low · repository settings · round-1 DOC-9)* + +**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 (four DONE entries whose bodies described the open state) 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)* +- [ ] The cited location was wrong (`current.md:37` never held this link), but the defect exists elsewhere. + `phase-2.5.5-hardening-and-remediation.md:11289`, in the record 964994c7 moved, links 'Execution order — Phase 2.5.5 + + Phase 2.6 … below' to `current.md`'s legacy anchor, which now sits above 'Pending after the MVP'. The section it + names is the register's own heading at :10708. Point the link in-page, to + #execution-order-`-phase-255--phase-26-temporary` in the same file. *(low · + docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md:11289 · first-pass R11-06)* +- [ ] 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)* ## Wave 3 NI — recorded low findings (2026-10-25) @@ -2256,17 +2640,17 @@ 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). **Scheduled → 2.6.M** (with tool-render v2). **Pulled into the CLI MVP 2026-11-04 (MVP-3, Tool-call rendering v2).** *(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). **Pulled into the CLI MVP 2026-11-04 (MVP-3, Tool-call rendering v2).** *(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 1121f33c..fc5d11e1 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 abc4dacc..04ff0885 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,67 @@ 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. + +**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 at the merged head.** Local `pnpm run ci` passed at `964994c7` (secrets controls 130/130, acceptance tools +1,027/1,027) and `pnpm coverage:enforced` passed (753 files, 16,244 tests; 94.75% lines, 92.83% branches). GitHub +CI at `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`. + +**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 from `64fb8772` | 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`. **D64's bar is not met yet:** round 1 found +medium findings, so acceptance needs one NEW complete cumulative round over the corrected head with no blocker, high +or medium finding. + ## 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 +11189,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. Five old lines are deliberately not repeated: the +> page's own `Last updated` and **Related** lines, 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)) and a paraphrased duplicate of the W7 +> round-1 review summary, whose canonical copy is in Phase 2.6.5's record; the 2.6.Q usage-capture line was extended +> in place in Phase 2.6. 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 +11210,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,7 +11335,7 @@ 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]() +[phases/phase-2.5.5-hardening-and-remediation.md](phase-2.5.5-hardening-and-remediation.md) and [Active now](../current.md#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 @@ -11412,7 +11477,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 67882d26..6779fb24 100644 --- a/docs/roadmap/phases/phase-2.6-conversational-authoring.md +++ b/docs/roadmap/phases/phase-2.6-conversational-authoring.md @@ -1306,11 +1306,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 @@ -1689,7 +1694,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 b3c1bbd3..bdcba77d 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 @@ -7096,7 +7096,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 +7499,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/standards/code-review.md b/docs/standards/code-review.md index ee1a9650..f76d30ca 100644 --- a/docs/standards/code-review.md +++ b/docs/standards/code-review.md @@ -15,7 +15,8 @@ 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 From 2f7224ece2b35bc366ae3185626de7c9b2a09cfb Mon Sep 17 00:00:00 2001 From: Cemil ILIK <cemililik@outlook.com> Date: Wed, 4 Nov 2026 22:05:42 +0300 Subject: [PATCH 004/148] fix(core): read a credential flag in value position as a flag The D64 round 2 on the PR #96 review correction confirmed a medium regression from 0826f30a: a boolean flag whose last word names a credential (`--pass-credentials`, `--with-token`, `--ask-password`) masked the element after it unconditionally, so a credential flag written there was masked in its place and its own value showed in toolInput and the journal digest (`helm ... --pass-credentials --password <pw>`). A credential flag in value position is now processed as a flag, which generalizes the round-1 fix for a boolean `-u`/`--user`. With it, lows from the same round: - the `--flag=value` spelling reads the flag name with the text rules, so a secret shape in a last-word flag's name stays masked; - a comment restores that a `,` in a username still ends the authority; - two surviving mutants are pinned (`-u=1000:1000`, a username key with a raw `?` in a `,`/`/` password). Every new case fails when its fix is reverted. A differential oracle over 365,640 argument vectors finds no credential that 64fb8772 masked and this head shows, in either projection. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- packages/core/src/tools/bounding.test.ts | 38 +++++++++++++++++++++++- packages/core/src/tools/bounding.ts | 27 ++++++++++++----- 2 files changed, 56 insertions(+), 9 deletions(-) diff --git a/packages/core/src/tools/bounding.test.ts b/packages/core/src/tools/bounding.test.ts index 8191edac..6c4b7d20 100644 --- a/packages/core/src/tools/bounding.test.ts +++ b/packages/core/src/tools/bounding.test.ts @@ -933,7 +933,17 @@ 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', + ], ...['--user', 'git+https://example.invalid/r.git'], ], }; @@ -943,6 +953,31 @@ describe('redactSecretShapedValue — the KEY decides, whatever the value looks expect( JSON.stringify(redactSecretShapedValue({ args: ['--user', key, '-u', key] })), ).not.toContain(key); + // A boolean flag whose last word names a credential leaves the credential flag after it in force too, + // in both projections; and 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', '--with-token', '--token', 'Qz7pw'], + [ + 'helm', + '--pass-credentials', + '--password', + '[redacted]', + '--with-token', + '--token', + '[redacted]', + ], + ], + [ + ['--ask-password', '--passphrase', '--secret', '-p', 'Qz7pw'], + ['--ask-password', '--passphrase', '--secret', '-p', '[redacted]'], + ], + [[`--github-${ghp}-token=Qz7pw`], ['--github-[redacted]-token=[redacted]']], + ] as const) { + expect(redactSecretShapedValue({ args })).toEqual({ args: expected }); + expect(projectSecretShapedValue({ args }).value).toEqual({ args: expected }); + } // A boolean -u/--user (unzip's update flag) leaves the credential flag after it in force. expect( redactSecretShapedValue({ @@ -1392,6 +1427,7 @@ describe('PR95 raw ?/# userinfo in the journal projection', () => { ['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'], + ['https://x-access-token:pa?x,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://xhttps://user:pw@h.example/', 'https://xhttps://[redacted]@h.example/'], // After a raw `?`/`#` there is no username: the key there masks its value to its own end. diff --git a/packages/core/src/tools/bounding.ts b/packages/core/src/tools/bounding.ts index 0bf365b0..f64ab08c 100644 --- a/packages/core/src/tools/bounding.ts +++ b/packages/core/src/tools/bounding.ts @@ -1146,7 +1146,8 @@ const HOST_SHAPED_MAX_LENGTH = 260; * 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}). + * password is read on to its `@` ({@link passwordPastDelimiter}), but a `,` in a username still ends the + * authority (`https://us,er:pw@host` stays as it is; the backstop masks it). */ function isPublicQueryContact(userinfo: string, query: number): boolean { // A longer prefix cannot be a hostname, and an unbounded dotted run would exhaust V8's RegExp @@ -1461,21 +1462,26 @@ function redactArgVector( const out: unknown[] = []; let redactNext: string | undefined; for (const item of items) { - // A `-u`/`--user` value that is no user:password pair is processed as any element, so a credential flag - // there (`unzip -u -P <pw>`) still masks its own value (a D64 round-1 finding on the PR #96 correction). + // A credential flag in value position (`--ask-password --password <pw>`), and a `-u`/`--user` value that is + // no user:password pair, are processed as any element, so a credential flag there still masks its own value + // (D64 round-1 and round-2 findings on the PR #96 correction). + const flag = typeof item === 'string' && isCredentialFlag(item); const masked = - redactNext === undefined ? undefined : redactCredentialArgument(redactNext, item, whole); + redactNext === undefined || flag + ? undefined + : redactCredentialArgument(redactNext, item, whole); redactNext = undefined; if (masked !== undefined) { out.push(masked); 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; @@ -1485,12 +1491,17 @@ 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}`; + // The name is read too: a last-word flag admits any name before its credential word. + if (masked !== undefined) return `${project(flag)}=${masked}`; } const glued = gluedUserPassword(item); return glued === undefined ? undefined : `-u${whole(glued, '[redacted]')}`; From f213d2a88179f0c38b0a7a31fc89239382ca2f6e Mon Sep 17 00:00:00 2001 From: Cemil ILIK <cemililik@outlook.com> Date: Wed, 4 Nov 2026 22:05:42 +0300 Subject: [PATCH 005/148] fix(repo): bound dependency requirements and pin every way a required check could skip Lows from the D64 round 2 on the PR #96 review correction: - A runtime-dependency requirement is at most 100 characters (npm reads a version over semver's 256 as a dist-tag), and a name follows npm's rules (no part begins with `.` or `_`, not `node_modules` or `favicon.ico`). Each guard of the shared allowlist now has a test that fails without it, including a non-string requirement. - The required-check pin also asserts that the coverage and Windows jobs have no `needs`, that the Windows Build step and the steps before it and every coverage step carry no `if` or `continue-on-error`, and that `ci` keeps its context name; the reviewer's six surviving mutants now fail. - ci.yml calls the Windows leg of #294 "then-advisory". Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- .github/workflows/ci.yml | 2 +- tools/ci/workspace-scheduling.test.mjs | 12 +++++++++++ tools/cli-package/archive.mjs | 13 +++++++++--- tools/cli-package/archive.test.mjs | 29 +++++++++++++++++++++++--- tools/cli-package/binding.test.mjs | 2 ++ 5 files changed, 51 insertions(+), 7 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6e65b823..01b16883 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/tools/ci/workspace-scheduling.test.mjs b/tools/ci/workspace-scheduling.test.mjs index 80ba640c..42065e17 100644 --- a/tools/ci/workspace-scheduling.test.mjs +++ b/tools/ci/workspace-scheduling.test.mjs @@ -217,11 +217,23 @@ test('required checks keep their branch-protection names and cannot be skipped', assert.equal(job.name, name, id); assert.equal(job.if, undefined, id); assert.equal(job['continue-on-error'], undefined, id); + // A `needs` would skip the required job whenever its dependency fails. + assert.equal(job.needs, 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'); + 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); } 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.slice(build + 1)) { assert.equal( step.if, diff --git a/tools/cli-package/archive.mjs b/tools/cli-package/archive.mjs index cecb4902..c7864647 100644 --- a/tools/cli-package/archive.mjs +++ b/tools/cli-package/archive.mjs @@ -90,20 +90,27 @@ function unpack(bytes) { 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) ); } diff --git a/tools/cli-package/archive.test.mjs b/tools/cli-package/archive.test.mjs index 5856753a..812f0061 100644 --- a/tools/cli-package/archive.test.mjs +++ b/tools/cli-package/archive.test.mjs @@ -309,11 +309,20 @@ 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. + // npm reads an empty prerelease identifier, or a version over semver's length limit, as a dist-tag. { 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(); @@ -344,8 +353,22 @@ test('a runtime dependency is admitted at an exact, caret, tilde or prerelease r requirement, ); } - for (const requirement of ['1.2.3-.', '1.2.3-a.', '1.2.3-..', 'latest', '>=1.0.0']) { - assert.equal(isPublicRuntimeDependency('commander', requirement), false, 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); } }); diff --git a/tools/cli-package/binding.test.mjs b/tools/cli-package/binding.test.mjs index b0f67b63..22c1830d 100644 --- a/tools/cli-package/binding.test.mjs +++ b/tools/cli-package/binding.test.mjs @@ -186,12 +186,14 @@ 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:' } }, + { 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(() => From 07f653e73d4af938072040999b0d457eb65501f0 Mon Sep 17 00:00:00 2001 From: Cemil ILIK <cemililik@outlook.com> Date: Wed, 4 Nov 2026 22:05:42 +0300 Subject: [PATCH 006/148] docs(repo): record the D64 round 2 and correct what it found stale - Record the round in the D61 checkpoint: one medium finding, fixed with its lows; two recorded limits (a URL password that begins with its `,` or `/`, a non-numeric port with an `@` in its path) go to deferred-tasks.md and built-in-tools.md. D64 still needs a clean round 3. - built-in-tools.md states that a credential flag in value position is read as a flag and that a `,` in a username still ends the authority. - current.md: `development` is ahead of `main` again and `main` keeps the redaction regressions the D64 fixes correct; the release standing rule leaves MVP-A23's choice open. packaging.md names the Windows job among the required contexts; README calls the desktop and VS Code apps placeholders. - The D61 record attributes the first regression to 0826f30a and labels where its local evidence ran. deferred-tasks.md closes three more PR95 DONE bodies, corrects two render-v2 entries that MVP-3 does not take, updates the Windows flake entry now that the job is required, and records the maintainer's decision to keep `enforce_admins` off. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- README.md | 4 +- docs/reference/cli/packaging.md | 2 +- docs/reference/shared-core/built-in-tools.md | 16 ++++--- docs/roadmap/current.md | 10 +++-- docs/roadmap/deferred-tasks.md | 43 +++++++++++++------ .../phase-2.5.5-hardening-and-remediation.md | 25 ++++++++--- 6 files changed, 69 insertions(+), 31 deletions(-) diff --git a/README.md b/README.md index 1ad25b04..bf569d89 100644 --- a/README.md +++ b/README.md @@ -179,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/docs/reference/cli/packaging.md b/docs/reference/cli/packaging.md index 67a8bbe4..8607b6ea 100644 --- a/docs/reference/cli/packaging.md +++ b/docs/reference/cli/packaging.md @@ -481,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/shared-core/built-in-tools.md b/docs/reference/shared-core/built-in-tools.md index 9642117e..9fd4011d 100644 --- a/docs/reference/shared-core/built-in-tools.md +++ b/docs/reference/shared-core/built-in-tools.md @@ -137,13 +137,17 @@ already-masked `[REDACTED]` password among them — is left as it is; it recogni 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://user:pa,ss@host` and `https://user:pa/ss@host` → `https://[redacted]@host`), while a `,` in the +username still ends the authority (`https://us,er:pw@host` stays as it is; the backstop masks it). A port (`https://host:8080/a@b`, `https://host:8080,bob@x.example`), a public query contact as above, another scheme, a quote, `}`, `]` or whitespace, or any raw `?` or `#` after the `,` or `/` (which may begin a path's query, as in `https://host:abc/p?e=a@b`) keeps the text public: `https://user:pa,ss?1@host` stays as it is, a recorded limit the backstop does not share for the comma spelling. After a raw `?` or `#` before the first `:` there is no username, as in the authority scan, so a key there masks its value to its own end -(`http://svc?token=a:b/c@d` → `http://[redacted]`). The backstop still stops at a `/` (a recorded low). +(`http://svc?token=a:b/c@d` → `http://[redacted]`). The backstop still stops at a `/` (a recorded low). Two +further recorded limits: a password that begins with the `,` or `/` (`https://user:/pw@host`) stays public, +and a non-numeric port with an `@` in its path (`http://localhost:$PORT/users/@me`) reads as a password through +that `@`, which masks benign text and merges the journal identities of calls that differ there. 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`, @@ -154,9 +158,11 @@ rule's words as written (`--auth-token`, `--access-token`, `--db-password`, `--p 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 `-u`/`--user` whose value is no -such pair is an ordinary flag: the element after it is read as any other, so a credential flag there -(`unzip -u -P <password>`) still masks its own value. Short-option clusters (`curl -su alice:secret`), +`-u` is masked too, and calls that differ only there share one journal identity. A credential flag in value position — after a +boolean flag whose last word names a credential (`--ask-password --password <password>`) or after a +`-u`/`--user` with no pair (`unzip -u -P <password>`) — is read as a flag, so it masks its own value, and a +`--flag=value` spelling's flag name is read by the text rules too. A password that is itself spelled like a +credential flag (`--password --token`) therefore stays visible, a recorded limit. 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. diff --git a/docs/roadmap/current.md b/docs/roadmap/current.md index 8dabb96c..9cead316 100644 --- a/docs/roadmap/current.md +++ b/docs/roadmap/current.md @@ -39,7 +39,7 @@ evidence summary, the recorded Wave 3 splits and the process amendments. | 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) | | 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 `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. The next planned checkpoint is MVP-10's | +| 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 (credential flags after a boolean flag, a query key in a `,`/`/` URL password) until the next checkpoint, MVP-10's unless the maintainer schedules one earlier | ### What a CLI user meets today @@ -80,7 +80,8 @@ Wave 3 split as `row <n>` in the [increment table](phases/phase-2.5.5-hardening- 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 round 1 found five medium findings, fixed since, and a clean round 2 is pending; + correction's D64 round 1 found five medium findings and round 2 one, all fixed since, and a clean round 3 is + pending; 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 @@ -408,8 +409,9 @@ places, each deliberate: 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; 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, which MVP-A23 creates and gives `NPM_TOKEN` (until then the - token is a repository secret). + 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 diff --git a/docs/roadmap/deferred-tasks.md b/docs/roadmap/deferred-tasks.md index 71e25902..b72ebf1a 100644 --- a/docs/roadmap/deferred-tasks.md +++ b/docs/roadmap/deferred-tasks.md @@ -394,10 +394,10 @@ measured or counted here. 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 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)* `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. 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. **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.* 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, 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.* 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. *(nit · 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)* @@ -661,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 @@ -1046,7 +1049,7 @@ 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; on a crash-resume where the model changes exactly that element, the slot replays + site.yml`) masks the next argument unless that is itself a credential flag (round 2); 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)* @@ -1077,14 +1080,28 @@ lows are recorded: 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)* -- [ ] **`main`'s branch protection does not bind administrators** (`enforce_admins` off), and the sole maintainer is - one, so the required checks bind only non-admins. code-review.md and current.md now say so. Maintainer call: enable - it, or keep the practice. *(low · repository settings · round-1 DOC-9)* +- **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 +<pw>`), and it was fixed with the round's lows but these two recorded limits: + +- [ ] **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)* +- [ ] **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)* **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 (four DONE entries whose bodies described the open state) and R12b-04 (entries the +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:* @@ -2640,13 +2657,13 @@ 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). **Pulled into the CLI MVP 2026-11-04 (MVP-3, Tool-call rendering v2).** *(low · apps/cli/src/render/tui/chat-projection.ts)* + a CVE gap (those chars cannot reorder a command to masquerade). **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). **Pulled into the CLI MVP 2026-11-04 (MVP-3, Tool-call rendering 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). 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 04ff0885..ae70868a 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 @@ -10722,10 +10722,11 @@ restored, [moved here](#moved-by-the-pr-96-review-correction--2026-11-04) and to 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 at the merged head.** Local `pnpm run ci` passed at `964994c7` (secrets controls 130/130, acceptance tools -1,027/1,027) and `pnpm coverage:enforced` passed (753 files, 16,244 tests; 94.75% lines, 92.83% branches). GitHub -CI at `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`. +**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`. **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; @@ -10741,7 +10742,7 @@ reports, five distinct medium findings: | Finding | Kind | Disposition | | --- | --- | --- | -| A boolean `-u`/`--user` swallowed the credential flag after it (`unzip -u -P <pw>`), a regression from `64fb8772` | Redaction | Fixed: a value that is no `user:password` pair is processed as any element | +| 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 | @@ -10753,10 +10754,22 @@ prerelease grammar and tests, a stranded JSDoc, the fixture generator's temporar 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`. **D64's bar is not met yet:** round 1 found +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. **D64's bar is still +not met:** acceptance needs a NEW round 3 with no blocker, high or medium finding. + ## 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 From 4b12fec7305d0ec14c56c9ff92964414efcca926 Mon Sep 17 00:00:00 2001 From: Cemil ILIK <cemililik@outlook.com> Date: Wed, 4 Nov 2026 23:16:54 +0300 Subject: [PATCH 007/148] fix(core): make the argv and URL-password readings only add masks The D64 round 3 on the PR #96 review correction found that each new reading could still show text 64fb8772 masked: - Round 2's fix read a credential flag in value position as a flag, so a password spelled like one (`--password -MySecret`) showed (medium). Such an element is now masked as the previous flag's value and still masks the element after it, so neither reading leaks; the cost is the flag name in that position. - The `,`/`/` password reading gave the username its text, which hid a credential-word username (`x-access-token:`) or a key after a raw `?`/`;`/`&` from the key rules, so a password's tail after a raw `@` showed. The username now owns no text there, so the key rules mask as they did before and the reading only adds a mask. A superset oracle (markers in every position, flag-shaped values and credential-word usernames included) finds no marker 64fb8772 hid that this head shows, over a million display, value and journal comparisons, and finds 60,299 at 07f653e7. New cases pin the password tail's five stops and the glued `-u-` guard; every mutant of the sweep fails a test. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- packages/core/src/tools/bounding.test.ts | 40 +++++++++++++++++++----- packages/core/src/tools/bounding.ts | 27 +++++++--------- 2 files changed, 44 insertions(+), 23 deletions(-) diff --git a/packages/core/src/tools/bounding.test.ts b/packages/core/src/tools/bounding.test.ts index 6c4b7d20..7a8b81ae 100644 --- a/packages/core/src/tools/bounding.test.ts +++ b/packages/core/src/tools/bounding.test.ts @@ -943,6 +943,7 @@ describe('redactSecretShapedValue — the KEY decides, whatever the value looks '--user=dave', '-uroot', '-u=1000:1000', + '-u-x:y', ], ...['--user', 'git+https://example.invalid/r.git'], ], @@ -953,25 +954,40 @@ describe('redactSecretShapedValue — the KEY decides, whatever the value looks expect( JSON.stringify(redactSecretShapedValue({ args: ['--user', key, '-u', key] })), ).not.toContain(key); - // A boolean flag whose last word names a credential leaves the credential flag after it in force too, - // in both projections; and a flag-shaped name keeps no secret shape in its `=` spelling. + // 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', '--with-token', '--token', 'Qz7pw'], [ 'helm', '--pass-credentials', '--password', - '[redacted]', + 'Qz7pw', + 'r', '--with-token', '--token', + 'Qz7pw', + ], + [ + 'helm', + '--pass-credentials', + '[redacted]', + '[redacted]', + 'r', + '--with-token', + '[redacted]', '[redacted]', ], ], [ ['--ask-password', '--passphrase', '--secret', '-p', 'Qz7pw'], - ['--ask-password', '--passphrase', '--secret', '-p', '[redacted]'], + ['--ask-password', '[redacted]', '[redacted]', '[redacted]', '[redacted]'], + ], + [ + ['mysql', '--password', '-Qz7Secret', 'db', '--token', '--Qz7-password'], + ['mysql', '--password', '[redacted]', '[redacted]', '--token', '[redacted]'], ], [[`--github-${ghp}-token=Qz7pw`], ['--github-[redacted]-token=[redacted]']], ] as const) { @@ -1425,9 +1441,11 @@ describe('PR95 raw ?/# userinfo in the journal projection', () => { ['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'], - ['https://x-access-token:pa?x,ss@github.com/o/r', 'https://[redacted]@github.com/o/r'], + // The username owns nothing here: a key there masks its value to its own end, as it did before this + // reading existed, so a password's text after a raw `@` stays masked. + ['https://x-access-token:pa,ss@github.com/o/r', 'https://[redacted]'], + ['https://x-access-token:Ab/cd@Ef9Gh2Jk@github.com/o/r.git', 'https://[redacted]'], + ['http://svc;token=a:b/c@d', 'http://[redacted]'], // With no `@` to read on to, the scan resumes inside the authority and finds the later scheme. ['https://xhttps://user:pw@h.example/', 'https://xhttps://[redacted]@h.example/'], // After a raw `?`/`#` there is no username: the key there masks its value to its own end. @@ -1454,6 +1472,12 @@ describe('PR95 raw ?/# userinfo in the journal projection', () => { // A raw `?`/`#` after the delimiter may begin a path's query: a recorded limit. 'https://user:pa,ss?1@host.example/', 'https://user:pa/ss#1@host.example/', + // Each delimiter the authority keeps also stops the read on its own. + '{"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); } diff --git a/packages/core/src/tools/bounding.ts b/packages/core/src/tools/bounding.ts index f64ab08c..cd2e5fa7 100644 --- a/packages/core/src/tools/bounding.ts +++ b/packages/core/src/tools/bounding.ts @@ -1181,11 +1181,11 @@ function passwordPastDelimiter(text: string, start: number, end: number): number } /** - * Mask a userinfo whose password holds a `,` or `/` ({@link passwordPastDelimiter}). As in - * {@link visitUrlUserinfo}, its username owns the text before the first `:` only when no raw `?`/`#` comes - * first; otherwise a key there (`http://svc?token=a:b/c@d`) is read by the key/value rules, which mask its - * value to its own end. Returns where the scheme scan resumes: after the `@` it masked through, or at - * `start` when there is none. + * Mask a userinfo whose password holds a `,` or `/` ({@link passwordPastDelimiter}). Its username owns no + * text, unlike in {@link visitUrlUserinfo}: every key there (`x-access-token:`, `?token=`, `;password=`) is + * still read by the key/value rules, which mask its value to its own end as they did before this reading + * existed, so the reading only adds masks. Returns where the scheme scan resumes: after the `@` it masked + * through, or at `start` when there is none. */ function maskPasswordPastDelimiter( text: string, @@ -1196,10 +1196,7 @@ function maskPasswordPastDelimiter( ): number { const passwordAt = passwordPastDelimiter(text, start, end); if (passwordAt < 0) return start; - const colon = text.indexOf(':', start); - const query = text.slice(start, end).search(/[?#]/); - const usernameEnd = query < 0 || colon - start < query ? colon : start; - mask(start, passwordAt, '[redacted]', { schemeStart, ownsUntil: usernameEnd }); + mask(start, passwordAt, '[redacted]', { schemeStart, ownsUntil: start }); return passwordAt + 1; } @@ -1462,17 +1459,17 @@ function redactArgVector( const out: unknown[] = []; let redactNext: string | undefined; for (const item of items) { - // A credential flag in value position (`--ask-password --password <pw>`), and a `-u`/`--user` value that is - // no user:password pair, are processed as any element, so a credential flag there still masks its own value - // (D64 round-1 and round-2 findings on the PR #96 correction). + // 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 || flag - ? undefined - : redactCredentialArgument(redactNext, item, whole); + redactNext === undefined ? undefined : redactCredentialArgument(redactNext, item, whole); redactNext = undefined; if (masked !== undefined) { out.push(masked); + if (flag) redactNext = item; continue; } const inline = From 71b621d532675a536654bb7a7d2d83d6de951bef Mon Sep 17 00:00:00 2001 From: Cemil ILIK <cemililik@outlook.com> Date: Wed, 4 Nov 2026 23:16:54 +0300 Subject: [PATCH 008/148] fix(repo): pin the hash-object batch bound, the other dependency fields and step shells Lows from the D64 round 3 on the PR #96 review correction: - The batch test asserts each hash-object command line stays within 16 KiB, and an answer with an extra line is no match. The blob id is exactly 40 hex digits, as sourceIdentity admits only SHA-1 repositories. - The allowlist's refusal is tested on optionalDependencies and peerDependencies too; its comment says leading zeros are admitted, as npm's and pnpm's loose reading accepts them. - The required-check pin also refuses a job-level default shell and a step shell (only the headless smoke may name bash), and its comment states what a skip and a rename each do. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- tools/ci/workspace-scheduling.test.mjs | 14 ++++++++-- tools/cli-package/archive.mjs | 5 ++-- tools/cli-package/archive.test.mjs | 23 +++++++++++++++- tools/cli-package/candidate.mjs | 6 ++-- tools/cli-package/candidate.test.mjs | 38 +++++++++++++++----------- 5 files changed, 61 insertions(+), 25 deletions(-) diff --git a/tools/ci/workspace-scheduling.test.mjs b/tools/ci/workspace-scheduling.test.mjs index 42065e17..29163bf5 100644 --- a/tools/ci/workspace-scheduling.test.mjs +++ b/tools/ci/workspace-scheduling.test.mjs @@ -204,8 +204,10 @@ test('required and local CI share all workspace tasks and uncached per-invocatio }); test('required checks keep their branch-protection names and cannot be skipped', () => { - // GitHub counts a skipped required check as passing, so a job-level `if`, `continue-on-error` or rename - // would silently drop a required gate (a D64 round-1 finding on the PR #96 correction). + // 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')); @@ -217,14 +219,15 @@ test('required checks keep their branch-protection names and cannot be skipped', assert.equal(job.name, name, id); assert.equal(job.if, undefined, id); assert.equal(job['continue-on-error'], undefined, id); - // A `needs` would skip the required job whenever its dependency fails. 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'); 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'); @@ -234,6 +237,11 @@ test('required checks keep their branch-protection names and cannot be skipped', 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, diff --git a/tools/cli-package/archive.mjs b/tools/cli-package/archive.mjs index c7864647..aeac97fd 100644 --- a/tools/cli-package/archive.mjs +++ b/tools/cli-package/archive.mjs @@ -84,8 +84,9 @@ function unpack(bytes) { * 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). The - * prerelease follows semver's dot-separated identifiers, since npm reads an empty one (`1.2.3-.`, - * `1.2.3-a..b`) as a dist-tag. + * 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}(?:\.[0-9A-Za-z-]{1,64}){0,15})?$/u; diff --git a/tools/cli-package/archive.test.mjs b/tools/cli-package/archive.test.mjs index 812f0061..963bf2d2 100644 --- a/tools/cli-package/archive.test.mjs +++ b/tools/cli-package/archive.test.mjs @@ -309,7 +309,8 @@ test('manifest rejects runtime drift and unresolved/private runtime dependencies { commander: '*' }, { commander: '>=12.0.0' }, { commander: 'latest' }, - // npm reads an empty prerelease identifier, or a version over semver's length limit, as a dist-tag. + // 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)}` }, @@ -338,6 +339,26 @@ 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); diff --git a/tools/cli-package/candidate.mjs b/tools/cli-package/candidate.mjs index c0a70db3..6045919e 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 */ @@ -341,7 +341,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 4f73f0af..d7d84df0 100644 --- a/tools/cli-package/candidate.test.mjs +++ b/tools/cli-package/candidate.test.mjs @@ -1071,7 +1071,11 @@ 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 = () => @@ -1106,25 +1110,27 @@ test( }, ); -test('a hash-object answer missing a line is not a match', async () => { +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 () => { From 7a12ee6f64d0cbbf1c0f4388d2ec425f9039c69b Mon Sep 17 00:00:00 2001 From: Cemil ILIK <cemililik@outlook.com> Date: Wed, 4 Nov 2026 23:16:54 +0300 Subject: [PATCH 009/148] docs(repo): record the D64 round 3 and correct round 2's oracle claim - D61 record: the round's medium finding and downgraded claim, the add-only fix and the superset oracle that checks it; round 2's oracle sentence now says its pool held no flag-shaped secret. The record also notes that the correction reached main before its D64 rounds by the maintainer's decision, and that main's push run at b3cf117e failed on its first attempt for want of a macOS runner. D64 needs a clean round 4. - built-in-tools.md and effect-journal.md describe the add-only readings, the `@`-before-delimiter limit and the argv exceptions. - current.md: the Wave 3 increments are on main; main's remaining regressions and the round count are current. - deferred-tasks.md: round 3's recorded limit and equivalent mutant, R11-06 closed with the three further links it named, the S3776 range corrected, and two DONE tails that still asked for a ruling. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- docs/reference/shared-core/built-in-tools.md | 21 ++++++----- docs/reference/shared-core/effect-journal.md | 4 ++- docs/roadmap/current.md | 7 ++-- docs/roadmap/deferred-tasks.md | 32 +++++++++++------ .../phase-2.5.5-hardening-and-remediation.md | 36 ++++++++++++++----- 5 files changed, 69 insertions(+), 31 deletions(-) diff --git a/docs/reference/shared-core/built-in-tools.md b/docs/reference/shared-core/built-in-tools.md index 9fd4011d..adca1d1d 100644 --- a/docs/reference/shared-core/built-in-tools.md +++ b/docs/reference/shared-core/built-in-tools.md @@ -136,15 +136,18 @@ the delimiters of the JSON and quoted texts it reads, so userinfo that contains 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 +password no longer ends an authority that holds no `@` before it: 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`), while a `,` in the username still ends the authority (`https://us,er:pw@host` stays as it is; the backstop masks it). A port (`https://host:8080/a@b`, `https://host:8080,bob@x.example`), a public query contact as above, another scheme, a quote, `}`, `]` or whitespace, or any raw `?` or `#` after the `,` or `/` (which may begin a path's query, as in `https://host:abc/p?e=a@b`) keeps the text public: `https://user:pa,ss?1@host` stays as it is, -a recorded limit the backstop does not share for the comma spelling. After a raw `?` or `#` before the first -`:` there is no username, as in the authority scan, so a key there masks its value to its own end -(`http://svc?token=a:b/c@d` → `http://[redacted]`). The backstop still stops at a `/` (a recorded low). Two +a recorded limit the backstop does not share for the comma spelling. This reading only adds masks: its username +owns no text, so every key there — a credential-word username, or one after a raw `?`, `#`, `;` or `&` — masks its +value to its own end as before 2026-11-04 (`https://x-access-token:pa,ss@github.com/o/r` and +`http://svc?token=a:b/c@d` → `https://[redacted]`, `http://[redacted]`). A password with an `@` before its `,` +or `/` keeps its tail after that `@` (`https://user:p@ss/word@vault/x`), a recorded limit. The backstop still stops at a `/` (a recorded low). Two further recorded limits: a password that begins with the `,` or `/` (`https://user:/pw@host`) stays public, and a non-numeric port with an `@` in its path (`http://localhost:$PORT/users/@me`) reads as a password through that `@`, which masks benign text and merges the journal identities of calls that differ there. @@ -158,11 +161,11 @@ rule's words as written (`--auth-token`, `--access-token`, `--db-password`, `--p 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 — after a -boolean flag whose last word names a credential (`--ask-password --password <password>`) or after a -`-u`/`--user` with no pair (`unzip -u -P <password>`) — is read as a flag, so it masks its own value, and a -`--flag=value` spelling's flag name is read by the text rules too. A password that is itself spelled like a -credential flag (`--password --token`) therefore stays visible, a recorded limit. Short-option clusters (`curl -su alice:secret`), +`-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 <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 <password>`) the element is read as any other, and a +`--flag=value` spelling's flag name is read by the text rules too. 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. diff --git a/docs/reference/shared-core/effect-journal.md b/docs/reference/shared-core/effect-journal.md index 704debb9..79b10a0d 100644 --- a/docs/reference/shared-core/effect-journal.md +++ b/docs/reference/shared-core/effect-journal.md @@ -420,7 +420,9 @@ 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 diff --git a/docs/roadmap/current.md b/docs/roadmap/current.md index 9cead316..2326d4a7 100644 --- a/docs/roadmap/current.md +++ b/docs/roadmap/current.md @@ -39,7 +39,7 @@ evidence summary, the recorded Wave 3 splits and the process amendments. | 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) | | 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 `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 (credential flags after a boolean flag, a query key in a `,`/`/` URL password) until the next checkpoint, MVP-10's unless the maintainer schedules one earlier | +| 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) until the next checkpoint, MVP-10's unless the maintainer schedules one earlier | ### What a CLI user meets today @@ -80,7 +80,7 @@ Wave 3 split as `row <n>` in the [increment table](phases/phase-2.5.5-hardening- 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 round 1 found five medium findings and round 2 one, all fixed since, and a clean round 3 is + correction's D64 rounds 1–3 found five, one and one medium findings, all fixed since, and a clean round 4 is pending; 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; @@ -202,7 +202,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. diff --git a/docs/roadmap/deferred-tasks.md b/docs/roadmap/deferred-tasks.md index b72ebf1a..81d19862 100644 --- a/docs/roadmap/deferred-tasks.md +++ b/docs/roadmap/deferred-tasks.md @@ -394,8 +394,8 @@ measured or counted here. 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 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. **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.* 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, 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. 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)* @@ -978,7 +978,7 @@ 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)* @@ -1049,7 +1049,7 @@ 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 unless that is itself a credential flag (round 2); on a crash-resume where the model changes exactly that element, the slot replays + 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)* @@ -1098,6 +1098,19 @@ boolean flag whose last word names a credential consuming the credential flag af 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: + +- [ ] **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)* + **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 @@ -1428,12 +1441,11 @@ MVP pulled without an annotation); both were fixed on 2026-11-04. 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)* -- [ ] The cited location was wrong (`current.md:37` never held this link), but the defect exists elsewhere. - `phase-2.5.5-hardening-and-remediation.md:11289`, in the record 964994c7 moved, links 'Execution order — Phase 2.5.5 - + Phase 2.6 … below' to `current.md`'s legacy anchor, which now sits above 'Pending after the MVP'. The section it - names is the register's own heading at :10708. Point the link in-page, to - #execution-order-`-phase-255--phase-26-temporary` in the same file. *(low · - docs/roadmap/phases/phase-2.5.5-hardening-and-remediation.md:11289 · first-pass R11-06)* +- **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 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 ae70868a..7061e4b7 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 @@ -10703,7 +10703,9 @@ unsanitized `relavium run` output; the maintainer accepted it because the produc 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. +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 @@ -10726,7 +10728,9 @@ the maintainer chose to record are in 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`. +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. **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; @@ -10767,8 +10771,24 @@ fixed with it: an inline flag name read by the text rules, two surviving mutants 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. **D64's bar is still -not met:** acceptance needs a NEW round 3 with no blocker, high or medium finding. +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 every position including flag-shaped values and credential-word usernames: 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 is still not met:** +acceptance needs a NEW round 4 with no blocker, high or medium finding. ## Execution record moved from current.md — 2026-11-04 @@ -11349,13 +11369,13 @@ A 2026-07-19 full-project code review then opened **Phase 2.5.5 (Hardening and R 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](phase-2.5.5-hardening-and-remediation.md) -and [Active now](../current.md#what-is-active-now). +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 @@ -11364,7 +11384,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). @@ -11444,7 +11464,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 From 93a8b0337854a826e484be61154e1ed9979810bd Mon Sep 17 00:00:00 2001 From: Cemil ILIK <cemililik@outlook.com> Date: Thu, 5 Nov 2026 00:10:50 +0300 Subject: [PATCH 010/148] test(repo): tolerate a child that exits between the liveness check and the kill `consumer-lifetime.test.mjs`'s cleanup checked a child with `alive()` and then sent SIGKILL; when the child exited in between, `process.kill` threw ESRCH and failed the "interrupt still removes the installation" case on the Linux CI at 7a12ee6f (W8 usage causal acceptance, run 41905108496). It failed once in the last 25 CI runs and 0 of 40 local runs; the cause is the cleanup's own check-then-kill race, not the consumer. Both cleanups in the file now kill and ignore ESRCH only, since a process that is gone needs no cleanup. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- tools/cli-package/consumer-lifetime.test.mjs | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/tools/cli-package/consumer-lifetime.test.mjs b/tools/cli-package/consumer-lifetime.test.mjs index 91f9ffb0..74636d6a 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); } }, ); From 10e033b11b077cc39df6e5fa061a327d584608bc Mon Sep 17 00:00:00 2001 From: Cemil ILIK <cemililik@outlook.com> Date: Thu, 5 Nov 2026 00:49:02 +0300 Subject: [PATCH 011/148] fix(core): leave nested URLs and escapes to the scheme scan, and keep masked flag names hidden The D64 round 4 on the PR #96 review correction found the add-only property still broken in two places (0826f30a): - When an authority ends in `scheme:` and the `/` delimiter begins a nested `//`, the `,`/`/` password reading masked to the nested URL's first `@` and moved the scheme scan past it, so that URL's password tail showed (`https://proxy:https://user:p@ss@target/`; medium). The reading now leaves a straddling `://`, and a backslash in its authority or tail (a JSON escape the key rules read), to the other rules. - A last-word flag's `--flag=value` spelling showed a name the text rules mask together with its value (`--db-token=<v>`). The name now stays only where the text rules keep it; otherwise the element is masked whole. An anchored name (`--password=`) stays as before. Two independent oracles, each first shown to catch 7a12ee6f, find no marker 64fb8772 hid that this head shows: the round's reviewer's (about 2.2 million comparisons) and ours extended with flag-name, nested-URL and escape shapes (one million). Each guard has a case that fails without it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- packages/core/src/tools/bounding.test.ts | 57 +++++++++++++++++++----- packages/core/src/tools/bounding.ts | 20 +++++++-- 2 files changed, 63 insertions(+), 14 deletions(-) diff --git a/packages/core/src/tools/bounding.test.ts b/packages/core/src/tools/bounding.test.ts index 7a8b81ae..f13eeda0 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]'] }, @@ -989,11 +991,28 @@ describe('redactSecretShapedValue — the KEY decides, whatever the value looks ['mysql', '--password', '-Qz7Secret', 'db', '--token', '--Qz7-password'], ['mysql', '--password', '[redacted]', '[redacted]', '--token', '[redacted]'], ], - [[`--github-${ghp}-token=Qz7pw`], ['--github-[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 boolean -u/--user (unzip's update flag) leaves the credential flag after it in force. expect( redactSecretShapedValue({ @@ -1446,6 +1465,11 @@ describe('PR95 raw ?/# userinfo in the journal projection', () => { ['https://x-access-token:pa,ss@github.com/o/r', 'https://[redacted]'], ['https://x-access-token:Ab/cd@Ef9Gh2Jk@github.com/o/r.git', 'https://[redacted]'], ['http://svc;token=a:b/c@d', 'http://[redacted]'], + // A nested URL whose `://` straddles the delimiter 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/', + ], // With no `@` to read on to, the scan resumes inside the authority and finds the later scheme. ['https://xhttps://user:pw@h.example/', 'https://xhttps://[redacted]@h.example/'], // After a raw `?`/`#` there is no username: the key there masks its value to its own end. @@ -3004,6 +3028,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 cd2e5fa7..b032a91e 100644 --- a/packages/core/src/tools/bounding.ts +++ b/packages/core/src/tools/bounding.ts @@ -1172,6 +1172,9 @@ function passwordPastDelimiter(text: string, start: number, end: number): number const authority = text.slice(start, end); const colon = authority.indexOf(':'); if (colon < 0 || /^\d*$/.test(authority.slice(colon + 1))) return -1; + // A scheme whose `://` straddles the delimiter starts a nested URL the scan reads on its own, and a + // backslash may be a JSON escape the key rules read: this reading leaves both to them (D64 round 4). + if (text.startsWith('://', end - 1) || authority.includes('\\')) return -1; const query = authority.search(/[?#]/); if (query >= 0 && isPublicQueryContact(authority, query)) return -1; PASSWORD_TAIL.lastIndex = end; @@ -1201,7 +1204,7 @@ function maskPasswordPastDelimiter( } /** A password's tail past a `,` or `/`, through its `@`; bounded so a long run is never rescanned. */ -const PASSWORD_TAIL = /[^\s"'}\]?#@]{0,512}@/y; +const PASSWORD_TAIL = /[^\s"'}\]?#@\\]{0,512}@/y; function visitUrlUserinfo(text: string, mask: SecretSpanVisitor): void { if (!text.includes('://')) return; @@ -1497,13 +1500,24 @@ function redactInlineCredential( const flag = separator > 0 ? item.slice(0, separator) : ''; if (isCredentialFlag(flag)) { const masked = redactCredentialArgument(flag, item.slice(separator + 1), whole); - // The name is read too: a last-word flag admits any name before its credential word. - if (masked !== undefined) return `${project(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]')}`; } +/** + * 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}=`); +} + /** * One credential-value rule for both argv spellings; only ambiguous -p may name a public port. `undefined` * means the value is not a credential after all (a `-u`/`--user` name without a password) and is walked as From 6c7bc70a71f8e9e9bad051ea8d28d1367daf09d3 Mon Sep 17 00:00:00 2001 From: Cemil ILIK <cemililik@outlook.com> Date: Thu, 5 Nov 2026 00:49:02 +0300 Subject: [PATCH 012/148] fix(repo): refuse relative candidate roots and pin batch packing and the aggregate's guards Lows from the D64 round 4 on the PR #96 review correction: - copyMatchesCommit refuses a relative repository or candidate root (PACKED_CLI_CANDIDATE_ROOT): the ordinary-file proof read against this process while Git hashed against the checkout. - The batch test pins that hash-object batches are packed, so a counter that is never reset (one git per path) fails it. - The required-check pin also holds the `ci` aggregate's guard steps to exactly their keys and refuses a default shell on `ci`. - ci.yml's floor-check comment names the three required checks. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- .github/workflows/ci.yml | 3 ++- tools/ci/workspace-scheduling.test.mjs | 8 ++++++++ tools/cli-package/candidate.mjs | 2 ++ tools/cli-package/candidate.test.mjs | 18 ++++++++++++++++++ 4 files changed, 30 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 01b16883..638ddda9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/tools/ci/workspace-scheduling.test.mjs b/tools/ci/workspace-scheduling.test.mjs index 29163bf5..60fa80fb 100644 --- a/tools/ci/workspace-scheduling.test.mjs +++ b/tools/ci/workspace-scheduling.test.mjs @@ -224,6 +224,14 @@ test('required checks keep their branch-protection names and cannot be skipped', } // 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); diff --git a/tools/cli-package/candidate.mjs b/tools/cli-package/candidate.mjs index 6045919e..e4501f8b 100644 --- a/tools/cli-package/candidate.mjs +++ b/tools/cli-package/candidate.mjs @@ -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], { diff --git a/tools/cli-package/candidate.test.mjs b/tools/cli-package/candidate.test.mjs index d7d84df0..542e46a0 100644 --- a/tools/cli-package/candidate.test.mjs +++ b/tools/cli-package/candidate.test.mjs @@ -1082,6 +1082,12 @@ test('every hash-object batch is compared, the last included', async () => { 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, @@ -1110,6 +1116,18 @@ test( }, ); +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); From 68eb689cf336bf3014e37f9741cdee013f484350 Mon Sep 17 00:00:00 2001 From: Cemil ILIK <cemililik@outlook.com> Date: Thu, 5 Nov 2026 00:49:02 +0300 Subject: [PATCH 013/148] docs(repo): record the D64 round 4 and the facts it found missing - D61 record: the round's medium finding and downgraded claim, the fix, the two validated oracles, the cleanup race fixed in 93a8b033, round 3's oracle scope stated, and main's re-run (attempt 2) green. D64 needs a clean round 5. - built-in-tools.md: when the `,`/`/` reading is skipped, and how a `--flag=value` spelling keeps or hides its name. - current.md: main's remaining regressions and the round count. - The W7 round-1 summary current.md held moves verbatim beside its Phase 2.6.5 copy, which lacked its facts; the move note says so. - code-review.md records when a checkpoint may carry a correction whose round is still open; the release runbook points the publish credential at MVP-A23. - deferred-tasks.md: the eleven remaining alive-then-kill cleanups, and the S5843 entry corrected to eight reports on seven patterns. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- docs/reference/shared-core/built-in-tools.md | 10 ++++-- docs/roadmap/current.md | 6 ++-- docs/roadmap/deferred-tasks.md | 13 +++++-- .../phase-2.5.5-hardening-and-remediation.md | 34 ++++++++++++++----- ...hase-2.6.5-core-reliability-remediation.md | 5 +++ docs/runbooks/release-a-surface.md | 5 +++ docs/standards/code-review.md | 4 +++ 7 files changed, 60 insertions(+), 17 deletions(-) diff --git a/docs/reference/shared-core/built-in-tools.md b/docs/reference/shared-core/built-in-tools.md index adca1d1d..d9066fb9 100644 --- a/docs/reference/shared-core/built-in-tools.md +++ b/docs/reference/shared-core/built-in-tools.md @@ -143,7 +143,9 @@ username still ends the authority (`https://us,er:pw@host` stays as it is; the b (`https://host:8080/a@b`, `https://host:8080,bob@x.example`), a public query contact as above, another scheme, a quote, `}`, `]` or whitespace, or any raw `?` or `#` after the `,` or `/` (which may begin a path's query, as in `https://host:abc/p?e=a@b`) keeps the text public: `https://user:pa,ss?1@host` stays as it is, -a recorded limit the backstop does not share for the comma spelling. This reading only adds masks: its username +a recorded limit the backstop does not share for the comma spelling. It is skipped when a nested scheme's `://` +straddles the `,` or `/` (`https://proxy:https://u:p@ss@h`) or the authority holds a backslash, a JSON escape the +key rules read: both are left to the other rules. This reading only adds masks: its username owns no text, so every key there — a credential-word username, or one after a raw `?`, `#`, `;` or `&` — masks its value to its own end as before 2026-11-04 (`https://x-access-token:pa,ss@github.com/o/r` and `http://svc?token=a:b/c@d` → `https://[redacted]`, `http://[redacted]`). A password with an `@` before its `,` @@ -164,8 +166,10 @@ not know the program, so a `name:group` (`docker run -u app:app`) or another col `-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 <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 <password>`) the element is read as any other, and a -`--flag=value` spelling's flag name is read by the text rules too. Short-option clusters (`curl -su alice:secret`), +name there. After a `-u`/`--user` with no pair (`unzip -u -P <password>`) 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=<value>`), the whole element is masked. +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. diff --git a/docs/roadmap/current.md b/docs/roadmap/current.md index 2326d4a7..2e6df1b7 100644 --- a/docs/roadmap/current.md +++ b/docs/roadmap/current.md @@ -39,7 +39,7 @@ evidence summary, the recorded Wave 3 splits and the process amendments. | 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) | | 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 `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) until the next checkpoint, MVP-10's unless the maintainer schedules one earlier | +| 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 | ### What a CLI user meets today @@ -80,8 +80,8 @@ Wave 3 split as `row <n>` in the [increment table](phases/phase-2.5.5-hardening- 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–3 found five, one and one medium findings, all fixed since, and a clean round 4 is - pending; + correction's D64 rounds 1–4 found five, one, one and one medium findings, all fixed since, and a clean round 5 + is pending; 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 diff --git a/docs/roadmap/deferred-tasks.md b/docs/roadmap/deferred-tasks.md index 81d19862..115a5d0f 100644 --- a/docs/roadmap/deferred-tasks.md +++ b/docs/roadmap/deferred-tasks.md @@ -982,8 +982,8 @@ names, unless the entry names another. `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)* @@ -1111,6 +1111,15 @@ them but these: 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)* + **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 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 7061e4b7..9545449a 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 @@ -10730,7 +10730,7 @@ lines, 92.83% branches). GitHub CI at the merged head `f25fb83d`: push run 41848 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. +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; @@ -10782,13 +10782,29 @@ verifier downgraded a second claim to low: the `,`/`/` reading gave a credential 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 every position including flag-shaped values and credential-word usernames: over 200,000 generated URL texts +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 is still not met:** -acceptance needs a NEW round 4 with no blocker, high or medium finding. +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 is still not met:** acceptance needs a NEW round 5 with no blocker, high or medium finding. ## Execution record moved from current.md — 2026-11-04 @@ -11222,11 +11238,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; headings in brackets are added. Five old lines are deliberately not repeated: the -> page's own `Last updated` and **Related** lines, 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)) and a paraphrased duplicate of the W7 -> round-1 review summary, whose canonical copy is in Phase 2.6.5's record; the 2.6.Q usage-capture line was extended -> in place in Phase 2.6. 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). 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 bdcba77d..8965fe74 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 diff --git a/docs/runbooks/release-a-surface.md b/docs/runbooks/release-a-surface.md index 8b8da656..f4f064a0 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 f76d30ca..b828534a 100644 --- a/docs/standards/code-review.md +++ b/docs/standards/code-review.md @@ -22,6 +22,10 @@ are actually enforced — automation catches format and types; a human catches t > 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 From bbffecc4f2094e273407a40a7b26551fa7eb62ec Mon Sep 17 00:00:00 2001 From: Cemil ILIK <cemililik@outlook.com> Date: Thu, 5 Nov 2026 01:22:02 +0300 Subject: [PATCH 014/148] fix(core): remove the comma/slash URL password reading and keep the argv rules additive The maintainer ruled on 2026-11-05 to stop the D64 rounds on the PR #96 correction after one careful pass. That pass found: - The `,`/`/` URL password reading (added for the review's A4) cannot be shown to only add masks: the projection repeats until stable, and a reading's mask can change the text other rules read in a later pass. D64 rounds 1, 3 and 4 each found a credential it newly showed. It is removed: visitUrlUserinfo is again the base's, and the text projection equals 64fb8772's on 200,000 generated texts. A4 is a recorded limit again; the test that pinned the reading now pins the base behavior. - 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; now whenever the element before it is any credential flag. A glued `-u` pair now keeps its `-u` only where the text rules keep it, like the `--flag=value` name (`-utoken:<v>`). Both superset oracles (ours and the round-4 reviewer's, each validated on a known-bad version) find no violation; core's 4,941 and the CLI's 5,828 tests pass. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- packages/core/src/tools/bounding.test.ts | 40 +++++++------ packages/core/src/tools/bounding.ts | 72 ++++-------------------- 2 files changed, 29 insertions(+), 83 deletions(-) diff --git a/packages/core/src/tools/bounding.test.ts b/packages/core/src/tools/bounding.test.ts index f13eeda0..a305adc8 100644 --- a/packages/core/src/tools/bounding.test.ts +++ b/packages/core/src/tools/bounding.test.ts @@ -1013,6 +1013,13 @@ describe('redactSecretShapedValue — the KEY decides, whatever the value looks 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({ @@ -1451,35 +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/'], - // The username owns nothing here: a key there masks its value to its own end, as it did before this - // reading existed, so a password's text after a raw `@` stays masked. - ['https://x-access-token:pa,ss@github.com/o/r', 'https://[redacted]'], + ['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://[redacted]'], - // A nested URL whose `://` straddles the delimiter is read by the scheme scan on its own. + ['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/', ], - // With no `@` to read on to, the scan resumes inside the authority and finds the later scheme. ['https://xhttps://user:pw@h.example/', 'https://xhttps://[redacted]@h.example/'], - // After a raw `?`/`#` there is no username: the key there masks its value to its own end. - ['http://svc?token=abc:de/fgh@ijk9', 'http://[redacted]'], - ['http://svc#token=abc:de,fgh@ijk9', 'http://[redacted]'], - ['https://gw?token=ab:cd/ef@gh@host.example/', 'https://[redacted]'], ] 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/', ); @@ -1493,10 +1493,8 @@ 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/`, - // A raw `?`/`#` after the delimiter may begin a path's query: a recorded limit. 'https://user:pa,ss?1@host.example/', 'https://user:pa/ss#1@host.example/', - // Each delimiter the authority keeps also stops the read on its own. '{"a":"https://h.example:x/p","b":"c@d.example"}', "https://u:pw/x'@y.example", 'https://u:pw/x}@y.example', diff --git a/packages/core/src/tools/bounding.ts b/packages/core/src/tools/bounding.ts index b032a91e..70c6776d 100644 --- a/packages/core/src/tools/bounding.ts +++ b/packages/core/src/tools/bounding.ts @@ -1141,13 +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}), but a `,` in a username still ends the - * authority (`https://us,er:pw@host` stays as it is; the backstop masks it). + * 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 @@ -1156,56 +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, and so does any raw `?` or `#` after the `,` or - * `/`, which may begin a path's query (`host:abc/p?e=a@b`): `https://user:pa,ss?1@host` stays public, a - * recorded limit (the LLM backstop masks that comma spelling, and neither masks its `/` twin). - */ -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; - // A scheme whose `://` straddles the delimiter starts a nested URL the scan reads on its own, and a - // backslash may be a JSON escape the key rules read: this reading leaves both to them (D64 round 4). - if (text.startsWith('://', end - 1) || authority.includes('\\')) 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 no - * text, unlike in {@link visitUrlUserinfo}: every key there (`x-access-token:`, `?token=`, `;password=`) is - * still read by the key/value rules, which mask its value to its own end as they did before this reading - * existed, so the reading only adds masks. 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: 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; @@ -1218,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(/[?#]/); @@ -1505,7 +1450,10 @@ function redactInlineCredential( } } 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]'); } /** From 18ea426b72a68462c6baaf4f77aedcf7bac100e9 Mon Sep 17 00:00:00 2001 From: Cemil ILIK <cemililik@outlook.com> Date: Thu, 5 Nov 2026 01:22:02 +0300 Subject: [PATCH 015/148] docs(repo): record the removal of the URL reading and close the PR #96 correction - built-in-tools.md and effect-journal.md: the authority scan again ends at a `,` or `/` in a password (the review's A4, a recorded limit, and why the reading was removed); the argv rules state that they only add masks. - deferred-tasks.md: A4 recorded with its trigger (a range-merging design validated by a superset oracle); the six entries about the removed reading closed as moot; the resolution noted after round 4. - D61 record and current.md: the maintainer's 2026-11-05 ruling to close the correction with one careful pass instead of a fifth round, a recorded deviation from D64. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- docs/reference/shared-core/built-in-tools.md | 36 +++++++------------ docs/reference/shared-core/effect-journal.md | 4 +-- docs/roadmap/current.md | 5 +-- docs/roadmap/deferred-tasks.md | 29 ++++++++++----- .../phase-2.5.5-hardening-and-remediation.md | 15 +++++++- 5 files changed, 52 insertions(+), 37 deletions(-) diff --git a/docs/reference/shared-core/built-in-tools.md b/docs/reference/shared-core/built-in-tools.md index d9066fb9..b024061d 100644 --- a/docs/reference/shared-core/built-in-tools.md +++ b/docs/reference/shared-core/built-in-tools.md @@ -131,28 +131,15 @@ user, so its value does not run on through the public host and path (`https://x-access-token:<t>@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 an authority that holds no `@` before it: 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`), while a `,` in the -username still ends the authority (`https://us,er:pw@host` stays as it is; the backstop masks it). A port -(`https://host:8080/a@b`, `https://host:8080,bob@x.example`), a public query contact as above, another -scheme, a quote, `}`, `]` or whitespace, or any raw `?` or `#` after the `,` or `/` (which may begin a path's -query, as in `https://host:abc/p?e=a@b`) keeps the text public: `https://user:pa,ss?1@host` stays as it is, -a recorded limit the backstop does not share for the comma spelling. It is skipped when a nested scheme's `://` -straddles the `,` or `/` (`https://proxy:https://u:p@ss@h`) or the authority holds a backslash, a JSON escape the -key rules read: both are left to the other rules. This reading only adds masks: its username -owns no text, so every key there — a credential-word username, or one after a raw `?`, `#`, `;` or `&` — masks its -value to its own end as before 2026-11-04 (`https://x-access-token:pa,ss@github.com/o/r` and -`http://svc?token=a:b/c@d` → `https://[redacted]`, `http://[redacted]`). A password with an `@` before its `,` -or `/` keeps its tail after that `@` (`https://user:p@ss/word@vault/x`), a recorded limit. The backstop still stops at a `/` (a recorded low). Two -further recorded limits: a password that begins with the `,` or `/` (`https://user:/pw@host`) stays public, -and a non-numeric port with an `@` in its path (`http://localhost:$PORT/users/@me`) reads as a password through -that `@`, which masks benign text and merges the journal identities of calls that differ there. +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 text after it stays +public unless another rule reads it (`https://user:pa,ss@host`; 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`, @@ -168,7 +155,10 @@ previous flag's value (`--password -MySecret`) or a flag of its own (`--ask-pass 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 <password>`) 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=<value>`), the whole element is masked. +text rules keep it too; where they mask it with its value (`--db-token=<value>`), 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. diff --git a/docs/reference/shared-core/effect-journal.md b/docs/reference/shared-core/effect-journal.md index 79b10a0d..7f266ecc 100644 --- a/docs/reference/shared-core/effect-journal.md +++ b/docs/reference/shared-core/effect-journal.md @@ -425,8 +425,8 @@ the following element or the complete value in `--token=value` form, with the ex `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. 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 diff --git a/docs/roadmap/current.md b/docs/roadmap/current.md index 2e6df1b7..682c58c7 100644 --- a/docs/roadmap/current.md +++ b/docs/roadmap/current.md @@ -80,8 +80,9 @@ Wave 3 split as `row <n>` in the [increment table](phases/phase-2.5.5-hardening- 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, and a clean round 5 - is pending; + 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 diff --git a/docs/roadmap/deferred-tasks.md b/docs/roadmap/deferred-tasks.md index 115a5d0f..bb0ea35f 100644 --- a/docs/roadmap/deferred-tasks.md +++ b/docs/roadmap/deferred-tasks.md @@ -997,9 +997,15 @@ 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 `/` keeps its tail after it public (the PR #96 review's A4).** *Medium as + reviewed; pre-existing, not a regression.* Core's authority scan ends at either, so `https://user:pa,ss@host` and + `https://user:pa/ss@host` keep `ss` visible in `toolInput`, summaries and the journal digest input; 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. **Trigger:** a design that merges a new reading's ranges with the base rules' on the + original text, so it provably only adds masks, with a superset oracle validated on a known-bad version. *(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 @@ -1057,14 +1063,14 @@ lows are recorded: `-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)* -- [ ] **A raw `?` or `#` after a URL password's `,` or `/` keeps the password public** (`https://user:pa,ss?1@host`); +- **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)* -- [ ] **The `,`/`/` reading stops at the first `@`; the main userinfo path masks to the last.** +- **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)* -- [ ] **The `,`/`/` tail scan runs once per scheme occurrence.** On dense `scheme://x:y,` input at the 1 MiB CLI +- **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)* @@ -1088,12 +1094,12 @@ lows are recorded: boolean flag whose last word names a credential consuming the credential flag after it (`--pass-credentials --password <pw>`), and it was fixed with the round's lows but these two recorded limits: -- [ ] **A URL password that begins with its `,` or `/` stays public** (`postgres://app:/Xk9@db`, +- **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)* -- [ ] **A non-numeric port with an `@` in its path reads as a password** (`http://localhost:$PORT/users/@me` → +- **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)* @@ -1102,7 +1108,7 @@ boolean flag whose last word names a credential consuming the credential flag af second claim downgraded to low were fixed by making both readings add masks only; the round's lows were fixed with them but these: -- [ ] **A URL password with an `@` before its `,` or `/` keeps its tail after that `@`** +- **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 @@ -1120,6 +1126,11 @@ downgraded claim (a masked `--flag=value` name shown) were fixed, with the round `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 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 9545449a..0eafa792 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 @@ -10804,7 +10804,20 @@ escape shapes (one million; 70,280 at `7a12ee6f`). The round's lows were fixed w 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 is still not met:** acceptance needs a NEW round 5 with no blocker, high or medium finding. +**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 From 6dd3f1e11ffc96a7c418ffa8c3ca3d9e2d9138f9 Mon Sep 17 00:00:00 2001 From: Cemil ILIK <cemililik@outlook.com> Date: Thu, 5 Nov 2026 01:38:37 +0300 Subject: [PATCH 016/148] docs(repo): record the MVP-1 plan rulings The maintainer ruled on the MVP-1 plan on 2026-11-05: - MVP-1 lands in two increments: 1a (UX tier: the run id, `list --runs` and `status <runId>`, the run-path pre-flight, the output budget) and 1b (security tier under D64: human run output, the gate decision check, `/export`, the default model and missing-key error, liveness). - An `approval` or `review` gate accepts `approved` or `rejected`; an `input` gate accepts `input_provided` or `rejected`, checked from the workflow definition's `gate_type`. - The run-path pre-flight refuses a workflow that grants an unwired egress/os tool before the run starts (exit 2), MCP tools exempt. - MVP-A7's change to the interactive Anthropic output default is recorded as a dated amendment to ADR-0101. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- docs/roadmap/current.md | 5 ++++- .../roadmap/phases/phase-2.6-conversational-authoring.md | 9 +++++++-- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/docs/roadmap/current.md b/docs/roadmap/current.md index 682c58c7..4f567f4f 100644 --- a/docs/roadmap/current.md +++ b/docs/roadmap/current.md @@ -95,7 +95,10 @@ Wave 3 split as `row <n>` 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 <runId>`, 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. 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 diff --git a/docs/roadmap/phases/phase-2.6-conversational-authoring.md b/docs/roadmap/phases/phase-2.6-conversational-authoring.md index 6779fb24..0f186424 100644 --- a/docs/roadmap/phases/phase-2.6-conversational-authoring.md +++ b/docs/roadmap/phases/phase-2.6-conversational-authoring.md @@ -194,7 +194,9 @@ 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 rides `validateWorkflowWithCatalog`. *(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 @@ -798,6 +800,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 @@ -1351,7 +1356,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, clamped, 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). | From 9458a2b5f4c2e4b84cf86860939552bcf34a1cac Mon Sep 17 00:00:00 2001 From: Cemil ILIK <cemililik@outlook.com> Date: Thu, 5 Nov 2026 01:40:20 +0300 Subject: [PATCH 017/148] feat(cli): show the run id in the live run view and its final summary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MVP-A2 (MVP-1 increment 1a): the TTY run view's footer now leads with the run id, and the persistent final summary names it with the command that continues from the outcome — `relavium logs <runId>`, or `relavium gate <runId>` for a paused run. The plain renderer already prints it on its `run <id> started` line; `--json` is unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- apps/cli/src/render/tui/RunApp.test.tsx | 15 ++++++++ apps/cli/src/render/tui/RunApp.tsx | 1 + apps/cli/src/render/tui/final-summary.test.ts | 35 +++++++++++++++++++ apps/cli/src/render/tui/final-summary.ts | 10 ++++++ docs/reference/cli/commands.md | 2 +- 5 files changed, 62 insertions(+), 1 deletion(-) diff --git a/apps/cli/src/render/tui/RunApp.test.tsx b/apps/cli/src/render/tui/RunApp.test.tsx index 1bde57e8..4a8fdd42 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(<RunApp store={staticStore(state)} />); + 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 = { diff --git a/apps/cli/src/render/tui/RunApp.tsx b/apps/cli/src/render/tui/RunApp.tsx index 29b6c149..0206c605 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) */} <Box marginTop={1}> <Text {...colorProps(color, 'gray')}> + {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 8bda7e32..1839eceb 100644 --- a/apps/cli/src/render/tui/final-summary.test.ts +++ b/apps/cli/src/render/tui/final-summary.test.ts @@ -99,6 +99,41 @@ 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: {} }, + ]); + expect(renderFinalSummary(completed)).toContain( + ` run id ${RUN} — replay with \`relavium logs ${RUN}\``, + ); + const paused = reduceAll([ + started, + { + type: 'run:paused', + runId: RUN, + timestamp: TS, + sequenceNumber: 2, + pendingGateCount: 1, + gateIds: ['approve-1'], + }, + ]); + expect(renderFinalSummary(paused)).toContain( + ` run id ${RUN} — resume with \`relavium gate ${RUN}\``, + ); + // Before any run:started the summary has no id to offer. + expect(renderFinalSummary(reduceAll([]))).not.toContain('run id'); + }); + 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 7d7e820c..f10e025c 100644 --- a/apps/cli/src/render/tui/final-summary.ts +++ b/apps/cli/src/render/tui/final-summary.ts @@ -54,6 +54,16 @@ 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) { + const id = sanitizeInline(state.runId); + lines.push( + summary?.outcome === 'paused' + ? ` run id ${id} — resume with \`relavium gate ${id}\`` + : ` run id ${id} — replay with \`relavium logs ${id}\``, + ); + } + 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. diff --git a/docs/reference/cli/commands.md b/docs/reference/cli/commands.md index eb289b54..344ed70d 100644 --- a/docs/reference/cli/commands.md +++ b/docs/reference/cli/commands.md @@ -205,7 +205,7 @@ printf 'api_key=%s\n' "$API_KEY" | relavium run ./workflows/code-review.relavium - `--allow-mcp-stdio <digest>` (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 <runId> --gate <gateId>`); with `--json` they are on the NDJSON event line, otherwise the plain/TUI renderer prints them inline (`paused at gate <gateId> (<type>)`, also echoed in the final summary). (`relavium status`, `relavium logs <runId>`, 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_<PROVIDER>_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 <runId>`. 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 (`relavium logs <runId>`, or `relavium gate <runId>` for a paused run; MVP-A2). 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_<PROVIDER>_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 <runId>`. 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. ### `relavium list` From fed29ded48596d1a58ab092c9553363f3ce58eb7 Mon Sep 17 00:00:00 2001 From: Cemil ILIK <cemililik@outlook.com> Date: Thu, 5 Nov 2026 01:48:17 +0300 Subject: [PATCH 018/148] test(cli): give the run-id summary case a complete run:completed event The MVP-A2 test built a `run:completed` event without the schema's duration, token and cost fields; vitest does not typecheck, so only `tsc` caught it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- apps/cli/src/render/tui/final-summary.test.ts | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/apps/cli/src/render/tui/final-summary.test.ts b/apps/cli/src/render/tui/final-summary.test.ts index 1839eceb..c14f2291 100644 --- a/apps/cli/src/render/tui/final-summary.test.ts +++ b/apps/cli/src/render/tui/final-summary.test.ts @@ -111,7 +111,16 @@ describe('renderFinalSummary', () => { }; const completed = reduceAll([ started, - { type: 'run:completed', runId: RUN, timestamp: TS, sequenceNumber: 2, outputs: {} }, + { + 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}\``, From 9a1d3ef008e93665291547284a8a063e6c40ee6c Mon Sep 17 00:00:00 2001 From: Cemil ILIK <cemililik@outlook.com> Date: Thu, 5 Nov 2026 01:48:17 +0300 Subject: [PATCH 019/148] feat(cli): add list --runs and status <runId> MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MVP-1 increment 1a (2.6.G non-interactive parity): - `relavium list --runs` lists every recorded run, newest first, from the home directory's history, so it needs no project: one line per run with its status, workflow slug (or id) and start time; `--json` emits one `{ kind: "runs", … }` record per run. It shows no amounts, since a total needs its usage coverage (ADR-0104). `--runs` and `--agents` exclude each other. - `relavium status <runId>` shows that one run, active or finished, in the listing's shape; an unknown id is exit 2. This is the re-check the exit-code-5 guidance already named. Without an id the listing is unchanged. The model command manifest gains the new argument and option (its frozen fixture is updated), and the grammar test merges cases that share a command. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- .../src/commands/descriptor-actions.test.ts | 63 ++++++++++++++----- apps/cli/src/commands/descriptors.ts | 30 +++++++-- apps/cli/src/commands/dispatch.ts | 12 +++- .../fixtures/legacy-command-manifest.json | 16 ++++- apps/cli/src/commands/list.test.ts | 39 ++++++++++++ apps/cli/src/commands/list.ts | 57 +++++++++++++++++ apps/cli/src/commands/status.test.ts | 25 ++++++++ apps/cli/src/commands/status.ts | 26 +++++++- docs/reference/cli/commands.md | 9 ++- 9 files changed, 251 insertions(+), 26 deletions(-) diff --git a/apps/cli/src/commands/descriptor-actions.test.ts b/apps/cli/src/commands/descriptor-actions.test.ts index 281a0b26..7315bb41 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<Options>((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<number> => { + 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 ab595735..62007819 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 4c8c3ca7..6ad8162e 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/fixtures/legacy-command-manifest.json b/apps/cli/src/commands/fixtures/legacy-command-manifest.json index 2bee3d9a..937c5f27 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/list.test.ts b/apps/cli/src/commands/list.test.ts index 33a31eb7..a374f61b 100644 --- a/apps/cli/src/commands/list.test.ts +++ b/apps/cli/src/commands/list.test.ts @@ -65,6 +65,45 @@ 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('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 diff --git a/apps/cli/src/commands/list.ts b/apps/cli/src/commands/list.ts index 0a306755..9933cdaf 100644 --- a/apps/cli/src/commands/list.ts +++ b/apps/cli/src/commands/list.ts @@ -15,6 +15,8 @@ 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,6 +50,12 @@ 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. if (json) { @@ -101,6 +109,55 @@ 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 <runId>` + * 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; + deps.io.writeOut(`${run.id} ${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 { diff --git a/apps/cli/src/commands/status.test.ts b/apps/cli/src/commands/status.test.ts index 5cc19eb2..cbca3a0a 100644 --- a/apps/cli/src/commands/status.test.ts +++ b/apps/cli/src/commands/status.test.ts @@ -27,6 +27,31 @@ describe('statusCommand', () => { return { io, global: globalOptions(json), openDb: () => ({ db, close: () => {} }) }; } + 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('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' }); diff --git a/apps/cli/src/commands/status.ts b/apps/cli/src/commands/status.ts index 8f15b7f0..7d818a9f 100644 --- a/apps/cli/src/commands/status.ts +++ b/apps/cli/src/commands/status.ts @@ -12,6 +12,7 @@ import { 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 +24,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; @@ -81,9 +87,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 <runId> --gate <gateId>` * (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<ExitCode> { +export async function statusCommand( + deps: StatusCommandDeps, + args: StatusCommandArgs = {}, +): Promise<ExitCode> { const { homeDir } = loadResolvedConfig({ cwd: deps.global.cwd, configPath: deps.global.configPath, @@ -107,7 +116,9 @@ export async function statusCommand(deps: StatusCommandDeps): Promise<ExitCode> // 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( { @@ -156,6 +167,15 @@ export async function statusCommand(deps: StatusCommandDeps): Promise<ExitCode> } } +/** The run `status <runId>` 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; +} + /** Reconstruct once per active run: a companion crash may leave a running projection with pending gates. */ function gatesOf( reader: RunHistoryReader, diff --git a/docs/reference/cli/commands.md b/docs/reference/cli/commands.md index 344ed70d..7be9854c 100644 --- a/docs/reference/cli/commands.md +++ b/docs/reference/cli/commands.md @@ -213,6 +213,13 @@ Lists the workflows discovered under the project `.relavium/workflows/`, **group 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, `<runId> <status> <workflow> <startedAt>`, where `<workflow>` 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 <runId>` reports. `--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/<id>.agent.yaml` or `.relavium/workflows/<id>.relavium.yaml`. @@ -254,7 +261,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 <runId> --gate <gateId>` — 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 <runId>`). 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 <runId> --gate <gateId>` — required when a run has more than one gate pending at once. Without an argument it lists every active run; `relavium status <runId>` 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 run's full event history stays `relavium logs <runId>`. 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. ### `relavium models` From 42336ae3483d7c93a136315ec708c6f8e547310c Mon Sep 17 00:00:00 2001 From: Cemil ILIK <cemililik@outlook.com> Date: Thu, 5 Nov 2026 01:53:32 +0300 Subject: [PATCH 020/148] feat(core): add a pre-flight for tools whose host arm a path leaves unwired validateWorkflowToolArms (2.6.A, MVP-1 increment 1a) refuses a workflow whose agent node grants a built-in needing a host arm the caller says is unwired: `egress` for http_request and web_search, `os` for read_clipboard and notify, derived from each built-in's policy. MCP tools reach through their own arm and pass. It reads the same authored grant as the catalog's tool check (a node's narrowing `tools:` over its inline agent's; a `$ref`'d agent defers) and throws a WorkflowValidationError naming the node and tools. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- packages/core/src/index.ts | 7 ++- packages/core/src/validate-catalog.test.ts | 55 ++++++++++++++++++- packages/core/src/validate-catalog.ts | 61 ++++++++++++++++++++-- 3 files changed, 118 insertions(+), 5 deletions(-) diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 4a21698b..077f064b 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 { diff --git a/packages/core/src/validate-catalog.test.ts b/packages/core/src/validate-catalog.test.ts index 91f9bb29..72720100 100644 --- a/packages/core/src/validate-catalog.test.ts +++ b/packages/core/src/validate-catalog.test.ts @@ -4,7 +4,11 @@ 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 { + 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 +259,52 @@ 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("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 27367375..cb56e584 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,52 @@ 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, which a run path wires (`host.mcp`), so it needs neither. + if (tool.policy.egress === 'http' || tool.policy.egress === 'search') 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]]; + }), +); + +/** + * 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 is a certain `tool_unavailable` at its first call, 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} + * listing every offending node, naming the tool, never a payload. + */ +export function validateWorkflowToolArms( + workflow: WorkflowDefinition, + opts: { readonly unwired: readonly ToolHostArm[] }, +): void { + const issues = workflow.workflow.nodes.flatMap((node): WorkflowIssue[] => { + const tools = grantedTools(node, workflow).filter((tool) => { + const arm = TOOL_HOST_ARMS.get(tool); + return arm !== undefined && opts.unwired.includes(arm); + }); + return tools.length === 0 + ? [] + : [ + { + field: `node \`${node.id}\`.tools`, + message: `${tools.join(', ')} ${tools.length === 1 ? 'needs' : 'need'} a host capability this run path does not provide (available in chat) — remove the grant to run this workflow here`, + }, + ]; + }); + if (issues.length > 0) { + throw new WorkflowValidationError(issues); + } +} From 97714f9d271936a1ae68892eb53a97ff7a62201e Mon Sep 17 00:00:00 2001 From: Cemil ILIK <cemililik@outlook.com> Date: Thu, 5 Nov 2026 01:53:32 +0300 Subject: [PATCH 021/148] feat(cli): refuse a run whose workflow grants a tool the run path cannot serve MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `relavium run` wires neither the egress nor the os arm, so a workflow granting web_search, http_request, read_clipboard or notify failed with tool_unavailable partway through a run. It is now refused before any key is read or the engine built (exit 2), naming the node and tools — the maintainer's 2026-11-05 ruling (2.6.A, MVP-1 increment 1a). The unwired arms come from the tool-host profile itself (`unwiredToolArms`), which also drives what `assembleToolEnv` wires, so the two cannot drift; the run path's profile is the shared `RUN_TOOL_PROFILE`. The create wizard's tools placeholder no longer suggests web_search. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- apps/cli/src/authoring/create-prompter.ts | 3 +- apps/cli/src/commands/drive.ts | 21 +++++++++++ apps/cli/src/commands/run.test.ts | 44 +++++++++++++++++++++++ apps/cli/src/commands/run.ts | 5 +++ apps/cli/src/engine/build-engine.ts | 7 ++-- apps/cli/src/engine/tool-host/assemble.ts | 12 +++++-- 6 files changed, 87 insertions(+), 5 deletions(-) diff --git a/apps/cli/src/authoring/create-prompter.ts b/apps/cli/src/authoring/create-prompter.ts index 4467adb0..42a588d5 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/commands/drive.ts b/apps/cli/src/commands/drive.ts index e15b64fe..da5e0ddf 100644 --- a/apps/cli/src/commands/drive.ts +++ b/apps/cli/src/commands/drive.ts @@ -1,11 +1,13 @@ import { EngineStateError, WorkflowValidationError, + validateWorkflowToolArms, validateWorkflowWithCatalog, type RunHandle, type RunDeparture, type WorkflowDefinition, type WorkflowEngine, + type ToolHostArm, type WorkflowModelCatalog, } from '@relavium/core'; import type { @@ -48,6 +50,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'; diff --git a/apps/cli/src/commands/run.test.ts b/apps/cli/src/commands/run.test.ts index f004b889..01c28465 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 3466340f..e014a5dc 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(); diff --git a/apps/cli/src/engine/build-engine.ts b/apps/cli/src/engine/build-engine.ts index c7e00d13..7f84b0ab 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 { /** @@ -120,6 +120,9 @@ export interface BuildEngineOptions { * tool needing one is cleanly `tool_unavailable`. Absent `toolEnv` (the in-memory unit/harness path) ⇒ a * fail-closed `{}` base host. */ +/** 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'; + export async function buildEngine(options: BuildEngineOptions = {}): Promise<WorkflowEngine> { const host = options.host ?? createCliHost(); const providers = options.providers ?? createProviderResolver(); @@ -137,7 +140,7 @@ export async function buildEngine(options: BuildEngineOptions = {}): Promise<Wor options.toolEnv === undefined ? {} : assembleToolEnv({ - profile: 'workflow-read-write', + profile: RUN_TOOL_PROFILE, fsScopeTier: options.toolEnv.fsScopeTier, workspaceDir: options.toolEnv.workspaceDir, ...(options.toolEnv.nativeProcesses === undefined diff --git a/apps/cli/src/engine/tool-host/assemble.ts b/apps/cli/src/engine/tool-host/assemble.ts index c7b31698..60520374 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,7 @@ 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'; + const wireEgressOs = unwiredToolArms(opts.profile).length === 0; const host: ToolHost = { // Strings in committed config cannot grant roots: only the pre-admitted host policy can widen project scope. fs: createNodeFsCapability({ From 2f5ec10d8fad098410f3b6228e153f76a85f8bcf Mon Sep 17 00:00:00 2001 From: Cemil ILIK <cemililik@outlook.com> Date: Thu, 5 Nov 2026 01:53:33 +0300 Subject: [PATCH 022/148] docs(repo): say where each egress and os tool runs built-in-tools.md states that http_request, web_search, read_clipboard and notify run only in the approval-gated chat host and that `relavium run` refuses a workflow granting one before it starts; workflow-yaml-spec.md links to it from the node `tools:` note. Both documented these tools as unconditionally available (2.6.A, MVP-1). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- docs/reference/contracts/workflow-yaml-spec.md | 2 ++ docs/reference/shared-core/built-in-tools.md | 6 ++++++ 2 files changed, 8 insertions(+) diff --git a/docs/reference/contracts/workflow-yaml-spec.md b/docs/reference/contracts/workflow-yaml-spec.md index 157619ed..e4f73e08 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 diff --git a/docs/reference/shared-core/built-in-tools.md b/docs/reference/shared-core/built-in-tools.md index b024061d..2377d2a9 100644 --- a/docs/reference/shared-core/built-in-tools.md +++ b/docs/reference/shared-core/built-in-tools.md @@ -33,6 +33,12 @@ 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). MCP tools reach +> through their own arm and are unaffected. + > **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. From 033063539b6c790c61faa2d40f05889014d8f106 Mon Sep 17 00:00:00 2001 From: Cemil ILIK <cemililik@outlook.com> Date: Thu, 5 Nov 2026 02:06:18 +0300 Subject: [PATCH 023/148] feat(shared): add [chat].max_tokens MVP-A7 (MVP-1 increment 1a): an optional positive output cap for a chat whose bound agent authors no `max_tokens`. The strict ChatConfigSchema gains the key; 0 is refused. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- packages/shared/src/config.test.ts | 3 +++ packages/shared/src/config.ts | 3 +++ 2 files changed, 6 insertions(+) diff --git a/packages/shared/src/config.test.ts b/packages/shared/src/config.test.ts index ffdc16c1..3228d7b8 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 33bcaaa0..18a02626 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` From 51c78d7fc0965fbc73f459d1f0c08ac8c4b7df09 Mon Sep 17 00:00:00 2001 From: Cemil ILIK <cemililik@outlook.com> Date: Thu, 5 Nov 2026 02:06:18 +0300 Subject: [PATCH 024/148] feat(cli): give interactive Anthropic chat its catalog output ceiling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MVP-A7 (MVP-1 increment 1a; ADR-0101 amended 2026-11-05): a chat whose bound agent authors no `max_tokens` now carries one on its effective agent — `[chat].max_tokens` when configured, for any provider; otherwise, for an Anthropic agent with no fallback chain on the official endpoint, the model's catalog ceiling instead of the adapter's implicit 4,096. The persisted agent is unchanged, so a resumed or rebound session recomputes the default from the current config and model. A session with a cost cap keeps the adapter default unless `[chat].max_tokens` is set: the governor reserves the effective cap before each turn, and the existing configured-fallback tests showed a 128,000-token reservation refusing the first turn of a $1 cap. Another provider, a custom endpoint and a fallback chain keep today's behaviour. `[chat].max_tokens` resolves with the project over the workspace layer. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- .../src/chat/compaction-persistence.test.ts | 1 + apps/cli/src/chat/persister.test.ts | 1 + apps/cli/src/chat/session-host.test.ts | 61 ++++++++++++++++++- apps/cli/src/chat/session-host.ts | 60 +++++++++++++++++- .../src/chat/session-structure.e2e.test.ts | 1 + apps/cli/src/chat/session-usage-ack.test.ts | 1 + .../src/chat/usage-dispatch-identity.test.ts | 1 + apps/cli/src/commands/chat.test.ts | 1 + apps/cli/src/config/resolve.test.ts | 11 ++++ apps/cli/src/config/resolve.ts | 3 + .../fixtures/usage-coverage-crash-worker.ts | 1 + apps/cli/src/engine/media-wiring.test.ts | 1 + .../cli/src/harness/session-chain.e2e.test.ts | 1 + 13 files changed, 141 insertions(+), 3 deletions(-) diff --git a/apps/cli/src/chat/compaction-persistence.test.ts b/apps/cli/src/chat/compaction-persistence.test.ts index 0c24bf99..10147426 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/persister.test.ts b/apps/cli/src/chat/persister.test.ts index e7e064f0..c71b6d64 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 40bab87d..fcff819f 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, 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'; @@ -47,6 +47,7 @@ import { buildGovernorWiring, buildResumedChatSession as buildRestoredSession, swapAgentModel, + withChatOutputDefault, type ChatBudgetWarning, } from './session-host.js'; import { @@ -163,6 +164,7 @@ const EMPTY_CHAT: ResolvedChatConfig = { autoCompact: undefined, compactThreshold: undefined, maxCostMicrocents: undefined, + maxTokens: undefined, onExceed: undefined, strictCostCap: false, allowedCommands: undefined, @@ -2192,3 +2194,60 @@ 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(); + } + }); +}); diff --git a/apps/cli/src/chat/session-host.ts b/apps/cli/src/chat/session-host.ts index f2c8e208..e17e00d9 100644 --- a/apps/cli/src/chat/session-host.ts +++ b/apps/cli/src/chat/session-host.ts @@ -30,6 +30,7 @@ import { unwiredEffectJournal, } from '@relavium/core'; import { + catalogModel, effortTiersFor, type EndpointKind, type PricingOverlay, @@ -983,7 +984,11 @@ export async function buildChatSession(opts: BuildChatSessionOptions): Promise<B const session = new AgentSession({ sessionId, agentRef: agent.id, - agent: narrowToWired(withMcpGrant(agent, mcp), host, deps.tools), + agent: withChatOutputDefault( + narrowToWired(withMcpGrant(agent, mcp), host, deps.tools), + chatOutputSettings(opts.chat), + officialEndpointOf(deps), + ), context, deps: { ...deps, whenReady }, }); @@ -1250,7 +1255,11 @@ export async function buildResumedChatSession( { sessionId: record.id, agentRef: agent.id, - agent: narrowToWired(withMcpGrant(agent, mcp), host, deps.tools), + agent: withChatOutputDefault( + narrowToWired(withMcpGrant(agent, mcp), host, deps.tools), + chatOutputSettings(opts.chat), + officialEndpointOf(deps), + ), context, deps: { ...deps, @@ -1466,6 +1475,53 @@ 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 }; +} + +/** 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 33a2ee8b..9dc3a797 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 5cbf9de9..e7731ecb 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 2458e1d4..3201ec20 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/chat.test.ts b/apps/cli/src/commands/chat.test.ts index 6169b3cc..389e1d1d 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, diff --git a/apps/cli/src/config/resolve.test.ts b/apps/cli/src/config/resolve.test.ts index 1af04594..62685f44 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 160e681b..f4cc02ea 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/fixtures/usage-coverage-crash-worker.ts b/apps/cli/src/engine/fixtures/usage-coverage-crash-worker.ts index 6a0a6352..695c6f2a 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, diff --git a/apps/cli/src/engine/media-wiring.test.ts b/apps/cli/src/engine/media-wiring.test.ts index 2d9fe315..2db879e3 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/harness/session-chain.e2e.test.ts b/apps/cli/src/harness/session-chain.e2e.test.ts index d8cd526d..0d2c15d5 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, From f01611ea4244606b9fb0bfa58d0c73cb4972c4c8 Mon Sep 17 00:00:00 2001 From: Cemil ILIK <cemililik@outlook.com> Date: Thu, 5 Nov 2026 02:06:18 +0300 Subject: [PATCH 025/148] docs(repo): record the interactive chat output default config-spec.md gains `[chat].max_tokens` and when the catalog ceiling applies; ADR-0101 gains a dated amendment for interactive chat (the maintainer's D68 ruling and the 2026-11-05 note form): the effective cap is still reserved, and a cost-capped session keeps the adapter default. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- ...stimates-apply-only-when-the-wire-is-uncapped.md | 13 +++++++++++++ docs/reference/contracts/config-spec.md | 1 + 2 files changed, 14 insertions(+) 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 66edbad6..c11d322c 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,16 @@ 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`. diff --git a/docs/reference/contracts/config-spec.md b/docs/reference/contracts/config-spec.md index 336e28c5..9230037a 100644 --- a/docs/reference/contracts/config-spec.md +++ b/docs/reference/contracts/config-spec.md @@ -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. The governor reserves this cap before each turn, so set it below the ceiling when a cost cap is set. 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 From e4783366fb7966d7c2bdf5692d1854404fb721ee Mon Sep 17 00:00:00 2001 From: Cemil ILIK <cemililik@outlook.com> Date: Thu, 5 Nov 2026 02:56:55 +0300 Subject: [PATCH 026/148] test(repo): re-pin the run workload count the run-id test moved The MVP-1 1a run-id test added one case to apps/cli/src/commands/run.test.ts, so the two secrets controls that run that complete file declared 84 cases against 85, and the push CI for f01611ea failed with "Declared workload drift 85 !== 84" in both secrets causal-control shards and the main job. Move the declared count, its provenance pin and the README to 85. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- tools/causal-controls/provenance.test.mjs | 4 ++-- tools/secrets-controls/README.md | 4 ++-- tools/secrets-controls/mutations.mjs | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/tools/causal-controls/provenance.test.mjs b/tools/causal-controls/provenance.test.mjs index f07879d7..e2e45a90 100644 --- a/tools/causal-controls/provenance.test.mjs +++ b/tools/causal-controls/provenance.test.mjs @@ -288,8 +288,8 @@ 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], + ['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', 73], ['export-command-review-notice', 'apps/cli/src/commands/chat-export.test.ts', 18], ]) { diff --git a/tools/secrets-controls/README.md b/tools/secrets-controls/README.md index 14f8440a..814efef5 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, 73 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 c7008a2f..23e6c26a 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', [ @@ -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', ), From d5cde86e576155dd76077c323fcda515a4dba50c Mon Sep 17 00:00:00 2001 From: Cemil ILIK <cemililik@outlook.com> Date: Thu, 5 Nov 2026 03:24:18 +0300 Subject: [PATCH 027/148] test(repo): move the run-reader outcome fixture to the 85-case workload e4783366 re-pinned the declared run.test.ts workload to 85 but missed the outcome.test.mjs fixture that models that control's complete workload (82 preserved + 2 named witnesses = 84). It now drifted against the declaration, so the acceptance-tools guard failed in every causal job and in the main job's W8 gate. Model 83 preserved cases plus the two witnesses, and shift the dependent counts and witness index by one. Verified with the full `pnpm test:acceptance-tools` list (1031 pass), not only the provenance file. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- tools/causal-controls/outcome.test.mjs | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/tools/causal-controls/outcome.test.mjs b/tools/causal-controls/outcome.test.mjs index 348fcd98..c4556848 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/); }); From fbc9c8879f3e80957e5800c244e66bf70920c567 Mon Sep 17 00:00:00 2001 From: Cemil ILIK <cemililik@outlook.com> Date: Thu, 5 Nov 2026 02:18:22 +0300 Subject: [PATCH 028/148] fix(core): refuse a gate decision its gate type does not accept 2.6.K (MVP-1 increment 1b), ruled by the maintainer on 2026-11-05: an `approval` or `review` gate accepts `approved` or `rejected`; an `input` gate accepts `input_provided` or `rejected`. WorkflowEngine#resume never cross-checked the decision against the gate's type, so `relavium gate --input` against an approval gate wrote the wrong decision shape with no error. The engine now reads the gate's `gate_type` from the workflow definition (the checkpoint does not carry it) and throws `invalid_decision` before anything is claimed or written: synchronously before the claim on the in-process path, and on the checkpoint path before the resume's first durable write, where the caller's catch already retires the execution and releases its fence. Tests pin both refusals (nothing persisted, the run still resumable). The checkpoint check's early position is pinned by reasoning only: the fixture's run has already started usage coverage, so the later in-process check refuses there too. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- packages/core/src/engine/engine.test.ts | 51 +++++++++++++++++++ packages/core/src/engine/engine.ts | 26 +++++++++- .../core/src/engine/gate-decision-fit.test.ts | 20 ++++++++ packages/core/src/engine/gate-decision-fit.ts | 27 ++++++++++ packages/core/src/index.ts | 1 + 5 files changed, 124 insertions(+), 1 deletion(-) create mode 100644 packages/core/src/engine/gate-decision-fit.test.ts create mode 100644 packages/core/src/engine/gate-decision-fit.ts diff --git a/packages/core/src/engine/engine.test.ts b/packages/core/src/engine/engine.test.ts index 999513e8..9a76a5c7 100644 --- a/packages/core/src/engine/engine.test.ts +++ b/packages/core/src/engine/engine.test.ts @@ -1966,6 +1966,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 @@ -2585,6 +2609,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 diff --git a/packages/core/src/engine/engine.ts b/packages/core/src/engine/engine.ts index ab067114..bdc896e9 100644 --- a/packages/core/src/engine/engine.ts +++ b/packages/core/src/engine/engine.ts @@ -125,6 +125,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'; @@ -1714,7 +1715,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 +1872,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 +1924,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 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 00000000..576142ff --- /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 00000000..7157226d --- /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/index.ts b/packages/core/src/index.ts index 077f064b..0cb1da06 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -205,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 { From feb7dc6f27192d87bf4ced41c2971033bd8b7e53 Mon Sep 17 00:00:00 2001 From: Cemil ILIK <cemililik@outlook.com> Date: Thu, 5 Nov 2026 02:18:22 +0300 Subject: [PATCH 029/148] fix(cli): refuse a gate decision that does not fit the gate before any engine work `relavium gate` now checks the decision against the pending gate's `gate_type` in the run's workflow snapshot before the engine is built, and refuses a misfit as an invocation error (exit 2) naming the flags to use (`--approve` or `--reject`; `--input` or `--reject` for an input gate). The engine re-checks it as defense in depth (2.6.K, MVP-1 1b). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- apps/cli/src/commands/gate.test.ts | 38 +++++++++++++++++++++++ apps/cli/src/commands/gate.ts | 5 +++- apps/cli/src/gate/decision.test.ts | 48 ++++++++++++++++++++++++++++++ apps/cli/src/gate/decision.ts | 32 ++++++++++++++++++++ 4 files changed, 122 insertions(+), 1 deletion(-) diff --git a/apps/cli/src/commands/gate.test.ts b/apps/cli/src/commands/gate.test.ts index c6d4a4b4..0daf4790 100644 --- a/apps/cli/src/commands/gate.test.ts +++ b/apps/cli/src/commands/gate.test.ts @@ -1360,6 +1360,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 <json> or --reject'], + ] as const) { + const { io } = captureIo(); + let built = false; + await expect( + gateCommand( + { runId, ...flags }, + { + ...deps(io), + buildEngine: (options) => { + built = true; + return buildEngine(options); + }, + }, + ), + ).rejects.toMatchObject({ + code: 'invalid_invocation', + message: expect.stringContaining(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) => { diff --git a/apps/cli/src/commands/gate.ts b/apps/cli/src/commands/gate.ts index 7abae049..e59b0f3d 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. diff --git a/apps/cli/src/gate/decision.test.ts b/apps/cli/src/gate/decision.test.ts index 1de40972..133a636e 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', + 'approval gate — use --approve or --reject', + ], + [ + 'look', + ['approved', 'rejected'], + 'input_provided', + 'review gate — use --approve or --reject', + ], + [ + 'ask', + ['input_provided', 'rejected'], + 'approved', + 'input gate — use --input <json> 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 3ae68895..8869d482 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. */ +const FLAG_FOR_DECISION: Readonly<Record<GateDecision['decision'], string>> = { + approved: '--approve', + rejected: '--reject', + input_provided: '--input <json>', +}; + +/** + * 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 an ${node.gate_type} gate — use ${flags}`, + ); +} From 4487c039e2cea718894b52e2320587acf0bf30c9 Mon Sep 17 00:00:00 2001 From: Cemil ILIK <cemililik@outlook.com> Date: Thu, 5 Nov 2026 02:18:22 +0300 Subject: [PATCH 030/148] docs(repo): state which decisions each human gate type accepts workflow-yaml-spec.md gains the rule under the human_gate node, and commands.md's `relavium gate` section points to it (2.6.K, MVP-1). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- docs/reference/cli/commands.md | 6 +++++- docs/reference/contracts/workflow-yaml-spec.md | 5 +++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/docs/reference/cli/commands.md b/docs/reference/cli/commands.md index 7be9854c..cd5fce3d 100644 --- a/docs/reference/cli/commands.md +++ b/docs/reference/cli/commands.md @@ -299,13 +299,17 @@ Resolves a pending human gate from the terminal — the surface-agnostic resume ```bash relavium gate <runId> --approve relavium gate <runId> --reject --comment "Too risky" -relavium gate <runId> --input '{"region": "us-east-1"}' # for gate_type=input +relavium gate <runId> --input '{"region": "us-east-1"}' # for gate_type=input (or --reject) relavium gate <runId> --gate <gateId> --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 <runId> --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 <text>` annotates an approve/reject rationale and is invalid with `--input` (which carries the payload). A bad combination is an invalid invocation (exit `2`). - `--input <value>` 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_<PROVIDER>_API_KEY` resolution path. diff --git a/docs/reference/contracts/workflow-yaml-spec.md b/docs/reference/contracts/workflow-yaml-spec.md index e4f73e08..e9b92c6b 100644 --- a/docs/reference/contracts/workflow-yaml-spec.md +++ b/docs/reference/contracts/workflow-yaml-spec.md @@ -324,6 +324,11 @@ 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 shape its type does not define. + `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). From 8d8fd708076c6c564731c0d92cfad218a2b9ca0b Mon Sep 17 00:00:00 2001 From: Cemil ILIK <cemililik@outlook.com> Date: Thu, 5 Nov 2026 02:18:53 +0300 Subject: [PATCH 031/148] test(cli): assert the gate refusal without an untyped matcher The 2.6.K refusal test used `expect.stringContaining` inside `toMatchObject`, an `any` value the lint gate refuses; it now reads the error through `isCliError`, as the file's other refusals do. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- apps/cli/src/commands/gate.test.ts | 28 ++++++++++++++-------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/apps/cli/src/commands/gate.test.ts b/apps/cli/src/commands/gate.test.ts index 0daf4790..f6840370 100644 --- a/apps/cli/src/commands/gate.test.ts +++ b/apps/cli/src/commands/gate.test.ts @@ -1369,21 +1369,21 @@ workflow: ] as const) { const { io } = captureIo(); let built = false; - await expect( - gateCommand( - { runId, ...flags }, - { - ...deps(io), - buildEngine: (options) => { - built = true; - return buildEngine(options); - }, + const caught: unknown = await gateCommand( + { runId, ...flags }, + { + ...deps(io), + buildEngine: (options) => { + built = true; + return buildEngine(options); }, - ), - ).rejects.toMatchObject({ - code: 'invalid_invocation', - message: expect.stringContaining(hint), - }); + }, + ).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() From 1f678628eb2dab20e2f8761cf0acbe8d1d7b035c Mon Sep 17 00:00:00 2001 From: Cemil ILIK <cemililik@outlook.com> Date: Thu, 5 Nov 2026 02:58:53 +0300 Subject: [PATCH 032/148] test(repo): re-pin the gate workload count the decision-check test moved The gate-decision refusal test added one case to apps/cli/src/commands/gate.test.ts (74 cases), so the secrets control that runs that complete file would fail with a declared workload drift of 74 !== 73. Move the declared count, its provenance pin and the README. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- tools/causal-controls/provenance.test.mjs | 2 +- tools/secrets-controls/README.md | 2 +- tools/secrets-controls/mutations.mjs | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/tools/causal-controls/provenance.test.mjs b/tools/causal-controls/provenance.test.mjs index e2e45a90..c8216bed 100644 --- a/tools/causal-controls/provenance.test.mjs +++ b/tools/causal-controls/provenance.test.mjs @@ -290,7 +290,7 @@ test('secrets workload corrections retain independent complete-count and reader- for (const [id, file, expectedTests] of [ ['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', 73], + ['gate-consumed-stdin-prompt-authority', 'apps/cli/src/commands/gate.test.ts', 74], ['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/secrets-controls/README.md b/tools/secrets-controls/README.md index 814efef5..730e6014 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, 85 run-command cases, three run-consent cases, 73 gate cases, +cases, 32 dispatch cases, 85 run-command cases, three run-consent cases, 74 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 diff --git a/tools/secrets-controls/mutations.mjs b/tools/secrets-controls/mutations.mjs index 23e6c26a..5f48a907 100644 --- a/tools/secrets-controls/mutations.mjs +++ b/tools/secrets-controls/mutations.mjs @@ -699,7 +699,7 @@ export const mutations = [ ' const mayPrompt = args.secretStdin !== true;', ' const mayPrompt = true;', ['apps/cli/src/commands/gate.test.ts'], - 73, + 74, 'refuses unapproved stdio after a real read despite stale TTY, without prompting or writing a grant', 'SECRETS_CAUSAL_RESUME_CONSUMED_STDIN_CONSENT', ), From e8b421d988f6d035e0fe5af0eb32d9edc290bb18 Mon Sep 17 00:00:00 2001 From: Cemil ILIK <cemililik@outlook.com> Date: Thu, 5 Nov 2026 02:58:53 +0300 Subject: [PATCH 033/148] feat(shared): add RunAlreadyTerminalError A typed refusal for a terminal appended to a run whose log already ends in one (ADR-0036's exactly-one-terminal). The run stores raise it; the next two commits wire it into the durable store and the in-memory reference store. Refs: ADR-0036, ADR-0078 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- packages/shared/src/run.ts | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/packages/shared/src/run.ts b/packages/shared/src/run.ts index 6f4ef979..d25dc24d 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). From f98ec0eb6376c24f79e9c8c1f130fe57b4a67daf Mon Sep 17 00:00:00 2001 From: Cemil ILIK <cemililik@outlook.com> Date: Thu, 5 Nov 2026 02:59:04 +0300 Subject: [PATCH 034/148] fix(db): refuse a second terminal in the durable run store MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit persistEvent now refuses run:completed, run:failed or run:cancelled for a run whose event rows already hold a terminal, inside the same IMMEDIATE transaction, after the append guard and the fence and before the derived writes. It reads the event rows, not runs.status, for the same no-second-source reason as max(seq). Neither existing check held the invariant: the engine exempts its terminal from the append guard (ADR-0078 §2) and a writer that never acquired a lease carries no fence. Wiring reconcile() into the CLI (next commits) makes the gap reachable: a reconcile in another process can settle a run between its durable run:started and its lease acquire, then release its claim; the run's own acquire then succeeds and its terminal landed behind the first. A probe of that interleaving produced two durable run:failed rows before this change and one after. Tests: a second terminal is refused guarded or not and writes nothing (the derived status is not relabelled); the refusal is scoped per run. Mutation: removing the refusal and dropping its run scope each fail these tests. Refs: ADR-0036, ADR-0078, ADR-0079 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- packages/db/src/run-history-store.test.ts | 62 +++++++++++++++++++++++ packages/db/src/run-history-store.ts | 24 +++++++++ 2 files changed, 86 insertions(+) diff --git a/packages/db/src/run-history-store.test.ts b/packages/db/src/run-history-store.test.ts index 45127e52..f84e381f 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 () => { diff --git a/packages/db/src/run-history-store.ts b/packages/db/src/run-history-store.ts index d4c2227c..06233654 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, @@ -396,6 +397,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. @@ -1031,6 +1039,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(), From 5e40f87998b9e29ad017cfe5f9d1489f5649676f Mon Sep 17 00:00:00 2001 From: Cemil ILIK <cemililik@outlook.com> Date: Thu, 5 Nov 2026 02:59:04 +0300 Subject: [PATCH 035/148] fix(core): mirror the second-terminal refusal in the in-memory run store The reference store mirrors every refusal the SQLite store applies, so a packages/core test cannot pass against a store that accepts what the real one rejects. It now refuses a second terminal per run with RunAlreadyTerminalError, and the RunStore contract states the rule. Also corrects two engine comments the CLI wiring makes stale: the run:started-to-acquire window is reachable by a reconcile() in another process, and its outcome (measured by the CLI's history/reconcile.test.ts) is one terminal either way; and drainTerminalOutbox no longer claims that reconcile() has no shipping caller. Tests: the in-memory store refuses a second terminal guarded or not, per run. Mutation: removing the mirror and widening it across runs each fail. Refs: ADR-0036, ADR-0078, ADR-0079 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- packages/core/src/engine/engine.ts | 29 ++++++++++++------- .../core/src/engine/execution-host.test.ts | 24 +++++++++++++++ packages/core/src/engine/execution-host.ts | 22 +++++++++++++- 3 files changed, 63 insertions(+), 12 deletions(-) diff --git a/packages/core/src/engine/engine.ts b/packages/core/src/engine/engine.ts index bdc896e9..0221e26e 100644 --- a/packages/core/src/engine/engine.ts +++ b/packages/core/src/engine/engine.ts @@ -1077,11 +1077,16 @@ 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 it must list, drain and acquire inside the few microtask turns between the two commits — + // reachable only if this process is descheduled right there. The outcome is measured (the CLI's + // `history/reconcile.test.ts`): the reconciler 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, so `UNIQUE(run_id, seq)` refuses it first. 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 @@ -6531,12 +6536,14 @@ 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[]> { const interrupted = await this.#host.store.listInterruptedRuns(); diff --git a/packages/core/src/engine/execution-host.test.ts b/packages/core/src/engine/execution-host.test.ts index cad0fe39..a882fc3c 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 ba96015f..8cd1dfd1 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, @@ -156,7 +157,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. */ @@ -340,6 +345,13 @@ function counterUuid(n: number): string { return `00000000-0000-4000-8000-${hex}`; } +/** The run terminals the reference store refuses to repeat, as the SQLite store does. */ +const IN_MEMORY_TERMINAL_TYPES: ReadonlySet<RunEvent['type']> = new Set<RunEvent['type']>([ + 'run:completed', + 'run:failed', + 'run:cancelled', +]); + /** * An in-memory {@link RunStore} — deterministic and dependency-free. Persists events per run so a *new* * engine constructed over the *same* store sees the prior "process'" runs, which is how the tests @@ -426,6 +438,14 @@ 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 ( + IN_MEMORY_TERMINAL_TYPES.has(event.type) && + (bucket ?? []).some((e) => IN_MEMORY_TERMINAL_TYPES.has(e.type)) + ) { + return Promise.reject(new RunAlreadyTerminalError(event.runId)); + } if (bucket === undefined) { this.#events.set(event.runId, [event]); } else { From 480e04c584793702d58aaa374c880f4ecda89b7d Mon Sep 17 00:00:00 2001 From: Cemil ILIK <cemililik@outlook.com> Date: Thu, 5 Nov 2026 02:59:18 +0300 Subject: [PATCH 036/148] feat(cli): reconcile interrupted runs at run/gate start and on Home open MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 2.6.G liveness (#229, MVP-1 1b). A killed or crashed run no longer stays `running` forever: - `run`, `gate` and `budget resume` call engine.reconcile() at start instead of drainTerminalOutbox(). reconcile() drains the outbox first, then settles each interrupted run as run:failed{internal}; it skips a run parked at a gate and a run whose lease is live (ADR-0079 §7). Best-effort, as before. - The Home reconciles before its first read of the runs, so the first frame shows a crashed run under Attention, never as `running`. It waits after the signal and exit nets (the #50 rule for an await before mount). - history/reconcile.ts builds a reconcile-only engine over the Home's own db connection: the durable store, its lease port, the media-reference port and the outbox, with an executor that rejects every node. - openSessionStore supplies the reconcile as a closure, pairing the database with the outbox of the same home: the drain prunes an entry whose run is not open in the database it reads, so a mismatched pair would delete live entries. An injected test store has none and mounts synchronously. - `status` stays read-only. Tests: an unowned run settles and its row leaves `running`; a live lease, a gate-parked run and a fresh database are left alone; an expired lease is taken over; a held outbox terminal lands instead of being relabelled; the opener pairs its home; the helper never rejects; and a reconcile inside a starting run's lease window (claim released or still held) leaves exactly one durable terminal with no node executed. The Home test pins reconcile before mount and the settled run in the first snapshot. The ADR-0082 chain-host lock lists the reconcile-only engine with needles proving it runs no node. Mutation: removing the Home call, not awaiting it, pairing another home's outbox, reverting run or gate to the drain, removing the helper's catch, and a no-op helper each fail a test; removing the store's terminal refusal fails the released-claim race test. Refs: ADR-0078, ADR-0079, ADR-0082 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- apps/cli/src/commands/gate.test.ts | 4 +- apps/cli/src/commands/gate.ts | 7 +- apps/cli/src/commands/run.ts | 14 +- .../src/commands/workflow-preparation.test.ts | 6 +- .../src/engine/effect-journal-wiring.test.ts | 8 + apps/cli/src/harness/durability.e2e.test.ts | 5 +- apps/cli/src/history/reconcile.test.ts | 311 ++++++++++++++++++ apps/cli/src/history/reconcile.ts | 58 ++++ apps/cli/src/history/session-open.ts | 18 +- apps/cli/src/home/drive-home.test.ts | 56 +++- apps/cli/src/home/drive-home.tsx | 7 + 11 files changed, 475 insertions(+), 19 deletions(-) create mode 100644 apps/cli/src/history/reconcile.test.ts create mode 100644 apps/cli/src/history/reconcile.ts diff --git a/apps/cli/src/commands/gate.test.ts b/apps/cli/src/commands/gate.test.ts index f6840370..87227f18 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; } diff --git a/apps/cli/src/commands/gate.ts b/apps/cli/src/commands/gate.ts index e59b0f3d..9aabcafa 100644 --- a/apps/cli/src/commands/gate.ts +++ b/apps/cli/src/commands/gate.ts @@ -563,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, diff --git a/apps/cli/src/commands/run.ts b/apps/cli/src/commands/run.ts index e014a5dc..ca753d19 100644 --- a/apps/cli/src/commands/run.ts +++ b/apps/cli/src/commands/run.ts @@ -471,13 +471,13 @@ 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{internal}`. It leaves alone a run parked at a gate 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/workflow-preparation.test.ts b/apps/cli/src/commands/workflow-preparation.test.ts index c344e8ff..881eda20 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<void>(); const release = deferred<void>(); 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/engine/effect-journal-wiring.test.ts b/apps/cli/src/engine/effect-journal-wiring.test.ts index e1f143b5..600b31c5 100644 --- a/apps/cli/src/engine/effect-journal-wiring.test.ts +++ b/apps/cli/src/engine/effect-journal-wiring.test.ts @@ -150,6 +150,14 @@ describe('the per-attempt deadline is wired on every chain-building surface (ADR // half that can go missing silently. needles: [/setTimer:\s*hostDeadlineTimer/], }, + { + file: 'history/reconcile.ts', + what: 'the reconcile-only engine — the Home-open liveness pass (2.6.G); it runs no node, so no chain', + // Not a deadline: the PROOF that no chain exists. The engine is built with an executor that rejects + // every node, so no agent runner and no provider attempt can start. Swapping in a real executor would + // make this a chain host that owes the deadline, and these needles stop matching. + needles: [/executor:\s*RECONCILE_ONLY_EXECUTOR/, /execute:\s*\(\)\s*=>\s*Promise\.reject\(/], + }, ]; for (const host of CHAIN_HOSTS) { diff --git a/apps/cli/src/harness/durability.e2e.test.ts b/apps/cli/src/harness/durability.e2e.test.ts index 318692cc..99173b32 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/history/reconcile.test.ts b/apps/cli/src/history/reconcile.test.ts new file mode 100644 index 00000000..71d473d0 --- /dev/null +++ b/apps/cli/src/history/reconcile.test.ts @@ -0,0 +1,311 @@ +import { randomUUID } from 'node:crypto'; +import { mkdirSync, mkdtempSync, 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, + 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('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('a reconcile inside a starting run’s lease window leaves exactly one durable terminal', async () => { + // `run:started` is durable a few microtask turns before the run acquires its lease. A reconcile in that + // window settles the run and releases its claim, so the run's own acquire then SUCCEEDS. Before the + // store refused a second terminal, the run's terminal — exempt from the append guard — landed behind the + // reconciler's: two terminals in one log. Driven here by running the 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()).toHaveLength(1); + expect(terminals()[0]).toMatchObject({ type: 'run:failed', error: { code: 'internal' } }); + expect(executed).toEqual([]); // no node ran for a run the log already calls failed + // The refused terminal is held, so the run reports `uncertain` — and the next start drops it. + expect(handle.durability()).toBe('uncertain'); + await reconcileInterruptedRuns(client.db, terminalOutboxPath(dir)); + expect(await createFileTerminalOutbox(terminalOutboxPath(dir)).list()).toEqual([]); + expect(terminals()).toHaveLength(1); + }); + + it('…and when the reconciler still holds its claim, the run is refused and still leaves one terminal', async () => { + // The other order inside the same window: the reconciler's 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 and `UNIQUE(run_id, seq)` refuses it before the + // store's terminal refusal is reached. 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 00000000..a186e7fd --- /dev/null +++ b/apps/cli/src/history/reconcile.ts @@ -0,0 +1,58 @@ +import { randomUUID } from 'node:crypto'; + +import { type NodeExecutor, WorkflowEngine } from '@relavium/core'; +import { 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` and `gate` call + * `engine.reconcile()` on the engine they already build. + * + * `reconcile()` first drains the terminal outbox (ADR-0078 §4), then writes `run:failed{internal}` for each + * interrupted run. It leaves alone a run parked at a gate and a run whose lease is live, which another process + * is executing (ADR-0079 §7). 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<void> { + 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), + }); + await new WorkflowEngine({ host, executor: RECONCILE_ONLY_EXECUTOR }).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 5583eeff..403bc8ec 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<void>; 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 1f794e50..dcdb70e0 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,58 @@ 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')); + }, + }), + }, + ); + const drivePromise = driveHome(deps); + await flush(); + const props = captured; + if (props === undefined) throw new Error('the injected render was never invoked'); + + expect(order).toEqual(['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 94121fde..77388fc5 100644 --- a/apps/cli/src/home/drive-home.tsx +++ b/apps/cli/src/home/drive-home.tsx @@ -1261,6 +1261,13 @@ 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 never shows a crashed run as `running`. 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 From f4cbabc9486fff013094a025e540283e2eb7a15e Mon Sep 17 00:00:00 2001 From: Cemil ILIK <cemililik@outlook.com> Date: Thu, 5 Nov 2026 02:59:18 +0300 Subject: [PATCH 037/148] docs(repo): record the liveness wiring and the second-terminal refusal commands.md's exit-code-5 note now says run, gate, budget resume and the Home reconcile at start and names the one reachable 5 that reconcile can cause; home.md says when the Home reconciles; database-schema.md records the store's second-terminal refusal beside the append guard and the fence; the phase 2.6 liveness bullet records what was wired and what stays open. Refs: ADR-0036, ADR-0078, ADR-0079 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- docs/reference/cli/commands.md | 2 +- docs/reference/cli/home.md | 2 ++ docs/reference/shared-core/database-schema.md | 1 + docs/roadmap/phases/phase-2.6-conversational-authoring.md | 6 ++++++ 4 files changed, 10 insertions(+), 1 deletion(-) diff --git a/docs/reference/cli/commands.md b/docs/reference/cli/commands.md index cd5fce3d..f01f9555 100644 --- a/docs/reference/cli/commands.md +++ b/docs/reference/cli/commands.md @@ -638,7 +638,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 does anything else (2.6.G liveness): it drains the outbox, then settles every run a dead process left `running` as `run:failed{internal}`. It leaves alone a run parked at a gate and 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). `chat` and `agent run` run on `AgentSession` and do not reconcile. One reachable `5` comes from that reconcile: in the instant between a run's durable `run:started` and its lease acquire, a concurrent start can settle the run. 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 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`. > > 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 049f2227..6d154ffb 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 `run:failed{internal}` and shows under *Attention required*, and a terminal held in the outbox is written. It waits for this after the signal and exit nets are registered and before it mounts, so the first frame never shows a crashed run as `running`; it is best-effort and never keeps the Home from opening. A run parked at a gate and a run another process holds a live lease on are left alone. The rules are the same as for `run` and `gate` ([exit code `5`](commands.md#exit-codes)). + ```text relavium ← branded header (bold) diff --git a/docs/reference/shared-core/database-schema.md b/docs/reference/shared-core/database-schema.md index d75762a1..618d248f 100644 --- a/docs/reference/shared-core/database-schema.md +++ b/docs/reference/shared-core/database-schema.md @@ -1362,6 +1362,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 reachable case is `reconcile()` (which the CLI calls at `run`/`gate` start and on Home open) 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. 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/roadmap/phases/phase-2.6-conversational-authoring.md b/docs/roadmap/phases/phase-2.6-conversational-authoring.md index 0f186424..cfbb1dd2 100644 --- a/docs/roadmap/phases/phase-2.6-conversational-authoring.md +++ b/docs/roadmap/phases/phase-2.6-conversational-authoring.md @@ -633,6 +633,12 @@ 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. From 37ac6c414c9994d031c1db15912a6121cb8fc7c6 Mon Sep 17 00:00:00 2001 From: Cemil ILIK <cemililik@outlook.com> Date: Thu, 5 Nov 2026 04:31:41 +0300 Subject: [PATCH 038/148] feat(cli): pick the default chat model from a keyed provider and refuse a keyless one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MVP-A4 (2.6.J, MVP-1 1b). - With no [chat]/[preferences] default_model or default_provider, the default agent's provider is the first of anthropic, openai, gemini, deepseek that has a key. Anthropic keeps claude-sonnet-4-6; any other provider gets the onboarding wizard's starter model, so a user with only an OpenAI key reaches a working chat. A default_provider without a model gets that provider's starter too, instead of the Anthropic default under the wrong provider. The choice asks the resolver's boolean hasKey only. - Every session build (fresh, resumed, /clear, reseat, agent run, the Home) refuses a bound agent whose provider has no key, before any MCP spawn or egress, with exit 2 and the same "no API key ... `relavium provider set-key`" message keyFor throws (now one missingKeyMessage). Before this the first turn failed inside the chain, which replaces credential errors with a fixed "credential resolution failed" so a host message cannot leak a secret. Only the primary is checked: an auth failure ends the whole chain, while a keyless fallback entry fails only if reached. - One resolver now serves the default choice, the pre-flight and the session runtime. Every production caller already passes the keychain-aware resolver. - The usage crash worker's credentials cut now models a key present at build and refused at the turn — the case the pre-flight leaves reachable. Tests: key-aware selection (each provider, order, none keyed, configured model/provider wins); a keyed-OpenAI-only session binds gpt-5.4-mini; a keyless primary is refused for a fresh build, a configured model and a resume. Mutation: ignoring keys, giving Anthropic the starter, not passing hasKey from the host, and removing either pre-flight each fail a test. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- apps/cli/src/chat/agent-source.test.ts | 46 +++++++++++++ apps/cli/src/chat/agent-source.ts | 41 ++++++++++-- apps/cli/src/chat/session-host.test.ts | 66 +++++++++++++++++++ apps/cli/src/chat/session-host.ts | 39 +++++++++-- .../fixtures/usage-coverage-crash-worker.ts | 5 +- apps/cli/src/engine/providers.ts | 14 ++-- 6 files changed, 196 insertions(+), 15 deletions(-) diff --git a/apps/cli/src/chat/agent-source.test.ts b/apps/cli/src/chat/agent-source.test.ts index b19a89dd..d07fae0e 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,51 @@ 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 provider 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 persisted provider without a model gets that provider's starter, never the Anthropic default. + 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 db94f056..101663f6 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,39 @@ 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 the + * configured `defaultProvider`, else the first provider in {@link KNOWN_PROVIDER_IDS} order that has a key, else + * Anthropic. 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 +90,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/session-host.test.ts b/apps/cli/src/chat/session-host.test.ts index fcff819f..65e6f48f 100644 --- a/apps/cli/src/chat/session-host.test.ts +++ b/apps/cli/src/chat/session-host.test.ts @@ -41,6 +41,7 @@ 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, @@ -2251,3 +2252,68 @@ describe('the interactive output default (MVP-A7, ADR-0101 amendment of 2026-11- } }); }); + +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<unknown>): Promise<string> => { + 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 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'); + }); +}); diff --git a/apps/cli/src/chat/session-host.ts b/apps/cli/src/chat/session-host.ts index e17e00d9..e689e1a0 100644 --- a/apps/cli/src/chat/session-host.ts +++ b/apps/cli/src/chat/session-host.ts @@ -58,7 +58,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 { @@ -816,12 +821,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, + providers: ProviderResolver, +): 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: (id) => providerHasKey(providers, id), // 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 @@ -835,6 +845,20 @@ 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 can never work, while a keyless fallback entry fails only if it is reached. + */ +function assertProviderKey(agent: AgentDefinition, providers: ProviderResolver): void { + if (providerHasKey(providers, agent.provider)) return; + throw new CliError('invalid_invocation', missingKeyMessage(agent.provider)); +} + /** * The MCP connect options, with every absent one OMITTED rather than passed as an explicit `undefined`. * @@ -934,7 +958,10 @@ export async function buildChatSession(opts: BuildChatSessionOptions): Promise<B opts.workOwner?.assertAdmitting(); assertSessionFsPolicy(opts.fsPolicy, opts.cwd, opts.chat); const sessionId = opts.uuid(); - const { agent, artifact } = bindChatAgent(opts); + // One resolver for the default-model choice, the key pre-flight and the session runtime. + const providers = opts.providers ?? createProviderResolver(); + const { agent, artifact } = bindChatAgent(opts, providers); + assertProviderKey(agent, providers); const mcpArtifact = opts.mcpArtifact ?? artifact; const context: SessionContext = { workingDir: opts.fsPolicy === undefined ? opts.cwd : fsPolicyWorkspace(opts.fsPolicy), @@ -969,7 +996,7 @@ export async function buildChatSession(opts: BuildChatSessionOptions): Promise<B attachDurabilityProbe, attachEffectJournal, attachEffectTurnAllocator, - } = buildSessionRuntime(opts, sessionId, mcp, context); + } = buildSessionRuntime({ ...opts, providers }, sessionId, mcp, context); // The session runs against the EFFECTIVE agent: its grant unioned with the discovered MCP tool ids (2.R) // and then narrowed by the 2.5.A advertise-filter to the tools whose ToolHost arm is actually wired (an // unwired tool is never offered). The ORIGINAL `agent` is what we return + persist (see {@link BuiltChatSession.agent}). @@ -1209,6 +1236,8 @@ export async function buildResumedChatSession( `session ${record.id} has no stored agent snapshot and cannot be resumed`, ); } + const providers = opts.providers ?? createProviderResolver(); + assertProviderKey(agent, providers); // 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. @@ -1246,7 +1275,7 @@ 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. 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 695c6f2a..d2bd85c6 100644 --- a/apps/cli/src/engine/fixtures/usage-coverage-crash-worker.ts +++ b/apps/cli/src/engine/fixtures/usage-coverage-crash-worker.ts @@ -281,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/providers.ts b/apps/cli/src/engine/providers.ts index 81e0007d..908df659 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; }, From cf1e35558f23051b4c4450c84689acb725baf823 Mon Sep 17 00:00:00 2001 From: Cemil ILIK <cemililik@outlook.com> Date: Thu, 5 Nov 2026 04:31:41 +0300 Subject: [PATCH 039/148] docs(repo): record the key-aware default chat model and the key pre-flight chat-session.md says how the zero-config default picks a keyed provider and that every session build refuses a keyless primary with the provider set-key remedy; config-spec.md's [chat].default_model line names the new last fallback. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- docs/reference/cli/chat-session.md | 2 ++ docs/reference/contracts/config-spec.md | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/reference/cli/chat-session.md b/docs/reference/cli/chat-session.md index 59947821..18fb6ce3 100644 --- a/docs/reference/cli/chat-session.md +++ b/docs/reference/cli/chat-session.md @@ -51,6 +51,8 @@ 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 `[chat].default_model` or `default_provider` at any layer, the default agent's provider is the first of `anthropic`, `openai`, `gemini`, `deepseek` that has a key (keychain or `RELAVIUM_<PROVIDER>_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` without a model gets that provider's starter model too. The check is a yes/no question to the key resolver and never reads a key into the session. +- **a provider with no key refuses before the session starts (MVP-A4).** Every session build (`chat`, `chat-resume`, `agent run`, the Home, `/clear` and a `/models` reseat) asks whether the bound agent's provider has a key before it spawns an MCP server or sends anything. If it has none, the build fails with exit `2` and the same message `provider set-key` documents: `no API key for provider '<id>' — store one with \`relavium provider set-key <id>\` or set RELAVIUM_<ID>_API_KEY.` 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 can never work, while a keyless `fallback_chain` entry fails only if it is reached. ### Model reseat (`/models`) diff --git a/docs/reference/contracts/config-spec.md b/docs/reference/contracts/config-spec.md index 9230037a..839ca210 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) From 5b490468a19c92e7c7e38a19fe582c922321e452 Mon Sep 17 00:00:00 2001 From: Cemil ILIK <cemililik@outlook.com> Date: Thu, 5 Nov 2026 04:45:17 +0300 Subject: [PATCH 040/148] feat(cli): /export writes into .relavium/workflows/ and offers the run command MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MVP-A3 (2.6.B, MVP-1 1b). - The in-REPL /export (chat and the in-Home chat) writes the scaffold to <project>/.relavium/workflows/<id>.relavium.yaml, or <cwd>/.relavium/ workflows/ when there is no project, with the session title's slug as the id — never a <sessionId>.relavium.yaml in the working directory. The id is written into the file as workflow.id, so the file name and the id agree. - It never overwrites another workflow: the id is the first of <slug>, <slug>-2, … (up to 100) that no catalog entry's slug or file stem holds (takenWorkflowIds: the stem counts because `relavium run <id>` resolves by file name first, so a new file would shadow <id>.yaml), and the file is created with wx, so one that appears in between is skipped. A second /export in the same session rewrites that session's own file in place. - Human output names the cwd-relative path and `relavium run <id>`; --json still emits session:exported with the absolute workflowPath. - writeScaffold gives mkdir its own catch: a parent that is a file fails mkdir with EEXIST too, which would otherwise read as "target exists" and walk every candidate id; it is now one invocation refusal ("a parent of the target path is not a directory"), also for chat-export --out. - chat-export is unchanged (its <sessionId> default and --out contract). Tests: the new location and id; the next free id with the id in the file and the other workflow untouched; a file stem and another file's workflow.id are taken; own-session rewrite, not another session's; both mkdir refusals (ENOTDIR and EEXIST); exhaustion names the escape hatch. chat.test.ts keeps its 133 cases (pinned by two secrets controls): the existing /export cases now assert the new path, the run hint and no -2 on re-export, and refuse by making .relavium/workflows a file. Mutation: not writing the id, ignoring taken ids, dropping stems, ignoring or over-applying `previous`, reading mkdir EEXIST as "exists", the REPL forgetting its export, and dropping the run hint each fail a test. Refs: ADR-0026 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- apps/cli/src/chat/export-project.test.ts | 192 +++++++++++++++++++++++ apps/cli/src/chat/export.test.ts | 7 +- apps/cli/src/chat/export.ts | 153 +++++++++++++++--- apps/cli/src/commands/chat.test.ts | 54 ++++--- apps/cli/src/commands/chat.ts | 28 +++- apps/cli/src/workflows/catalog.ts | 15 ++ 6 files changed, 401 insertions(+), 48 deletions(-) create mode 100644 apps/cli/src/chat/export-project.test.ts 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 00000000..81b1e2a7 --- /dev/null +++ b/apps/cli/src/chat/export-project.test.ts @@ -0,0 +1,192 @@ +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, 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 { AgentSchema, type AgentSessionRecord, type SessionMessage } from '@relavium/shared'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { isCliError } from '../process/errors.js'; +import { exportSessionToProject } from './export.js'; + +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('rewrites this session’s own earlier export in place, and only this session’s', () => { + const first = exportS1(); + writeFileSync(first.path, 'edited by hand'); + const again = exportSessionToProject({ + store, + sessionId: 's1', + cwd, + projectConfigDir: join(cwd, '.relavium'), + previous: first, + }); + expect(again.path).toBe(first.path); + expect(idIn(again.path)?.[1]).toBe('my-session'); + // Another session handed s1's result does not get to overwrite it. + const other = exportSessionToProject({ + store, + sessionId: 's2', + cwd, + projectConfigDir: join(cwd, '.relavium'), + previous: first, + }); + expect(other.workflowId).toBe('my-session-2'); + expect(idIn(first.path)?.[1]).toBe('my-session'); + }); + + 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('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 <sessionId> --out <path>'); + }); +}); diff --git a/apps/cli/src/chat/export.test.ts b/apps/cli/src/chat/export.test.ts index 05c81b9e..a1d2ea8c 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 e0464f94..70e26b51 100644 --- a/apps/cli/src/chat/export.ts +++ b/apps/cli/src/chat/export.ts @@ -1,8 +1,8 @@ import { dirname, join, resolve } from 'node:path'; -import { serializeWorkflow, sessionToWorkflow } from '@relavium/core'; +import { 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 { @@ -11,6 +11,7 @@ import { 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)) — @@ -63,13 +64,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 +74,144 @@ 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 ⇒ `<cwd>/.relavium`, created by the write. */ + readonly projectConfigDir: string | undefined; + /** + * This session's own earlier export from the same REPL. When it names this session, the export rewrites that + * file in place and keeps its id, so a second `/export` updates the scaffold rather than adding `-2`. + */ + readonly previous?: ExportResult; +} + +/** 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, + * `<project>/.relavium/workflows/<id>.relavium.yaml`, with the session title's slug as the id, so + * `relavium run <id>` finds it — never a UUID-named file in the working directory. + * + * It never overwrites another workflow. The id is the first of `<slug>`, `<slug>-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 `previous`: this session's own export from this REPL. + */ +export function exportSessionToProject(opts: ExportToProjectOptions): ExportResult { + const loaded = loadSession(opts.store, opts.sessionId); + const scaffold = sessionToWorkflow(loaded.session, loaded.messages); + const previous = opts.previous?.record.id === opts.sessionId ? opts.previous : undefined; + if (previous !== undefined) { + const definition = withWorkflowId(scaffold, previous.workflowId); + const label = sourceLabel(opts.cwd, previous.path); + writeScaffold(previous.path, serializeWorkflow(definition), true, label); + return exportResult(previous.path, definition, loaded); + } + const root = opts.projectConfigDir ?? join(opts.cwd, '.relavium'); + const dir = join(root, 'workflows'); + const taken = + opts.projectConfigDir === undefined + ? new Set<string>() + : takenWorkflowIds(opts.projectConfigDir, opts.cwd); + const base = scaffold.workflow.id; + for (let attempt = 1; attempt <= MAX_EXPORT_ID_ATTEMPTS; attempt += 1) { + const id = attempt === 1 ? base : `${base}-${String(attempt)}`; + if (taken.has(id)) continue; + // The id becomes a file name: refuse anything that is not a kebab id, whatever the scaffold produced. + if (!WORKFLOW_ID_GRAMMAR.test(id)) { + throw new CliError('cli_internal', 'the export produced an unusable workflow id'); + } + 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 <sessionId> --out <path>\``, + ); +} + +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') { + if (code === 'EEXIST' || code === 'ENOTDIR') { 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 +221,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/commands/chat.test.ts b/apps/cli/src/commands/chat.test.ts index 389e1d1d..cbad6682 100644 --- a/apps/cli/src/commands/chat.test.ts +++ b/apps/cli/src/commands/chat.test.ts @@ -100,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) => { @@ -1108,14 +1119,17 @@ 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') }), ]); } else { - expect(err()).toContain(`exported session to ${join(cwd, 'id-0.relavium.yaml')}`); + expect(err()).toContain( + 'exported session to .relavium/workflows/private-user-sentinel.relavium.yaml — run it with `relavium run private-user-sentinel`', + ); } }, ); @@ -1127,7 +1141,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 }, { @@ -1156,7 +1170,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<typeof render> | undefined; @@ -1213,7 +1227,7 @@ describe('chatCommand', () => { '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); } expect(err()).not.toContain('note: exported workflow'); await ctx.processLine('/exit'); @@ -1250,37 +1264,39 @@ 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 .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's `previous`) — 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 @@ -2036,7 +2052,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 12865b47..6bbab422 100644 --- a/apps/cli/src/commands/chat.ts +++ b/apps/cli/src/commands/chat.ts @@ -29,7 +29,12 @@ import { type Memory, type ReasoningEffort, } from '@relavium/shared'; -import { exportSession, SESSION_EXPORT_REVIEW_NOTICE } from '../chat/export.js'; +import { + exportSessionToProject, + SESSION_EXPORT_REVIEW_NOTICE, + type ExportResult, +} 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'; @@ -1392,6 +1397,8 @@ export function createChatLineHandler( store.setContextWindow?.(built.contextWindowTokens); let stop = false; let cancelled = false; + // This session's last `/export` (MVP-A3): a second `/export` rewrites that file in place instead of adding `-2`. + let lastExport: ExportResult | undefined; // Set by `/clear` (ADR-0062 §7): the loop stopped to SWAP the session, not to end the REPL — `stopReason` reports // it so the surface (the standalone re-drive loop, or the Home) rebuilds a fresh session instead of exiting. let clearRequested = false; @@ -1464,14 +1471,20 @@ 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 (`previous`). + 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, + ...(lastExport === undefined ? {} : { previous: lastExport }), }); + lastExport = result; emitOutput(SESSION_EXPORT_REVIEW_NOTICE.trimEnd()); if (noticesStopped()) return; if (deps.global.json) { @@ -1481,7 +1494,10 @@ 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. + deps.io.writeErr( + `exported session to ${sourceLabel(deps.global.cwd, result.path)} — run it with \`relavium run ${result.workflowId}\`\n`, + ); } } catch (err) { workOwner?.failure(err); diff --git a/apps/cli/src/workflows/catalog.ts b/apps/cli/src/workflows/catalog.ts index 1b573a7e..d9a92123 100644 --- a/apps/cli/src/workflows/catalog.ts +++ b/apps/cli/src/workflows/catalog.ts @@ -106,6 +106,21 @@ export function discoverCatalog(opts: { return entries.sort((a, b) => byCodeUnit(a.slug, b.slug)); } +/** + * Every id a NEW workflow file must not take in `<projectConfigDir>/workflows/` (MVP-A3): each entry's slug — a + * valid file's `workflow.id`, an invalid one's file stem — and each file's stem. The stem counts on its own because + * `relavium run <id>` resolves a bare id by file name first, so a new `<id>.relavium.yaml` would shadow a file whose + * name is `<id>.yaml` but whose `workflow.id` differs. Throws the catalog's own error for an unreadable directory. + */ +export function takenWorkflowIds(projectConfigDir: string, cwd: string): ReadonlySet<string> { + const taken = new Set<string>(); + for (const entry of discoverCatalog({ projectConfigDir, cwd, kind: 'workflows' })) { + taken.add(entry.slug); + taken.add(fileStem(basename(entry.path))); + } + 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 { From a30bda1e896591ab2664be8d24a467e16235d43b Mon Sep 17 00:00:00 2001 From: Cemil ILIK <cemililik@outlook.com> Date: Thu, 5 Nov 2026 04:45:17 +0300 Subject: [PATCH 041/148] docs(repo): record where /export writes and how it picks the workflow id chat-session.md's /export row and export section say it writes into the project's .relavium/workflows/ under the title slug, never over another workflow, and prints the run command; commands.md's chat-export section notes the difference. Refs: ADR-0026 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- docs/reference/cli/chat-session.md | 4 ++-- docs/reference/cli/commands.md | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/reference/cli/chat-session.md b/docs/reference/cli/chat-session.md index 18fb6ce3..992506fd 100644 --- a/docs/reference/cli/chat-session.md +++ b/docs/reference/cli/chat-session.md @@ -103,7 +103,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 <sessionId>` (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 `<sessionId>.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 — `<project>/.relavium/workflows/<id>.relavium.yaml`, or `<cwd>/.relavium/workflows/` when there is no project — with the session title's slug as the id (written into the file as `workflow.id`), and prints the path and the command that runs it (`run it with \`relavium run <id>\``) (MVP-A3). It never overwrites another workflow: the id is the first of `<slug>`, `<slug>-2`, … that no catalog file or `workflow.id` already holds, and a second `/export` in the same session rewrites that session's own file in place. 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. | @@ -289,4 +289,4 @@ Provider keys are read from the **OS keychain** exactly as for `relavium run` ## Export to workflow -`/export` (interactive) and `relavium chat-export <sessionId>` 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 <sessionId>` 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 `<sessionId>.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 f01f9555..6ff43cde 100644 --- a/docs/reference/cli/commands.md +++ b/docs/reference/cli/commands.md @@ -450,7 +450,7 @@ 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 `<sessionId>.relavium.yaml` in cwd by default (the file name is keyed on the unique session id, so two sessions never collide); `--out <path>` 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 `<sessionId>.relavium.yaml` in cwd by default (the file name is keyed on the unique session id, so two sessions never collide); `--out <path>` 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. From 80f6dc90fdaf77d97a6954a39151dcbc2cf170b3 Mon Sep 17 00:00:00 2001 From: Cemil ILIK <cemililik@outlook.com> Date: Thu, 5 Nov 2026 04:52:53 +0300 Subject: [PATCH 042/148] feat(cli): print the workflow's outputs on the human run surfaces MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MVP-A1 (2.6.G, MVP-1 1b). `relavium run` and `relavium logs` now show a completed run's run:completed.outputs; --json is unchanged. - One formatter (render/run-outputs.ts) serves the TUI final summary, the plain (no-TTY) line renderer and `logs <runId>`. A one-line value sits beside its key, a multi-line value is indented under it (so it cannot forge a row at the summary's indentation), anything else is compact JSON, and a secret input the engine masked stays that masked object. - A new terminal sink for untrusted content: every value goes through sanitizeUntrusted (secret shapes masked on the original text, then terminal controls stripped); keys get sanitizeInline. - Bounded: 40 lines and 4,000 code points per value, 12,000 per run, never splitting a code point; what is cut is marked and names `relavium logs <runId> --json` for the full record. - A failed or cancelled run shows no outputs block. The view model's completed summary now carries the outputs; seedRun takes them for tests. Tests: empty outputs; layout of one-line, JSON and masked values; indented multi-line rows; controls stripped, a key shape masked, a key collapsed; per-value line and character bounds with the note; the per-run bound; no split code point; and each surface — TUI summary (and not on failure), the plain renderer, and logs human output with --json still raw. Mutation: unsanitized values or keys, unindented rows, no line cap, no run budget, a code-unit cut, and each surface dropping the block each fail a test. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- apps/cli/src/commands/logs.ts | 7 + apps/cli/src/render/renderer.ts | 6 +- apps/cli/src/render/run-outputs.test.ts | 176 ++++++++++++++++++ apps/cli/src/render/run-outputs.ts | 94 ++++++++++ apps/cli/src/render/tui/final-summary.ts | 7 + .../cli/src/render/tui/run-view-model.test.ts | 1 + apps/cli/src/render/tui/run-view-model.ts | 3 + apps/cli/src/test-support.ts | 4 +- 8 files changed, 296 insertions(+), 2 deletions(-) create mode 100644 apps/cli/src/render/run-outputs.test.ts create mode 100644 apps/cli/src/render/run-outputs.ts diff --git a/apps/cli/src/commands/logs.ts b/apps/cli/src/commands/logs.ts index 44ae981e..7ef52567 100644 --- a/apps/cli/src/commands/logs.ts +++ b/apps/cli/src/commands/logs.ts @@ -9,6 +9,7 @@ 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 { openHistoryReader } from '../history/reader.js'; export interface LogsCommandArgs { @@ -81,6 +82,12 @@ export function logsCommand(args: LogsCommandArgs, deps: LogsCommandDeps): ExitC ); for (const event of events) { deps.io.writeOut(`${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)) { + deps.io.writeOut(`${line}\n`); + } + } } return EXIT_CODES.success; } finally { diff --git a/apps/cli/src/render/renderer.ts b/apps/cli/src/render/renderer.ts index e0382eb3..22d32c5f 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 00000000..9b15789c --- /dev/null +++ b/apps/cli/src/render/run-outputs.test.ts @@ -0,0 +1,176 @@ +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<string, unknown>): 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 <runId> --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('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'); + }); + + describe('relavium logs <runId>', () => { + let client: DbClient; + beforeEach(() => { + client = createClient(':memory:'); + runMigrations(client.db); + }); + afterEach(() => { + client.sqlite.close(); + }); + + 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<typeof captureIo>['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 00000000..8d073bcc --- /dev/null +++ b/apps/cli/src/render/run-outputs.ts @@ -0,0 +1,94 @@ +import { sanitizeInline, sanitizeUntrusted } 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 <runId>`. `--json` is untouched — `run:completed` there already carries + * `outputs` verbatim. + * + * **A new terminal sink for untrusted content, so it gets the whole floor.** An output value is whatever the + * last node produced — model text, a tool result, an authored literal — so every value goes through + * {@link sanitizeUntrusted} (secret shapes masked on the ORIGINAL text, then terminal controls stripped) before + * it is laid out. 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. + * + * **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 <runId> --json`). Every value line is indented under its key, so a multi-line value cannot + * forge a row at the summary's own indentation. + */ + +/** 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 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 <runId> --json`. + */ +export function formatRunOutputs( + outputs: Readonly<Record<string, unknown>>, + 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; + for (const key of keys) { + const label = `${indent} ${sanitizeInline(key)}:`; + if (budget <= 0) { + lines.push(`${label} …`); + truncated = true; + continue; + } + const text = sanitizeUntrusted(valueText(outputs[key])); + const shown = clip(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) { + const id = runId === undefined ? '<runId>' : sanitizeInline(runId); + lines.push(`${indent} (outputs shortened — the full record: \`relavium logs ${id} --json\`)`); + } + return lines; +} + +/** A string as itself; anything else as compact JSON (a masked secret stays the masked object). */ +function valueText(value: unknown): string { + if (typeof value === 'string') return value; + try { + return JSON.stringify(value) ?? String(value); + } catch { + // A value JSON cannot represent (a cycle, a BigInt) — said, not thrown: the summary must still print. + return '[unrepresentable value]'; + } +} + +/** 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/final-summary.ts b/apps/cli/src/render/tui/final-summary.ts index f10e025c..c236c96c 100644 --- a/apps/cli/src/render/tui/final-summary.ts +++ b/apps/cli/src/render/tui/final-summary.ts @@ -7,6 +7,7 @@ import { } from './format.js'; import { nodeSuffix } from './projection.js'; import type { RunViewState } from './run-view-model.js'; +import { formatRunOutputs } from '../run-outputs.js'; import { sanitizeInline, sanitizeUntrustedInline } from '../sanitize.js'; /** @@ -93,5 +94,11 @@ 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`; } 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 d0d99a62..bf872228 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) }); }); diff --git a/apps/cli/src/render/tui/run-view-model.ts b/apps/cli/src/render/tui/run-view-model.ts index ca0ab4e0..e4359c27 100644 --- a/apps/cli/src/render/tui/run-view-model.ts +++ b/apps/cli/src/render/tui/run-view-model.ts @@ -66,6 +66,8 @@ 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<Record<string, unknown>>; } export interface RunViewState { @@ -584,6 +586,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 11f9f3f5..41ad60be 100644 --- a/apps/cli/src/test-support.ts +++ b/apps/cli/src/test-support.ts @@ -141,6 +141,8 @@ 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<Record<string, unknown>>; } & SeedGateFields; const DEFAULT_TS_MS = 1_750_000_000_000; @@ -200,7 +202,7 @@ export async function seedRun(db: Db, opts: SeedRunOptions): Promise<string> { await emitPausedState(emit, opts, ts, tsMs); } else if (opts.state === 'completed') { await emit('run:completed', { - outputs: {}, + outputs: opts.outputs ?? {}, totalTokensUsed: { input: 1, output: 2 }, totalCostMicrocents: 100, durationMs: 9, From e3be7ddae336b2098750c71590595c57d65bda19 Mon Sep 17 00:00:00 2001 From: Cemil ILIK <cemililik@outlook.com> Date: Thu, 5 Nov 2026 04:52:53 +0300 Subject: [PATCH 043/148] docs(repo): record the outputs block on the human run and logs surfaces commands.md's run section describes the outputs block (layout, sanitize floor, bounds and the full-record pointer) and its logs section says the replay shows a completed run's outputs the same way. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- docs/reference/cli/commands.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/reference/cli/commands.md b/docs/reference/cli/commands.md index 6ff43cde..3dab4d5c 100644 --- a/docs/reference/cli/commands.md +++ b/docs/reference/cli/commands.md @@ -200,6 +200,7 @@ 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 <digest>` 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, so it goes through the shared sanitize floor (secret shapes masked, terminal controls stripped) and is bounded: 40 lines and 4,000 characters per value, 12,000 characters per run. Anything cut is marked `…` and named: `(outputs shortened — the full record: \`relavium logs <runId> --json\`)`. 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_<PROVIDER>_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 <digest>` (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). @@ -245,7 +246,7 @@ For this detector probe only, a recognised bare template reference without a pat ### `relavium logs <runId>` -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 <runId> --gate <gateId>`. 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 <runId> --gate <gateId>`. 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) From 723a971e67f56370b89427f950929c108fcdb46d Mon Sep 17 00:00:00 2001 From: Cemil ILIK <cemililik@outlook.com> Date: Thu, 5 Nov 2026 04:53:12 +0300 Subject: [PATCH 044/148] docs(repo): record MVP-1 1b's progress in the roadmap 1a is on development; 1b (gate decision check, liveness with the store's second-terminal refusal, the key-aware default model, /export into .relavium/workflows/, human run outputs) is committed locally and owes the D64 rigor pass, one review round and the security sitting before its push. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- docs/roadmap/current.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/roadmap/current.md b/docs/roadmap/current.md index 4f567f4f..96c431cd 100644 --- a/docs/roadmap/current.md +++ b/docs/roadmap/current.md @@ -98,7 +98,9 @@ Wave 3 split as `row <n>` in the [increment table](phases/phase-2.5.5-hardening- 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 <runId>`, 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. + error, and liveness. **Progress 2026-11-05:** 1a is on `development`. 1b is implemented and committed locally, not + pushed until it is accepted. Wiring liveness exposed a second-terminal race, so the run store now refuses a second + terminal. Still owed: the rigor pass, one new complete review round and a separate security sitting. 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 From 9fc2e331f04c8ab1c08568835e2549811ab696b8 Mon Sep 17 00:00:00 2001 From: Cemil ILIK <cemililik@outlook.com> Date: Thu, 5 Nov 2026 05:16:40 +0300 Subject: [PATCH 045/148] test(repo): count the session key pre-flight in the packaged chat smoke MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The MVP-A4 pre-flight asks the resolver's boolean hasKey before a session spawns or sends anything, so each `chat`/`chat-resume` process now reads the (denied) keychain entry twice — once for the pre-flight, once for the turn's keyFor — and the env key still serves the turn. The smoke pinned two reads for the two processes and failed `smoke:cli-package` with four. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- tools/cli-package/smoke/chat.mjs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tools/cli-package/smoke/chat.mjs b/tools/cli-package/smoke/chat.mjs index 2a9b35e3..32a6ed6d 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, From ed953a161d71ea614851d82f7527341cc3be3be4 Mon Sep 17 00:00:00 2001 From: Cemil ILIK <cemililik@outlook.com> Date: Thu, 5 Nov 2026 05:16:40 +0300 Subject: [PATCH 046/148] fix(cli): give replayed log rows the one-line floor Found by the 1b sibling search beside MVP-A1's new logs sink: `relavium logs <runId>` wrote each row's stored fields (a gate id, decidedBy, a node id) without sanitizeInline, so a control sequence or newline in a stored field reached the terminal and could forge a row. Each row now gets the same one-line floor the plain run renderer applies. Also makes the /export message assertions in chat.test.ts join the path with the platform separator (sourceLabel prints `\` on Windows); the file keeps its 133 cases. Test: a gate id carrying ESC and a forged `[9] run:completed` row is stripped and collapsed onto its own row. Mutation: removing the floor fails it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- apps/cli/src/commands/chat.test.ts | 4 +-- apps/cli/src/commands/logs.ts | 5 +++- apps/cli/src/render/run-outputs.test.ts | 36 +++++++++++++++++++++++++ 3 files changed, 42 insertions(+), 3 deletions(-) diff --git a/apps/cli/src/commands/chat.test.ts b/apps/cli/src/commands/chat.test.ts index cbad6682..895d226c 100644 --- a/apps/cli/src/commands/chat.test.ts +++ b/apps/cli/src/commands/chat.test.ts @@ -1128,7 +1128,7 @@ describe('chatCommand', () => { ]); } else { expect(err()).toContain( - 'exported session to .relavium/workflows/private-user-sentinel.relavium.yaml — run it with `relavium run private-user-sentinel`', + `exported session to ${join('.relavium', 'workflows', 'private-user-sentinel.relavium.yaml')} — run it with \`relavium run private-user-sentinel\``, ); } }, @@ -1266,7 +1266,7 @@ describe('chatCommand', () => { expect(existsSync(exportedAt(cwd, 'hello'))).toBe(true); expect(err()).toContain( - 'exported session to .relavium/workflows/hello.relavium.yaml — run it with `relavium run hello`', + `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 diff --git a/apps/cli/src/commands/logs.ts b/apps/cli/src/commands/logs.ts index 7ef52567..d0373a27 100644 --- a/apps/cli/src/commands/logs.ts +++ b/apps/cli/src/commands/logs.ts @@ -10,6 +10,7 @@ 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 { @@ -81,7 +82,9 @@ export function logsCommand(args: LogsCommandArgs, deps: LogsCommandDeps): ExitC `run ${run.id} — ${run.status} (${events.length} event${events.length === 1 ? '' : 's'})\n`, ); for (const event of events) { - deps.io.writeOut(`${formatLogLine(event)}\n`); + // 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. + deps.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)) { diff --git a/apps/cli/src/render/run-outputs.test.ts b/apps/cli/src/render/run-outputs.test.ts index 9b15789c..7bc9ef28 100644 --- a/apps/cli/src/render/run-outputs.test.ts +++ b/apps/cli/src/render/run-outputs.test.ts @@ -142,6 +142,42 @@ describe('the outputs reach every human run surface (MVP-A1)', () => { 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', From 5f2a0eb8182343bc01c24be7f15d1ad46e8644e4 Mon Sep 17 00:00:00 2001 From: Cemil ILIK <cemililik@outlook.com> Date: Thu, 5 Nov 2026 08:36:20 +0300 Subject: [PATCH 047/148] test(repo): give the compiled-CLI MCP orphan probe a synthetic key The MVP-A4 session pre-flight refuses a keyless primary provider before any MCP spawn, so the probe's `agent run` stopped at "no API key" instead of reaching the consent gate whose refusal prints the digest it feeds back, and `smoke:cli` failed. A synthetic RELAVIUM_ANTHROPIC_API_KEY lets the run reach the gate and the connect the check is about; it never reaches a provider (consent refuses the first call, and the second waits on a server that never completes initialize until it is signalled). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- tools/cli-smoke/check.mjs | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/tools/cli-smoke/check.mjs b/tools/cli-smoke/check.mjs index 7d86660e..7c9ea0be 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'); From bcaf362e1233d696c9d3a1ee780caa387f0d38ea Mon Sep 17 00:00:00 2001 From: Cemil ILIK <cemililik@outlook.com> Date: Thu, 5 Nov 2026 10:03:48 +0300 Subject: [PATCH 048/148] docs(repo): draft the eight Gate 0 ADRs Gate 0 of the CLI MVP owes drafts for the decisions ruled on 2026-11-04. All eight are Proposed and wait for the maintainer: - ADR-0114: in-house markdown rendering for the chat transcript (phase 2.6 ADR row 11, part 1; no dependency). - ADR-0115: one interrupt keymap (D68): Ctrl-C interrupts, a second press ends the session, and at a pending approval it rejects first. - ADR-0116: the built-in agent can edit, and the starting mode is configurable; a repository config can never start a session in auto (D62). - ADR-0117: a denial decided from the call and the session's policy returns to the model; state-dependent denials stay fatal (D67). - ADR-0118: a command outside the allowlist shows a typed card; "remember" is operator-owned and keyed by the project root (D63). - ADR-0119: an interrupted turn stays as a content-free marker (the CR-71 residual, D67). - ADR-0120: the toolbelt additions and the per-tool preview contract (phase 2.6 ADR row 9, part 1). - ADR-0121: pre-1.0 releases publish the smoke-verified package-test tarball, with a mechanical sunset and npm trusted publishing (D59). Refs: D59, D62, D63, D67, D68 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- ...kdown-rendering-for-the-chat-transcript.md | 204 ++++++++++++++++++ ...nterrupt-keymap-for-the-interactive-cli.md | 152 +++++++++++++ ...t-and-the-starting-mode-is-configurable.md | 149 +++++++++++++ ...a-denied-tool-call-returns-to-the-model.md | 146 +++++++++++++ ...e-the-allowlist-asks-instead-of-failing.md | 174 +++++++++++++++ ...ted-turn-stays-as-a-content-free-marker.md | 160 ++++++++++++++ ...rows-and-a-tool-call-shows-what-it-does.md | 169 +++++++++++++++ ...the-smoke-verified-package-test-tarball.md | 184 ++++++++++++++++ docs/decisions/README.md | 8 + docs/roadmap/current.md | 2 +- 10 files changed, 1347 insertions(+), 1 deletion(-) create mode 100644 docs/decisions/0114-in-house-markdown-rendering-for-the-chat-transcript.md create mode 100644 docs/decisions/0115-one-interrupt-keymap-for-the-interactive-cli.md create mode 100644 docs/decisions/0116-the-built-in-chat-agent-can-edit-and-the-starting-mode-is-configurable.md create mode 100644 docs/decisions/0117-a-denied-tool-call-returns-to-the-model.md create mode 100644 docs/decisions/0118-a-command-outside-the-allowlist-asks-instead-of-failing.md create mode 100644 docs/decisions/0119-an-interrupted-turn-stays-as-a-content-free-marker.md create mode 100644 docs/decisions/0120-the-toolbelt-grows-and-a-tool-call-shows-what-it-does.md create mode 100644 docs/decisions/0121-pre-1-0-releases-publish-the-smoke-verified-package-test-tarball.md 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 00000000..21b2f45d --- /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 `<Static>` 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 `<Text>` 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 00000000..c59641e9 --- /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 00000000..542b88c2 --- /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 00000000..1c12939d --- /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 00000000..40e39b8e --- /dev/null +++ b/docs/decisions/0118-a-command-outside-the-allowlist-asks-instead-of-failing.md @@ -0,0 +1,174 @@ +# ADR-0118: A command outside the allowlist asks instead of failing + +- **Status**: Proposed +- **Date**: 2026-11-05 +- **Related**: [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.** It shows: + +- the argv, one argument per element, so spaces and quotes are unambiguous; +- the working directory; +- why the command is not pre-approved. + +Everything is terminal-safe text. The card never offers an approving answer for +a command it does not show in full. 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 command string for the rest of this session + instance. It is held in memory and not persisted, like ADR-0057's `[a]`. +- **Remember.** Persist an exact-string grant for this project root (§2). +- **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. +- **How they are written.** Through the + [ADR-0063](0063-cli-config-write-contract.md) write primitive: parse, set, + re-validate, write atomically. It is secret-incapable by construction. No + project, workspace or agent file is ever written. +- **What a grant contains.** Only an exact command string, never a glob. "Remember + `npm test`" never authorizes `npm test -- --exec=…`. +- **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. Allowlist entries and remembered + grants match exact strings, and globs stay opt-in and config-authored. +- **ADR-0063.** Gains a second operator-owned write target: remembered command + grants. +- **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 <command>` 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. +- **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. + The config edit and restart are no longer needed. +- A remembered grant serves one operator on one tree and never travels with the + repository. +- `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). +- 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 00000000..52958506 --- /dev/null +++ b/docs/decisions/0119-an-interrupted-turn-stays-as-a-content-free-marker.md @@ -0,0 +1,160 @@ +# ADR-0119: An interrupted turn stays in the session as a content-free marker + +- **Status**: Proposed +- **Date**: 2026-11-05 +- **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 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. + +Provider adapters may need consecutive user-role messages merged. That is +`@relavium/llm`'s concern, behind the seam. 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. +- 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 00000000..904fdd52 --- /dev/null +++ b/docs/decisions/0120-the-toolbelt-grows-and-a-tool-call-shows-what-it-does.md @@ -0,0 +1,169 @@ +# ADR-0120: The toolbelt grows, and a tool call shows what it does + +- **Status**: Proposed +- **Date**: 2026-11-05 +- **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.** 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`). +- **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 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 renders a `ToolPreview` through + ADR-0114's span pipeline, which 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 as what it will do. 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 00000000..14330c46 --- /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 8b9136a2..2d6e4ef4 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/roadmap/current.md b/docs/roadmap/current.md index 96c431cd..8e0e1917 100644 --- a/docs/roadmap/current.md +++ b/docs/roadmap/current.md @@ -86,7 +86,7 @@ Wave 3 split as `row <n>` in the [increment table](phases/phase-2.5.5-hardening- 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. From 61139117185b7af0a7f0183df4e09bba787a6c7c Mon Sep 17 00:00:00 2001 From: Cemil ILIK <cemililik@outlook.com> Date: Thu, 5 Nov 2026 10:32:17 +0300 Subject: [PATCH 049/148] fix(shared): refuse an input gate that would approve on timeout An input gate takes its input or is rejected, so a deadline has nothing to approve. Since the gate-decision check, the engine refuses the timeout's approval and the run fails with run_timeout, while admission still accepted `timeout_action: approve` on an input gate. Authoring now refuses that pairing at the action field. The frozen snapshot reader applies no such rule, so a run parked before it still loads. D64 round 1 of MVP-1 1b, finding gate-1. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- packages/shared/src/node.test.ts | 40 ++++++++++++++++++++++++++++++++ packages/shared/src/node.ts | 16 ++++++++++++- 2 files changed, 55 insertions(+), 1 deletion(-) diff --git a/packages/shared/src/node.test.ts b/packages/shared/src/node.test.ts index 51b49f34..44b64e11 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 827feb8b..ce9f695b 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); From 19651cfafaa5dbbe78ab17e3e9b50996e9fe5095 Mon Sep 17 00:00:00 2001 From: Cemil ILIK <cemililik@outlook.com> Date: Thu, 5 Nov 2026 10:32:17 +0300 Subject: [PATCH 050/148] fix(core): leave a run whose held terminal is unwritten for the next start MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit reconcile() drained the terminal outbox and then repaired every interrupted run from the same list. When the drain could not write a held terminal (a full disk, a declined claim), the repair wrote run:failed past it, and the next drain dropped the held run:completed as already terminal: a completed run was relabelled failed and its outputs lost, which the outbox exists to prevent (ADR-0078 §4). The drain now reports the runs whose held terminal stayed, and the repair skips them. An outbox that cannot be read holds every run. Also pins that a parked input gate whose deadline would approve it fails with run_timeout, and corrects the held-claim comment: the store's terminal refusal stops that write, with UNIQUE(run_id, seq) as the fallback. D64 round 1 of MVP-1 1b, findings liveness-1, gate-1 and liveness-3. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- packages/core/src/engine/engine.test.ts | 83 +++++++++++++++++++++++++ packages/core/src/engine/engine.ts | 39 +++++++++--- 2 files changed, 113 insertions(+), 9 deletions(-) diff --git a/packages/core/src/engine/engine.test.ts b/packages/core/src/engine/engine.test.ts index 9a76a5c7..5dc96957 100644 --- a/packages/core/src/engine/engine.test.ts +++ b/packages/core/src/engine/engine.test.ts @@ -2220,6 +2220,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( @@ -6134,6 +6158,65 @@ 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('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']); + }); }); // --- 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 0221e26e..4f6a7cad 100644 --- a/packages/core/src/engine/engine.ts +++ b/packages/core/src/engine/engine.ts @@ -1085,8 +1085,8 @@ class RunExecution { // 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, so `UNIQUE(run_id, seq)` refuses it first. One - // terminal either way, never two. + // 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. 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 @@ -6349,8 +6349,15 @@ export class WorkflowEngine { // 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. const interrupted = await this.#host.store.listInterruptedRuns(); - const reconciled: RunEvent[] = [...(await this.#drainTerminalOutbox(interrupted))]; + const drained = await this.#drainTerminalOutbox(interrupted); + 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 @@ -6547,21 +6554,31 @@ export class WorkflowEngine { */ async drainTerminalOutbox(): Promise<readonly RunEvent[]> { const interrupted = await this.#host.store.listInterruptedRuns(); - return this.#drainTerminalOutbox(interrupted); + return (await this.#drainTerminalOutbox(interrupted)).written; } - async #drainTerminalOutbox(interrupted: readonly InterruptedRun[]): Promise<readonly RunEvent[]> { + /** + * 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 cannot be read 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. + */ + async #drainTerminalOutbox(interrupted: readonly InterruptedRun[]): Promise<{ + readonly written: readonly RunEvent[]; + readonly holds: (runId: string) => boolean; + }> { let held: readonly RunEvent[]; try { held = await this.#host.terminalOutbox.list(); } catch { - return []; + 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; @@ -6579,7 +6596,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, @@ -6593,12 +6613,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. */ From 42d2d34128f3c5bbb3c843abb3dc9da389b1d802 Mon Sep 17 00:00:00 2001 From: Cemil ILIK <cemililik@outlook.com> Date: Thu, 5 Nov 2026 10:32:17 +0300 Subject: [PATCH 051/148] feat(core): read back the session a workflow file was exported from readSessionExportMarker decodes a file with the shared hardened YAML profile (ADR-0035) and returns the session id from the `metadata.relaviumExport` marker sessionToWorkflow writes. It does not validate the workflow, so an export whose historical agent snapshot no longer validates is still recognized. The CLI's /export uses it to find a session's own earlier export. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- .../core/src/export/session-marker.test.ts | 46 +++++++++++++++++++ packages/core/src/export/session-marker.ts | 36 +++++++++++++++ packages/core/src/index.ts | 1 + 3 files changed, 83 insertions(+) create mode 100644 packages/core/src/export/session-marker.test.ts create mode 100644 packages/core/src/export/session-marker.ts 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 00000000..19d7040e --- /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 00000000..59bc730c --- /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 0cb1da06..ab516e55 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -361,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 From dc6a9c8e5932597bdbf743f7d6b0ff985f45f772 Mon Sep 17 00:00:00 2001 From: Cemil ILIK <cemililik@outlook.com> Date: Thu, 5 Nov 2026 10:32:30 +0300 Subject: [PATCH 052/148] fix(cli): never promise an input gate it will be approved on timeout The gate card printed `auto-approve on timeout` for an input gate whose deadline would approve it, but the engine fails that run with run_timeout. Admission now refuses the pairing; a run parked before the rule shows "the run fails on timeout". The decision refusal also reads "is a review gate" rather than "is an review gate". D64 round 1 of MVP-1 1b, findings gate-1 and gate-3. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- apps/cli/src/gate/clack-prompter.test.ts | 15 +++++++++++++++ apps/cli/src/gate/clack-prompter.ts | 16 ++++++++++++---- apps/cli/src/gate/decision.test.ts | 6 +++--- apps/cli/src/gate/decision.ts | 2 +- 4 files changed, 31 insertions(+), 8 deletions(-) diff --git a/apps/cli/src/gate/clack-prompter.test.ts b/apps/cli/src/gate/clack-prompter.test.ts index c4cae564..29cc10d9 100644 --- a/apps/cli/src/gate/clack-prompter.test.ts +++ b/apps/cli/src/gate/clack-prompter.test.ts @@ -176,6 +176,21 @@ describe('createClackGatePrompter', () => { expect(note.mock.calls[0]?.[0]).toContain('auto-reject'); }); + 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 () => { const note = vi.fn<(message: string, title: string) => void>(); await createClackGatePrompter(deps({ note })).prompt( diff --git a/apps/cli/src/gate/clack-prompter.ts b/apps/cli/src/gate/clack-prompter.ts index 72f37e82..91b02e7d 100644 --- a/apps/cli/src/gate/clack-prompter.ts +++ b/apps/cli/src/gate/clack-prompter.ts @@ -65,14 +65,22 @@ 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. An input gate cannot be approved, so a deadline that would approve one fails the run + * instead: admission refuses that pairing now, but a run parked before the rule still carries it. + */ +function onTimeout(event: HumanGatePausedEvent): string { + if (event.gateType === 'input' && event.timeoutAction === 'approve') { + return 'the run fails on timeout'; + } + return `auto-${sanitizeInline(event.timeoutAction ?? 'reject')} on timeout`; +} + export function createClackGatePrompter(deps: ClackPromptDeps = defaultDeps): GatePrompter { return { prompt: async (event, budget, signal) => { diff --git a/apps/cli/src/gate/decision.test.ts b/apps/cli/src/gate/decision.test.ts index 133a636e..5f6960fe 100644 --- a/apps/cli/src/gate/decision.test.ts +++ b/apps/cli/src/gate/decision.test.ts @@ -110,19 +110,19 @@ describe('assertDecisionFitsGateType (2.6.K, ruled 2026-11-05)', () => { 'ok', ['approved', 'rejected'], 'input_provided', - 'approval gate — use --approve or --reject', + 'is an approval gate — use --approve or --reject', ], [ 'look', ['approved', 'rejected'], 'input_provided', - 'review gate — use --approve or --reject', + 'is a review gate — use --approve or --reject', ], [ 'ask', ['input_provided', 'rejected'], 'approved', - 'input gate — use --input <json> or --reject', + 'is an input gate — use --input <json> or --reject', ], ] as const) { for (const kind of fits) { diff --git a/apps/cli/src/gate/decision.ts b/apps/cli/src/gate/decision.ts index 8869d482..097af1db 100644 --- a/apps/cli/src/gate/decision.ts +++ b/apps/cli/src/gate/decision.ts @@ -127,6 +127,6 @@ export function assertDecisionFitsGateType( .join(' or '); throw new CliError( 'invalid_invocation', - `the gate at node ${sanitizeInline(node.id)} is an ${node.gate_type} gate — use ${flags}`, + `the gate at node ${sanitizeInline(node.id)} is ${node.gate_type === 'review' ? 'a' : 'an'} ${node.gate_type} gate — use ${flags}`, ); } From bc0341ddb88cea4c76def5d12e54af82da7106a1 Mon Sep 17 00:00:00 2001 From: Cemil ILIK <cemililik@outlook.com> Date: Thu, 5 Nov 2026 10:32:30 +0300 Subject: [PATCH 053/148] fix(cli): mask output fields before serializing, and say when a value was masked A value that is not a string was masked after JSON.stringify, which writes a newline as the two characters `\n`; a key right after it had no word boundary, so it printed in full on the run summary and in `relavium logs`. Each string, and each key, is now masked before serialization. That also stops one field's URL joining the next field's `@` into a false match. Outputs no longer get the diagnostics-only whole-text fallback, which blanked ordinary prose. The shared scrubber's own fail-closed residual still masks a value whole when its text forms a shape without whitespace, and a masked block now says so and points at the exact record. A final newline is no longer an empty row or a cut. D64 round 1 of MVP-1 1b, findings outputs-1, outputs-2, outputs-3 and outputs-4. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- apps/cli/src/render/run-outputs.test.ts | 82 +++++++++++++++++++- apps/cli/src/render/run-outputs.ts | 99 ++++++++++++++++++++----- 2 files changed, 162 insertions(+), 19 deletions(-) diff --git a/apps/cli/src/render/run-outputs.test.ts b/apps/cli/src/render/run-outputs.test.ts index 7bc9ef28..8588553b 100644 --- a/apps/cli/src/render/run-outputs.test.ts +++ b/apps/cli/src/render/run-outputs.test.ts @@ -104,6 +104,83 @@ describe('formatRunOutputs (MVP-A1)', () => { 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('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<string, unknown> = {}; + 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)}` }, ''); @@ -129,7 +206,10 @@ describe('the outputs reach every human run surface (MVP-A1)', () => { 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'); + 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 <runId>', () => { diff --git a/apps/cli/src/render/run-outputs.ts b/apps/cli/src/render/run-outputs.ts index 8d073bcc..19eea3f6 100644 --- a/apps/cli/src/render/run-outputs.ts +++ b/apps/cli/src/render/run-outputs.ts @@ -1,20 +1,30 @@ -import { sanitizeInline, sanitizeUntrusted } from './sanitize.js'; +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 <runId>`. `--json` is untouched — `run:completed` there already carries * `outputs` verbatim. * - * **A new terminal sink for untrusted content, so it gets the whole floor.** An output value is whatever the - * last node produced — model text, a tool result, an authored literal — so every value goes through - * {@link sanitizeUntrusted} (secret shapes masked on the ORIGINAL text, then terminal controls stripped) before - * it is laid out. 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. + * **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. 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 <runId> --json`). Every value line is indented under its key, so a multi-line value cannot - * forge a row at the summary's own indentation. + * (`relavium logs <runId> --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. */ @@ -38,6 +48,7 @@ export function formatRunOutputs( 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) { @@ -45,8 +56,10 @@ export function formatRunOutputs( truncated = true; continue; } - const text = sanitizeUntrusted(valueText(outputs[key])); - const shown = clip(text, Math.min(MAX_VALUE_CHARS, budget)); + const value = displayValue(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(value.text.replace(/\n+$/u, ''), Math.min(MAX_VALUE_CHARS, budget)); budget -= shown.chars; truncated ||= shown.truncated; const rows = shown.text.split('\n'); @@ -58,24 +71,74 @@ export function formatRunOutputs( for (const row of rows) lines.push(`${indent} ${row}`); if (shown.truncated) lines.push(`${indent} …`); } - if (truncated) { + if (truncated || masked) { const id = runId === undefined ? '<runId>' : sanitizeInline(runId); - lines.push(`${indent} (outputs shortened — the full record: \`relavium logs ${id} --json\`)`); + lines.push( + `${indent} (outputs ${noteCause(truncated, masked)} — the full record: \`relavium logs ${id} --json\`)`, + ); } return lines; } -/** A string as itself; anything else as compact JSON (a masked secret stays the masked object). */ -function valueText(value: unknown): string { - if (typeof value === 'string') return value; +/** 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'; +} + +/** + * One output 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). `masked` says whether the scrubber changed anything. + */ +function displayValue(value: unknown): { readonly text: string; readonly masked: boolean } { + let masked = false; + const scrub = (text: string): string => { + const out = scrubSecrets(text); + if (out !== text) masked = true; + return out; + }; + if (typeof value === 'string') return { text: stripTerminalControls(scrub(value)), masked }; try { - return JSON.stringify(value) ?? String(value); + const json = JSON.stringify(scrubLeaves(value, scrub, new Set())); + return { text: stripTerminalControls(json ?? String(value)), masked }; } catch { - // A value JSON cannot represent (a cycle, a BigInt) — said, not thrown: the summary must still print. - return '[unrepresentable value]'; + // 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`. 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, + ancestors: Set<object>, +): unknown { + if (typeof value === 'string') return scrub(value); + if (typeof value !== 'object' || value === null) return value; + if (hasToJson(value)) return scrubLeaves(value.toJSON(), scrub, 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, ancestors)); + const copy: Record<string, unknown> = {}; + for (const [key, item] of Object.entries(value)) { + copy[scrub(key)] = scrubLeaves(item, scrub, ancestors); + } + return copy; + } finally { + ancestors.delete(value); + } +} + +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, From f1c8416234d1bdb237c2ba0b604b2844b27de588 Mon Sep 17 00:00:00 2001 From: Cemil ILIK <cemililik@outlook.com> Date: Thu, 5 Nov 2026 10:32:30 +0300 Subject: [PATCH 054/148] fix(cli): find a session's own export from the file, and show /export in the view /export recognized its own earlier export from a value held in the REPL, so a `/models` reseat or a `chat-resume` lost it and the next export wrote `-2`, while a stale value could force-overwrite another workflow put at the old path. The export now recognizes its own file by the session marker inside it: a regular file, never a link, at one of its candidate names. It rewrites that file atomically, after the catalog scan has refused a workflows directory outside the project. The path, the run hint and a refusal now go through the notice channel. In the full-screen view a raw stderr line was overdrawn and then discarded with the alternate buffer. Agent ids, case-folded file names and Windows device names now count as taken ids. D64 round 1 of MVP-1 1b, findings export-1, export-2, export-3, export-4, export-5, export-7, export-8 and export-9. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- apps/cli/src/chat/export-project.test.ts | 125 ++++++++++++++++++++--- apps/cli/src/chat/export.ts | 105 +++++++++++++++---- apps/cli/src/commands/chat.test.ts | 14 ++- apps/cli/src/commands/chat.ts | 19 ++-- apps/cli/src/process/owned-storage.ts | 46 ++++++++- apps/cli/src/workflows/catalog.ts | 21 ++-- 6 files changed, 274 insertions(+), 56 deletions(-) diff --git a/apps/cli/src/chat/export-project.test.ts b/apps/cli/src/chat/export-project.test.ts index 81b1e2a7..273b0210 100644 --- a/apps/cli/src/chat/export-project.test.ts +++ b/apps/cli/src/chat/export-project.test.ts @@ -1,4 +1,15 @@ -import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { + existsSync, + lstatSync, + mkdirSync, + mkdtempSync, + readFileSync, + readdirSync, + renameSync, + rmSync, + symlinkSync, + writeFileSync, +} from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; @@ -9,6 +20,7 @@ import { type DbClient, type SessionStore, } from '@relavium/db'; +import { readSessionExportMarker } from '@relavium/core'; import { AgentSchema, type AgentSessionRecord, type SessionMessage } from '@relavium/shared'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; @@ -126,30 +138,115 @@ describe('exportSessionToProject (MVP-A3)', () => { ).toBe('my-session-4'); }); - it('rewrites this session’s own earlier export in place, and only this session’s', () => { + it('rewrites this session’s own earlier export in place — recognized from the file, not from memory', () => { const first = exportS1(); - writeFileSync(first.path, 'edited by hand'); - const again = exportSessionToProject({ - store, - sessionId: 's1', - cwd, - projectConfigDir: join(cwd, '.relavium'), - previous: first, - }); + // 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(idIn(again.path)?.[1]).toBe('my-session'); - // Another session handed s1's result does not get to overwrite it. + 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'), - previous: first, }); expect(other.workflowId).toBe('my-session-2'); - expect(idIn(first.path)?.[1]).toBe('my-session'); + 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; diff --git a/apps/cli/src/chat/export.ts b/apps/cli/src/chat/export.ts index 70e26b51..6e1497fb 100644 --- a/apps/cli/src/chat/export.ts +++ b/apps/cli/src/chat/export.ts @@ -1,12 +1,20 @@ +import { closeSync, constants, fstatSync, lstatSync, openSync, readFileSync } from 'node:fs'; import { dirname, join, resolve } from 'node:path'; -import { serializeWorkflow, sessionToWorkflow, type WorkflowDefinition } from '@relavium/core'; +import { + MAX_SOURCE_CHARS, + readSessionExportMarker, + serializeWorkflow, + sessionToWorkflow, + type WorkflowDefinition, +} from '@relavium/core'; import type { SessionStore } from '@relavium/db'; import type { AgentSessionRecord, SessionMessage } from '@relavium/shared'; import { CliError } from '../process/errors.js'; import { mkdirForStorage, + replaceFileForStorage, writeFileForStorage, storageBoundaryError, } from '../process/owned-storage.js'; @@ -87,13 +95,22 @@ export interface ExportToProjectOptions { readonly cwd: string; /** The discovered `.relavium/` directory; absent ⇒ `<cwd>/.relavium`, created by the write. */ readonly projectConfigDir: string | undefined; - /** - * This session's own earlier export from the same REPL. When it names this session, the export rewrites that - * file in place and keeps its id, so a second `/export` updates the scaffold rather than adding `-2`. - */ - readonly previous?: ExportResult; } +/** + * 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<string> = 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; @@ -111,32 +128,48 @@ const WORKFLOW_ID_GRAMMAR = /^[a-z0-9]+(?:-[a-z0-9]+)*$/u; * It never overwrites another workflow. The id is the first of `<slug>`, `<slug>-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 `previous`: this session's own export from this REPL. + * 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 isOwnExport}). 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. */ export function exportSessionToProject(opts: ExportToProjectOptions): ExportResult { const loaded = loadSession(opts.store, opts.sessionId); const scaffold = sessionToWorkflow(loaded.session, loaded.messages); - const previous = opts.previous?.record.id === opts.sessionId ? opts.previous : undefined; - if (previous !== undefined) { - const definition = withWorkflowId(scaffold, previous.workflowId); - const label = sourceLabel(opts.cwd, previous.path); - writeScaffold(previous.path, serializeWorkflow(definition), true, label); - return exportResult(previous.path, definition, loaded); - } 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<string>() : takenWorkflowIds(opts.projectConfigDir, opts.cwd); - const base = scaffold.workflow.id; - for (let attempt = 1; attempt <= MAX_EXPORT_ID_ATTEMPTS; attempt += 1) { - const id = attempt === 1 ? base : `${base}-${String(attempt)}`; - if (taken.has(id)) continue; - // The id becomes a file name: refuse anything that is not a kebab id, whatever the scaffold produced. - if (!WORKFLOW_ID_GRAMMAR.test(id)) { - throw new CliError('cli_internal', 'the export produced an unusable workflow id'); + const own = candidates.find((id) => + isOwnExport(join(dir, `${id}.relavium.yaml`), opts.sessionId), + ); + if (own !== undefined) { + const path = join(dir, `${own}.relavium.yaml`); + const definition = withWorkflowId(scaffold, own); + try { + replaceFileForStorage(path, serializeWorkflow(definition)); + } catch (err) { + 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); @@ -150,6 +183,36 @@ export function exportSessionToProject(opts: ExportToProjectOptions): ExportResu ); } +/** + * Whether `path` 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 parser's size bound, whose + * `metadata.relaviumExport` names `sessionId`. Any doubt (missing, unreadable, swapped between checks, oversized, + * undecodable) is `false`, which only ever means "do not overwrite". + */ +function isOwnExport(path: string, sessionId: string): boolean { + let fd: number | undefined; + try { + const linked = lstatSync(path, { bigint: true }); + if (!linked.isFile() || linked.size > BigInt(MAX_SOURCE_CHARS * 4)) return false; + // `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 false; + if (opened.size > BigInt(MAX_SOURCE_CHARS * 4)) return false; + return readSessionExportMarker(readFileSync(fd, 'utf8')) === sessionId; + } catch { + return false; + } finally { + if (fd !== undefined) { + try { + closeSync(fd); + } catch { + // a close fault changes nothing about the answer + } + } + } +} + function loadSession( store: SessionStore, sessionId: string, diff --git a/apps/cli/src/commands/chat.test.ts b/apps/cli/src/commands/chat.test.ts index 895d226c..aa3fcae7 100644 --- a/apps/cli/src/commands/chat.test.ts +++ b/apps/cli/src/commands/chat.test.ts @@ -1219,15 +1219,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(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'); diff --git a/apps/cli/src/commands/chat.ts b/apps/cli/src/commands/chat.ts index 6bbab422..3851444d 100644 --- a/apps/cli/src/commands/chat.ts +++ b/apps/cli/src/commands/chat.ts @@ -29,11 +29,7 @@ import { type Memory, type ReasoningEffort, } from '@relavium/shared'; -import { - exportSessionToProject, - SESSION_EXPORT_REVIEW_NOTICE, - type ExportResult, -} 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'; @@ -1398,7 +1394,6 @@ export function createChatLineHandler( let stop = false; let cancelled = false; // This session's last `/export` (MVP-A3): a second `/export` rewrites that file in place instead of adding `-2`. - let lastExport: ExportResult | undefined; // Set by `/clear` (ADR-0062 §7): the loop stopped to SWAP the session, not to end the REPL — `stopReason` reports // it so the surface (the standalone re-drive loop, or the Home) rebuilds a fresh session instead of exiting. let clearRequested = false; @@ -1482,9 +1477,7 @@ export function createChatLineHandler( sessionId: built.sessionId, cwd: deps.global.cwd, projectConfigDir, - ...(lastExport === undefined ? {} : { previous: lastExport }), }); - lastExport = result; emitOutput(SESSION_EXPORT_REVIEW_NOTICE.trimEnd()); if (noticesStopped()) return; if (deps.global.json) { @@ -1494,9 +1487,11 @@ export function createChatLineHandler( // driveJson serializer (subscribed) writes it to stdout, keeping the stream pure + complete. built.emitSessionEvent({ type: 'session:exported', workflowPath: result.path }); } else { - // The cwd-relative path and the command that runs it — the export offers the next step. - deps.io.writeErr( - `exported session to ${sourceLabel(deps.global.cwd, result.path)} — run it with \`relavium run ${result.workflowId}\`\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) { @@ -1508,7 +1503,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. diff --git a/apps/cli/src/process/owned-storage.ts b/apps/cli/src/process/owned-storage.ts index 327b6311..d2eab444 100644 --- a/apps/cli/src/process/owned-storage.ts +++ b/apps/cli/src/process/owned-storage.ts @@ -1,5 +1,16 @@ -import { chmodSync, mkdirSync, writeFileSync } from 'node:fs'; +import { randomUUID } from 'node:crypto'; +import { + chmodSync, + closeSync, + fsyncSync, + mkdirSync, + openSync, + 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 +46,39 @@ export function writeFileForStorage(path: string, content: string, force: boolea } } +/** + * 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. + */ +export function replaceFileForStorage(path: string, content: string): void { + const temp = join(dirname(path), `.${basename(path)}.${randomUUID()}.tmp`); + let fd: number | undefined; + try { + fd = openSync(temp, 'wx'); + writeFileSync(fd, content, 'utf8'); + fsyncSync(fd); + closeSync(fd); + fd = undefined; + 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); + } +} + /** Concrete native acquisition for the editor's private transcript directory. */ export async function mkdtempForStorage(prefix: string): Promise<string> { try { diff --git a/apps/cli/src/workflows/catalog.ts b/apps/cli/src/workflows/catalog.ts index d9a92123..448ee2d2 100644 --- a/apps/cli/src/workflows/catalog.ts +++ b/apps/cli/src/workflows/catalog.ts @@ -107,16 +107,23 @@ export function discoverCatalog(opts: { } /** - * Every id a NEW workflow file must not take in `<projectConfigDir>/workflows/` (MVP-A3): each entry's slug — a - * valid file's `workflow.id`, an invalid one's file stem — and each file's stem. The stem counts on its own because - * `relavium run <id>` resolves a bare id by file name first, so a new `<id>.relavium.yaml` would shadow a file whose - * name is `<id>.yaml` but whose `workflow.id` differs. Throws the catalog's own error for an unreadable directory. + * Every id a NEW workflow file must not take in `<projectConfigDir>/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 <id>` + * resolves a bare id by file name first, so a new `<id>.relavium.yaml` would shadow a file whose name is `<id>.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<string> { const taken = new Set<string>(); - for (const entry of discoverCatalog({ projectConfigDir, cwd, kind: 'workflows' })) { - taken.add(entry.slug); - taken.add(fileStem(basename(entry.path))); + 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; } From 2a5dc99b707ef79cd2dc29abc3fdcc37d7a85463 Mon Sep 17 00:00:00 2001 From: Cemil ILIK <cemililik@outlook.com> Date: Thu, 5 Nov 2026 10:32:42 +0300 Subject: [PATCH 055/148] fix(cli): open an interactive resume whose provider lost its key The key pre-flight refused `chat-resume` before the REPL existed. That blocked the `/models` reseat, the one way to move a conversation to a provider that does have a key. An interactive resume now opens. The view names both remedies on open and again in place of each message, which is neither sent nor recorded. A key stored meanwhile lets the next message through. A non-interactive resume, a reseat, `/clear` and the Home still refuse a keyless provider when they build. One session build now asks the key store at most once per provider. The default-model choice and the pre-flight share that answer, which removes the extra keychain read on the zero-config path. A test pins the pre-flight before MCP consent and spawn. D64 round 1 of MVP-1 1b, findings keys-1, keys-3 and keys-4. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- apps/cli/src/chat/agent-source.ts | 7 +- apps/cli/src/chat/session-host.test.ts | 60 +++++ apps/cli/src/chat/session-host.ts | 49 +++- .../commands/chat-resume-missing-key.test.ts | 238 ++++++++++++++++++ apps/cli/src/commands/chat.ts | 52 +++- 5 files changed, 390 insertions(+), 16 deletions(-) create mode 100644 apps/cli/src/commands/chat-resume-missing-key.test.ts diff --git a/apps/cli/src/chat/agent-source.ts b/apps/cli/src/chat/agent-source.ts index 101663f6..5beb3e87 100644 --- a/apps/cli/src/chat/agent-source.ts +++ b/apps/cli/src/chat/agent-source.ts @@ -28,9 +28,10 @@ export interface ResolveChatAgentOptions { /** * The default agent's model and provider (MVP-A4). * - * A configured `defaultModel` wins, with its persisted provider (ADR-0059). Otherwise the provider is the - * configured `defaultProvider`, else the first provider in {@link KNOWN_PROVIDER_IDS} order that has a key, else - * Anthropic. Anthropic keeps the long-standing zero-config {@link DEFAULT_CHAT_MODEL}; any other provider gets the + * 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. */ diff --git a/apps/cli/src/chat/session-host.test.ts b/apps/cli/src/chat/session-host.test.ts index 65e6f48f..603203c8 100644 --- a/apps/cli/src/chat/session-host.test.ts +++ b/apps/cli/src/chat/session-host.test.ts @@ -522,6 +522,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; @@ -2315,5 +2332,48 @@ describe('the default model follows the stored key (MVP-A4)', () => { 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 e689e1a0..65eef69d 100644 --- a/apps/cli/src/chat/session-host.ts +++ b/apps/cli/src/chat/session-host.ts @@ -823,7 +823,7 @@ function buildSessionRuntime( */ function bindChatAgent( opts: BuildChatSessionOptions, - providers: ProviderResolver, + hasKey: (id: ProviderId) => boolean, ): ResolvedChatAgent { if (opts.agent !== undefined) return { agent: opts.agent, artifact: undefined }; return resolveChatAgentSource(opts.agentRef, { @@ -831,7 +831,7 @@ function bindChatAgent( 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: (id) => providerHasKey(providers, id), + 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 @@ -852,13 +852,27 @@ function bindChatAgent( * "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 can never work, while a keyless fallback entry fails only if it is reached. + * 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, providers: ProviderResolver): void { - if (providerHasKey(providers, agent.provider)) return; +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<ProviderId, boolean>(); + 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`. * @@ -960,8 +974,11 @@ export async function buildChatSession(opts: BuildChatSessionOptions): Promise<B const sessionId = opts.uuid(); // One resolver for the default-model choice, the key pre-flight and the session runtime. const providers = opts.providers ?? createProviderResolver(); - const { agent, artifact } = bindChatAgent(opts, providers); - assertProviderKey(agent, providers); + // The default-model choice and the pre-flight ask the same question of the same provider; one answer per + // provider per build keeps them agreeing by construction and keeps the build at one keychain read per provider. + const hasKey = memoizedHasKey(providers); + const { agent, artifact } = bindChatAgent(opts, hasKey); + assertProviderKey(agent, hasKey); const mcpArtifact = opts.mcpArtifact ?? artifact; const context: SessionContext = { workingDir: opts.fsPolicy === undefined ? opts.cwd : fsPolicyWorkspace(opts.fsPolicy), @@ -1141,6 +1158,11 @@ 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< @@ -1150,6 +1172,13 @@ export interface BuildResumedChatSessionOptions extends Pick< /** 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 @@ -1237,7 +1266,10 @@ export async function buildResumedChatSession( ); } const providers = opts.providers ?? createProviderResolver(); - assertProviderKey(agent, providers); + 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. @@ -1324,6 +1356,7 @@ export async function buildResumedChatSession( emitSessionEvent: emit, resumeState, nextSequenceNumber, + ...(missingKeyProvider === undefined ? {} : { missingKeyProvider }), mcpSkipped: mcp?.skipped ?? [], ...(mcp === undefined ? {} 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 00000000..7974ca44 --- /dev/null +++ b/apps/cli/src/commands/chat-resume-missing-key.test.ts @@ -0,0 +1,238 @@ +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<string, string | undefined>(); + + 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<ReturnType<typeof createSessionStore>> => { + 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 the view showed. */ + const resumeInteractively = async ( + store: ReturnType<typeof createSessionStore>, + providers: ProviderResolver, + lines: readonly string[], + between?: () => void, + ): Promise<{ readonly code: number; readonly notices: readonly string[] }> => { + const { io } = captureIo(); + let id = 0; + let notices: 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; + } + notices = ctx.store + .getSnapshot() + .state.transcript.filter((entry) => entry.role === 'notice') + .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 }; + }; + + 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 } = await resumeInteractively(store, providers, ['hello again', '/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(2); // once on open, once for the refused message + 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 + }); + + 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.ts b/apps/cli/src/commands/chat.ts index 3851444d..c3b4e32a 100644 --- a/apps/cli/src/commands/chat.ts +++ b/apps/cli/src/commands/chat.ts @@ -93,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'; @@ -905,6 +910,9 @@ async function chatResumeCommandBody( let consentGate: NonNullable<BuildChatSessionOptions['consentGate']>; 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; @@ -990,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, @@ -1008,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. @@ -1127,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, @@ -1140,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, @@ -1174,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; @@ -1184,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: a refusal text keeps the message from being sent, recorded or persisted, 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, @@ -1382,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 { @@ -1799,6 +1836,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); From b5c0d66ba7c998fa3e3266f0083f08f118be00b3 Mon Sep 17 00:00:00 2001 From: Cemil ILIK <cemililik@outlook.com> Date: Thu, 5 Nov 2026 10:32:42 +0300 Subject: [PATCH 056/148] fix(cli): name the Home among the commands that recover a held terminal `relavium status` listed `run`, `gate` and `budget resume` as the commands that retry a held terminal. Opening the Home has reconciled since the liveness wiring, so the hint names it too. The held-claim test's comment now matches what stops the second terminal. D64 round 1 of MVP-1 1b, findings liveness-7 and liveness-3. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- apps/cli/src/commands/status.ts | 3 ++- apps/cli/src/history/reconcile.test.ts | 5 +++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/apps/cli/src/commands/status.ts b/apps/cli/src/commands/status.ts index 7d818a9f..31865124 100644 --- a/apps/cli/src/commands/status.ts +++ b/apps/cli/src/commands/status.ts @@ -258,7 +258,8 @@ function renderRun(io: CliIo, status: ActiveRunStatus): void { // 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) { diff --git a/apps/cli/src/history/reconcile.test.ts b/apps/cli/src/history/reconcile.test.ts index 71d473d0..8bb754e9 100644 --- a/apps/cli/src/history/reconcile.test.ts +++ b/apps/cli/src/history/reconcile.test.ts @@ -208,8 +208,9 @@ describe('reconcileInterruptedRuns (2.6.G liveness, #229)', () => { it('…and when the reconciler still holds its claim, the run is refused and still leaves one terminal', async () => { // The other order inside the same window: the reconciler's 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 and `UNIQUE(run_id, seq)` refuses it before the - // store's terminal refusal is reached. Pinned for the outcome, which must not depend on the order. + // 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 = { From 60bc9727ea5a370f10e63bd7879bd7ecf606c359 Mon Sep 17 00:00:00 2001 From: Cemil ILIK <cemililik@outlook.com> Date: Thu, 5 Nov 2026 10:32:42 +0300 Subject: [PATCH 057/148] docs(repo): record the MVP-1 1b round 1 fixes and its deferred lows - workflow-yaml-spec: an input gate refuses `timeout_action: approve`. - chat-session: the key pre-flight's behaviour on each surface, the default model rule without the lone default_provider promise, and /export's file-based own-export rule, its messages and where it lands under the home directory. - commands: how output values are masked and noted, when a command reconciles, how a drain failure leaves a run unrepaired, and what a script should read after exit 5. - home: a crashed run whose dead process's lease is still live shows `running` until it expires. - ADR-0079: a dated note that the run:started-to-acquire window is now reachable from other processes' reconcile, bounded by the store's exactly-one-terminal refusal. - deferred-tasks: the drain entry narrowed, and five round 1 lows recorded. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- ...s-run-ownership-lease-and-fencing-token.md | 7 +++ docs/reference/cli/chat-session.md | 12 +++-- docs/reference/cli/commands.md | 6 +-- docs/reference/cli/home.md | 2 +- .../reference/contracts/workflow-yaml-spec.md | 6 ++- docs/roadmap/deferred-tasks.md | 45 +++++++++++++++++-- 6 files changed, 65 insertions(+), 13 deletions(-) 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 13840fe3..ae845a0e 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,13 @@ 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)): +> the run's own terminal is refused, it exits durability-uncertain, and its log keeps the reconciler's single +> terminal. `run:started` stays unfenced for the reason above. + ### 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/reference/cli/chat-session.md b/docs/reference/cli/chat-session.md index 992506fd..c83c81a8 100644 --- a/docs/reference/cli/chat-session.md +++ b/docs/reference/cli/chat-session.md @@ -51,8 +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 `[chat].default_model` or `default_provider` at any layer, the default agent's provider is the first of `anthropic`, `openai`, `gemini`, `deepseek` that has a key (keychain or `RELAVIUM_<PROVIDER>_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` without a model gets that provider's starter model too. The check is a yes/no question to the key resolver and never reads a key into the session. -- **a provider with no key refuses before the session starts (MVP-A4).** Every session build (`chat`, `chat-resume`, `agent run`, the Home, `/clear` and a `/models` reseat) asks whether the bound agent's provider has a key before it spawns an MCP server or sends anything. If it has none, the build fails with exit `2` and the same message `provider set-key` documents: `no API key for provider '<id>' — store one with \`relavium provider set-key <id>\` or set RELAVIUM_<ID>_API_KEY.` 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 can never work, while a keyless `fallback_chain` entry fails only if it is reached. +- **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_<PROVIDER>_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 '<id>' — store one with \`relavium provider set-key <id>\` or set RELAVIUM_<ID>_API_KEY.` What happens next depends on the surface: + - `chat`, `agent run` and a non-interactive `chat-resume` refuse with exit `2`. + - An **interactive** `chat-resume` opens anyway, because `/models` can move the conversation to a provider that has a key. 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`) @@ -103,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 <sessionId>` (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 where workflows live — `<project>/.relavium/workflows/<id>.relavium.yaml`, or `<cwd>/.relavium/workflows/` when there is no project — with the session title's slug as the id (written into the file as `workflow.id`), and prints the path and the command that runs it (`run it with \`relavium run <id>\``) (MVP-A3). It never overwrites another workflow: the id is the first of `<slug>`, `<slug>-2`, … that no catalog file or `workflow.id` already holds, and a second `/export` in the same session rewrites that session's own file in place. 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 — `<project>/.relavium/workflows/<id>.relavium.yaml`, where `<project>/.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 `<cwd>/.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 <id>\``) (MVP-A3) — in the transcript on the interactive chat and Home, on stderr on the plain and `--json` drivers, like a refusal (`export failed: …`). It never overwrites another workflow: the id is the first of `<slug>`, `<slug>-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. 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. | diff --git a/docs/reference/cli/commands.md b/docs/reference/cli/commands.md index 3dab4d5c..e0fc7f72 100644 --- a/docs/reference/cli/commands.md +++ b/docs/reference/cli/commands.md @@ -200,7 +200,7 @@ 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 <digest>` 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, so it goes through the shared sanitize floor (secret shapes masked, terminal controls stripped) and is bounded: 40 lines and 4,000 characters per value, 12,000 characters per run. Anything cut is marked `…` and named: `(outputs shortened — the full record: \`relavium logs <runId> --json\`)`. A failed or cancelled run shows no outputs block, and `--json` is unchanged. +- **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 secret shapes in the original text, 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. The scrubber is fail-closed, so a value whose text forms a secret shape once whitespace is removed — a bare `https://host` and a later `@`, for one — is masked whole. 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 <runId> --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_<PROVIDER>_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 <digest>` (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). @@ -453,7 +453,7 @@ No per-session attribution or transcript is loaded for this listing. 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 `<sessionId>.relavium.yaml` in cwd by default (the file name is keyed on the unique session id, so two sessions never collide); `--out <path>` 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. Live `/export` uses the rendered transcript in interactive chat and Home, and stderr on plain/JSON drivers, for the review notice, the path and run hint, and a refusal alike. 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. @@ -639,7 +639,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`**, or the next time the bare-invocation Home opens. Each of these **reconciles** before it does anything else (2.6.G liveness): it drains the outbox, then settles every run a dead process left `running` as `run:failed{internal}`. It leaves alone a run parked at a gate and 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). `chat` and `agent run` run on `AgentSession` and do not reconcile. One reachable `5` comes from that reconcile: in the instant between a run's durable `run:started` and its lease acquire, a concurrent start can settle the run. 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 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{internal}`. It leaves alone a run parked at a gate and 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). `chat` and `agent run` run on `AgentSession` and do not reconcile. One reachable `5` comes from that reconcile: in the instant between a run's durable `run:started` and its lease acquire, a concurrent start can settle the run. 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, so a run that completed is never relabelled failed. A script seeing `5` should read the durable outcome rather than assume one: `relavium status <runId>` shows whether the held terminal has landed, or, in the race 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 6d154ffb..84b03cd4 100644 --- a/docs/reference/cli/home.md +++ b/docs/reference/cli/home.md @@ -50,7 +50,7 @@ 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 `run:failed{internal}` and shows under *Attention required*, and a terminal held in the outbox is written. It waits for this after the signal and exit nets are registered and before it mounts, so the first frame never shows a crashed run as `running`; it is best-effort and never keeps the Home from opening. A run parked at a gate and a run another process holds a live lease on are left alone. The rules are the same as for `run` and `gate` ([exit code `5`](commands.md#exit-codes)). +Before its first read the Home **reconciles** (2.6.G liveness): a run a dead process left `running` is settled `run:failed{internal}` and shows under *Attention required*, and a terminal held in the outbox is written. It 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. A run parked at a gate and a run another process holds a live lease on are left alone. The rules are the same as for `run` and `gate` ([exit code `5`](commands.md#exit-codes)). ```text relavium ← branded header (bold) diff --git a/docs/reference/contracts/workflow-yaml-spec.md b/docs/reference/contracts/workflow-yaml-spec.md index e9b92c6b..b3d7cf76 100644 --- a/docs/reference/contracts/workflow-yaml-spec.md +++ b/docs/reference/contracts/workflow-yaml-spec.md @@ -327,11 +327,13 @@ Each node has an `id` (kebab-case, unique within the workflow) and a `type`. The **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 shape its type does not define. +records a decision shape its type does not define. 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/roadmap/deferred-tasks.md b/docs/roadmap/deferred-tasks.md index bb0ea35f..e03ca670 100644 --- a/docs/roadmap/deferred-tasks.md +++ b/docs/roadmap/deferred-tasks.md @@ -1472,6 +1472,43 @@ MVP pulled without an annotation); both were fixed on 2026-11-04. 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 round 1 of MVP-1's security increment at `5f2a0eb8` found nine medium findings, all fixed before the next +round, and the lows below that were not fixed with them. **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)* + ## Wave 3 NI — recorded low findings (2026-10-25) Round 1 of the native-identity increment at `fc5af701` accepted it with these lows @@ -2185,10 +2222,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 From 499d1fdd42a8207815282f48b2e32365e0e12c05 Mon Sep 17 00:00:00 2001 From: Cemil ILIK <cemililik@outlook.com> Date: Thu, 5 Nov 2026 12:37:58 +0300 Subject: [PATCH 058/148] fix(cli): read an output field with its name, so a header-named key is masked Masking each leaf alone (bc0341dd) lost the shared scrubber's header rule, which needs the name, the `:` and the value in one text: an opaque value under `x-api-key`, `api-key` or `x-goog-api-key` printed in full on the run summary and in `relavium logs`, where round 1's head masked it. Each object member with a primitive value, and each top-level output, is now also read as its one-member JSON text; when that masks, the value is masked whole. One member never joins its neighbour, so round 1's cross-field fix holds. A marker oracle over 264 shapes and placements, validated on 60bc9727 (it flags the 35 header cases there), finds nothing round 1's head masked that this one shows, except a query name split by whitespace, recorded as a low. The copy keeps an own `__proto__` member rather than dropping it unsaid, and the final-newline trim is a loop, not a regex that went quadratic on a long interior newline run. D64 round 2 of MVP-1 1b, findings outputs-1 (medium), outputs-2, outputs-4. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- apps/cli/src/render/run-outputs.test.ts | 47 +++++++++++++++++ apps/cli/src/render/run-outputs.ts | 70 ++++++++++++++++++++----- 2 files changed, 105 insertions(+), 12 deletions(-) diff --git a/apps/cli/src/render/run-outputs.test.ts b/apps/cli/src/render/run-outputs.test.ts index 8588553b..806ec830 100644 --- a/apps/cli/src/render/run-outputs.test.ts +++ b/apps/cli/src/render/run-outputs.test.ts @@ -121,6 +121,53 @@ describe('formatRunOutputs (MVP-A1)', () => { 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('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. diff --git a/apps/cli/src/render/run-outputs.ts b/apps/cli/src/render/run-outputs.ts index 19eea3f6..79a685a3 100644 --- a/apps/cli/src/render/run-outputs.ts +++ b/apps/cli/src/render/run-outputs.ts @@ -12,8 +12,12 @@ import { sanitizeInline, stripTerminalControls } from './sanitize.js'; * 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. 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. + * 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 object member with a primitive value is also read as the one-member JSON text `{"<key>":<value>}`, the + * form the scrubber reads in a serialized object; when that text masks, the member's value is masked whole. One + * member never joins its neighbour. 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 @@ -34,6 +38,9 @@ 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 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 <runId> --json`. @@ -56,10 +63,10 @@ export function formatRunOutputs( truncated = true; continue; } - const value = displayValue(outputs[key]); + 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(value.text.replace(/\n+$/u, ''), Math.min(MAX_VALUE_CHARS, budget)); + const shown = clip(withoutFinalNewlines(value.text), Math.min(MAX_VALUE_CHARS, budget)); budget -= shown.chars; truncated ||= shown.truncated; const rows = shown.text.split('\n'); @@ -80,6 +87,16 @@ export function formatRunOutputs( 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.charCodeAt(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'; @@ -87,20 +104,27 @@ function noteCause(truncated: boolean, masked: boolean): string { } /** - * One output 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). `masked` says whether the scrubber changed anything. + * 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 is also read with its output key, as a nested member is. + * `masked` says whether the scrubber changed anything. */ -function displayValue(value: unknown): { readonly text: string; readonly masked: boolean } { +function displayValue( + key: string, + value: unknown, +): { readonly text: string; readonly masked: boolean } { let masked = false; const scrub = (text: string): string => { const out = scrubSecrets(text); if (out !== text) masked = true; return out; }; - if (typeof value === 'string') return { text: stripTerminalControls(scrub(value)), masked }; + if (typeof value === 'string') { + const shown = maskedAsMember(key, scrub(value), scrub); + return { text: stripTerminalControls(typeof shown === 'string' ? shown : MEMBER_MASK), masked }; + } try { - const json = JSON.stringify(scrubLeaves(value, scrub, new Set())); + const json = JSON.stringify(maskedAsMember(key, scrubLeaves(value, scrub, new Set()), scrub)); 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 @@ -110,7 +134,8 @@ function displayValue(value: unknown): { readonly text: string; readonly masked: } /** - * A copy of `value` with every string, and every object key, passed through `scrub`. It follows `JSON.stringify`'s + * A copy of `value` with every string, and every object key, passed through `scrub`, and every object member with a + * primitive value read once more as its one-member JSON text (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( @@ -127,7 +152,16 @@ function scrubLeaves( if (Array.isArray(value)) return value.map((item) => scrubLeaves(item, scrub, ancestors)); const copy: Record<string, unknown> = {}; for (const [key, item] of Object.entries(value)) { - copy[scrub(key)] = scrubLeaves(item, scrub, ancestors); + const member = scrubLeaves(item, scrub, 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, scrub), + enumerable: true, + configurable: true, + writable: true, + }); } return copy; } finally { @@ -135,6 +169,18 @@ function scrubLeaves( } } +/** + * `member` (already masked leaf by leaf), or the scrubber's mask in its place when the one-member JSON text + * `{"<key>":<member>}` masks: the key names a credential the value alone does not show. Only a primitive value is + * read this way; an object or array value is read member by member by the walk itself. + */ +function maskedAsMember(key: string, member: unknown, scrub: (text: string) => string): unknown { + if (typeof member === 'object' && member !== null) return member; + const view = JSON.stringify({ [key]: member }); + if (view === undefined || scrub(view) === view) return member; + return MEMBER_MASK; +} + function hasToJson(value: object): value is { toJSON: () => unknown } { return 'toJSON' in value && typeof value.toJSON === 'function'; } From 8dc9f128fc2eddc2711d66a581f6c80c0c387fbf Mon Sep 17 00:00:00 2001 From: Cemil ILIK <cemililik@outlook.com> Date: Thu, 5 Nov 2026 12:37:58 +0300 Subject: [PATCH 059/148] fix(cli): tell an unreadable terminal outbox from an empty one The file outbox read an existing but unreadable file as holding nothing, so the engine's hold-every-run branch for an unreadable outbox (19651cfa) never fired from the CLI: a start or Home open could write run:failed over a run whose held run:completed it could not see, and the next start then dropped that terminal. `list` now rejects, with fixed text, on any read failure but an absent file; `put` and `remove` still never throw, and `status` already warns on a rejection. D64 round 2 of MVP-1 1b, finding liveness-1. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- apps/cli/src/engine/terminal-outbox.test.ts | 7 +++-- apps/cli/src/engine/terminal-outbox.ts | 35 +++++++++++++++++---- apps/cli/src/history/reconcile.test.ts | 31 +++++++++++++++++- 3 files changed, 64 insertions(+), 9 deletions(-) diff --git a/apps/cli/src/engine/terminal-outbox.test.ts b/apps/cli/src/engine/terminal-outbox.test.ts index 3dff3772..cd605b22 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 26f103c1..ac0c8a58 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<string, RunEvent> { const held = new Map<string, RunEvent>(); - 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/history/reconcile.test.ts b/apps/cli/src/history/reconcile.test.ts index 8bb754e9..ff05bc17 100644 --- a/apps/cli/src/history/reconcile.test.ts +++ b/apps/cli/src/history/reconcile.test.ts @@ -1,5 +1,5 @@ import { randomUUID } from 'node:crypto'; -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; @@ -155,6 +155,35 @@ describe('reconcileInterruptedRuns (2.6.G liveness, #229)', () => { 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 exactly one durable terminal', async () => { // `run:started` is durable a few microtask turns before the run acquires its lease. A reconcile in that // window settles the run and releases its claim, so the run's own acquire then SUCCEEDS. Before the From b08fd6a4c46a6815c877f9308717d67fad2ae7e8 Mon Sep 17 00:00:00 2001 From: Cemil ILIK <cemililik@outlook.com> Date: Thu, 5 Nov 2026 12:37:58 +0300 Subject: [PATCH 060/148] fix(cli): never call a run already recorded failed "held" In the reconcile race that exit 5 documents, the reconciler's run:failed is durable and the run's own refused terminal is in the outbox too. `status` reported that run as finished with its terminal not yet durable, though the next drain drops the entry rather than landing it. `terminalHeld` is now set only for a run that is still non-terminal. D64 round 2 of MVP-1 1b, findings liveness-2 and docs-3. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- apps/cli/src/commands/status.test.ts | 38 +++++++++++++++++++++++++--- apps/cli/src/commands/status.ts | 25 +++++++++++++----- 2 files changed, 52 insertions(+), 11 deletions(-) diff --git a/apps/cli/src/commands/status.test.ts b/apps/cli/src/commands/status.test.ts index cbca3a0a..88d6e68a 100644 --- a/apps/cli/src/commands/status.test.ts +++ b/apps/cli/src/commands/status.test.ts @@ -105,10 +105,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 <runId>` 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 <runId>` — 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({ @@ -131,6 +130,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) => { diff --git a/apps/cli/src/commands/status.ts b/apps/cli/src/commands/status.ts index 31865124..8bcabebd 100644 --- a/apps/cli/src/commands/status.ts +++ b/apps/cli/src/commands/status.ts @@ -1,6 +1,6 @@ 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'; @@ -66,6 +66,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<RunStatus> = new Set<RunStatus>([ + 'completed', + 'failed', + 'cancelled', +]); + const defaultReadTerminalOutbox = (homeDir: string): Promise<readonly RunEvent[]> => createFileTerminalOutbox(terminalOutboxPath(homeDir)).list(); @@ -106,11 +113,15 @@ export async function statusCommand( // 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 <runId>` 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 <runId>`, 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. @@ -139,7 +150,7 @@ export async function statusCommand( 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. From 08723f08a6d3ae3a69cc9ad563f58f17ed0abe8e Mon Sep 17 00:00:00 2001 From: Cemil ILIK <cemililik@outlook.com> Date: Thu, 5 Nov 2026 12:38:14 +0300 Subject: [PATCH 061/148] fix(cli): keep an own export's permission bits, and refuse where it cannot land - The atomic rewrite of a session's own export created its temp with the umask's default mode, so a scaffold the user had narrowed to 0600 came back 0644, and a read-only one was replaced. The rewrite now refuses a file it cannot write, as the in-place write it replaced did, and gives the new file the old one's permission bits. - A dangling or looping link where the workflows directory belongs is now an `export failed:` refusal, not an internal fault that ended the chat. - The own-export check stops at the workflow parser's character bound before it decodes any YAML; an export that large could not be run anyway. - replaceFileForStorage gets real-filesystem tests: a link at the path is replaced, not written through; a failed rename leaves no temp; the mode is exact; a read-only file is refused. D64 round 2 of MVP-1 1b, findings export-2, export-3, export-4, export-5. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- apps/cli/src/chat/export-project.test.ts | 41 +++++++- apps/cli/src/chat/export.ts | 45 +++++---- .../src/process/owned-storage-replace.test.ts | 97 +++++++++++++++++++ apps/cli/src/process/owned-storage.ts | 13 ++- 4 files changed, 173 insertions(+), 23 deletions(-) create mode 100644 apps/cli/src/process/owned-storage-replace.test.ts diff --git a/apps/cli/src/chat/export-project.test.ts b/apps/cli/src/chat/export-project.test.ts index 273b0210..82e6f978 100644 --- a/apps/cli/src/chat/export-project.test.ts +++ b/apps/cli/src/chat/export-project.test.ts @@ -1,4 +1,5 @@ import { + chmodSync, existsSync, lstatSync, mkdirSync, @@ -7,6 +8,7 @@ import { readdirSync, renameSync, rmSync, + statSync, symlinkSync, writeFileSync, } from 'node:fs'; @@ -20,7 +22,7 @@ import { type DbClient, type SessionStore, } from '@relavium/db'; -import { readSessionExportMarker } from '@relavium/core'; +import { MAX_SOURCE_CHARS, readSessionExportMarker } from '@relavium/core'; import { AgentSchema, type AgentSessionRecord, type SessionMessage } from '@relavium/shared'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; @@ -270,6 +272,43 @@ describe('exportSessionToProject (MVP-A3)', () => { 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')( + '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) { diff --git a/apps/cli/src/chat/export.ts b/apps/cli/src/chat/export.ts index 6e1497fb..9c90341b 100644 --- a/apps/cli/src/chat/export.ts +++ b/apps/cli/src/chat/export.ts @@ -48,7 +48,7 @@ export interface ExportSessionOptions { /** * `--out <path>` override (absolute, or relative to {@link cwd}); the default is `<sessionId>.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. */ @@ -131,7 +131,7 @@ const WORKFLOW_ID_GRAMMAR = /^[a-z0-9]+(?:-[a-z0-9]+)*$/u; * 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 isOwnExport}). So a second + * memory: a candidate file whose `metadata.relaviumExport` names this session ({@link ownExportMode}). 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. @@ -155,14 +155,13 @@ export function exportSessionToProject(opts: ExportToProjectOptions): ExportResu opts.projectConfigDir === undefined ? new Set<string>() : takenWorkflowIds(opts.projectConfigDir, opts.cwd); - const own = candidates.find((id) => - isOwnExport(join(dir, `${id}.relavium.yaml`), opts.sessionId), - ); - if (own !== undefined) { - const path = join(dir, `${own}.relavium.yaml`); - const definition = withWorkflowId(scaffold, own); + for (const id of candidates) { + const path = join(dir, `${id}.relavium.yaml`); + const mode = ownExportMode(path, opts.sessionId); + if (mode === undefined) continue; + const definition = withWorkflowId(scaffold, id); try { - replaceFileForStorage(path, serializeWorkflow(definition)); + replaceFileForStorage(path, serializeWorkflow(definition), mode); } catch (err) { throw storageBoundaryError(err); } @@ -184,24 +183,29 @@ export function exportSessionToProject(opts: ExportToProjectOptions): ExportResu } /** - * Whether `path` 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 parser's size bound, whose - * `metadata.relaviumExport` names `sessionId`. Any doubt (missing, unreadable, swapped between checks, oversized, - * undecodable) is `false`, which only ever means "do not overwrite". + * The permission bits of `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". An + * export the parser could not load is not recognized either; it could not be run. */ -function isOwnExport(path: string, sessionId: string): boolean { +function ownExportMode(path: string, sessionId: string): number | undefined { let fd: number | undefined; try { const linked = lstatSync(path, { bigint: true }); - if (!linked.isFile() || linked.size > BigInt(MAX_SOURCE_CHARS * 4)) return false; + 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 false; - if (opened.size > BigInt(MAX_SOURCE_CHARS * 4)) return false; - return readSessionExportMarker(readFileSync(fd, 'utf8')) === sessionId; + if (!opened.isFile() || opened.dev !== linked.dev || opened.ino !== linked.ino) + return undefined; + if (opened.size > BigInt(MAX_SOURCE_CHARS * 4)) return undefined; + const text = readFileSync(fd, '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 ? Number(opened.mode & 0o777n) : undefined; } catch { - return false; + return undefined; } finally { if (fd !== undefined) { try { @@ -256,7 +260,8 @@ function writeScaffold( mkdirForStorage(dirname(path)); } catch (err) { const code = exportTargetCode(err); - if (code === 'EEXIST' || code === 'ENOTDIR') { + // 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', `cannot write ${label}: a parent of the target path is not a directory`, 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 00000000..b0924553 --- /dev/null +++ b/apps/cli/src/process/owned-storage-replace.test.ts @@ -0,0 +1,97 @@ +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 } 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; + + 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); + expect(readFileSync(path, 'utf8')).toBe('new'); + expect(temps()).toEqual([]); + }); + + it.skipIf(process.platform === 'win32')( + 'replaces a link at the path instead of writing through it', + () => { + const outside = join(dir, 'outside.yaml'); + writeFileSync(outside, 'not ours'); + const path = join(dir, 'w.relavium.yaml'); + symlinkSync(outside, path); + replaceFileForStorage(path, 'new', 0o644); + expect(lstatSync(path).isSymbolicLink()).toBe(false); + expect(readFileSync(path, 'utf8')).toBe('new'); + expect(readFileSync(outside, 'utf8')).toBe('not ours'); + expect(temps()).toEqual([]); + }, + ); + + it('removes its temp when the rename fails, and leaves what was there', () => { + const path = join(dir, 'w.relavium.yaml'); + mkdirSync(path); // a directory: the rename onto it fails + expect(() => { + replaceFileForStorage(path, 'new', 0o644); + }).toThrow(); + expect(statSync(path).isDirectory()).toBe(true); + 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); + 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); + } 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 d2eab444..e2c07f47 100644 --- a/apps/cli/src/process/owned-storage.ts +++ b/apps/cli/src/process/owned-storage.ts @@ -1,7 +1,10 @@ import { randomUUID } from 'node:crypto'; import { + accessSync, chmodSync, closeSync, + constants, + fchmodSync, fsyncSync, mkdirSync, openSync, @@ -51,12 +54,18 @@ export function writeFileForStorage(path: string, content: string, force: boolea * 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. */ -export function replaceFileForStorage(path: string, content: string): void { +export function replaceFileForStorage(path: string, content: string, mode: number): void { const temp = join(dirname(path), `.${basename(path)}.${randomUUID()}.tmp`); let fd: number | undefined; try { - fd = openSync(temp, 'wx'); + 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); From 2d4354e4ab7f78f2bf03c884e3b429b772b0a6ee Mon Sep 17 00:00:00 2001 From: Cemil ILIK <cemililik@outlook.com> Date: Thu, 5 Nov 2026 12:38:14 +0300 Subject: [PATCH 062/148] fix(cli): refuse /compact like a message while a resumed session has no key - On an interactive resume deferred for a missing key, `/compact` asked for a summary anyway and showed the fallback chain's opaque credential failure. It now shows the same remedy a refused message does. - A /clear or reseat refused by the key pre-flight printed the remedy with a double full stop. - The --json /export test now pins the documented split: the event carries the path, and no path line or run hint is printed. - Comments that still described the removed in-memory own-export state now say the own export is recognized from its file. D64 round 2 of MVP-1 1b, findings keys-2, keys-4, export-1 and export-6. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- .../commands/chat-resume-missing-key.test.ts | 9 +++++++-- apps/cli/src/commands/chat.test.ts | 13 ++++++++++--- apps/cli/src/commands/chat.ts | 17 ++++++++++++----- 3 files changed, 29 insertions(+), 10 deletions(-) diff --git a/apps/cli/src/commands/chat-resume-missing-key.test.ts b/apps/cli/src/commands/chat-resume-missing-key.test.ts index 7974ca44..8fc5e94b 100644 --- a/apps/cli/src/commands/chat-resume-missing-key.test.ts +++ b/apps/cli/src/commands/chat-resume-missing-key.test.ts @@ -178,11 +178,16 @@ describe('chat-resume when the session’s provider has no key (MVP-A4)', () => const persistedBefore = store.loadFull('id-0')?.messages.length; const { providers, requests } = keyedResolver(() => false); - const { code, notices } = await resumeInteractively(store, providers, ['hello again', '/exit']); + const { code, notices } = 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(2); // once on open, once for the refused message + 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'); diff --git a/apps/cli/src/commands/chat.test.ts b/apps/cli/src/commands/chat.test.ts index aa3fcae7..8fc62619 100644 --- a/apps/cli/src/commands/chat.test.ts +++ b/apps/cli/src/commands/chat.test.ts @@ -1126,6 +1126,10 @@ describe('chatCommand', () => { expect(events.filter((event) => event.type === 'session:exported')).toEqual([ 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('.relavium', 'workflows', 'private-user-sentinel.relavium.yaml')} — run it with \`relavium run private-user-sentinel\``, @@ -1286,8 +1290,9 @@ describe('chatCommand', () => { }); it('re-exports on a second /export (force overwrites the session OWN scaffold)', async () => { - // A 2nd /export rewrites this session's own file in place (MVP-A3's `previous`) — it must neither fail nor - // take the next id as if its own earlier export were another workflow. + // 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(); @@ -1706,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(); @@ -1718,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 }); diff --git a/apps/cli/src/commands/chat.ts b/apps/cli/src/commands/chat.ts index c3b4e32a..461b320b 100644 --- a/apps/cli/src/commands/chat.ts +++ b/apps/cli/src/commands/chat.ts @@ -1216,8 +1216,8 @@ interface ReplWiring { /** 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: a refusal text keeps the message from being sent, recorded or persisted, and - * is shown instead. Set only by an interactive resume whose provider has no key (MVP-A4); a reseat or `/clear` + * 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; @@ -1430,7 +1430,6 @@ export function createChatLineHandler( store.setContextWindow?.(built.contextWindowTokens); let stop = false; let cancelled = false; - // This session's last `/export` (MVP-A3): a second `/export` rewrites that file in place instead of adding `-2`. // Set by `/clear` (ADR-0062 §7): the loop stopped to SWAP the session, not to end the REPL — `stopReason` reports // it so the surface (the standalone re-drive loop, or the Home) rebuilds a fresh session instead of exiting. let clearRequested = false; @@ -1504,7 +1503,7 @@ export function createChatLineHandler( // standalone `chat-export` command marks it). A failure is reported, never crashing the REPL. try { // 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 (`previous`). + // re-export rewrites this session's own file, recognized from its `metadata.relaviumExport`. const { projectConfigDir } = loadResolvedConfig({ cwd: deps.global.cwd, configPath: deps.global.configPath, @@ -1723,6 +1722,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)); @@ -3077,13 +3083,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 }; } From 9734475c18a1e9d7f98ff72730b39c916e597769 Mon Sep 17 00:00:00 2001 From: Cemil ILIK <cemililik@outlook.com> Date: Thu, 5 Nov 2026 12:38:14 +0300 Subject: [PATCH 063/148] test(cli): pin the resume key pre-flight and the Home reconcile orders - A non-interactive resume of a keyless session whose agent declares a stdio MCP server is refused before any consent prompt or server start. - The Home reconciles after its signal and exit nets, and before it mounts. - Comments corrected: the Home's first frame still shows a crashed run as `running` until its dead owner's lease expires; exit 5 tells a script to read the durable outcome; reconcile settles `internal` or, after a recorded budget rejection, `budget_exceeded`. D64 round 2 of MVP-1 1b, findings keys-3, docs-2, liveness-3, liveness-4 and docs-6. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- apps/cli/src/chat/session-host.test.ts | 41 +++++++++++++++++++++++++- apps/cli/src/commands/run.ts | 7 +++-- apps/cli/src/home/drive-home.test.ts | 11 ++++++- apps/cli/src/home/drive-home.tsx | 7 +++-- apps/cli/src/process/exit-codes.ts | 3 +- 5 files changed, 60 insertions(+), 9 deletions(-) diff --git a/apps/cli/src/chat/session-host.test.ts b/apps/cli/src/chat/session-host.test.ts index 603203c8..dfcf2211 100644 --- a/apps/cli/src/chat/session-host.test.ts +++ b/apps/cli/src/chat/session-host.test.ts @@ -28,7 +28,12 @@ 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'; @@ -2308,6 +2313,40 @@ describe('the default model follows the stored key (MVP-A4)', () => { ).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'); diff --git a/apps/cli/src/commands/run.ts b/apps/cli/src/commands/run.ts index ca753d19..09afc2a2 100644 --- a/apps/cli/src/commands/run.ts +++ b/apps/cli/src/commands/run.ts @@ -474,9 +474,10 @@ async function runOwned( // **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{internal}`. It leaves alone a run parked at a gate 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. + // process left `running` as `run:failed` (`internal`, or `budget_exceeded` after a recorded budget + // rejection). It leaves alone a run parked at a gate 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. diff --git a/apps/cli/src/home/drive-home.test.ts b/apps/cli/src/home/drive-home.test.ts index dcdb70e0..92eeba66 100644 --- a/apps/cli/src/home/drive-home.test.ts +++ b/apps/cli/src/home/drive-home.test.ts @@ -578,6 +578,15 @@ describe('driveHome (2.5.B / ADR-0054)', () => { 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); @@ -585,7 +594,7 @@ describe('driveHome (2.5.B / ADR-0054)', () => { const props = captured; if (props === undefined) throw new Error('the injected render was never invoked'); - expect(order).toEqual(['reconcile', 'mount']); + 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]); diff --git a/apps/cli/src/home/drive-home.tsx b/apps/cli/src/home/drive-home.tsx index 77388fc5..7e6a0d0d 100644 --- a/apps/cli/src/home/drive-home.tsx +++ b/apps/cli/src/home/drive-home.tsx @@ -1262,9 +1262,10 @@ 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 never shows a crashed run as `running`. 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 + // 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(); diff --git a/apps/cli/src/process/exit-codes.ts b/apps/cli/src/process/exit-codes.ts index 34921188..b62082a1 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 <runId>` 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 From ef25a7ebfdd4a5f7c7a04c9b6ad9de752d988bc5 Mon Sep 17 00:00:00 2001 From: Cemil ILIK <cemililik@outlook.com> Date: Thu, 5 Nov 2026 12:38:14 +0300 Subject: [PATCH 064/148] test(repo): resume a historical input gate that approves on timeout New authoring refuses an input gate with `timeout_action: approve`, but a run parked before that rule must still load and resume through `relavium gate`. The case is pinned end to end: refused by the authoring schema and the parser, admitted by the frozen reader, resumed with --input, and its snapshot unchanged. gate.test.ts now has 75 cases; the secrets-controls and causal-controls pins follow. D64 round 2 of MVP-1 1b, finding gate-3. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- apps/cli/src/commands/gate.test.ts | 36 +++++++++++++++++++++++ tools/causal-controls/provenance.test.mjs | 2 +- tools/secrets-controls/README.md | 2 +- tools/secrets-controls/mutations.mjs | 2 +- 4 files changed, 39 insertions(+), 3 deletions(-) diff --git a/apps/cli/src/commands/gate.test.ts b/apps/cli/src/commands/gate.test.ts index 87227f18..a47ad330 100644 --- a/apps/cli/src/commands/gate.test.ts +++ b/apps/cli/src/commands/gate.test.ts @@ -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[] }> => diff --git a/tools/causal-controls/provenance.test.mjs b/tools/causal-controls/provenance.test.mjs index c8216bed..440c2cbe 100644 --- a/tools/causal-controls/provenance.test.mjs +++ b/tools/causal-controls/provenance.test.mjs @@ -290,7 +290,7 @@ test('secrets workload corrections retain independent complete-count and reader- for (const [id, file, expectedTests] of [ ['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', 74], + ['gate-consumed-stdin-prompt-authority', 'apps/cli/src/commands/gate.test.ts', 75], ['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/secrets-controls/README.md b/tools/secrets-controls/README.md index 730e6014..6bd2f9d5 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, 85 run-command cases, three run-consent cases, 74 gate cases, +cases, 32 dispatch cases, 85 run-command cases, three run-consent cases, 75 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 diff --git a/tools/secrets-controls/mutations.mjs b/tools/secrets-controls/mutations.mjs index 5f48a907..604ba6c7 100644 --- a/tools/secrets-controls/mutations.mjs +++ b/tools/secrets-controls/mutations.mjs @@ -699,7 +699,7 @@ export const mutations = [ ' const mayPrompt = args.secretStdin !== true;', ' const mayPrompt = true;', ['apps/cli/src/commands/gate.test.ts'], - 74, + 75, 'refuses unapproved stdio after a real read despite stale TTY, without prompting or writing a grant', 'SECRETS_CAUSAL_RESUME_CONSUMED_STDIN_CONSENT', ), From ac596a396700c3a16dfa4cf0a9c56254e0419a1b Mon Sep 17 00:00:00 2001 From: Cemil ILIK <cemililik@outlook.com> Date: Thu, 5 Nov 2026 12:38:14 +0300 Subject: [PATCH 065/148] docs(core): say an input gate's auto-approval fails at its deadline The GateRequest and #onGateTimeout docblocks said `approve` always resolves the gate; on an input gate that decision does not fit, and the run fails with run_timeout. D64 round 2 of MVP-1 1b, finding gate-2. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- packages/core/src/engine/engine.ts | 3 ++- packages/core/src/engine/node-executor.ts | 5 +++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/packages/core/src/engine/engine.ts b/packages/core/src/engine/engine.ts index 4f6a7cad..b694df96 100644 --- a/packages/core/src/engine/engine.ts +++ b/packages/core/src/engine/engine.ts @@ -4042,7 +4042,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, diff --git a/packages/core/src/engine/node-executor.ts b/packages/core/src/engine/node-executor.ts index d8121c6e..20bdb98a 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'; From c2afd8badf96a0a934ea7b12d8bcb98bdf8e622e Mon Sep 17 00:00:00 2001 From: Cemil ILIK <cemililik@outlook.com> Date: Thu, 5 Nov 2026 12:38:14 +0300 Subject: [PATCH 066/148] docs(shared): name the input-gate refusal among the frozen reader's exemptions D64 round 2 of MVP-1 1b, finding gate-2. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- packages/shared/src/workflow.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/shared/src/workflow.ts b/packages/shared/src/workflow.ts index 1e7f3892..acdb785d 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({ From 662b023f688acf11edda1df6564e40d40bf9b8cb Mon Sep 17 00:00:00 2001 From: Cemil ILIK <cemililik@outlook.com> Date: Thu, 5 Nov 2026 12:38:19 +0300 Subject: [PATCH 067/148] docs(repo): record the MVP-1 1b round 2 fixes and its deferred lows - chat-session, commands: under --json, /export prints no path line or run hint; the `session:exported` event carries the path. - commands: an output field is read with its name, so a value named like an API-key header is masked even with no secret shape of its own; any other name masks nothing; a query name split by whitespace is not joined. Reconcile settles `internal`, or `budget_exceeded` after a recorded budget rejection. - home: the same reconcile wording. - workflow-yaml-spec: the fit check reads the decision kind only. - current: 1b's progress through D64 round 2. - deferred-tasks: four round 2 lows recorded (keys-1, gate-1, outputs-3, outputs-5). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- docs/reference/cli/chat-session.md | 2 +- docs/reference/cli/commands.md | 6 ++-- docs/reference/cli/home.md | 2 +- .../reference/contracts/workflow-yaml-spec.md | 3 +- docs/roadmap/current.md | 4 ++- docs/roadmap/deferred-tasks.md | 31 +++++++++++++++++-- 6 files changed, 38 insertions(+), 10 deletions(-) diff --git a/docs/reference/cli/chat-session.md b/docs/reference/cli/chat-session.md index c83c81a8..bd7faef5 100644 --- a/docs/reference/cli/chat-session.md +++ b/docs/reference/cli/chat-session.md @@ -109,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 <sessionId>` (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 where workflows live — `<project>/.relavium/workflows/<id>.relavium.yaml`, where `<project>/.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 `<cwd>/.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 <id>\``) (MVP-A3) — in the transcript on the interactive chat and Home, on stderr on the plain and `--json` drivers, like a refusal (`export failed: …`). It never overwrites another workflow: the id is the first of `<slug>`, `<slug>-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. 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 — `<project>/.relavium/workflows/<id>.relavium.yaml`, where `<project>/.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 `<cwd>/.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 <id>\``) (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 `<slug>`, `<slug>-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. 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. | diff --git a/docs/reference/cli/commands.md b/docs/reference/cli/commands.md index e0fc7f72..c5befb79 100644 --- a/docs/reference/cli/commands.md +++ b/docs/reference/cli/commands.md @@ -200,7 +200,7 @@ 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 <digest>` 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 secret shapes in the original text, 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. The scrubber is fail-closed, so a value whose text forms a secret shape once whitespace is removed — a bare `https://host` and a later `@`, for one — is masked whole. 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 <runId> --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. +- **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 secret shapes in the original text, 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, is also read together with its name, as in serialized JSON, so a value named like an API-key header (`x-api-key`, `api-key`, `x-goog-api-key`) is masked even when it has no secret shape of its own; any other name masks nothing, so an opaque value under `password` or `token` is shown. 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 <runId> --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_<PROVIDER>_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 <digest>` (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). @@ -453,7 +453,7 @@ No per-session attribution or transcript is loaded for this listing. 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 `<sessionId>.relavium.yaml` in cwd by default (the file name is keyed on the unique session id, so two sessions never collide); `--out <path>` 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, for the review notice, the path and run hint, and a refusal alike. 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. Live `/export` uses the rendered transcript in interactive chat and Home, and stderr on the plain and JSON drivers, for the review notice and a refusal alike. The path and run hint follow the review notice on the same channel, except under `--json`, where the `session:exported` event carries the path and no run hint is printed. 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. @@ -639,7 +639,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`**, 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{internal}`. It leaves alone a run parked at a gate and 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). `chat` and `agent run` run on `AgentSession` and do not reconcile. One reachable `5` comes from that reconcile: in the instant between a run's durable `run:started` and its lease acquire, a concurrent start can settle the run. 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, so a run that completed is never relabelled failed. A script seeing `5` should read the durable outcome rather than assume one: `relavium status <runId>` shows whether the held terminal has landed, or, in the race 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`. +> 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` — `internal`, or `budget_exceeded` for a run with a recorded budget rejection ([sse-event-schema.md](../contracts/sse-event-schema.md#durable-budget-authorization)). It leaves alone a run parked at a gate and 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). `chat` and `agent run` run on `AgentSession` and do not reconcile. One reachable `5` comes from that reconcile: in the instant between a run's durable `run:started` and its lease acquire, a concurrent start can settle the run. 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, so a run that completed is never relabelled failed. A script seeing `5` should read the durable outcome rather than assume one: `relavium status <runId>` shows whether the held terminal has landed, or, in the race 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 84b03cd4..70fbd0e6 100644 --- a/docs/reference/cli/home.md +++ b/docs/reference/cli/home.md @@ -50,7 +50,7 @@ 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 `run:failed{internal}` and shows under *Attention required*, and a terminal held in the outbox is written. It 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. A run parked at a gate and a run another process holds a live lease on are left alone. The rules are the same as for `run` and `gate` ([exit code `5`](commands.md#exit-codes)). +Before its first read the Home **reconciles** (2.6.G liveness): a run a dead process left `running` is settled `run:failed` (`internal`, or `budget_exceeded` after a recorded budget rejection) and shows under *Attention required*, and a terminal held in the outbox is written. It 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. A run parked at a gate and a run another process holds a live lease on are left alone. The rules are the same as for `run` and `gate` ([exit code `5`](commands.md#exit-codes)). ```text relavium ← branded header (bold) diff --git a/docs/reference/contracts/workflow-yaml-spec.md b/docs/reference/contracts/workflow-yaml-spec.md index b3d7cf76..949728ae 100644 --- a/docs/reference/contracts/workflow-yaml-spec.md +++ b/docs/reference/contracts/workflow-yaml-spec.md @@ -327,7 +327,8 @@ Each node has an `id` (kebab-case, unique within the workflow) and a `type`. The **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 shape its type does not define. There is nothing for a deadline to approve on an `input` gate, so +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. diff --git a/docs/roadmap/current.md b/docs/roadmap/current.md index 8e0e1917..3e170850 100644 --- a/docs/roadmap/current.md +++ b/docs/roadmap/current.md @@ -100,7 +100,9 @@ Wave 3 split as `row <n>` in the [increment table](phases/phase-2.5.5-hardening- (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 implemented and committed locally, not pushed until it is accepted. Wiring liveness exposed a second-terminal race, so the run store now refuses a second - terminal. Still owed: the rigor pass, one new complete review round and a separate security sitting. + terminal. The rigor pass is done. D64 rounds 1 (at `5f2a0eb8`) and 2 (at `60bc9727`) were not accepted, with nine + and two medium findings, all fixed; their lows are fixed or recorded in [deferred-tasks.md](deferred-tasks.md). + Still owed: one new complete round with no medium or higher finding, then a separate security sitting. 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 diff --git a/docs/roadmap/deferred-tasks.md b/docs/roadmap/deferred-tasks.md index e03ca670..32d50176 100644 --- a/docs/roadmap/deferred-tasks.md +++ b/docs/roadmap/deferred-tasks.md @@ -1474,9 +1474,9 @@ MVP pulled without an annotation); both were fixed on 2026-11-04. ## MVP-1 1b — recorded low findings (2026-11-05) -D64 round 1 of MVP-1's security increment at `5f2a0eb8` found nine medium findings, all fixed before the next -round, and the lows below that were not fixed with them. **Trigger for every entry:** the next change to the file it -names. +D64 rounds 1 (at `5f2a0eb8`) and 2 (at `60bc9727`) of MVP-1's security increment found nine and two medium findings, +all fixed before the next round, and the lows below that were not fixed with them. **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 @@ -1509,6 +1509,31 @@ names. 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)* +- [ ] **On a resumed run the outputs note names `<runId>`.** *Low; cosmetic.* *(round 2, outputs-5)* 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)* + ## Wave 3 NI — recorded low findings (2026-10-25) Round 1 of the native-identity increment at `fc5af701` accepted it with these lows From 224b8f69f8ed7d45dcae2971404f1c57928f1280 Mon Sep 17 00:00:00 2001 From: Cemil ILIK <cemililik@outlook.com> Date: Thu, 5 Nov 2026 12:53:02 +0300 Subject: [PATCH 068/148] style(cli): collapse the session-host test's shared type import The `Agent` rename in 9734475c left the import short enough for one line; `format:check` requires it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- apps/cli/src/chat/session-host.test.ts | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/apps/cli/src/chat/session-host.test.ts b/apps/cli/src/chat/session-host.test.ts index dfcf2211..1138f056 100644 --- a/apps/cli/src/chat/session-host.test.ts +++ b/apps/cli/src/chat/session-host.test.ts @@ -28,12 +28,7 @@ import { type McpClient, type McpConnection, } from '@relavium/mcp'; -import type { - AbortSignalLike, - Agent, - 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'; From aa4e27cdf88f11943989bf9928b8a93a46c017eb Mon Sep 17 00:00:00 2001 From: Cemil ILIK <cemililik@outlook.com> Date: Thu, 5 Nov 2026 14:43:13 +0300 Subject: [PATCH 069/148] fix(cli): decide a whole-value output mask by the field's name alone Round 2's member view read a primitive member as `{"<key>":<value>}` and masked the value whole whenever the scrubber changed that text. JSON writes a newline as the two characters `\n`, so a token the leaf pass had masked in place at a line end ran on into the next line, the text changed, and an ordinary deliverable was masked whole: a README with `## Basic usage`, a curl line with `Authorization: Bearer $TOKEN`, under any name. A URL-shaped name and an `@` in the value also joined into a false mask. The name is now read on its own, as `{"<key>":"v"}` with a stand-in value, and the value is masked whole only when the scrubber masks the stand-in. The value's own text never enters that view. A member JSON drops (undefined, a function, a symbol) is left as JSON leaves it, and the probe no longer sets the masked note by itself. A marker oracle covers both directions: no leak that round 1's or round 2's head masked (except the recorded split query name), and, for every name that is not a credential label, output identical to 60bc9727's. Eight mutants killed. Refs: MVP-1 1b D64 round 3 (outputs-1, docs-1, outputs-2) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- apps/cli/src/render/run-outputs.test.ts | 40 +++++++++++++++ apps/cli/src/render/run-outputs.ts | 67 +++++++++++++++++-------- 2 files changed, 86 insertions(+), 21 deletions(-) diff --git a/apps/cli/src/render/run-outputs.test.ts b/apps/cli/src/render/run-outputs.test.ts index 806ec830..6966b52b 100644 --- a/apps/cli/src/render/run-outputs.test.ts +++ b/apps/cli/src/render/run-outputs.test.ts @@ -153,6 +153,46 @@ describe('formatRunOutputs (MVP-A1)', () => { ); }); + 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('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'); diff --git a/apps/cli/src/render/run-outputs.ts b/apps/cli/src/render/run-outputs.ts index 79a685a3..4c2097bd 100644 --- a/apps/cli/src/render/run-outputs.ts +++ b/apps/cli/src/render/run-outputs.ts @@ -13,11 +13,12 @@ import { sanitizeInline, stripTerminalControls } from './sanitize.js'; * 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 object member with a primitive value is also read as the one-member JSON text `{"<key>":<value>}`, the - * form the scrubber reads in a serialized object; when that text masks, the member's value is masked whole. One - * member never joins its neighbour. 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. + * so each member with a primitive value, a top-level output included, also has its NAME read: as the one-member JSON + * text `{"<key>":"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. 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 @@ -40,6 +41,8 @@ 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` @@ -106,25 +109,30 @@ function noteCause(truncated: boolean, masked: boolean): string { /** * 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 is also read with its output key, as a nested member is. - * `masked` says whether the scrubber changed anything. + * 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) masked = true; + if (out !== text) onMask(); return out; }; if (typeof value === 'string') { - const shown = maskedAsMember(key, scrub(value), scrub); + 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, new Set()), scrub)); + 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 @@ -135,29 +143,31 @@ function displayValue( /** * A copy of `value` with every string, and every object key, passed through `scrub`, and every object member with a - * primitive value read once more as its one-member JSON text (see the module note). It follows `JSON.stringify`'s + * 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<object>, ): unknown { if (typeof value === 'string') return scrub(value); if (typeof value !== 'object' || value === null) return value; - if (hasToJson(value)) return scrubLeaves(value.toJSON(), scrub, ancestors); + 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, ancestors)); + if (Array.isArray(value)) + return value.map((item) => scrubLeaves(item, scrub, onMask, ancestors)); const copy: Record<string, unknown> = {}; for (const [key, item] of Object.entries(value)) { - const member = scrubLeaves(item, scrub, ancestors); + 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, scrub), + value: maskedAsMember(key, member, onMask), enumerable: true, configurable: true, writable: true, @@ -170,17 +180,32 @@ function scrubLeaves( } /** - * `member` (already masked leaf by leaf), or the scrubber's mask in its place when the one-member JSON text - * `{"<key>":<member>}` masks: the key names a credential the value alone does not show. Only a primitive value is - * read this way; an object or array value is read member by member by the walk itself. + * `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, scrub: (text: string) => string): unknown { +function maskedAsMember(key: string, member: unknown, onMask: () => void): unknown { if (typeof member === 'object' && member !== null) return member; - const view = JSON.stringify({ [key]: member }); - if (view === undefined || scrub(view) === view) 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 `{"<key>":"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, and a key that is itself a secret shape masks only itself (it is + * shown masked), not its value. + */ +function namesCredential(key: string): boolean { + 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'; } From 80d935fd1761fa805ae39ce68da811cc5407a166 Mon Sep 17 00:00:00 2001 From: Cemil ILIK <cemililik@outlook.com> Date: Thu, 5 Nov 2026 14:43:13 +0300 Subject: [PATCH 070/148] fix(cli): strip terminal controls from REPL command output on the plain and json drivers `/export` reads the project configuration since MVP-A3, and its refusal names what it could not read: a config error carries a `project.toml` key verbatim, so a quoted key holding ESC or BEL reached the terminal raw on the plain and `--json` drivers, which write `emitOutput` straight to stderr. The full-screen view's notice already stripped controls. `emitOutput`'s non-interactive branch now applies the same floor. Refs: MVP-1 1b D64 round 3 (export-1) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- .../src/commands/chat-export-terminal.test.ts | 92 +++++++++++++++++++ apps/cli/src/commands/chat.ts | 4 +- 2 files changed, 95 insertions(+), 1 deletion(-) create mode 100644 apps/cli/src/commands/chat-export-terminal.test.ts 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 00000000..52ad7faf --- /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<string, string | undefined>(); + + 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<string> { + 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.ts b/apps/cli/src/commands/chat.ts index 461b320b..d19aefc2 100644 --- a/apps/cli/src/commands/chat.ts +++ b/apps/cli/src/commands/chat.ts @@ -1464,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`); } }; From e84d7c9f4996e2f4f0e3a432e78d14b4c0bd1572 Mon Sep 17 00:00:00 2001 From: Cemil ILIK <cemililik@outlook.com> Date: Thu, 5 Nov 2026 14:43:13 +0300 Subject: [PATCH 071/148] test(cli): pin a refused message out of the view and an own export's replacing rewrite A deferred keyless resume's test now reads the live view's user turns: moving the send guard below `appendUser` passed every test before. The export tests now check that a second `/export` replaces its own file (a new inode), which a call-site swap back to an in-place write passed before. Refs: MVP-1 1b D64 round 3 (keys-1, export-3) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- apps/cli/src/chat/export-project.test.ts | 12 +++++++++++ .../commands/chat-resume-missing-key.test.ts | 21 ++++++++++++------- 2 files changed, 25 insertions(+), 8 deletions(-) diff --git a/apps/cli/src/chat/export-project.test.ts b/apps/cli/src/chat/export-project.test.ts index 82e6f978..fb091e3e 100644 --- a/apps/cli/src/chat/export-project.test.ts +++ b/apps/cli/src/chat/export-project.test.ts @@ -282,6 +282,18 @@ describe('exportSessionToProject (MVP-A3)', () => { }, ); + 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', () => { diff --git a/apps/cli/src/commands/chat-resume-missing-key.test.ts b/apps/cli/src/commands/chat-resume-missing-key.test.ts index 8fc5e94b..1510bf7d 100644 --- a/apps/cli/src/commands/chat-resume-missing-key.test.ts +++ b/apps/cli/src/commands/chat-resume-missing-key.test.ts @@ -135,16 +135,21 @@ describe('chat-resume when the session’s provider has no key (MVP-A4)', () => return store; }; - /** Resume `id-0` interactively, typing `lines`, and report the notices the view showed. */ + /** Resume `id-0` interactively, typing `lines`, and report the notices and user turns the view showed. */ const resumeInteractively = async ( store: ReturnType<typeof createSessionStore>, providers: ProviderResolver, lines: readonly string[], between?: () => void, - ): Promise<{ readonly code: number; readonly notices: readonly string[] }> => { + ): 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()); @@ -153,10 +158,9 @@ describe('chat-resume when the session’s provider has no key (MVP-A4)', () => between?.(); if (ctx.shouldStop()) break; } - notices = ctx.store - .getSnapshot() - .state.transcript.filter((entry) => entry.role === 'notice') - .map((entry) => entry.text); + 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 = { @@ -170,7 +174,7 @@ describe('chat-resume when the session’s provider has no key (MVP-A4)', () => uuid: () => `r-${String(id++)}`, }; const code = await chatResumeCommand({ sessionId: 'id-0' }, deps); - return { code, notices }; + return { code, notices, userTurns }; }; it('opens an interactive resume, names both remedies, and sends nothing while the key is missing', async () => { @@ -178,7 +182,7 @@ describe('chat-resume when the session’s provider has no key (MVP-A4)', () => const persistedBefore = store.loadFull('id-0')?.messages.length; const { providers, requests } = keyedResolver(() => false); - const { code, notices } = await resumeInteractively(store, providers, [ + const { code, notices, userTurns } = await resumeInteractively(store, providers, [ 'hello again', '/compact', '/exit', @@ -194,6 +198,7 @@ describe('chat-resume when the session’s provider has no key (MVP-A4)', () => } 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 () => { From f8dd0d47786b47f8039fb3d73aeb19a876c4d031 Mon Sep 17 00:00:00 2001 From: Cemil ILIK <cemililik@outlook.com> Date: Thu, 5 Nov 2026 14:43:13 +0300 Subject: [PATCH 072/148] docs(cli): say what reconcile settles and what an own export is not checked for The Home reconcile helper's docstring now says `internal` or `budget_exceeded`, names `budget resume`, and lists the runs it leaves alone, including every run while the outbox exists but cannot be read. `ownExportMode` no longer claims to skip an export the parser could not load: it checks the size bound and the marker, not the workflow, on purpose. Refs: MVP-1 1b D64 round 3 (liveness-4, docs-2, docs-3) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- apps/cli/src/chat/export.ts | 4 ++-- apps/cli/src/history/reconcile.ts | 13 +++++++------ 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/apps/cli/src/chat/export.ts b/apps/cli/src/chat/export.ts index 9c90341b..142efd64 100644 --- a/apps/cli/src/chat/export.ts +++ b/apps/cli/src/chat/export.ts @@ -186,8 +186,8 @@ export function exportSessionToProject(opts: ExportToProjectOptions): ExportResu * The permission bits of `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". An - * export the parser could not load is not recognized either; it could not be run. + * 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 ownExportMode(path: string, sessionId: string): number | undefined { let fd: number | undefined; diff --git a/apps/cli/src/history/reconcile.ts b/apps/cli/src/history/reconcile.ts index a186e7fd..28b00d4a 100644 --- a/apps/cli/src/history/reconcile.ts +++ b/apps/cli/src/history/reconcile.ts @@ -24,13 +24,14 @@ const RECONCILE_ONLY_EXECUTOR: NodeExecutor = { /** * 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` and `gate` call - * `engine.reconcile()` on the engine they already build. + * 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{internal}` for each - * interrupted run. It leaves alone a run parked at a gate and a run whose lease is live, which another process - * is executing (ADR-0079 §7). The store, the lease port and the media-reference port are built over `db`, the - * same connection the caller reads from. + * `reconcile()` first drains the terminal outbox (ADR-0078 §4), then writes `run:failed` for each interrupted + * run: `internal`, or `budget_exceeded` after a recorded budget rejection. It leaves alone a run parked at a gate, + * 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 From 2668481787189589460cbb7ade6312c590b6dcbb Mon Sep 17 00:00:00 2001 From: Cemil ILIK <cemililik@outlook.com> Date: Thu, 5 Nov 2026 14:43:13 +0300 Subject: [PATCH 073/148] docs(core): name the other order of the run:started to acquire race If the run's own terminal commits before the reconciler's, the reconciler's guarded write is the one refused. One terminal either way. Refs: MVP-1 1b D64 round 3 (liveness-3) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- packages/core/src/engine/engine.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/core/src/engine/engine.ts b/packages/core/src/engine/engine.ts index b694df96..a6ce6410 100644 --- a/packages/core/src/engine/engine.ts +++ b/packages/core/src/engine/engine.ts @@ -1086,7 +1086,9 @@ class RunExecution { // 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. One terminal either way, never two. + // `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 From b70f9defbe5644f2088d25362401b97583fd2297 Mon Sep 17 00:00:00 2001 From: Cemil ILIK <cemililik@outlook.com> Date: Thu, 5 Nov 2026 14:43:14 +0300 Subject: [PATCH 074/148] docs(repo): record the MVP-1 1b round 3 fixes and its deferred lows - commands.md: a field's name is read on its own; a name masks its value when the scrubber reads it as a credential's label (an API-key header name, or a name ending in a credential prefix); an unreadable outbox repairs nothing; the chat-export section links to the `/export` row rather than restating it. - home.md: the same unreadable-outbox rule. - ADR-0079's 2026-11-05 amendment: one terminal lands either way, in both orders. - VS Code and desktop references: an input gate offers an input field, not Approve. - current.md: round 3 not accepted, two mediums fixed. - deferred-tasks.md: liveness-1, liveness-2, export-2 and export-4. Refs: MVP-1 1b D64 round 3 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- ...s-run-ownership-lease-and-fencing-token.md | 5 +-- docs/reference/cli/commands.md | 6 ++-- docs/reference/cli/home.md | 2 +- docs/reference/desktop/routes-and-screens.md | 2 +- docs/reference/vscode/extension-api.md | 2 +- docs/roadmap/current.md | 7 ++-- docs/roadmap/deferred-tasks.md | 32 +++++++++++++++++-- 7 files changed, 42 insertions(+), 14 deletions(-) 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 ae845a0e..0403d03c 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 @@ -80,8 +80,9 @@ All contention is on the resume path, which is already async. > `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)): -> the run's own terminal is refused, it exits durability-uncertain, and its log keeps the reconciler's single -> terminal. `run:started` stays unfenced for the reason above. +> 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. ### 4. A losing resume refuses before it reads anything diff --git a/docs/reference/cli/commands.md b/docs/reference/cli/commands.md index c5befb79..f699c2c3 100644 --- a/docs/reference/cli/commands.md +++ b/docs/reference/cli/commands.md @@ -200,7 +200,7 @@ 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 <digest>` 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 secret shapes in the original text, 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, is also read together with its name, as in serialized JSON, so a value named like an API-key header (`x-api-key`, `api-key`, `x-goog-api-key`) is masked even when it has no secret shape of its own; any other name masks nothing, so an opaque value under `password` or `token` is shown. 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 <runId> --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. +- **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 secret shapes in the original text, 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). 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 <runId> --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_<PROVIDER>_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 <digest>` (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). @@ -453,7 +453,7 @@ No per-session attribution or transcript is loaded for this listing. 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 `<sessionId>.relavium.yaml` in cwd by default (the file name is keyed on the unique session id, so two sessions never collide); `--out <path>` 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 the plain and JSON drivers, for the review notice and a refusal alike. The path and run hint follow the review notice on the same channel, except under `--json`, where the `session:exported` event carries the path and no run hint is printed. 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 review notice, path, run hint and refusal is stated with the `/export` command in [chat-session.md](chat-session.md#the-multi-turn-loop). 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. @@ -639,7 +639,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`**, 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` — `internal`, or `budget_exceeded` for a run with a recorded budget rejection ([sse-event-schema.md](../contracts/sse-event-schema.md#durable-budget-authorization)). It leaves alone a run parked at a gate and 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). `chat` and `agent run` run on `AgentSession` and do not reconcile. One reachable `5` comes from that reconcile: in the instant between a run's durable `run:started` and its lease acquire, a concurrent start can settle the run. 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, so a run that completed is never relabelled failed. A script seeing `5` should read the durable outcome rather than assume one: `relavium status <runId>` shows whether the held terminal has landed, or, in the race 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`. +> 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` — `internal`, or `budget_exceeded` for a run with a recorded budget rejection ([sse-event-schema.md](../contracts/sse-event-schema.md#durable-budget-authorization)). It leaves alone a run parked at a gate and 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). `chat` and `agent run` run on `AgentSession` and do not reconcile. One reachable `5` comes from that reconcile: in the instant between a run's durable `run:started` and its lease acquire, a concurrent start can settle the run. 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, 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 <runId>` shows whether the held terminal has landed, or, in the race 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 70fbd0e6..5148b2ef 100644 --- a/docs/reference/cli/home.md +++ b/docs/reference/cli/home.md @@ -50,7 +50,7 @@ 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 `run:failed` (`internal`, or `budget_exceeded` after a recorded budget rejection) and shows under *Attention required*, and a terminal held in the outbox is written. It 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. A run parked at a gate and a run another process holds a live lease on are left alone. The rules are the same as for `run` and `gate` ([exit code `5`](commands.md#exit-codes)). +Before its first read the Home **reconciles** (2.6.G liveness): a run a dead process left `running` is settled `run:failed` (`internal`, or `budget_exceeded` after a recorded budget rejection) and shows under *Attention required*, and a terminal held in the outbox is written. It 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. While the terminal outbox exists but cannot be read, nothing is settled, because no run can be shown to have no held terminal; `relavium status` warns that the outbox is unavailable. A run parked at a gate and a run another process holds a live lease on are left alone. The rules are the same as for `run` and `gate` ([exit code `5`](commands.md#exit-codes)). ```text relavium ← branded header (bold) diff --git a/docs/reference/desktop/routes-and-screens.md b/docs/reference/desktop/routes-and-screens.md index 2c9c9538..4b4df35e 100644 --- a/docs/reference/desktop/routes-and-screens.md +++ b/docs/reference/desktop/routes-and-screens.md @@ -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/vscode/extension-api.md b/docs/reference/vscode/extension-api.md index 0f423908..dfcbf1be 100644 --- a/docs/reference/vscode/extension-api.md +++ b/docs/reference/vscode/extension-api.md @@ -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 3e170850..e41edc58 100644 --- a/docs/roadmap/current.md +++ b/docs/roadmap/current.md @@ -100,9 +100,10 @@ Wave 3 split as `row <n>` in the [increment table](phases/phase-2.5.5-hardening- (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 implemented and committed locally, not pushed until it is accepted. Wiring liveness exposed a second-terminal race, so the run store now refuses a second - terminal. The rigor pass is done. D64 rounds 1 (at `5f2a0eb8`) and 2 (at `60bc9727`) were not accepted, with nine - and two medium findings, all fixed; their lows are fixed or recorded in [deferred-tasks.md](deferred-tasks.md). - Still owed: one new complete round with no medium or higher finding, then a separate security sitting. + terminal. The rigor pass is done. D64 rounds 1 (at `5f2a0eb8`), 2 (at `60bc9727`) and 3 (at `224b8f69`) were not + accepted, with nine, two and two medium findings, all fixed; their lows are fixed or recorded in + [deferred-tasks.md](deferred-tasks.md). Still owed: one new complete round with no medium or higher finding, then a + separate security sitting. 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 diff --git a/docs/roadmap/deferred-tasks.md b/docs/roadmap/deferred-tasks.md index 32d50176..8144323d 100644 --- a/docs/roadmap/deferred-tasks.md +++ b/docs/roadmap/deferred-tasks.md @@ -1474,9 +1474,9 @@ MVP pulled without an annotation); both were fixed on 2026-11-04. ## MVP-1 1b — recorded low findings (2026-11-05) -D64 rounds 1 (at `5f2a0eb8`) and 2 (at `60bc9727`) of MVP-1's security increment found nine and two medium findings, -all fixed before the next round, and the lows below that were not fixed with them. **Trigger for every entry:** the -next change to the file it names. +D64 rounds 1 (at `5f2a0eb8`), 2 (at `60bc9727`) and 3 (at `224b8f69`) of MVP-1's security increment found nine, two +and two medium findings, all fixed before the next round, and the lows below that were not fixed with them. +**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 @@ -1534,6 +1534,32 @@ next change to the file it names. 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)* + ## Wave 3 NI — recorded low findings (2026-10-25) Round 1 of the native-identity increment at `fc5af701` accepted it with these lows From 6e7314fe543b6aff8489b0315a8d3574bdd34b81 Mon Sep 17 00:00:00 2001 From: Cemil ILIK <cemililik@outlook.com> Date: Thu, 5 Nov 2026 16:38:03 +0300 Subject: [PATCH 075/148] fix(cli): keep the output value under a name that is itself a secret shape Round 3 read a member's name on its own, as `{"<key>":"v"}`, and masked the value whole when the scrubber masked the stand-in. A name whose own token reaches its end (`Basic plan`, `Bearer token`, a URL ending in `?key=1`) has nothing after that token in the name, so in the serialized view the token ran over the closing quote into the stand-in, and an ordinary value such as a plan's price was masked whole. The name is now scrubbed alone first. A name the scrubber changes and masks up to its end is a secret shape, not a label: it is shown masked as a member name and keeps its value. A label the scrubber leaves whole on its own (a header name, with a colon or space after it, a URL ending in `?token=`, or a header name after another secret shape in the name) still masks the value. aa4e27cd claimed output identical to 60bc9727 for every name that is not a credential label. Its oracle had no name with a space or a query value, so the claim was incomplete. The round-4 oracle adds 23 such names: over 2880 cases, output for every non-label name is identical to 60bc9727's, and b70f9def differs in 960. Leak direction: no regression against 224b8f69 or b70f9def beyond the recorded split query name and the values under names masked to their end (an opaque value under `Bearer x-api-key` is shown again, as it was at 60bc9727). The new test kills the revert and a broad-skip mutant. Refs: MVP-1 1b D64 round 4 (outputs-1, docs-1) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- apps/cli/src/render/run-outputs.test.ts | 35 +++++++++++++++++++++++++ apps/cli/src/render/run-outputs.ts | 16 +++++++---- 2 files changed, 46 insertions(+), 5 deletions(-) diff --git a/apps/cli/src/render/run-outputs.test.ts b/apps/cli/src/render/run-outputs.test.ts index 6966b52b..ccf64adc 100644 --- a/apps/cli/src/render/run-outputs.test.ts +++ b/apps/cli/src/render/run-outputs.test.ts @@ -193,6 +193,41 @@ describe('formatRunOutputs (MVP-A1)', () => { ]); }); + 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'); diff --git a/apps/cli/src/render/run-outputs.ts b/apps/cli/src/render/run-outputs.ts index 4c2097bd..2b6a49ea 100644 --- a/apps/cli/src/render/run-outputs.ts +++ b/apps/cli/src/render/run-outputs.ts @@ -15,9 +15,10 @@ import { sanitizeInline, stripTerminalControls } from './sanitize.js'; * 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 `{"<key>":"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. 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 }`) + * 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 @@ -198,10 +199,15 @@ function maskedAsMember(key: string, member: unknown, onMask: () => void): unkno * Whether `key` labels a credential: read as the one-member JSON text `{"<key>":"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, and a key that is itself a secret shape masks only itself (it is - * shown masked), not its value. + * 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); } From 55fe5429f17fe56a23226606921038d6850dcb76 Mon Sep 17 00:00:00 2001 From: Cemil ILIK <cemililik@outlook.com> Date: Thu, 5 Nov 2026 16:38:03 +0300 Subject: [PATCH 076/148] fix(core): name a concurrent reconcile when a fresh run cannot take its lease Since the CLI reconciles at every start (2.6.G), a refused acquire for a fresh run is reachable without a broken lease port: another process's reconcile can claim the run between its run:started and the acquire. The #acquireLease docstring still called that refusal a broken port, and the run's durable terminal said only that the lease port refused it. The message now names both causes, and the docstring says so. The public reconcile() docstring now says which runs it leaves for a later reconcile (a run whose lease it could not take, a run whose held terminal it could not write, every run while the outbox cannot be read), instead of promising that a crashed run never lingers. A new test pins the drain's declined-claim hold: a lease port that throws on the drain's claim and succeeds on the repair's. Without the hold the repair writes run:failed past the held completion; the mutant that drops it fails. Refs: MVP-1 1b D64 round 4 (liveness-2, liveness-3, liveness-4) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- packages/core/src/engine/engine.test.ts | 44 +++++++++++++++++++++++++ packages/core/src/engine/engine.ts | 30 ++++++++++------- 2 files changed, 62 insertions(+), 12 deletions(-) diff --git a/packages/core/src/engine/engine.test.ts b/packages/core/src/engine/engine.test.ts index 5dc96957..f9380076 100644 --- a/packages/core/src/engine/engine.test.ts +++ b/packages/core/src/engine/engine.test.ts @@ -6201,6 +6201,50 @@ describe('WorkflowEngine — internal failures and handle-side controls', () => 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'); diff --git a/packages/core/src/engine/engine.ts b/packages/core/src/engine/engine.ts index a6ce6410..81309843 100644 --- a/packages/core/src/engine/engine.ts +++ b/packages/core/src/engine/engine.ts @@ -1092,13 +1092,14 @@ class RunExecution { 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, }, }; @@ -4166,10 +4167,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. */ @@ -6339,11 +6342,14 @@ 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{internal}`, or `budget_exceeded` when ordered gate history already records a budget rejection, + * continuing that run's `sequenceNumber`. Returns the reconciled events. Resumable runs (parked at a gate) + * are left for `resume`. Also left alone, for a later reconcile: 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, 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 From 88f79b878f018686b0595c00fa4cf7a3b27746cf Mon Sep 17 00:00:00 2001 From: Cemil ILIK <cemililik@outlook.com> Date: Thu, 5 Nov 2026 16:38:03 +0300 Subject: [PATCH 077/148] test(cli): say a provider without a model reaches the starter only from a direct caller Config resolution drops a persisted provider that has no model, so only a caller passing one reaches the provider's starter model. The test name and comment said a persisted provider did. Refs: MVP-1 1b D64 round 4 (keys-1) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- apps/cli/src/chat/agent-source.test.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/apps/cli/src/chat/agent-source.test.ts b/apps/cli/src/chat/agent-source.test.ts index d07fae0e..debdc869 100644 --- a/apps/cli/src/chat/agent-source.test.ts +++ b/apps/cli/src/chat/agent-source.test.ts @@ -95,7 +95,7 @@ describe('resolveChatAgent', () => { expect(resolve([])).toMatchObject({ model: DEFAULT_CHAT_MODEL, provider: 'anthropic' }); }); - it('MVP-A4: a configured model or provider wins over the keys', () => { + 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, { @@ -106,7 +106,8 @@ describe('resolveChatAgent', () => { hasKey: keyedOpenAi, }), ).toMatchObject({ model: 'claude-haiku-4-5', provider: 'anthropic' }); - // A persisted provider without a model gets that provider's starter, never the Anthropic default. + // 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, From df685a52685e6aee20a652d151e5a76e1773ef11 Mon Sep 17 00:00:00 2001 From: Cemil ILIK <cemililik@outlook.com> Date: Thu, 5 Nov 2026 16:38:03 +0300 Subject: [PATCH 078/148] docs(repo): record the MVP-1 1b round 4 fixes and its deferred lows commands.md's output-masking sentence now says a name the scrubber masks up to its own end is a secret shape, not a label, and keeps its value. The chat-export section points the live /export's review notice at the canonical mapping, and home.md links to the exit-5 note for what reconcile settles instead of restating it. The VS Code sidebar row and gate command, and the desktop shell, no longer offer only an approval at every gate. current.md records round 4 as not accepted (one medium, fixed). deferred-tasks.md records its four deferred lows, among them a 0.1.x run settled by a newer start's reconcile, to decide before v0.2.0. Refs: MVP-1 1b D64 round 4 (docs-1, docs-2, docs-3, gate-1) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- docs/reference/cli/commands.md | 4 +-- docs/reference/cli/home.md | 2 +- docs/reference/desktop/routes-and-screens.md | 2 +- docs/reference/vscode/extension-api.md | 4 +-- docs/roadmap/current.md | 8 ++--- docs/roadmap/deferred-tasks.md | 34 ++++++++++++++++++-- 6 files changed, 42 insertions(+), 12 deletions(-) diff --git a/docs/reference/cli/commands.md b/docs/reference/cli/commands.md index f699c2c3..f4de6fbc 100644 --- a/docs/reference/cli/commands.md +++ b/docs/reference/cli/commands.md @@ -200,7 +200,7 @@ 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 <digest>` 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 secret shapes in the original text, 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). 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 <runId> --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. +- **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 secret shapes in the original text, 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 <runId> --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_<PROVIDER>_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 <digest>` (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). @@ -453,7 +453,7 @@ No per-session attribution or transcript is loaded for this listing. 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 `<sessionId>.relavium.yaml` in cwd by default (the file name is keyed on the unique session id, so two sessions never collide); `--out <path>` 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. Where the live `/export` puts its review notice, path, run hint and refusal is stated with the `/export` command in [chat-session.md](chat-session.md#the-multi-turn-loop). 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. diff --git a/docs/reference/cli/home.md b/docs/reference/cli/home.md index 5148b2ef..892f2039 100644 --- a/docs/reference/cli/home.md +++ b/docs/reference/cli/home.md @@ -50,7 +50,7 @@ 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 `run:failed` (`internal`, or `budget_exceeded` after a recorded budget rejection) and shows under *Attention required*, and a terminal held in the outbox is written. It 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. While the terminal outbox exists but cannot be read, nothing is settled, because no run can be shown to have no held terminal; `relavium status` warns that the outbox is unavailable. A run parked at a gate and a run another process holds a live lease on are left alone. The rules are the same as for `run` and `gate` ([exit code `5`](commands.md#exit-codes)). +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/desktop/routes-and-screens.md b/docs/reference/desktop/routes-and-screens.md index 4b4df35e..81e691fc 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 diff --git a/docs/reference/vscode/extension-api.md b/docs/reference/vscode/extension-api.md index dfcbf1be..27abd361 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). | diff --git a/docs/roadmap/current.md b/docs/roadmap/current.md index e41edc58..f41e24b7 100644 --- a/docs/roadmap/current.md +++ b/docs/roadmap/current.md @@ -100,10 +100,10 @@ Wave 3 split as `row <n>` in the [increment table](phases/phase-2.5.5-hardening- (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 implemented and committed locally, not pushed until it is accepted. Wiring liveness exposed a second-terminal race, so the run store now refuses a second - terminal. The rigor pass is done. D64 rounds 1 (at `5f2a0eb8`), 2 (at `60bc9727`) and 3 (at `224b8f69`) were not - accepted, with nine, two and two medium findings, all fixed; their lows are fixed or recorded in - [deferred-tasks.md](deferred-tasks.md). Still owed: one new complete round with no medium or higher finding, then a - separate security sitting. + terminal. The rigor pass is done. 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; their lows are fixed or + recorded in [deferred-tasks.md](deferred-tasks.md). Still owed: one new complete round with no medium or higher + finding, then a separate security sitting. 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 diff --git a/docs/roadmap/deferred-tasks.md b/docs/roadmap/deferred-tasks.md index 8144323d..d9326650 100644 --- a/docs/roadmap/deferred-tasks.md +++ b/docs/roadmap/deferred-tasks.md @@ -1474,8 +1474,9 @@ MVP pulled without an annotation); both were fixed on 2026-11-04. ## MVP-1 1b — recorded low findings (2026-11-05) -D64 rounds 1 (at `5f2a0eb8`), 2 (at `60bc9727`) and 3 (at `224b8f69`) of MVP-1's security increment found nine, two -and two medium findings, all fixed before the next round, and the lows below that were not fixed with them. +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. **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.* @@ -1560,6 +1561,35 @@ and two medium findings, all fixed before the next round, and the lows below tha **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"). **Re-check before the v0.2.0 release:** either tell users in the + release notes to finish or cancel 0.1.x runs first, or treat a run with no lease row whose last event is younger + than the lease TTL as live, as an ADR-0079 amendment. *(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)* + ## Wave 3 NI — recorded low findings (2026-10-25) Round 1 of the native-identity increment at `fc5af701` accepted it with these lows From ab5e46e16dfe9543593d0a91dc149c6f84987f9d Mon Sep 17 00:00:00 2001 From: Cemil ILIK <cemililik@outlook.com> Date: Thu, 5 Nov 2026 18:23:05 +0300 Subject: [PATCH 079/148] docs(repo): record the MVP-1 1b round 5 lows D64 round 5 at df685a52 found no blocker, high or medium finding. Its nine lows are recorded in deferred-tasks.md: two liveness (the exit-code table's retry wording, the reconcile() docstring), one keys (/clear before a /models rescue), one gate (the desktop overlay's Reject), three outputs (a delimiter after a self-masked name, an unpinned clause, a masked-name collision) and two export (the list sink /export now feeds, the export mapping's filename line). Refs: MVP-1 1b D64 round 5 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- docs/roadmap/deferred-tasks.md | 61 +++++++++++++++++++++++++++++++++- 1 file changed, 60 insertions(+), 1 deletion(-) diff --git a/docs/roadmap/deferred-tasks.md b/docs/roadmap/deferred-tasks.md index d9326650..9db5f158 100644 --- a/docs/roadmap/deferred-tasks.md +++ b/docs/roadmap/deferred-tasks.md @@ -1476,7 +1476,8 @@ MVP pulled without an annotation); both were fixed on 2026-11-04. 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. +were not fixed with them. Round 5 (at `df685a52`) found no blocker, high or medium finding; its nine lows are the +last group below. **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.* @@ -1590,6 +1591,64 @@ were not fixed with them. 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)* +- [ ] **`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)* 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)* + ## Wave 3 NI — recorded low findings (2026-10-25) Round 1 of the native-identity increment at `fc5af701` accepted it with these lows From e11167eaaf082c966530b412014a1efc3ff8ca6e Mon Sep 17 00:00:00 2001 From: Cemil ILIK <cemililik@outlook.com> Date: Thu, 5 Nov 2026 18:43:19 +0300 Subject: [PATCH 080/148] docs(repo): accept MVP-1 1b and record the security sitting's lows D64 round 5 at df685a52 found no blocker, high or medium finding, and the separate security sitting at ab5e46e1 passed, so 1b meets the D64 bar and is accepted. current.md says so and names what MVP-1 still owes: the milestone review that covers 1a's UX tier. The sitting's two lows go to deferred-tasks.md: a run whose process is stopped past the lease TTL is settled by the next start (liveness only; the woken owner is fenced), and a symlinked .relavium sends the /export transcript to the link's target (extends round 1's export-6). Refs: MVP-1 1b D64 round 5, security sitting Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- docs/roadmap/current.md | 15 ++++++++------- docs/roadmap/deferred-tasks.md | 22 +++++++++++++++++++++- 2 files changed, 29 insertions(+), 8 deletions(-) diff --git a/docs/roadmap/current.md b/docs/roadmap/current.md index f41e24b7..1681b74c 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-05 - **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) @@ -98,12 +98,13 @@ Wave 3 split as `row <n>` in the [increment table](phases/phase-2.5.5-hardening- 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 <runId>`, 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 implemented and committed locally, not - pushed until it is accepted. Wiring liveness exposed a second-terminal race, so the run store now refuses a second - terminal. The rigor pass is done. 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; their lows are fixed or - recorded in [deferred-tasks.md](deferred-tasks.md). Still owed: one new complete round with no medium or higher - finding, then a separate security sitting. + 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. Still owed for MVP-1: the milestone review that covers 1a's UX tier. 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 diff --git a/docs/roadmap/deferred-tasks.md b/docs/roadmap/deferred-tasks.md index 9db5f158..3aa4b8d1 100644 --- a/docs/roadmap/deferred-tasks.md +++ b/docs/roadmap/deferred-tasks.md @@ -1477,7 +1477,7 @@ MVP pulled without an annotation); both were fixed on 2026-11-04. 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 -last group below. +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.* @@ -1649,6 +1649,26 @@ last group below. `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)* + ## Wave 3 NI — recorded low findings (2026-10-25) Round 1 of the native-identity increment at `fc5af701` accepted it with these lows From 9da0a6461241e3d91ac06487bba6b8e1e4e6b884 Mon Sep 17 00:00:00 2001 From: Cemil ILIK <cemililik@outlook.com> Date: Fri, 6 Nov 2026 00:14:37 +0300 Subject: [PATCH 081/148] ci(repo): give the Windows headless chat smoke a fake provider key Since MVP-A4 a keyless `relavium chat` refuses with exit 2 before it chooses a driver, and the Windows runner stores no key, so the headless smoke stopped reaching the plain driver and failed on e11167ea ("headless chat exited 2"). The smoke now gives that one `chat` call a fake env key; an empty stdin sends nothing, so the key is never sent. Locally, with an empty keyring and a scratch HOME: no key exits 2 with the missing-key message, the fake key exits 4 on the plain driver. The structural test's fake node now records whether each call carried the key, and the harness clears any key from the developer's shell, so dropping the key from ci.yml fails 7 of its 8 tests. Refs: MVP-1 1b (MVP-A4) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- .github/workflows/ci.yml | 6 ++++-- tools/ci/windows-headless-smoke.test.mjs | 10 ++++++---- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 638ddda9..42bc2862 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -604,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/tools/ci/windows-headless-smoke.test.mjs b/tools/ci/windows-headless-smoke.test.mjs index ddef2d30..55d65b42 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']); }); } From 837a57c40e67bbf0c26b9c55a3516761de3cf836 Mon Sep 17 00:00:00 2001 From: Cemil ILIK <cemililik@outlook.com> Date: Fri, 6 Nov 2026 00:14:37 +0300 Subject: [PATCH 082/148] test(repo): read GnuPG's own AEAD choice in the custody test ubuntu-latest moved from ubuntu-24.04 to ubuntu-26.04 between two runs, and the GnuPG there reports SEIPDv1 for the AEAD-advertising fixture. The product's profile check rightly admitted it, but the test inferred OCB from "version >= 2.3" and expected a PROFILE refusal, so six Linux jobs failed on e11167ea. The test now captures the real GnuPG's BEGIN_ENCRYPTION status line through the recording run port, matched with its own string compare rather than the product's parser, expects admission only for exactly "2 9" and a PROFILE refusal otherwise, fails when no single encryption line was captured, and prints which branch ran. Locally GnuPG 2.5.21 chose "0 9 2" and PROFILE is refused; a product mutant that admits AEAD fails the test. The synthetic status rewrites still cover the refusal logic on every runner. deferred-tasks.md records that CI no longer runs the real OCB refusal, and moves a line reference the edit shifted. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- docs/roadmap/deferred-tasks.md | 9 +++++++- tools/cli-package/custody.test.mjs | 37 +++++++++++++++++++++++++----- 2 files changed, 39 insertions(+), 7 deletions(-) diff --git a/docs/roadmap/deferred-tasks.md b/docs/roadmap/deferred-tasks.md index 3aa4b8d1..2c756eb4 100644 --- a/docs/roadmap/deferred-tasks.md +++ b/docs/roadmap/deferred-tasks.md @@ -1041,7 +1041,14 @@ 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. diff --git a/tools/cli-package/custody.test.mjs b/tools/cli-package/custody.test.mjs index 6126516c..66263561 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 () => { From 158459a0ddc58c43376d13febad8df943c5376d6 Mon Sep 17 00:00:00 2001 From: Cemil ILIK <cemililik@outlook.com> Date: Fri, 6 Nov 2026 00:17:46 +0300 Subject: [PATCH 083/148] fix(cli): sanitize every catalog field relavium list prints MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `relavium list` printed a workflow or agent name, each tag and an invalid file's slug (its file name) raw to stdout; only the parse reason went through sanitizeUntrustedInline. A name is free authored text: since MVP-A3 `/export` writes a session's title there, and a cloned repository can commit any name, tag or file name, so a window-title OSC, a screen clear or a bidi override reached the terminal. Every catalog field now passes the same sink sanitizer, as `list --runs` already does for a workflow id. A new test feeds `ESC ] … BEL`, `ESC [2J` and U+202E through a workflow name, a tag, a file-name slug and an agent name; dropping the sanitizer on the tag, the name or the slug each fails it. Refs: MVP-1 1b D64 round 5 (export-1), security sitting item 6 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- apps/cli/src/commands/list.test.ts | 55 ++++++++++++++++++++++++++++++ apps/cli/src/commands/list.ts | 16 ++++++--- 2 files changed, 67 insertions(+), 4 deletions(-) diff --git a/apps/cli/src/commands/list.test.ts b/apps/cli/src/commands/list.test.ts index a374f61b..618d69de 100644 --- a/apps/cli/src/commands/list.test.ts +++ b/apps/cli/src/commands/list.test.ts @@ -193,6 +193,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‮'; + 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('‮'); + } + 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 diff --git a/apps/cli/src/commands/list.ts b/apps/cli/src/commands/list.ts index 9933cdaf..778a3c9b 100644 --- a/apps/cli/src/commands/list.ts +++ b/apps/cli/src/commands/list.ts @@ -221,7 +221,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 @@ -231,11 +232,18 @@ 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}]`); From 624bb2e73a1cb8d698c6d00163a6a7e4d94cc108 Mon Sep 17 00:00:00 2001 From: Cemil ILIK <cemililik@outlook.com> Date: Fri, 6 Nov 2026 00:17:46 +0300 Subject: [PATCH 084/148] docs(repo): say relavium list sanitizes every catalog field commands.md's `relavium list` section now says each field read from a catalog file is printed on one line with terminal controls stripped and secret shapes masked, and deferred-tasks.md closes round 5's export-1 entry. Refs: MVP-1 1b D64 round 5 (export-1) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- docs/reference/cli/commands.md | 2 +- docs/roadmap/deferred-tasks.md | 7 +++++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/docs/reference/cli/commands.md b/docs/reference/cli/commands.md index f4de6fbc..6ca53112 100644 --- a/docs/reference/cli/commands.md +++ b/docs/reference/cli/commands.md @@ -210,7 +210,7 @@ printf 'api_key=%s\n' "$API_KEY" | relavium run ./workflows/code-review.relavium ### `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: <reason>)` 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: <reason>)` rather than hidden. 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 secret shapes masked: a name is free authored text, and `/export` writes a session's title there. 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). diff --git a/docs/roadmap/deferred-tasks.md b/docs/roadmap/deferred-tasks.md index 2c756eb4..0029ba55 100644 --- a/docs/roadmap/deferred-tasks.md +++ b/docs/roadmap/deferred-tasks.md @@ -1641,8 +1641,11 @@ next group below, and the separate security sitting's two lows (at `ab5e46e1`) a **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)* -- [ ] **`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)* The scaffold's `name` is the raw session title (only its +- [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 From 314ad50c55c4bca2b6a3809002217b65f7c4662d Mon Sep 17 00:00:00 2001 From: Cemil ILIK <cemililik@outlook.com> Date: Fri, 6 Nov 2026 01:17:59 +0300 Subject: [PATCH 085/148] fix(core): bound the run-path tool pre-flight and fail closed on new egress kinds validateWorkflowToolArms runs before the plan's 500-node ceiling, and it re-filtered and re-joined each node's full grant: nodes binding one inline agent repeated that agent's list, duplicates were echoed, and every issue was kept. A 944 KB workflow with 5,000 nodes over one 60,000-entry grant made `relavium run` abort out of memory under a 1 GB heap where it used to exit 2; at the 500-node ceiling it held about 400 MB and printed a 720 KB line (MVP-1 1a milestone review, preflight-1). Each grant list is now read once (nodes sharing an inline agent share it), a node names each offending tool once, and only the first 10 nodes are named before the rest are counted. Any egress kind other than mcp now maps to the egress arm, so an unknown kind is refused rather than admitted. The message drops "(available in chat)": web_search and http_request do not work in chat yet. New tests cover several offending nodes after an input node and a 600-node, 4,000-entry grant bounded to 11 issues under 2,000 characters. Refs: MVP-1 1a milestone review (preflight-1, preflight-4, preflight-9, docs-7) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- packages/core/src/validate-catalog.test.ts | 32 ++++++++++++ packages/core/src/validate-catalog.ts | 58 ++++++++++++++++------ 2 files changed, 75 insertions(+), 15 deletions(-) diff --git a/packages/core/src/validate-catalog.test.ts b/packages/core/src/validate-catalog.test.ts index 72720100..eef49a52 100644 --- a/packages/core/src/validate-catalog.test.ts +++ b/packages/core/src/validate-catalog.test.ts @@ -5,6 +5,7 @@ import { describe, expect, it } from 'vitest'; import { WorkflowValidationError } from './errors.js'; import { parseWorkflow, type WorkflowDefinition } from './parser.js'; import { + MAX_REPORTED_TOOL_ARM_NODES, validateWorkflowToolArms, validateWorkflowWithCatalog, type WorkflowModelCatalog, @@ -299,6 +300,37 @@ describe('run-path tool pre-flight for unwired host arms (2.6.A, MVP-1)', () => 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( diff --git a/packages/core/src/validate-catalog.ts b/packages/core/src/validate-catalog.ts index cb56e584..255d8127 100644 --- a/packages/core/src/validate-catalog.ts +++ b/packages/core/src/validate-catalog.ts @@ -227,8 +227,9 @@ 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, which a run path wires (`host.mcp`), so it needs neither. - if (tool.policy.egress === 'http' || tool.policy.egress === 'search') return 'egress'; + // 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; } @@ -239,32 +240,59 @@ const TOOL_HOST_ARMS: ReadonlyMap<string, ToolHostArm> = new Map( }), ); +/** 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 is a certain `tool_unavailable` at its first call, 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} - * listing every offending node, naming the tool, never a payload. + * 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 issues = workflow.workflow.nodes.flatMap((node): WorkflowIssue[] => { - const tools = grantedTools(node, workflow).filter((tool) => { + 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); - return arm !== undefined && opts.unwired.includes(arm); + 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`, }); - return tools.length === 0 - ? [] - : [ - { - field: `node \`${node.id}\`.tools`, - message: `${tools.join(', ')} ${tools.length === 1 ? 'needs' : 'need'} a host capability this run path does not provide (available in chat) — 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); } From f969d25477b961deb93322450b5681e6e5e40c6b Mon Sep 17 00:00:00 2001 From: Cemil ILIK <cemililik@outlook.com> Date: Fri, 6 Nov 2026 01:17:59 +0300 Subject: [PATCH 086/148] refactor(cli): wire the egress and os arms from their own unwired entries assembleToolEnv wired both arms only when a profile left neither unwired, so a future profile leaving one arm unwired would have wired neither while the pre-flight admitted the other's tools. Each arm now reads its own entry. RUN_TOOL_PROFILE moves above buildEngine's JSDoc, which it had detached, and the JSDoc says `relavium run` refuses such a grant before the engine is built. Refs: MVP-1 1a milestone review (preflight-4, preflight-8) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- apps/cli/src/engine/build-engine.ts | 10 +++++----- apps/cli/src/engine/tool-host/assemble.ts | 16 +++++++++++----- 2 files changed, 16 insertions(+), 10 deletions(-) diff --git a/apps/cli/src/engine/build-engine.ts b/apps/cli/src/engine/build-engine.ts index 7f84b0ab..22d37565 100644 --- a/apps/cli/src/engine/build-engine.ts +++ b/apps/cli/src/engine/build-engine.ts @@ -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,12 +120,9 @@ 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. */ -/** 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'; - export async function buildEngine(options: BuildEngineOptions = {}): Promise<WorkflowEngine> { const host = options.host ?? createCliHost(); const providers = options.providers ?? createProviderResolver(); diff --git a/apps/cli/src/engine/tool-host/assemble.ts b/apps/cli/src/engine/tool-host/assemble.ts index 60520374..d7248ee5 100644 --- a/apps/cli/src/engine/tool-host/assemble.ts +++ b/apps/cli/src/engine/tool-host/assemble.ts @@ -105,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 = unwiredToolArms(opts.profile).length === 0; + // 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({ @@ -128,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 — From abe14ca783b17b5daf86f1fd8ea4b10b2861bf6f Mon Sep 17 00:00:00 2001 From: Cemil ILIK <cemililik@outlook.com> Date: Fri, 6 Nov 2026 01:17:59 +0300 Subject: [PATCH 087/148] fix(cli): point a paused run's summary at the command that accepts it The TUI final summary told every paused run to `relavium gate <runId>`, which refuses a budget gate (MVP-1 1a milestone review, runid-1/docs-1). It now names `relavium gate` when a human gate is pending, `relavium budget resume <id> --gate <g>` for a budget gate (through the same shell-inert check the plain renderer uses, with a pointer to `relavium status` for the amount), and `relavium status` for a run parked on a media job only. A stream resumed by `gate` or `budget resume` carries no run:started, so the view now takes the run id from its first applied event; the footer, the id line and the outputs note name it (runid-2, closing 1b round 2's outputs-5). An event newer than the reducer still leaves the state unchanged. The test covers failed, cancelled, no-terminal, budget, mixed, media-park and resumed streams; dropping the budget branch or the id latch fails it. Refs: MVP-1 1a milestone review (runid-1, runid-2, runid-4, docs-1) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- apps/cli/src/render/tui/final-summary.test.ts | 81 ++++++++++++++++++- apps/cli/src/render/tui/final-summary.ts | 41 ++++++++-- apps/cli/src/render/tui/run-view-model.ts | 11 ++- 3 files changed, 125 insertions(+), 8 deletions(-) diff --git a/apps/cli/src/render/tui/final-summary.test.ts b/apps/cli/src/render/tui/final-summary.test.ts index c14f2291..c03ca134 100644 --- a/apps/cli/src/render/tui/final-summary.test.ts +++ b/apps/cli/src/render/tui/final-summary.test.ts @@ -139,10 +139,89 @@ describe('renderFinalSummary', () => { expect(renderFinalSummary(paused)).toContain( ` run id ${RUN} — resume with \`relavium gate ${RUN}\``, ); - // Before any run:started the summary has no id to offer. + // 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 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 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, paused(3, ['approve-1', 'budget-1'])])).toEqual([ + gate, + 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', + }; + expect(linesOf([resumed, paused(8, ['approve-2'])])).toEqual([gate]); + }); + 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 c236c96c..13721033 100644 --- a/apps/cli/src/render/tui/final-summary.ts +++ b/apps/cli/src/render/tui/final-summary.ts @@ -7,6 +7,7 @@ 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'; @@ -57,12 +58,7 @@ export function renderFinalSummary(state: RunViewState): string { // The run id `logs`, `status` and `gate` accept (MVP-A2), with the command that continues from here. if (state.runId !== undefined) { - const id = sanitizeInline(state.runId); - lines.push( - summary?.outcome === 'paused' - ? ` run id ${id} — resume with \`relavium gate ${id}\`` - : ` run id ${id} — replay with \`relavium logs ${id}\``, - ); + lines.push(...continuationLines(state, state.runId)); } if (summary?.errorMessage !== undefined) { @@ -102,3 +98,36 @@ export function renderFinalSummary(state: RunViewState): string { 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` refuses), and `relavium status` for a run parked on a media job only. + */ +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 lines: string[] = []; + if (parked.some((gateId) => !budgetGates.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 (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.ts b/apps/cli/src/render/tui/run-view-model.ts index e4359c27..ed006366 100644 --- a/apps/cli/src/render/tui/run-view-model.ts +++ b/apps/cli/src/render/tui/run-view-model.ts @@ -332,6 +332,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 }; @@ -536,7 +545,7 @@ 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; } } } From 199601bdcbe60429f078c222a6f689f5f03b577f Mon Sep 17 00:00:00 2001 From: Cemil ILIK <cemililik@outlook.com> Date: Fri, 6 Nov 2026 01:18:15 +0300 Subject: [PATCH 088/148] fix(cli): show nothing pending for a finished run in status <runId> `relavium status <runId>` ran the active-run gate reconstruction on a finished run, so a run cancelled or failed while a gate waited (Esc at an inline gate prompt cancels without resolving it) was shown with a pending approval gate, or copyable `budget resume` commands, that `gate` and `budget resume` refuse and `gate list` does not show (MVP-1 1a milestone review, history-1). A finished run now has nothing pending, a step its node never finished prints as `stopped` (the --json step keeps the recorded status), and a finished run with no step rows reads "(no node ran)". Identifiers read back from history.db (the run id, workflow id, node ids, node type, gate ids) are printed one-line with controls stripped in `status`, as is the run id in `list --runs` and the directory in `list`'s no-project line (history-3, security-1, security-2). seedRun gains `cancelAfterPause`; new tests seed a cancelled run with open human and budget gates, and history identifiers and a directory name carrying ESC, BEL and U+202E. Dropping the finished-run guard, the `stopped` mapping, or either list sanitizer fails them. Refs: MVP-1 1a milestone review (history-1, history-3, history-7, security-1, security-2, docs-6) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- apps/cli/src/commands/list.test.ts | 35 ++++++++++++++++++++++++++++ apps/cli/src/commands/list.ts | 13 +++++++---- apps/cli/src/commands/status.test.ts | 28 ++++++++++++++++++++++ apps/cli/src/commands/status.ts | 31 ++++++++++++++++++++---- apps/cli/src/test-support.ts | 6 +++++ 5 files changed, 103 insertions(+), 10 deletions(-) diff --git a/apps/cli/src/commands/list.test.ts b/apps/cli/src/commands/list.test.ts index 618d69de..88021f15 100644 --- a/apps/cli/src/commands/list.test.ts +++ b/apps/cli/src/commands/list.test.ts @@ -297,4 +297,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‮', + 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('‮'); + }); }); diff --git a/apps/cli/src/commands/list.ts b/apps/cli/src/commands/list.ts index 778a3c9b..8898fe68 100644 --- a/apps/cli/src/commands/list.ts +++ b/apps/cli/src/commands/list.ts @@ -8,7 +8,7 @@ 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'; @@ -57,11 +57,13 @@ export function listCommand(args: ListCommandArgs, deps: ListCommandDeps): ExitC } 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; } @@ -151,7 +153,8 @@ function listRuns(deps: ListCommandDeps, homeDir: string): void { for (const run of runs) { const workflow = sanitizeUntrustedInline(slugById.get(run.workflowId) ?? run.workflowId); const started = run.startedAt ?? run.createdAt; - deps.io.writeOut(`${run.id} ${run.status.padEnd(9)} ${workflow} ${started}\n`); + // The run id is read back from history.db: one line, controls stripped, never masked (it is copied). + deps.io.writeOut(`${sanitizeInline(run.id)} ${run.status.padEnd(9)} ${workflow} ${started}\n`); } } finally { if (deps.commandLifetime === undefined) close(); diff --git a/apps/cli/src/commands/status.test.ts b/apps/cli/src/commands/status.test.ts index 88d6e68a..45e8589c 100644 --- a/apps/cli/src/commands/status.test.ts +++ b/apps/cli/src/commands/status.test.ts @@ -52,6 +52,34 @@ describe('statusCommand', () => { ).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` refuse a finished run. + 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' }); + expect(parseNdjson(machine.out())[0]).toMatchObject({ + status: 'cancelled', + pendingGates: [], + pendingBudgetGates: [], + }); + }); + 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' }); diff --git a/apps/cli/src/commands/status.ts b/apps/cli/src/commands/status.ts index 8bcabebd..28514801 100644 --- a/apps/cli/src/commands/status.ts +++ b/apps/cli/src/commands/status.ts @@ -156,7 +156,10 @@ export async function statusCommand( // 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 <runId>`) has nothing pending, whatever its log left open: a run cancelled or + // failed while a gate waited never resolves that gate, and `gate` / `budget resume` refuse it, as + // `gate list` does. + ...(TERMINAL_STATUSES.has(run.status) ? NOTHING_PENDING : gatesOf(reader, run)), }; }); @@ -187,6 +190,13 @@ function requireRun(reader: RunHistoryReader, runId: string): RunRecord { return run; } +/** What a finished run has pending: nothing (see the call site). */ +const NOTHING_PENDING = { + pendingGates: [], + pendingBudgetGates: [], + gatesUnavailable: false, +} as const; + /** Reconstruct once per active run: a companion crash may leave a running projection with pending gates. */ function gatesOf( reader: RunHistoryReader, @@ -253,16 +263,25 @@ function toJson(status: ActiveRunStatus): unknown { /** 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)} — ${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'); + io.writeOut(finished ? ' (no node ran)\n' : ' (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`); + // A run terminal does not close a step row the node never finished, so under a finished run it says so. + const shown = finished && step.status === 'running' ? 'stopped' : step.status; + io.writeOut( + ` ${shown.padEnd(9)} ${sanitizeInline(step.nodeId)} [${sanitizeInline(step.nodeType)}]${attempt}\n`, + ); } if (status.terminalHeld) { // The run reads `running` because its terminal never became durable; without this line the exit-5 @@ -294,6 +313,8 @@ 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`, + ); } } diff --git a/apps/cli/src/test-support.ts b/apps/cli/src/test-support.ts index 41ad60be..49ca13f8 100644 --- a/apps/cli/src/test-support.ts +++ b/apps/cli/src/test-support.ts @@ -143,6 +143,11 @@ export type SeedRunOptions = { 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<Record<string, unknown>>; + /** + * 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; } & SeedGateFields; const DEFAULT_TS_MS = 1_750_000_000_000; @@ -200,6 +205,7 @@ export async function seedRun(db: Db, opts: SeedRunOptions): Promise<string> { } if (opts.state === 'paused') { await emitPausedState(emit, opts, ts, tsMs); + if (opts.cancelAfterPause === true) await emit('run:cancelled', { cumulativeCostMicrocents: 100 }); } else if (opts.state === 'completed') { await emit('run:completed', { outputs: opts.outputs ?? {}, From 272e0991266fdf308bf162f234d0ea00b01d389b Mon Sep 17 00:00:00 2001 From: Cemil ILIK <cemililik@outlook.com> Date: Fri, 6 Nov 2026 01:18:15 +0300 Subject: [PATCH 089/148] fix(cli): scale a chat's attempt deadline to its output cap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MVP-A7 raised an Anthropic chat's default output cap to the catalog ceiling (128,000 on claude-sonnet-4-6), but ADR-0082's per-attempt deadline is an absolute 120 s that does not reset per chunk, so a healthy reply streaming past it failed as a billed timeout and was rolled back, where the old 4,096 cap had ended it cleanly as truncated (MVP-1 1a milestone review, budget-1). As ADR-0082 §6 lets a host, the chat host now arms a deadline long enough to stream the effective cap at 25 tokens per second, never below the default and never above 30 minutes (chatAttemptDeadline), on fresh, resumed and rebuilt sessions alike. The output default and its deadline are for chat surfaces only: `relavium agent run` passes `oneShot` and keeps the adapter's implicit cap and the default deadline (budget-2). A `deadlineTimer` seam lets the tests observe the armed delay: a fresh and a resumed chat arm the scaled deadline and send the ceiling, a one-shot arms the default and sends no cap. Dropping the deadline on either path, or ignoring oneShot, fails that test. Refs: MVP-1 1a milestone review (budget-1, budget-2, budget-5, docs-5, docs-9), ADR-0101, ADR-0082 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- apps/cli/src/chat/session-host.test.ts | 94 +++++++++++++++++++++++++- apps/cli/src/chat/session-host.ts | 69 +++++++++++++++---- apps/cli/src/commands/agent-run.ts | 1 + 3 files changed, 150 insertions(+), 14 deletions(-) diff --git a/apps/cli/src/chat/session-host.test.ts b/apps/cli/src/chat/session-host.test.ts index 1138f056..d60cb30d 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 { catalogModel, 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'; @@ -46,6 +46,8 @@ import { buildDefaultChatAgent } from './default-agent.js'; import { buildChatSession as buildFreshSession, buildGovernorWiring, + chatAttemptDeadline, + MAX_CHAT_ATTEMPT_TIMEOUT_MS, buildResumedChatSession as buildRestoredSession, swapAgentModel, withChatOutputDefault, @@ -2268,6 +2270,96 @@ describe('the interactive output default (MVP-A7, ADR-0101 amendment of 2026-11- 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<unknown>; + cancel: () => void; + }; + recordedEvents: Promise<unknown>; + }, + start = true, + ): Promise<void> => { + 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)', () => { diff --git a/apps/cli/src/chat/session-host.ts b/apps/cli/src/chat/session-host.ts index 65eef69d..ae598f9c 100644 --- a/apps/cli/src/chat/session-host.ts +++ b/apps/cli/src/chat/session-host.ts @@ -31,6 +31,7 @@ import { } from '@relavium/core'; import { catalogModel, + DEFAULT_ATTEMPT_TIMEOUT_MS, effortTiersFor, type EndpointKind, type PricingOverlay, @@ -107,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 <ref>` (path or bare id); `undefined` ⇒ the built-in default agent over `[chat].default_model`. */ @@ -333,6 +341,7 @@ type SessionRuntimeOptions = Pick< BuildChatSessionOptions, | 'chat' | 'maxTokensEstimate' + | 'deadlineTimer' | 'now' | 'providers' | 'toolHost' @@ -704,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(), @@ -1025,16 +1034,21 @@ export async function buildChatSession(opts: BuildChatSessionOptions): Promise<B const attached: { handle?: SessionHandle } = {}; const whenReady = (): Promise<void> => 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: withChatOutputDefault( - narrowToWired(withMcpGrant(agent, mcp), host, deps.tools), - chatOutputSettings(opts.chat), - officialEndpointOf(deps), - ), + agent: sessionAgent, context, - deps: { ...deps, whenReady }, + deps: { + ...deps, + ...(opts.oneShot === true ? {} : chatAttemptDeadline(sessionAgent)), + whenReady, + }, }); const handle = createSessionHandle( bus, @@ -1167,7 +1181,12 @@ export interface BuiltResumedChatSession extends BuiltChatSession { 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; @@ -1312,18 +1331,20 @@ export async function buildResumedChatSession( // 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: withChatOutputDefault( - narrowToWired(withMcpGrant(agent, mcp), host, deps.tools), - chatOutputSettings(opts.chat), - officialEndpointOf(deps), - ), + agent: sessionAgent, context, deps: { ...deps, + ...chatAttemptDeadline(sessionAgent), whenReady: () => attached.handle?.whenConsumersReady() ?? Promise.resolve(), }, }, @@ -1572,6 +1593,28 @@ function chatOutputSettings(chat: ResolvedChatConfig): { 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) => { diff --git a/apps/cli/src/commands/agent-run.ts b/apps/cli/src/commands/agent-run.ts index 38be940c..90007f37 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 From 52de04b81da21da2cc54e10616c3bdddca35a74b Mon Sep 17 00:00:00 2001 From: Cemil ILIK <cemililik@outlook.com> Date: Fri, 6 Nov 2026 01:18:15 +0300 Subject: [PATCH 090/148] docs(repo): record the MVP-1 1a milestone review fixes and its deferred lows - commands.md: the summary's continuation command per outcome; `run` wires fs and process only and refuses egress/os grants with exit 2; the `status` and `list` table rows; `status <runId>` on a finished run; `list --runs` status is the derived projection; `status <runId>` joins the unknown-runId rule. - built-in-tools.md: the pre-flight's limits ($ref grants, mcp_call without a server, resumes). - config-spec.md and ADR-0101's 2026-11-06 note: chat surfaces only, the deadline that scales with the cap, and the compaction and thinking effects. - phase-2.6: the 2.6.A ruling names validateWorkflowToolArms and is marked landed, as are list --runs and status <runId>; MVP-A7 is not clamped. - current.md: the milestone review's four mediums, fixed; a new round owed. - deferred-tasks.md: 1b round 2's outputs-5 closed; eight 1a lows recorded. Refs: MVP-1 1a milestone review Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- ...es-apply-only-when-the-wire-is-uncapped.md | 15 ++++++ docs/reference/cli/commands.md | 12 ++--- docs/reference/contracts/config-spec.md | 2 +- docs/reference/shared-core/built-in-tools.md | 7 ++- docs/roadmap/current.md | 8 ++- docs/roadmap/deferred-tasks.md | 51 ++++++++++++++++++- .../phase-2.6-conversational-authoring.md | 10 ++-- 7 files changed, 91 insertions(+), 14 deletions(-) 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 c11d322c..b0545e9e 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 @@ -361,3 +361,18 @@ This dated note preserves the earlier decision and status history; W8 and the ph > 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/reference/cli/commands.md b/docs/reference/cli/commands.md index 6ca53112..96dbdb2b 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 <sessionId>` | Export a session to a `.relavium.yaml` scaffold for review ([ADR-0026](../../decisions/0026-session-export-to-workflow.md)). | | `relavium agent run <agent> [--fixture <path>] [--json] [--allow-mcp-stdio <digest>]` | 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 <path> [--force]` | Import an external `.relavium.yaml` / `.agent.yaml` into the project, validated + slug-deduplicated. | | `relavium export <id> [--out <path>] [--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 <runId>` | Print the persisted event/log stream for a past run. | -| `relavium status` | Show active runs and their per-node status. | +| `relavium status [<runId>]` | Show active runs and their per-node status, or one run by id, active or finished. | | `relavium gate <runId>` | Resolve a pending human gate (approve / reject / provide input). | | `relavium gate list [<runId>]` | 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 <runId> [--gate <gateId>] --approve-amount <microcents> \| --abort` | Resolve a recorded budget gate with its exact frozen amount, or reject it. See [`budget resume`](#relavium-budget-resume). | @@ -206,7 +206,7 @@ printf 'api_key=%s\n' "$API_KEY" | relavium run ./workflows/code-review.relavium - `--allow-mcp-stdio <digest>` (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 <runId> --gate <gateId>`); with `--json` they are on the NDJSON event line, otherwise the plain/TUI renderer prints them inline (`paused at gate <gateId> (<type>)`, also echoed in the final summary). (`relavium status`, `relavium logs <runId>`, 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 that leads with the run id, and a persistent final summary that names the run id with the command that continues from it (`relavium logs <runId>`, or `relavium gate <runId>` for a paused run; MVP-A2). 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_<PROVIDER>_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 <runId>`. 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 <runId>` for a run that ended; for a paused run, `relavium gate <runId>` when a human gate is pending, `relavium budget resume <runId> --gate <gateId>` for a budget gate (which `gate` refuses), and `relavium status <runId>` for a run parked on a media job only. A run resumed by `gate` or `budget resume` names its id too. 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_<PROVIDER>_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 <runId>`. 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` @@ -219,7 +219,7 @@ is needed): one line per run, `<runId> <status> <workflow> <startedAt>`, wher 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 <runId>` reports. `--runs` and `--agents` exclude each other (MVP-1). +`relavium status <runId> --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 <runId>` names it. `--runs` and `--agents` exclude each other (MVP-1). ### `relavium create` @@ -262,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 <runId> --gate <gateId>` — required when a run has more than one gate pending at once. Without an argument it lists every active run; `relavium status <runId>` 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 run's full event history stays `relavium logs <runId>`. 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 <runId> --gate <gateId>` — required when a run has more than one gate pending at once. Without an argument it lists every active run; `relavium status <runId>` 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` refuse it), and a step its node never finished prints as `stopped` (its `--json` step keeps the recorded status). A run's full event history stays `relavium logs <runId>`. 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. ### `relavium models` @@ -291,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 <runId>`) 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` diff --git a/docs/reference/contracts/config-spec.md b/docs/reference/contracts/config-spec.md index 839ca210..498206dd 100644 --- a/docs/reference/contracts/config-spec.md +++ b/docs/reference/contracts/config-spec.md @@ -183,7 +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. The governor reserves this cap before each turn, so set it below the ceiling when a cost cap is set. +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/shared-core/built-in-tools.md b/docs/reference/shared-core/built-in-tools.md index 2377d2a9..e5fadbfd 100644 --- a/docs/reference/shared-core/built-in-tools.md +++ b/docs/reference/shared-core/built-in-tools.md @@ -36,8 +36,11 @@ A tool declaration in YAML has a `type` discriminator: > **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). MCP tools reach -> through their own arm and are unaffected. +> 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. > **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). diff --git a/docs/roadmap/current.md b/docs/roadmap/current.md index 1681b74c..6665d85f 100644 --- a/docs/roadmap/current.md +++ b/docs/roadmap/current.md @@ -104,7 +104,13 @@ Wave 3 split as `row <n>` in the [increment table](phases/phase-2.5.5-hardening- 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. Still owed for MVP-1: the milestone review that covers 1a's UX tier. + 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. Its lows are fixed or recorded + in [deferred-tasks.md](deferred-tasks.md). Still owed for MVP-1: a new milestone review round with no medium or + higher finding. 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 diff --git a/docs/roadmap/deferred-tasks.md b/docs/roadmap/deferred-tasks.md index 0029ba55..e54eb0f8 100644 --- a/docs/roadmap/deferred-tasks.md +++ b/docs/roadmap/deferred-tasks.md @@ -1537,7 +1537,9 @@ next group below, and the separate security sitting's two lows (at `ab5e46e1`) a 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)* -- [ ] **On a resumed run the outputs note names `<runId>`.** *Low; cosmetic.* *(round 2, outputs-5)* The TUI final +- [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 · @@ -1679,6 +1681,53 @@ next group below, and the separate security sitting's two lows (at `ab5e46e1`) a 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. The lows below were not +fixed with them. **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–12×. + **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)* + ## Wave 3 NI — recorded low findings (2026-10-25) Round 1 of the native-identity increment at `fc5af701` accepted it with these lows diff --git a/docs/roadmap/phases/phase-2.6-conversational-authoring.md b/docs/roadmap/phases/phase-2.6-conversational-authoring.md index cfbb1dd2..8b4f04a0 100644 --- a/docs/roadmap/phases/phase-2.6-conversational-authoring.md +++ b/docs/roadmap/phases/phase-2.6-conversational-authoring.md @@ -196,7 +196,10 @@ the same core. 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+). **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 rides `validateWorkflowWithCatalog`. + 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 @@ -641,7 +644,8 @@ last drill-down position. 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 @@ -1362,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. Recorded as a dated amendment to ADR-0101 (maintainer, 2026-11-05). | +| 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). | From fbcd73e196060518c29952f2cad5357f148f2c7b Mon Sep 17 00:00:00 2001 From: Cemil ILIK <cemililik@outlook.com> Date: Fri, 6 Nov 2026 01:31:50 +0300 Subject: [PATCH 091/148] test(cli): let the deadline wiring guard accept the chat's test timer seam 272e0991 added a `deadlineTimer` test seam, so session-host.ts now arms `opts.deadlineTimer ?? hostDeadlineTimer`, and the source guard that requires the ADR-0082 timer on every chain-building surface stopped matching (the full `pnpm run ci` caught it). The needle now accepts exactly that form: production must still fall back to the host timer, and a mutant without the fallback fails it. Refs: MVP-1 1a milestone review (budget-1), ADR-0082 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- apps/cli/src/engine/effect-journal-wiring.test.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/apps/cli/src/engine/effect-journal-wiring.test.ts b/apps/cli/src/engine/effect-journal-wiring.test.ts index 600b31c5..f15352ea 100644 --- a/apps/cli/src/engine/effect-journal-wiring.test.ts +++ b/apps/cli/src/engine/effect-journal-wiring.test.ts @@ -147,8 +147,9 @@ describe('the per-attempt deadline is wired on every chain-building surface (ADR file: 'chat/session-host.ts', what: 'every session surface — `chat`, `chat-resume`, `agent run`, the Home', // `SessionDeps.newAbortController` is REQUIRED, so tsc catches its absence here; the timer is the - // half that can go missing silently. - needles: [/setTimer:\s*hostDeadlineTimer/], + // half that can go missing silently. A test may inject its own timer (`deadlineTimer`, the MVP-A7 seam + // that observes the chat's scaled deadline); production must still fall back to the host timer. + needles: [/setTimer:\s*(?:opts\.deadlineTimer\s*\?\?\s*)?hostDeadlineTimer\b/], }, { file: 'history/reconcile.ts', From d8762a2319ddb512c03528a20b37887d2b3d6a88 Mon Sep 17 00:00:00 2001 From: Cemil ILIK <cemililik@outlook.com> Date: Fri, 6 Nov 2026 01:44:48 +0300 Subject: [PATCH 092/148] style(cli): format the list run row and the seedRun cancel line 199601bd's two long lines were edited without prettier, so `pnpm run ci`'s format:check failed. Line breaks only. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- apps/cli/src/commands/list.ts | 4 +++- apps/cli/src/test-support.ts | 3 ++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/apps/cli/src/commands/list.ts b/apps/cli/src/commands/list.ts index 8898fe68..809acafd 100644 --- a/apps/cli/src/commands/list.ts +++ b/apps/cli/src/commands/list.ts @@ -154,7 +154,9 @@ function listRuns(deps: ListCommandDeps, homeDir: string): void { 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)} ${run.status.padEnd(9)} ${workflow} ${started}\n`); + deps.io.writeOut( + `${sanitizeInline(run.id)} ${run.status.padEnd(9)} ${workflow} ${started}\n`, + ); } } finally { if (deps.commandLifetime === undefined) close(); diff --git a/apps/cli/src/test-support.ts b/apps/cli/src/test-support.ts index 49ca13f8..58905de6 100644 --- a/apps/cli/src/test-support.ts +++ b/apps/cli/src/test-support.ts @@ -205,7 +205,8 @@ export async function seedRun(db: Db, opts: SeedRunOptions): Promise<string> { } if (opts.state === 'paused') { await emitPausedState(emit, opts, ts, tsMs); - if (opts.cancelAfterPause === true) await emit('run:cancelled', { cumulativeCostMicrocents: 100 }); + if (opts.cancelAfterPause === true) + await emit('run:cancelled', { cumulativeCostMicrocents: 100 }); } else if (opts.state === 'completed') { await emit('run:completed', { outputs: opts.outputs ?? {}, From dc05e2044f9d422d48a8aab0decb37028a197bc8 Mon Sep 17 00:00:00 2001 From: Cemil ILIK <cemililik@outlook.com> Date: Fri, 6 Nov 2026 03:24:15 +0300 Subject: [PATCH 093/148] fix(cli): point a resumed run's restored gate at relavium status The final summary classified every parked gate this stream had not announced as a budget gate as a human gate. A resumed engine re-parks on the gates it restored from its checkpoint without announcing them again, so after the round-1 run-id latch a resumed run parked on a budget gate was told to use `relavium gate`, which answers "already resolved" with exit 0 while the run stays parked. The view now keeps the human gates this stream announced (one per run and node, dropped on resume and at a terminal). The summary names `relavium gate` only for those, `relavium budget resume` for an announced budget gate, and `relavium status` for a gate it never saw announced: status reads the whole log and prints each gate's command. Mutation: a gate line for any non-budget gate, no status line for an unannounced gate, an unrecorded human gate, a resume or terminal that keeps it, no per-node replacement, and a legacy resume that removes nothing each fail a test. The round-2 finder's real-engine probe now prints the status pointer after both a `gate` and a `budget resume`. Refs: MVP-1 1a milestone review round 2 (runid-5, docs-1) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- apps/cli/src/render/tui/RunApp.test.tsx | 1 + apps/cli/src/render/tui/final-summary.test.ts | 57 ++++++++++++-- apps/cli/src/render/tui/final-summary.ts | 17 ++++- .../cli/src/render/tui/run-view-model.test.ts | 76 +++++++++++++++++++ apps/cli/src/render/tui/run-view-model.ts | 54 +++++++++---- 5 files changed, 180 insertions(+), 25 deletions(-) diff --git a/apps/cli/src/render/tui/RunApp.test.tsx b/apps/cli/src/render/tui/RunApp.test.tsx index 4a8fdd42..ea2083b5 100644 --- a/apps/cli/src/render/tui/RunApp.test.tsx +++ b/apps/cli/src/render/tui/RunApp.test.tsx @@ -84,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/final-summary.test.ts b/apps/cli/src/render/tui/final-summary.test.ts index c03ca134..32b1a14d 100644 --- a/apps/cli/src/render/tui/final-summary.test.ts +++ b/apps/cli/src/render/tui/final-summary.test.ts @@ -128,10 +128,20 @@ describe('renderFinalSummary', () => { const paused = reduceAll([ started, { - type: 'run:paused', + 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'], }, @@ -163,6 +173,16 @@ describe('renderFinalSummary', () => { 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, @@ -175,6 +195,7 @@ describe('renderFinalSummary', () => { 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') @@ -200,10 +221,18 @@ describe('renderFinalSummary', () => { // 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, paused(3, ['approve-1', 'budget-1'])])).toEqual([ - gate, - budget, - ]); + 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}\``, @@ -219,7 +248,23 @@ describe('renderFinalSummary', () => { decision: 'approved', decidedBy: 'cli', }; - expect(linesOf([resumed, paused(8, ['approve-2'])])).toEqual([gate]); + // 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)', () => { diff --git a/apps/cli/src/render/tui/final-summary.ts b/apps/cli/src/render/tui/final-summary.ts index 13721033..9823114a 100644 --- a/apps/cli/src/render/tui/final-summary.ts +++ b/apps/cli/src/render/tui/final-summary.ts @@ -102,7 +102,11 @@ export function renderFinalSummary(state: RunViewState): string { /** * 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` refuses), and `relavium status` for a run parked on a media job only. + * 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); @@ -113,8 +117,11 @@ function continuationLines(state: RunViewState, runId: string): string[] { 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) => !budgetGates.has(gateId))) { + 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))) { @@ -126,7 +133,11 @@ function continuationLines(state: RunViewState, runId: string): string[] { : ` run id ${id} — approve or abort the budget gate with \`${command}\` (\`relavium status ${id}\` shows the amount)`, ); } - if (lines.length === 0) { + 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 bf872228..7dc7ea7b 100644 --- a/apps/cli/src/render/tui/run-view-model.test.ts +++ b/apps/cli/src/render/tui/run-view-model.test.ts @@ -1088,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<RunEvent, { type: 'human_gate:paused' }> => ({ + 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 ed006366..0df31f72 100644 --- a/apps/cli/src/render/tui/run-view-model.ts +++ b/apps/cli/src/render/tui/run-view-model.ts @@ -70,6 +70,13 @@ export interface RunSummary { readonly outputs?: Readonly<Record<string, unknown>>; } +/** 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 { readonly runId?: string; /** Node ids in first-seen order (stable list ordering for the render). */ @@ -96,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[]; @@ -132,6 +142,7 @@ export function initialRunViewState(): RunViewState { gapDetected: false, warnings: [], pendingBudgetNotices: [], + pendingHumanGates: [], producedMedia: [], }; } @@ -452,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'}`, @@ -462,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), }; @@ -519,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 } }; @@ -550,6 +559,19 @@ export function reduceRunEvent(state: RunViewState, event: RunEvent): RunViewSta } } +/** 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<RunEvent, { type: 'human_gate:resumed' }>, +): 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 }, From faf28ed4f754d553b26a9b8ffd550f3e0d1a8941 Mon Sep 17 00:00:00 2001 From: Cemil ILIK <cemililik@outlook.com> Date: Fri, 6 Nov 2026 03:24:15 +0300 Subject: [PATCH 094/148] test(cli): pin status's history-identifier sanitizers and finished-run rendering New status cases seed a run id, workflow id, step node id and type, and gate id carrying ESC, BEL and U+202E; a finished run with no step row; and the cancelled run's open step, whose `--json` record keeps the recorded `running`. Dropping any of the six identifier sanitizers, the "(no node ran)" line or the JSON step's recorded status fails a test. The status and gate-list comments now say what each guard does: `gate` and `budget resume` answer a finished run with an exit-0 "nothing to resume" rather than refusing it, and `gate list` reads only paused runs while status also reads a running one. Refs: MVP-1 1a milestone review round 2 (security-2, docs-4, history-10, history-9, history-11) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- apps/cli/src/commands/gate-list.ts | 5 +-- apps/cli/src/commands/status.test.ts | 51 ++++++++++++++++++++++++++-- apps/cli/src/commands/status.ts | 4 +-- 3 files changed, 54 insertions(+), 6 deletions(-) diff --git a/apps/cli/src/commands/gate-list.ts b/apps/cli/src/commands/gate-list.ts index d703e0e0..9fde071b 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] : []; } diff --git a/apps/cli/src/commands/status.test.ts b/apps/cli/src/commands/status.test.ts index 45e8589c..4b2b67da 100644 --- a/apps/cli/src/commands/status.test.ts +++ b/apps/cli/src/commands/status.test.ts @@ -54,7 +54,8 @@ describe('statusCommand', () => { 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` refuse a finished run. + // 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', @@ -73,11 +74,57 @@ describe('statusCommand', () => { expect(text).not.toContain('budget resume'); const machine = captureIo(); await statusCommand({ ...deps(machine.io, true), ...noOutbox }, { runId: 'cancelled-1' }); - expect(parseNdjson(machine.out())[0]).toMatchObject({ + 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('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 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?' }, + }); + 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 file written without the constraint + // 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).toContain('pending gate gate'); + expect(text).toMatch(/\[t[^\]]*\]/u); // the step row printed, with its type + 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 () => { diff --git a/apps/cli/src/commands/status.ts b/apps/cli/src/commands/status.ts index 28514801..fc188d29 100644 --- a/apps/cli/src/commands/status.ts +++ b/apps/cli/src/commands/status.ts @@ -157,8 +157,8 @@ export async function statusCommand( // 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". // A FINISHED run (`status <runId>`) has nothing pending, whatever its log left open: a run cancelled or - // failed while a gate waited never resolves that gate, and `gate` / `budget resume` refuse it, as - // `gate list` does. + // 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. ...(TERMINAL_STATUSES.has(run.status) ? NOTHING_PENDING : gatesOf(reader, run)), }; }); From 32d1a8a720b4bf7158d4e51d7127cb70a9e725aa Mon Sep 17 00:00:00 2001 From: Cemil ILIK <cemililik@outlook.com> Date: Fri, 6 Nov 2026 03:24:15 +0300 Subject: [PATCH 095/148] docs(repo): record the MVP-1 1a review round 2 fix and its deferred lows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit commands.md says the TUI summary points a gate a resumed run restored from its checkpoint at `relavium status`, and that `gate` and `budget resume` answer a finished run with an exit-0 "nothing to resume". current.md records round 2 (one medium, fixed). deferred-tasks.md records thirteen round-2 lows, round 1's untested `officialEndpointOf` half of budget-5 among them, and corrects the budget-shaped thinking growth to about 8–16x. Refs: MVP-1 1a milestone review round 2 (docs-1, docs-3, docs-2, budget-10) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- docs/reference/cli/commands.md | 4 +- docs/roadmap/current.md | 8 ++-- docs/roadmap/deferred-tasks.md | 70 ++++++++++++++++++++++++++++++++-- 3 files changed, 74 insertions(+), 8 deletions(-) diff --git a/docs/reference/cli/commands.md b/docs/reference/cli/commands.md index 96dbdb2b..c98dc623 100644 --- a/docs/reference/cli/commands.md +++ b/docs/reference/cli/commands.md @@ -206,7 +206,7 @@ printf 'api_key=%s\n' "$API_KEY" | relavium run ./workflows/code-review.relavium - `--allow-mcp-stdio <digest>` (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 <runId> --gate <gateId>`); with `--json` they are on the NDJSON event line, otherwise the plain/TUI renderer prints them inline (`paused at gate <gateId> (<type>)`, also echoed in the final summary). (`relavium status`, `relavium logs <runId>`, 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 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 <runId>` for a run that ended; for a paused run, `relavium gate <runId>` when a human gate is pending, `relavium budget resume <runId> --gate <gateId>` for a budget gate (which `gate` refuses), and `relavium status <runId>` for a run parked on a media job only. A run resumed by `gate` or `budget resume` names its id too. 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_<PROVIDER>_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 <runId>`. 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. +> **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 <runId>` for a run that ended; for a paused run, `relavium gate <runId>` when a human gate is pending, `relavium budget resume <runId> --gate <gateId>` for a budget gate (which `gate` declines), and `relavium status <runId>` 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 <runId>`, 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_<PROVIDER>_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 <runId>`. 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` @@ -262,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 <runId> --gate <gateId>` — required when a run has more than one gate pending at once. Without an argument it lists every active run; `relavium status <runId>` 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` refuse it), and a step its node never finished prints as `stopped` (its `--json` step keeps the recorded status). A run's full event history stays `relavium logs <runId>`. 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 <runId> --gate <gateId>` — required when a run has more than one gate pending at once. Without an argument it lists every active run; `relavium status <runId>` 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` (its `--json` step keeps the recorded status). A run's full event history stays `relavium logs <runId>`. 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. ### `relavium models` diff --git a/docs/roadmap/current.md b/docs/roadmap/current.md index 6665d85f..dedf57ba 100644 --- a/docs/roadmap/current.md +++ b/docs/roadmap/current.md @@ -108,9 +108,11 @@ Wave 3 split as `row <n>` in the [increment table](phases/phase-2.5.5-hardening- `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. Its lows are fixed or recorded - in [deferred-tasks.md](deferred-tasks.md). Still owed for MVP-1: a new milestone review round with no medium or - higher finding. + 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`. Both rounds' lows are fixed or + recorded in [deferred-tasks.md](deferred-tasks.md), round 1's `officialEndpointOf` half of budget-5 included. + Still owed for MVP-1: a new milestone review round with no medium or higher finding. 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 diff --git a/docs/roadmap/deferred-tasks.md b/docs/roadmap/deferred-tasks.md index e54eb0f8..1f415fee 100644 --- a/docs/roadmap/deferred-tasks.md +++ b/docs/roadmap/deferred-tasks.md @@ -1686,8 +1686,11 @@ next group below, and the separate security sitting's two lows (at `ab5e46e1`) a 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. The lows below were not -fixed with them. **Trigger for every entry:** the next change to the file it names. +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. 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 @@ -1723,11 +1726,72 @@ fixed with them. **Trigger for every entry:** the next change to the file it nam - [ ] **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–12×. + 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 complete commands. **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)* +- [ ] **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. **Re-check:** sanitize the status strings too, or validate + them in `fromRunRow` and the step reader. *(low · apps/cli/src/commands/status.ts, apps/cli/src/commands/list.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)* + ## Wave 3 NI — recorded low findings (2026-10-25) Round 1 of the native-identity increment at `fc5af701` accepted it with these lows From 84a758eee0ebf3f59747ca66532c2aea737a9a31 Mon Sep 17 00:00:00 2001 From: Cemil ILIK <cemililik@outlook.com> Date: Fri, 6 Nov 2026 05:08:24 +0300 Subject: [PATCH 096/148] fix(cli): print the command that continues each pending human gate in status A resumed run's final summary sends a gate restored from its checkpoint to `relavium status`, saying it shows the command that continues it, but `status` printed commands only for a budget gate; a human gate got its id alone (MVP-1 1a review round 3: runid-7, history-13, docs-1). `status` now prints, under each pending human gate, one copyable `relavium gate <runId> --gate <gateId>` per decision its type accepts (`--approve`/`--reject`, or `--input <json>`/`--reject` for an input gate). The commands come from the same shell-inert check `resumeCommandBase` applies to budget hints; an identifier that fails it gets a sentence naming the flags instead. The identifier test now also seeds a hostile gate node id. faf28ed4 said all six status identifier sanitizers were pinned, but the gate node id was not (history-14, docs-2, security-1). Seven mutants each fail a test: hints removed, the fallback bypassed, the labels swapped, the gate node id unsanitized, the gate type ignored, the fallback flags fixed, and sanitized ids fed to the command. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- apps/cli/src/commands/status.test.ts | 57 +++++++++++++++++++++++++--- apps/cli/src/commands/status.ts | 10 ++++- apps/cli/src/gate/decision.ts | 4 +- apps/cli/src/gate/pending.test.ts | 30 ++++++++++++++- apps/cli/src/gate/pending.ts | 41 ++++++++++++++++++-- apps/cli/src/test-support.ts | 6 ++- 6 files changed, 133 insertions(+), 15 deletions(-) diff --git a/apps/cli/src/commands/status.test.ts b/apps/cli/src/commands/status.test.ts index 4b2b67da..b8e5c2f6 100644 --- a/apps/cli/src/commands/status.test.ts +++ b/apps/cli/src/commands/status.test.ts @@ -99,19 +99,24 @@ describe('statusCommand', () => { 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 all come from it. + // `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?' }, + 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 file written without the constraint - // carries a hostile one. + // 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'); @@ -122,8 +127,13 @@ describe('statusCommand', () => { ); const text = out(); expect(text).toMatch(/^run r.* — paused \(workflow w/u); - expect(text).toContain('pending gate gate'); + 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); }); @@ -311,10 +321,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 <json>\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 fc188d29..435d61f0 100644 --- a/apps/cli/src/commands/status.ts +++ b/apps/cli/src/commands/status.ts @@ -5,7 +5,12 @@ 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 { + humanGateResumeHints, + pendingGateDisplays, + type PendingGate, + type PendingBudgetGate, +} from '../gate/pending.js'; import { budgetIdentifier, budgetPromptDetails, @@ -316,5 +321,8 @@ function renderRun(io: CliIo, status: ActiveRunStatus): void { 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/gate/decision.ts b/apps/cli/src/gate/decision.ts index 097af1db..fe7f397c 100644 --- a/apps/cli/src/gate/decision.ts +++ b/apps/cli/src/gate/decision.ts @@ -103,8 +103,8 @@ 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. */ -const FLAG_FOR_DECISION: Readonly<Record<GateDecision['decision'], string>> = { +/** The `relavium gate` flag that records each decision kind, for an actionable refusal and `status`'s resume hints. */ +export const FLAG_FOR_DECISION: Readonly<Record<GateDecision['decision'], string>> = { approved: '--approve', rejected: '--reject', input_provided: '--input <json>', diff --git a/apps/cli/src/gate/pending.test.ts b/apps/cli/src/gate/pending.test.ts index 5cecf2d8..63d152f3 100644 --- a/apps/cli/src/gate/pending.test.ts +++ b/apps/cli/src/gate/pending.test.ts @@ -2,7 +2,7 @@ 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 { humanGateResumeHints, pendingHumanGates } from './pending.js'; describe('pendingHumanGates', () => { let client: DbClient; @@ -40,3 +40,31 @@ 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 <json>', + '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 <json> or --reject.', + ]); + }); +}); diff --git a/apps/cli/src/gate/pending.ts b/apps/cli/src/gate/pending.ts index 95a5e97c..a4395df6 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,36 @@ 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<Record<GateDecision['decision'], string>> = { + 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 `<json>` + * 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]}`, + ); +} + export interface PendingBudgetGate { readonly gateId: string; readonly nodeId: string; diff --git a/apps/cli/src/test-support.ts b/apps/cli/src/test-support.ts index 58905de6..3547a84c 100644 --- a/apps/cli/src/test-support.ts +++ b/apps/cli/src/test-support.ts @@ -116,11 +116,13 @@ type EventBody<T extends RunEvent['type']> = Omit< /** The schema-validated event emitter `seedRun` threads through its state helpers. */ type EmitFn = <T extends RunEvent['type']>(type: T, rest: EventBody<T>) => Promise<void>; -/** 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; } /** @@ -278,7 +280,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?', From 8e7f41f26a3563a4cc10208e497f7fd43b413110 Mon Sep 17 00:00:00 2001 From: Cemil ILIK <cemililik@outlook.com> Date: Fri, 6 Nov 2026 05:08:24 +0300 Subject: [PATCH 097/148] docs(repo): record the MVP-1 1a review round 3 fix and its deferred lows commands.md says `status` prints the command for each pending human gate. deferred-tasks.md records round 3's fix, corrects round 2's record of the gate node-id sanitizer, and adds three lows (runid-8/docs-3, history-15, budget-12). current.md records round 3 as not accepted. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- docs/reference/cli/commands.md | 2 +- docs/roadmap/current.md | 8 +++++--- docs/roadmap/deferred-tasks.md | 28 +++++++++++++++++++++++++--- 3 files changed, 31 insertions(+), 7 deletions(-) diff --git a/docs/reference/cli/commands.md b/docs/reference/cli/commands.md index c98dc623..008fbaa7 100644 --- a/docs/reference/cli/commands.md +++ b/docs/reference/cli/commands.md @@ -262,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 <runId> --gate <gateId>` — required when a run has more than one gate pending at once. Without an argument it lists every active run; `relavium status <runId>` 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` (its `--json` step keeps the recorded status). A run's full event history stays `relavium logs <runId>`. 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 <runId> --gate <gateId>` — 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 <json>` 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 <runId>` 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` (its `--json` step keeps the recorded status). A run's full event history stays `relavium logs <runId>`. 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. ### `relavium models` diff --git a/docs/roadmap/current.md b/docs/roadmap/current.md index dedf57ba..f4995be5 100644 --- a/docs/roadmap/current.md +++ b/docs/roadmap/current.md @@ -110,9 +110,11 @@ Wave 3 split as `row <n>` in the [increment table](phases/phase-2.5.5-hardening- 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`. Both rounds' lows are fixed or - recorded in [deferred-tasks.md](deferred-tasks.md), round 1's `officialEndpointOf` half of budget-5 included. - Still owed for MVP-1: a new milestone review round with no medium or higher finding. + `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. The three + rounds' lows are fixed or recorded in [deferred-tasks.md](deferred-tasks.md), round 1's `officialEndpointOf` half + of budget-5 included. Still owed for MVP-1: a new milestone review round with no medium or higher finding. 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 diff --git a/docs/roadmap/deferred-tasks.md b/docs/roadmap/deferred-tasks.md index 1f415fee..f02388be 100644 --- a/docs/roadmap/deferred-tasks.md +++ b/docs/roadmap/deferred-tasks.md @@ -1689,8 +1689,12 @@ run's open gate as pending, the run-path tool pre-flight grew with nodes times g 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. The lows -below were not fixed. **Trigger for every entry:** the next change to the file it names. +`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). 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 @@ -1727,7 +1731,7 @@ below were not fixed. **Trigger for every entry:** the next change to the file i 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`. + 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)* @@ -1792,6 +1796,24 @@ below were not fixed. **Trigger for every entry:** the next change to the file i 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 complete command per 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)* + ## Wave 3 NI — recorded low findings (2026-10-25) Round 1 of the native-identity increment at `fc5af701` accepted it with these lows From e1e42a03eed38d90fb2db32329c367188facc24c Mon Sep 17 00:00:00 2001 From: Cemil ILIK <cemililik@outlook.com> Date: Fri, 6 Nov 2026 06:54:28 +0300 Subject: [PATCH 098/148] fix(cli): show a decided human gate's step as completed in status `relavium status <runId>` printed an approved or rejected gate's step as `stopped` under its completed run (MVP-1 1a review round 4: runid-9). No `node:completed` follows a gate decision, so the projection leaves the gate node's step row `running`, and 199601bd's finished-run mapping turned that into `stopped`. `status` now reads each run's log once for its gates. A new `decidedGateNodes` fold names the nodes whose latest gate event is a `human_gate:resumed`; a later `node:started` or `human_gate:paused`, as in a loop, clears it. A `human_in_the_loop` step still `running` whose node is in that set prints `completed`, as the engine marks the gate vertex, under an active run as well as a finished one. Any other step still `running` under a finished run stays `stopped`, and `--json` keeps the recorded status. A finished run's log is now read too. When that read fails the run loses only the decided labels: it has nothing pending, so it raises no `gatesUnavailable`. Ten mutants each fail a test: the relabel removed, the node-type guard dropped, the decided set ignored, either reset dropped, the decision not recorded, finished runs denied the set, `gatesUnavailable` raised for a finished run, pending gates listed for a finished run, and the `stopped` mapping dropped. A real-engine probe prints `completed g` after `relavium gate --approve` completes the run. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- apps/cli/src/commands/status.test.ts | 75 ++++++++++++++++++++++++++++ apps/cli/src/commands/status.ts | 61 +++++++++++++++------- apps/cli/src/gate/pending.test.ts | 51 ++++++++++++++++++- apps/cli/src/gate/pending.ts | 18 +++++++ apps/cli/src/test-support.ts | 20 ++++++++ 5 files changed, 205 insertions(+), 20 deletions(-) diff --git a/apps/cli/src/commands/status.test.ts b/apps/cli/src/commands/status.test.ts index b8e5c2f6..9845477a 100644 --- a/apps/cli/src/commands/status.test.ts +++ b/apps/cli/src/commands/status.test.ts @@ -86,6 +86,81 @@ describe('statusCommand', () => { 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(); diff --git a/apps/cli/src/commands/status.ts b/apps/cli/src/commands/status.ts index 435d61f0..249ac2c9 100644 --- a/apps/cli/src/commands/status.ts +++ b/apps/cli/src/commands/status.ts @@ -6,6 +6,7 @@ import type { Db, RunHistoryReader, RunRecord, StepRecord } from '@relavium/db'; import { loadResolvedConfig } from '../config/load.js'; import { + decidedGateNodes, humanGateResumeHints, pendingGateDisplays, type PendingGate, @@ -90,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<string>; /** `true` when this run's event log could not be read, so `pendingGates` is a stand-in (#W15-15). */ readonly gatesUnavailable: boolean; } @@ -164,7 +167,7 @@ export async function statusCommand( // A FINISHED run (`status <runId>`) 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. - ...(TERMINAL_STATUSES.has(run.status) ? NOTHING_PENDING : gatesOf(reader, run)), + ...gatesOf(reader, run, TERMINAL_STATUSES.has(run.status)), }; }); @@ -195,27 +198,33 @@ function requireRun(reader: RunHistoryReader, runId: string): RunRecord { return run; } -/** What a finished run has pending: nothing (see the call site). */ -const NOTHING_PENDING = { - pendingGates: [], - pendingBudgetGates: [], - gatesUnavailable: false, -} as const; +interface RunGates { + readonly pendingGates: readonly PendingGate[]; + readonly pendingBudgetGates: readonly PendingBudgetGate[]; + readonly decidedGateNodes: ReadonlySet<string>; +} -/** Reconstruct once per active run: a companion crash may leave a running projection with pending gates. */ +/** + * 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<ReturnType<typeof pendingGateDisplays>>( - { pendingGates: [], pendingBudgetGates: [] }, - () => pendingGateDisplays(reader.loadRunEvents(run.id)), + finished: boolean, +): RunGates & { gatesUnavailable: boolean } { + const read = readPerRunOrDegrade<RunGates>( + { 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. */ @@ -266,6 +275,21 @@ 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>, +): 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 { // Every identifier here is read back from history.db, so each is printed one-line with controls stripped, as @@ -282,8 +306,7 @@ function renderRun(io: CliIo, status: ActiveRunStatus): void { } for (const step of status.steps) { const attempt = step.attemptNumber > 1 ? ` (attempt ${step.attemptNumber})` : ''; - // A run terminal does not close a step row the node never finished, so under a finished run it says so. - const shown = finished && step.status === 'running' ? 'stopped' : step.status; + const shown = shownStepStatus(step, finished, status.decidedGateNodes); io.writeOut( ` ${shown.padEnd(9)} ${sanitizeInline(step.nodeId)} [${sanitizeInline(step.nodeType)}]${attempt}\n`, ); diff --git a/apps/cli/src/gate/pending.test.ts b/apps/cli/src/gate/pending.test.ts index 63d152f3..5d942e2d 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 { humanGateResumeHints, pendingHumanGates } from './pending.js'; +import { decidedGateNodes, humanGateResumeHints, pendingHumanGates } from './pending.js'; +import type { RunEvent } from '@relavium/shared'; describe('pendingHumanGates', () => { let client: DbClient; @@ -68,3 +69,51 @@ describe('humanGateResumeHints', () => { ]); }); }); + +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 a4395df6..1bb0dc2c 100644 --- a/apps/cli/src/gate/pending.ts +++ b/apps/cli/src/gate/pending.ts @@ -58,6 +58,24 @@ export function humanGateResumeHints( ); } +/** + * 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<string> { + const decided = new Set<string>(); + 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/test-support.ts b/apps/cli/src/test-support.ts index 3547a84c..d4f4fd82 100644 --- a/apps/cli/src/test-support.ts +++ b/apps/cli/src/test-support.ts @@ -150,6 +150,11 @@ export type SeedRunOptions = { * 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; @@ -209,6 +214,21 @@ export async function seedRun(db: Db, opts: SeedRunOptions): Promise<string> { await emitPausedState(emit, opts, ts, tsMs); if (opts.cancelAfterPause === true) await emit('run:cancelled', { cumulativeCostMicrocents: 100 }); + if (opts.decideAfterPause === true) { + for (const gate of opts.gates ?? (opts.gate === undefined ? [] : [opts.gate])) { + await 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: opts.outputs ?? {}, From 3354dda10cc26b791bafe624c308978eb87b3982 Mon Sep 17 00:00:00 2001 From: Cemil ILIK <cemililik@outlook.com> Date: Fri, 6 Nov 2026 06:54:28 +0300 Subject: [PATCH 099/148] docs(repo): record the MVP-1 1a review round 4 fix and its deferred lows commands.md says `status` prints a decided gate's step as `completed` and that only an active run's damaged log raises `gatesUnavailable`. deferred-tasks.md records round 4's fix, no longer calls the commands `status` prints complete, and adds six lows (runid-10, history-16/docs-1, security-1, docs-2, preflight-13, budget-13). current.md records round 4 as not accepted. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- docs/reference/cli/commands.md | 2 +- docs/roadmap/current.md | 8 +++++--- docs/roadmap/deferred-tasks.md | 37 +++++++++++++++++++++++++++++++--- 3 files changed, 40 insertions(+), 7 deletions(-) diff --git a/docs/reference/cli/commands.md b/docs/reference/cli/commands.md index 008fbaa7..c5651e61 100644 --- a/docs/reference/cli/commands.md +++ b/docs/reference/cli/commands.md @@ -262,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 <runId> --gate <gateId>` — 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 <json>` 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 <runId>` 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` (its `--json` step keeps the recorded status). A run's full event history stays `relavium logs <runId>`. 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 <runId> --gate <gateId>` — 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 <json>` 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 <runId>` 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 <runId>`. 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` diff --git a/docs/roadmap/current.md b/docs/roadmap/current.md index f4995be5..bce2a4b7 100644 --- a/docs/roadmap/current.md +++ b/docs/roadmap/current.md @@ -112,9 +112,11 @@ Wave 3 split as `row <n>` in the [increment table](phases/phase-2.5.5-hardening- 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. The three - rounds' lows are fixed or recorded in [deferred-tasks.md](deferred-tasks.md), round 1's `officialEndpointOf` half - of budget-5 included. Still owed for MVP-1: a new milestone review round with no medium or higher finding. + 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 <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 prints `completed`. The four rounds' + lows are fixed or recorded in [deferred-tasks.md](deferred-tasks.md), round 1's `officialEndpointOf` half of + budget-5 included. Still owed for MVP-1: a new milestone review round with no medium or higher finding. 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 diff --git a/docs/roadmap/deferred-tasks.md b/docs/roadmap/deferred-tasks.md index f02388be..74fd5c21 100644 --- a/docs/roadmap/deferred-tasks.md +++ b/docs/roadmap/deferred-tasks.md @@ -1693,8 +1693,10 @@ checkpoint at `relavium gate`, although such a gate can be a budget gate `gate` 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). The lows below were not fixed. **Trigger for every entry:** the next change to the -file it names. +(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`. 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 @@ -1800,7 +1802,7 @@ file it names. 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 complete command per gate. **Re-check:** print one `relavium gate <runId> --gate <gateId>` per gate through + 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 @@ -1814,6 +1816,35 @@ file it names. `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 + the complete commands. **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)* + ## Wave 3 NI — recorded low findings (2026-10-25) Round 1 of the native-identity increment at `fc5af701` accepted it with these lows From 843924afe4beb96136d280acc4421d7500e314ef Mon Sep 17 00:00:00 2001 From: Cemil ILIK <cemililik@outlook.com> Date: Fri, 6 Nov 2026 08:31:30 +0300 Subject: [PATCH 100/148] docs(repo): accept MVP-1 after a clean 1a milestone review round 5 Round 5 of the 1a milestone review (at 3354dda1) found no blocker, high or medium finding, so 1a is accepted under D64 and MVP-1 is done. current.md records the round and the acceptance. deferred-tasks.md records round 5's lows: the decided-gate relabel is tested only on a finished run with an approval (runid-11, history-17, docs-2); an approved budget gate lists its agent node twice, a pre-existing projection behaviour (runid-12); an empty fallback_chain misses the chat's catalog-ceiling default (budget-14); two comments that overstate since round 4 (docs-3); and a gate message that can soft-wrap into a fake command row (security-1). It also fixes docs-1: the runid-6 and runid-10 entries no longer call status's commands complete, and point at history-16 for the secret-input case. Refs: D64 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- docs/roadmap/current.md | 11 ++++---- docs/roadmap/deferred-tasks.md | 50 +++++++++++++++++++++++++++++++--- 2 files changed, 52 insertions(+), 9 deletions(-) diff --git a/docs/roadmap/current.md b/docs/roadmap/current.md index bce2a4b7..dd2d7abc 100644 --- a/docs/roadmap/current.md +++ b/docs/roadmap/current.md @@ -1,7 +1,7 @@ # Current state > Status: Living -> Last updated: 2026-11-05 +> 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) @@ -36,7 +36,7 @@ evidence summary, the recorded Wave 3 splits and the process amendments. | 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 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) | +| 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 `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 | @@ -114,9 +114,10 @@ Wave 3 split as `row <n>` in the [increment table](phases/phase-2.5.5-hardening- 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 <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 prints `completed`. The four rounds' - lows are fixed or recorded in [deferred-tasks.md](deferred-tasks.md), round 1's `officialEndpointOf` half of - budget-5 included. Still owed for MVP-1: a new milestone review round with no medium or higher finding. + 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 diff --git a/docs/roadmap/deferred-tasks.md b/docs/roadmap/deferred-tasks.md index 74fd5c21..b5b83779 100644 --- a/docs/roadmap/deferred-tasks.md +++ b/docs/roadmap/deferred-tasks.md @@ -1695,8 +1695,10 @@ round 2's fix sent such a gate to `relavium status` for the command that continu 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`. The lows below were not fixed. **Trigger for every -entry:** the next change to the file it names. +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 @@ -1740,7 +1742,8 @@ entry:** the next change to the file it names. - [ ] **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 complete commands. **Re-check:** keep the allowance context on + 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 @@ -1818,7 +1821,7 @@ entry:** the next change to the file it names. - [ ] **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 - the complete commands. **Re-check:** keep the gate type on the view's human-gate entry and print + 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:` @@ -1845,6 +1848,45 @@ entry:** the next change to the file it names. 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)* + ## Wave 3 NI — recorded low findings (2026-10-25) Round 1 of the native-identity increment at `fc5af701` accepted it with these lows From a417a57de45dfa5563cb8e1a6aad7cdd09654ee9 Mon Sep 17 00:00:00 2001 From: Cemil ILIK <cemililik@outlook.com> Date: Fri, 6 Nov 2026 13:31:10 +0300 Subject: [PATCH 101/148] refactor(cli): clear the SonarCloud findings on the checkpoint PR MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SonarCloud's first analysis of this code (PR #97) failed the quality gate on four bidirectional-character warnings and raised five code smells. Each fix keeps behaviour unchanged: - list.test.ts writes its U+202E bidi override as the ‮ escape instead of the raw character, as status.test.ts already does (S6389; CodeRabbit raised the same). - renderRun in status.ts moves its step rows and its pending-gate rows into renderSteps and renderPendingGates (S3776). - logsCommand moves the skipped-row note and the human rendering into writeSkippedNote and writeHumanLog (S3776). - buildChatSession and buildResumedChatSession share one releaseFailedBuild helper for their identical post-connect cleanup (S3776). Making it skip the MCP close fails both paths' self-clean tests. - withoutFinalNewlines reads codePointAt, which compares the same with 0x0a (S7758). - seedRun emits a decided gate's resumes through an ordered reduce rather than an await in a loop (S9382). The CLI suite passes under the CI environment (5,947 tests, the same count as before). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- apps/cli/src/chat/session-host.ts | 55 ++++++++++++-------------- apps/cli/src/commands/list.test.ts | 8 ++-- apps/cli/src/commands/logs.ts | 63 +++++++++++++++++------------- apps/cli/src/commands/status.ts | 30 +++++++++----- apps/cli/src/render/run-outputs.ts | 2 +- apps/cli/src/test-support.ts | 19 +++++++-- 6 files changed, 101 insertions(+), 76 deletions(-) diff --git a/apps/cli/src/chat/session-host.ts b/apps/cli/src/chat/session-host.ts index ae598f9c..f1c0349e 100644 --- a/apps/cli/src/chat/session-host.ts +++ b/apps/cli/src/chat/session-host.ts @@ -1087,25 +1087,34 @@ export async function buildChatSession(opts: BuildChatSessionOptions): Promise<B // Self-clean: a post-connect construction fault (e.g. a duplicate-id `createToolRegistry` build) must not // leak the just-spawned MCP children — tear them down before the failure propagates. The build is then // all-or-nothing: it either returns a session that OWNS `closeMcp`, or it has already closed the client. - // Best-effort: a teardown rejection must NOT mask the original construction 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; } } +/** + * 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<void> { + 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 @@ -1394,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; } } diff --git a/apps/cli/src/commands/list.test.ts b/apps/cli/src/commands/list.test.ts index 88021f15..e18b7d97 100644 --- a/apps/cli/src/commands/list.test.ts +++ b/apps/cli/src/commands/list.test.ts @@ -197,7 +197,7 @@ describe('listCommand', () => { // `/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‮'; + const hostile = '\u001b]0;PWNED\u0007\u001b[2J\u202e'; const workflows: CatalogEntry[] = [ { slug: 'fix-bug', @@ -234,7 +234,7 @@ describe('listCommand', () => { expect(text).toContain(isAgents ? 'helper' : 'fix-bug'); expect(text).not.toContain('\u001b'); expect(text).not.toContain('\u0007'); - expect(text).not.toContain('‮'); + expect(text).not.toContain('\u202e'); } const { io, out } = captureIo(); listCommand({ agents: false }, deps(io, { catalog: workflows })); @@ -320,7 +320,7 @@ describe('listCommand', () => { it('--runs prints history identifiers with control characters on one line', async () => { await seedRun(db, { - slug: 'wf\u001b[2J‮', + slug: 'wf\u001b[2J\u202e', runId: 'r\u001b]0;PWNED\u0007', state: 'completed', }); @@ -330,6 +330,6 @@ describe('listCommand', () => { expect(text).toMatch(/^r.*completed {2}wf/u); expect(text).not.toContain('\u001b'); expect(text).not.toContain('\u0007'); - expect(text).not.toContain('‮'); + expect(text).not.toContain('\u202e'); }); }); diff --git a/apps/cli/src/commands/logs.ts b/apps/cli/src/commands/logs.ts index d0373a27..010c4417 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'; @@ -60,44 +60,53 @@ 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) { - // 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. - deps.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)) { - deps.io.writeOut(`${line}\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. + 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; + 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 { + io.writeOut( + `run ${run.id} — ${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: `[<seq>] <type> <nodeId?> <detail?>`. */ function formatLogLine(event: RunEvent): string { const head = `[${event.sequenceNumber}] ${event.type}`; diff --git a/apps/cli/src/commands/status.ts b/apps/cli/src/commands/status.ts index 249ac2c9..2cb69f8b 100644 --- a/apps/cli/src/commands/status.ts +++ b/apps/cli/src/commands/status.ts @@ -301,16 +301,7 @@ function renderRun(io: CliIo, status: ActiveRunStatus): void { 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(finished ? ' (no node ran)\n' : ' (no node activity recorded yet)\n'); - } - for (const step of status.steps) { - const attempt = step.attemptNumber > 1 ? ` (attempt ${step.attemptNumber})` : ''; - const shown = shownStepStatus(step, finished, status.decidedGateNodes); - io.writeOut( - ` ${shown.padEnd(9)} ${sanitizeInline(step.nodeId)} [${sanitizeInline(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. @@ -326,6 +317,25 @@ 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) { + const attempt = step.attemptNumber > 1 ? ` (attempt ${step.attemptNumber})` : ''; + const shown = shownStepStatus(step, finished, status.decidedGateNodes); + io.writeOut( + ` ${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( diff --git a/apps/cli/src/render/run-outputs.ts b/apps/cli/src/render/run-outputs.ts index 2b6a49ea..a4ebdb5d 100644 --- a/apps/cli/src/render/run-outputs.ts +++ b/apps/cli/src/render/run-outputs.ts @@ -97,7 +97,7 @@ export function formatRunOutputs( */ function withoutFinalNewlines(text: string): string { let end = text.length; - while (end > 0 && text.charCodeAt(end - 1) === 0x0a) end -= 1; + while (end > 0 && text.codePointAt(end - 1) === 0x0a) end -= 1; return text.slice(0, end); } diff --git a/apps/cli/src/test-support.ts b/apps/cli/src/test-support.ts index d4f4fd82..08136ae0 100644 --- a/apps/cli/src/test-support.ts +++ b/apps/cli/src/test-support.ts @@ -215,13 +215,13 @@ export async function seedRun(db: Db, opts: SeedRunOptions): Promise<string> { if (opts.cancelAfterPause === true) await emit('run:cancelled', { cumulativeCostMicrocents: 100 }); if (opts.decideAfterPause === true) { - for (const gate of opts.gates ?? (opts.gate === undefined ? [] : [opts.gate])) { - await emit('human_gate:resumed', { + 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 }, @@ -246,6 +246,17 @@ export async function seedRun(db: Db, opts: SeedRunOptions): Promise<string> { } /** Emit the `paused`-state events for {@link seedRun} — a human/budget gate park, or (no gate) a media-job park. */ +/** Emit one event per item, each after the last: an event's sequence number follows the one before it. */ +async function emitInOrder<T>( + items: readonly T[], + emitOne: (item: T) => Promise<void>, +): Promise<void> { + await items.reduce<Promise<void>>( + (previous, item) => previous.then(() => emitOne(item)), + Promise.resolve(), + ); +} + async function emitPausedState( emit: EmitFn, opts: SeedRunOptions, From 077650a838d955a21020b1367d3b06fcb9ed195f Mon Sep 17 00:00:00 2001 From: Cemil ILIK <cemililik@outlook.com> Date: Fri, 6 Nov 2026 13:31:10 +0300 Subject: [PATCH 102/148] docs(repo): withhold Remember for a credential-shaped command and fix code spans CodeRabbit's review of PR #97 found that ADR-0118's draft called a remembered command grant secret-incapable because ADR-0063's writer stores it. That writer's guarantee covers its typed, non-secret keys; a command string is free-form, so a command carrying a password or token would be written to operator config in plaintext. By the maintainer's ruling the Proposed draft now withholds Remember for a command in which the tool preview's credential masking finds anything, shows the exact string a grant would store, and records that a credential shape the masking does not recognize can still be stored, for the approval-path security sitting. chat-session.md and commands.md had three code spans with backslash-escaped backticks inside, which Markdown does not support; they now use double backtick delimiters. Refs: ADR-0118 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- ...e-the-allowlist-asks-instead-of-failing.md | 22 +++++++++++++++---- docs/reference/cli/chat-session.md | 4 ++-- docs/reference/cli/commands.md | 2 +- 3 files changed, 21 insertions(+), 7 deletions(-) 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 index 40e39b8e..22acb72f 100644 --- 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 @@ -1,7 +1,8 @@ # ADR-0118: A command outside the allowlist asks instead of failing - **Status**: Proposed -- **Date**: 2026-11-05 +- **Date**: 2026-11-05 (revised 2026-11-06 after the PR #97 review: a remembered grant is free-form text, so §2 + no longer calls it secret-incapable and withholds Remember for a command the credential masking would mask) - **Related**: [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 @@ -71,7 +72,8 @@ expanded before it can be approved. - **Once.** This invocation only. - **Session.** This exact command string for the rest of this session instance. It is held in memory and not persisted, like ADR-0057's `[a]`. -- **Remember.** Persist an exact-string grant for this project root (§2). +- **Remember.** Persist an exact-string grant for this project root (§2). It is + not offered for a command that may carry a credential (§2). - **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 @@ -103,10 +105,19 @@ pre-approved is denied. That denial returns to the model under ADR-0117. shares none. - **How they are written.** Through the [ADR-0063](0063-cli-config-write-contract.md) write primitive: parse, set, - re-validate, write atomically. It is secret-incapable by construction. No - project, workspace or agent file is ever written. + re-validate, write atomically. No project, workspace or agent file is ever + written. That primitive's secret-free guarantee covers its typed, non-secret + keys. A command string is free-form, so the guarantee does not cover a grant. - **What a grant contains.** Only an exact command string, never a glob. "Remember `npm test`" never authorizes `npm test -- --exec=…`. +- **What cannot be remembered.** Remember is not offered for a command in which + the [credential masking](../reference/shared-core/built-in-tools.md#finite-diagnostic-credential-masking) + that tool previews use finds anything to mask. The card then offers Once, + Session and Deny, and says why. When Remember is offered, the card shows the + exact string it would store. The masking recognizes a finite set of credential + shapes, so a credential it does not recognize can still be remembered, and it + is then stored in plaintext in operator configuration. That is a recorded + limit, not a guarantee; the approval-path security sitting examines it. - **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. @@ -169,6 +180,9 @@ accepted. decision. - Operator config gains a growing, per-root grant list. It needs bounds, strict parsing and a revocation view (2.6.G). +- A remembered grant is a plaintext command string. Remember is withheld when + the credential masking finds anything, but a credential shape it does not + recognize can still be stored (§2). - 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/reference/cli/chat-session.md b/docs/reference/cli/chat-session.md index bd7faef5..3c63f47c 100644 --- a/docs/reference/cli/chat-session.md +++ b/docs/reference/cli/chat-session.md @@ -52,7 +52,7 @@ A session **binds one agent for its whole lifetime** — there is no mid-session - 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_<PROVIDER>_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 '<id>' — store one with \`relavium provider set-key <id>\` or set RELAVIUM_<ID>_API_KEY.` What happens next depends on the surface: +- **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 '<id>' — store one with `relavium provider set-key <id>` or set RELAVIUM_<ID>_API_KEY.`` What happens next depends on the surface: - `chat`, `agent run` and a non-interactive `chat-resume` refuse with exit `2`. - An **interactive** `chat-resume` opens anyway, because `/models` can move the conversation to a provider that has a key. 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. @@ -109,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 <sessionId>` (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 where workflows live — `<project>/.relavium/workflows/<id>.relavium.yaml`, where `<project>/.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 `<cwd>/.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 <id>\``) (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 `<slug>`, `<slug>-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. 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 — `<project>/.relavium/workflows/<id>.relavium.yaml`, where `<project>/.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 `<cwd>/.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 <id>` ``) (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 `<slug>`, `<slug>-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. 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. | diff --git a/docs/reference/cli/commands.md b/docs/reference/cli/commands.md index c5651e61..6d3970cb 100644 --- a/docs/reference/cli/commands.md +++ b/docs/reference/cli/commands.md @@ -200,7 +200,7 @@ 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 <digest>` 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 secret shapes in the original text, 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 <runId> --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. +- **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 secret shapes in the original text, 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 <runId> --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_<PROVIDER>_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 <digest>` (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). From 315e76f71ac20a0d75121c2c8f4f6197802066d1 Mon Sep 17 00:00:00 2001 From: Cemil ILIK <cemililik@outlook.com> Date: Fri, 6 Nov 2026 15:30:11 +0300 Subject: [PATCH 103/148] fix(core): keep reconcile from relabelling a held or just-started run MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Found by the PR #97 reviews and confirmed by probes: - The repair could settle a run failed while its owner was holding the run's terminal in the outbox: the owner was writing a terminal the store refused when reconcile read an empty outbox, then put it and released its lease before reconcile claimed the run. The next drain dropped the held completion. Reconcile now reads the outbox again under its claim and leaves the run for the next start when it holds an entry or cannot be read. - The drain listed the open runs before reading the outbox, so a run that started, finished and had its terminal refused between the two reads lost that terminal as "already landed". Both reconcile() and drainTerminalOutbox() now read the outbox first. - 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), so such a run may have started a moment ago in another process, or under a version that takes no lease. InterruptedRun gains an optional startedAtMs; absent, the run gets no grace. - A crashed run with an unresolved external effect, or whose effect journal cannot be read, is settled as effect_needs_attention rather than internal (effect-journal.md §8: surfaced, never retried). The reconcile fixtures that model a crashed run now start it long before the in-memory host's clock. The in-memory store's duplicate terminal set is gone (CodeRabbit). Seven new tests; each fix's mutation fails at least one of them. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- .../engine/budget-rejection-reconcile.test.ts | 2 +- packages/core/src/engine/engine.test.ts | 240 +++++++++++++++++- packages/core/src/engine/engine.ts | 138 ++++++++-- packages/core/src/engine/execution-host.ts | 19 +- .../src/engine/m2-e2e-harness.e2e.test.ts | 3 +- packages/core/src/engine/run-lease.test.ts | 2 +- 6 files changed, 359 insertions(+), 45 deletions(-) diff --git a/packages/core/src/engine/budget-rejection-reconcile.test.ts b/packages/core/src/engine/budget-rejection-reconcile.test.ts index ddf58996..71cf3e3b 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 f9380076..af0f56db 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', @@ -6129,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', @@ -6261,6 +6269,230 @@ describe('WorkflowEngine — internal failures and handle-side controls', () => ); 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 }); + expect( + await new WorkflowEngine({ host: young, executor: new StubExecutor() }).reconcile(), + ).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('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]); + 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('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 81309843..d2ad3f69 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, @@ -6357,8 +6358,15 @@ export class WorkflowEngine { // 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. + // + // **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 drained = 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 @@ -6375,6 +6383,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, @@ -6385,23 +6398,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` @@ -6562,26 +6576,96 @@ export class WorkflowEngine { * 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 (await this.#drainTerminalOutbox(interrupted)).written; + return (await this.#drainTerminalOutbox(interrupted, outbox)).written; + } + + /** The outbox's entries, or `undefined` when it cannot be read. */ + async #readOutbox(): Promise<readonly RunEvent[] | undefined> { + try { + return await this.#host.terminalOutbox.list(); + } catch { + 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 less than one lease TTL ago by this host's clock (a start in its future counts as recent). */ + #startedWithinLeaseTtl(run: InterruptedRun): boolean { + if (run.startedAtMs === undefined) return false; + return this.#nowMs() - run.startedAtMs < 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 cannot be read 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. + * 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[]): Promise<{ + async #drainTerminalOutbox( + interrupted: readonly InterruptedRun[], + held: readonly RunEvent[] | undefined, + ): Promise<{ readonly written: readonly RunEvent[]; readonly holds: (runId: string) => boolean; }> { - let held: readonly RunEvent[]; - try { - held = await this.#host.terminalOutbox.list(); - } catch { - return { written: [], holds: () => true }; - } + 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. diff --git a/packages/core/src/engine/execution-host.ts b/packages/core/src/engine/execution-host.ts index 8cd1dfd1..dfefc83e 100644 --- a/packages/core/src/engine/execution-host.ts +++ b/packages/core/src/engine/execution-host.ts @@ -125,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; } /** @@ -345,13 +351,6 @@ function counterUuid(n: number): string { return `00000000-0000-4000-8000-${hex}`; } -/** The run terminals the reference store refuses to repeat, as the SQLite store does. */ -const IN_MEMORY_TERMINAL_TYPES: ReadonlySet<RunEvent['type']> = new Set<RunEvent['type']>([ - 'run:completed', - 'run:failed', - 'run:cancelled', -]); - /** * An in-memory {@link RunStore} — deterministic and dependency-free. Persists events per run so a *new* * engine constructed over the *same* store sees the prior "process'" runs, which is how the tests @@ -440,10 +439,7 @@ 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 ( - IN_MEMORY_TERMINAL_TYPES.has(event.type) && - (bucket ?? []).some((e) => IN_MEMORY_TERMINAL_TYPES.has(e.type)) - ) { + if (TERMINAL_TYPES.has(event.type) && (bucket ?? []).some((e) => TERMINAL_TYPES.has(e.type))) { return Promise.reject(new RunAlreadyTerminalError(event.runId)); } if (bucket === undefined) { @@ -500,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/m2-e2e-harness.e2e.test.ts b/packages/core/src/engine/m2-e2e-harness.e2e.test.ts index 8b076000..ba964b28 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/run-lease.test.ts b/packages/core/src/engine/run-lease.test.ts index b833f4f5..251db9a5 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', From c496587ae0f6eb48ae6aeaa1ea946e5cd2c33a41 Mon Sep 17 00:00:00 2001 From: Cemil ILIK <cemililik@outlook.com> Date: Fri, 6 Nov 2026 15:30:11 +0300 Subject: [PATCH 104/148] fix(db): report when each interrupted run started listInterruptedRuns now carries runs.started_at as startedAtMs, so reconcile can leave a run younger than one lease TTL alone. Pinned in the interrupted-runs test; dropping the field fails it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- packages/db/src/run-history-store.test.ts | 2 ++ packages/db/src/run-history-store.ts | 4 ++++ 2 files changed, 6 insertions(+) diff --git a/packages/db/src/run-history-store.test.ts b/packages/db/src/run-history-store.test.ts index f84e381f..b28e9a73 100644 --- a/packages/db/src/run-history-store.test.ts +++ b/packages/db/src/run-history-store.test.ts @@ -841,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 06233654..64da1f51 100644 --- a/packages/db/src/run-history-store.ts +++ b/packages/db/src/run-history-store.ts @@ -110,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. */ @@ -516,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, @@ -534,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, { From ce8cb121dbf0bc3bfeee53d799932b66e751efc1 Mon Sep 17 00:00:00 2001 From: Cemil ILIK <cemililik@outlook.com> Date: Fri, 6 Nov 2026 15:30:12 +0300 Subject: [PATCH 105/148] test(db): give each media-temporary worker's first stage a cold-start budget The required Windows job failed at 077650a8 because a forked fixture took 3.3 s to reach its loader and 4.4 s to reach the worker, inside the fixed 5 s budget for its first stage. Over the last 58 CI runs that job hit a 5 s budget six times, all in the slow-runner cohort, none a product defect. A worker's first stage now gets 12 s, under the case's 15 s; later IPC barriers keep 5 s. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- packages/db/src/media-store-temporaries.test.ts | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/packages/db/src/media-store-temporaries.test.ts b/packages/db/src/media-store-temporaries.test.ts index 038ad89a..7369d530 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, From bf80877861089c5be8820ba5b7f6084d08d5900d Mon Sep 17 00:00:00 2001 From: Cemil ILIK <cemililik@outlook.com> Date: Fri, 6 Nov 2026 15:30:31 +0300 Subject: [PATCH 106/148] fix(cli): read the effect journal when the Home reconciles crashed runs The Home's reconcile-only engine now carries the journal's resume half, as run and gate already do, so a crashed run with an unresolved external effect is settled as effect_needs_attention. Removing that wiring fails the new CLI test. The race test that drove a real reconcile into a starting run's lease window asserted the old outcome: the run settled failed before any node ran. Reconcile now leaves a run younger than one lease TTL alone, so the test expects the run to complete and stay durable. A second new test pins that a just-started run is left alone through the real store. The budget crash-recovery test reconciles from a later start. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- .../engine/budget-rejection-reconcile.test.ts | 5 +- apps/cli/src/history/reconcile.test.ts | 79 +++++++++++++------ apps/cli/src/history/reconcile.ts | 23 ++++-- 3 files changed, 78 insertions(+), 29 deletions(-) diff --git a/apps/cli/src/engine/budget-rejection-reconcile.test.ts b/apps/cli/src/engine/budget-rejection-reconcile.test.ts index 0dc4cd68..bac8a35b 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/history/reconcile.test.ts b/apps/cli/src/history/reconcile.test.ts index ff05bc17..7706f157 100644 --- a/apps/cli/src/history/reconcile.test.ts +++ b/apps/cli/src/history/reconcile.test.ts @@ -6,6 +6,8 @@ import { join } from 'node:path'; import { parseWorkflow, WorkflowEngine, type RunStore } from '@relavium/core'; import { createClient, + createEffectJournalPort, + createEffectJournalStore, createRunHistoryStore, createRunLeasePort, runMigrations, @@ -97,6 +99,43 @@ describe('reconcileInterruptedRuns (2.6.G liveness, #229)', () => { }); }); + 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(); @@ -184,11 +223,11 @@ describe('reconcileInterruptedRuns (2.6.G liveness, #229)', () => { expect(terminal(runId)).toMatchObject({ type: 'run:completed', outputs: { answer: 42 } }); }); - it('a reconcile inside a starting run’s lease window leaves exactly one durable terminal', async () => { - // `run:started` is durable a few microtask turns before the run acquires its lease. A reconcile in that - // window settles the run and releases its claim, so the run's own acquire then SUCCEEDS. Before the - // store refused a second terminal, the run's terminal — exempt from the append guard — landed behind the - // reconciler's: two terminals in one log. Driven here by running the reconcile inside that window. + 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 = { @@ -220,26 +259,22 @@ describe('reconcileInterruptedRuns (2.6.G liveness, #229)', () => { 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({ type: 'run:failed', error: { code: 'internal' } }); - expect(executed).toEqual([]); // no node ran for a run the log already calls failed - // The refused terminal is held, so the run reports `uncertain` — and the next start drops it. - expect(handle.durability()).toBe('uncertain'); - await reconcileInterruptedRuns(client.db, terminalOutboxPath(dir)); + 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([]); - expect(terminals()).toHaveLength(1); }); - it('…and when the reconciler still holds its claim, the run is refused and still leaves one terminal', async () => { - // The other order inside the same window: the reconciler's 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. + it('…and a reconciler that claims the run anyway still leaves one terminal', async () => { + // A reconciler without the TTL grace (an older binary, or one whose clock runs 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 = { diff --git a/apps/cli/src/history/reconcile.ts b/apps/cli/src/history/reconcile.ts index 28b00d4a..db10da72 100644 --- a/apps/cli/src/history/reconcile.ts +++ b/apps/cli/src/history/reconcile.ts @@ -1,7 +1,13 @@ import { randomUUID } from 'node:crypto'; import { type NodeExecutor, WorkflowEngine } from '@relavium/core'; -import { createRunHistoryStore, createRunLeasePort, type Db } from '@relavium/db'; +import { + createEffectJournalStore, + createEffectResumePort, + createRunHistoryStore, + createRunLeasePort, + type Db, +} from '@relavium/db'; import { createCliHost } from '../engine/host.js'; @@ -28,9 +34,11 @@ const RECONCILE_ONLY_EXECUTOR: NodeExecutor = { * 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: `internal`, or `budget_exceeded` after a recorded budget rejection. It leaves alone a run parked at a gate, - * 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 + * 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 @@ -52,7 +60,12 @@ export async function reconcileInterruptedRuns(db: Db, terminalOutboxPath: strin terminalOutboxPath, runLeases: createRunLeasePort(store), }); - await new WorkflowEngine({ host, executor: RECONCILE_ONLY_EXECUTOR }).reconcile(); + // 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. } From a0733a03b2ff31ccae2fb0de2d479eed2dc47643 Mon Sep 17 00:00:00 2001 From: Cemil ILIK <cemililik@outlook.com> Date: Fri, 6 Nov 2026 15:30:31 +0300 Subject: [PATCH 107/148] fix(cli): make a bare Enter reject a gate card, and close a card a deadline decided Found by the PR #97 reviews; all were already on main. - Clack's confirm defaults to Yes, so Enter approved an approval, review or budget-amount card. Those cards now pass initialValue: false, as the MCP consent prompt does. A real-Clack test drives Enter through both. - A card stayed open after its gate's deadline approved it, and the user's later answer reached engine.resume, which ignores a decided gate: the run went on approved and nothing was said. The run driver now closes the open card when a human_gate:resumed it did not make arrives for that gate, and stderr says the answer was not recorded, under the card before the live view returns. - Esc at the optional rejection reason cancelled the whole run; it now keeps the rejection without a reason. An abort there still cancels. - The card promised "auto-reject on timeout" where a reject deadline fails the run with run_timeout; it now says "the run fails on timeout", and "auto-approve on timeout" only for an approve deadline on an approval or review gate. The timeout action is compared, never printed. Each fix's mutation fails at least one test. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- apps/cli/src/commands/drive.test.ts | 87 +++++++++++++++++++ apps/cli/src/commands/drive.ts | 55 +++++++++++- .../src/gate/budget-excluded-display.test.ts | 1 + .../src/gate/clack-prompter-lifecycle.test.ts | 41 +++++++++ apps/cli/src/gate/clack-prompter.test.ts | 58 +++++++++++-- apps/cli/src/gate/clack-prompter.ts | 22 +++-- 6 files changed, 247 insertions(+), 17 deletions(-) diff --git a/apps/cli/src/commands/drive.test.ts b/apps/cli/src/commands/drive.test.ts index d912b29e..2e220c7f 100644 --- a/apps/cli/src/commands/drive.test.ts +++ b/apps/cli/src/commands/drive.test.ts @@ -30,6 +30,21 @@ workflow: - { from: g, to: out } `; +// g has an approve deadline and g2 none, so the run continues after the deadline decides g. +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 } +`; + async function startGatedRun(): Promise<{ engine: WorkflowEngine; handle: ReturnType<WorkflowEngine['start']>; @@ -91,6 +106,78 @@ 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, and says the late answer was not recorded', 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. + 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<GateDecision>({ decision: 'approved', decidedBy: 'cli' }); + if (signal !== undefined) signals.push(signal); + return new Promise<GateDecision | null>((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('completed'); + expect(signals[0]?.aborted).toBe(true); + expect(err()).toMatch( + /Gate \S+ was already decided \(approved by its deadline\); your answer was not recorded\./u, + ); + // g2 was answered on its own card, which is not "decided elsewhere". + expect(err().match(/was already decided/gu)).toHaveLength(1); + expect(order.slice(0, 2)).toEqual(['err', 'resume']); + const decidedG = events.filter((e) => e.type === 'human_gate:resumed' && e.nodeId === 'g'); + expect(decidedG).toEqual([ + expect.objectContaining({ decision: 'approved', decidedBy: 'timeout' }), + ]); + }); + 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 da5e0ddf..b6b92de3 100644 --- a/apps/cli/src/commands/drive.ts +++ b/apps/cli/src/commands/drive.ts @@ -20,7 +20,8 @@ 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 { DECIDED_BY_CLI } from '../gate/decision.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'; @@ -124,6 +125,30 @@ export async function driveRun(deps: DriveRunDeps): Promise<RunOutcome | undefin let inlineRefused = false; let inlineTerminating = false; let activePrompt: AbortController | undefined; + /** + * The gate card on screen. A decision for its gate that this card did not make — a deadline's — closes it, so + * the user's later answer is never silently dropped by the engine's idempotent resume. + */ + let openCard: + | { + readonly gate: HumanGatePausedEvent; + readonly controller: AbortController; + decided?: string; + } + | undefined; + const closeCardDecidedElsewhere = ( + gateId: string | undefined, + nodeId: string, + decision: string, + decidedBy: string, + ): void => { + const card = openCard; + if (card === undefined || decidedBy === DECIDED_BY_CLI) return; + // A legacy resumed 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 => { @@ -292,6 +317,7 @@ export async function driveRun(deps: DriveRunDeps): Promise<RunOutcome | undefin budgetContexts.delete(id); } } + closeCardDecidedElsewhere(event.gateId, event.nodeId, event.decision, event.decidedBy); } if (isTerminalOutcome(outcome)) { inlineTerminating = true; @@ -381,6 +407,8 @@ export async function driveRun(deps: DriveRunDeps): Promise<RunOutcome | undefin ) { const controller = new AbortController(); activePrompt = controller; + const card: NonNullable<typeof openCard> = { gate, controller }; + openCard = card; const recorded = budgetContexts.get(gate.gateId); try { const handled = await joined( @@ -397,6 +425,7 @@ export async function driveRun(deps: DriveRunDeps): Promise<RunOutcome | undefin }, signal: controller.signal, runPrompt: (invoke) => terminal.runPrompt(invoke), + decidedElsewhere: () => card.decided, runViewOperation: (invoke, mayAcquire) => terminal.runViewOperation(invoke, mayAcquire), failed: recordFault, @@ -417,6 +446,7 @@ export async function driveRun(deps: DriveRunDeps): Promise<RunOutcome | undefin requestCancellation(); } finally { if (activePrompt === controller) activePrompt = undefined; + if (openCard === card) openCard = undefined; } continue; } @@ -531,6 +561,8 @@ interface InlineGateLifecycle { readonly runPrompt: ( invoke: () => ReturnType<GatePrompter['prompt']>, ) => ReturnType<GatePrompter['prompt']>; + /** How the gate was decided while its card was open, when something other than the card decided it. */ + readonly decidedElsewhere: () => string | undefined; readonly failed: (cause: unknown) => void; readonly runViewOperation: ( invoke: () => void | Promise<void>, @@ -563,6 +595,17 @@ async function resolveGateInline( budget?: BudgetPromptContext, ): Promise<boolean> { let decision: Awaited<ReturnType<GatePrompter['prompt']>>; + let reported = false; + /** Say, once, that the card's answer was not 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)}); ` + + 'your answer was not recorded.\n', + ); + }; try { await lifecycle.runViewOperation(() => renderer.suspend?.(), false); } catch (error) { @@ -575,6 +618,8 @@ async function resolveGateInline( () => lifecycle.runPrompt(() => prompter.prompt(event, budget, lifecycle.signal)), lifecycle.signal, ); + // 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); @@ -591,13 +636,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/gate/budget-excluded-display.test.ts b/apps/cli/src/gate/budget-excluded-display.test.ts index 355caf93..e19a11f7 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 b4c143d2..f1ed613f 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 29cc10d9..50602f6c 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,35 @@ describe('createClackGatePrompter', () => { expect(d).toBeNull(); }); - it('cancelling the rejection-comment prompt returns null (no half-built decision)', async () => { + 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) }), ).prompt(gate()); + expect(d).toEqual({ decision: 'rejected', decidedBy: 'cli' }); + }); + + 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<ClackPromptDeps['confirm']>(() => 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 +191,30 @@ 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 () => { @@ -202,7 +243,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'); }); }); @@ -225,6 +266,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( diff --git a/apps/cli/src/gate/clack-prompter.ts b/apps/cli/src/gate/clack-prompter.ts index 91b02e7d..cd4b0cb3 100644 --- a/apps/cli/src/gate/clack-prompter.ts +++ b/apps/cli/src/gate/clack-prompter.ts @@ -23,6 +23,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<boolean | symbol>; readonly text: (opts: { @@ -71,14 +73,15 @@ function cardBody(event: HumanGatePausedEvent): string { } /** - * What the deadline does. An input gate cannot be approved, so a deadline that would approve one fails the run - * instead: admission refuses that pairing now, but a run parked before the rule still carries it. + * 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 { - if (event.gateType === 'input' && event.timeoutAction === 'approve') { - return 'the run fails on timeout'; - } - return `auto-${sanitizeInline(event.timeoutAction ?? 'reject')} on timeout`; + return event.gateType !== 'input' && event.timeoutAction === 'approve' + ? 'auto-approve on timeout' + : 'the run fails on timeout'; } export function createClackGatePrompter(deps: ClackPromptDeps = defaultDeps): GatePrompter { @@ -104,10 +107,12 @@ export function createClackGatePrompter(deps: ClackPromptDeps = defaultDeps): Ga return signal?.aborted || deps.isCancel(value) ? null : inputDecision(value); } + // A bare Enter rejects: approving takes a deliberate choice, as at the MCP consent prompt. const approved = await deps.confirm({ message: 'Approve?', active: 'Approve', inactive: 'Reject', + initialValue: false, ...(signal === undefined ? {} : { signal }), }); if (signal?.aborted || deps.isCancel(approved)) { @@ -121,7 +126,9 @@ export function createClackGatePrompter(deps: ClackPromptDeps = defaultDeps): Ga placeholder: '', ...(signal === undefined ? {} : { signal }), }); - return signal?.aborted || deps.isCancel(comment) ? null : rejectionDecision(comment); + if (signal?.aborted) return null; + // The reason is optional, so Esc here skips it: the rejection already chosen stands. + return rejectionDecision(deps.isCancel(comment) ? undefined : comment); }, }; } @@ -166,6 +173,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; From 3f00c4de6890bf2a4d8c4b5a40487f9b7b37e18b Mon Sep 17 00:00:00 2001 From: Cemil ILIK <cemililik@outlook.com> Date: Fri, 6 Nov 2026 15:30:31 +0300 Subject: [PATCH 108/148] fix(cli): replace an own export only if it is still the file that was checked /export recognized the session's earlier export, then serialized and wrote the replacement and renamed over the path without looking again. A probe in the PR #97 review swapped a hand-authored workflow in during that window, tens of milliseconds for a long transcript, and it was destroyed. replaceFileForStorage now takes the identity the caller checked (device, inode, ctime) and compares the file at the path with it before it starts and again just before the rename, throwing StorageTargetChangedError when it was replaced, rewritten or removed. /export then leaves that file alone and takes the next free id. A path that became a link is refused rather than replaced. A swap in the microseconds between the last check and the rename is still possible: Node has no rename that compares first. The docs say so. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- apps/cli/src/chat/export-project.test.ts | 38 +++++++- apps/cli/src/chat/export.ts | 40 ++++++--- .../src/process/owned-storage-replace.test.ts | 88 ++++++++++++++++--- apps/cli/src/process/owned-storage.ts | 60 ++++++++++++- 4 files changed, 201 insertions(+), 25 deletions(-) diff --git a/apps/cli/src/chat/export-project.test.ts b/apps/cli/src/chat/export-project.test.ts index fb091e3e..5b56edea 100644 --- a/apps/cli/src/chat/export-project.test.ts +++ b/apps/cli/src/chat/export-project.test.ts @@ -24,11 +24,25 @@ import { } 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 } from 'vitest'; +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<typeof import('node:fs')>(); + return { + ...native, + openSync: (...args: Parameters<typeof native.openSync>) => { + 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', @@ -140,6 +154,28 @@ describe('exportSessionToProject (MVP-A3)', () => { ).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. diff --git a/apps/cli/src/chat/export.ts b/apps/cli/src/chat/export.ts index 142efd64..de496883 100644 --- a/apps/cli/src/chat/export.ts +++ b/apps/cli/src/chat/export.ts @@ -15,6 +15,9 @@ import { CliError } from '../process/errors.js'; import { mkdirForStorage, replaceFileForStorage, + StorageTargetChangedError, + storageFileIdentity, + type StorageFileIdentity, writeFileForStorage, storageBoundaryError, } from '../process/owned-storage.js'; @@ -131,10 +134,12 @@ const WORKFLOW_ID_GRAMMAR = /^[a-z0-9]+(?:-[a-z0-9]+)*$/u; * 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 ownExportMode}). So a second + * 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. + * 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 takes the next free id instead. A swap in the microseconds 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); @@ -157,12 +162,15 @@ export function exportSessionToProject(opts: ExportToProjectOptions): ExportResu : takenWorkflowIds(opts.projectConfigDir, opts.cwd); for (const id of candidates) { const path = join(dir, `${id}.relavium.yaml`); - const mode = ownExportMode(path, opts.sessionId); - if (mode === undefined) continue; + const own = ownExport(path, opts.sessionId); + if (own === undefined) continue; const definition = withWorkflowId(scaffold, id); try { - replaceFileForStorage(path, serializeWorkflow(definition), mode); + 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); @@ -182,14 +190,20 @@ export function exportSessionToProject(opts: ExportToProjectOptions): ExportResu ); } +/** 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; +} + /** - * The permission bits of `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. + * `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 ownExportMode(path: string, sessionId: string): number | undefined { +function ownExport(path: string, sessionId: string): OwnExport | undefined { let fd: number | undefined; try { const linked = lstatSync(path, { bigint: true }); @@ -203,7 +217,9 @@ function ownExportMode(path: string, sessionId: string): number | undefined { const text = readFileSync(fd, '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 ? Number(opened.mode & 0o777n) : undefined; + return readSessionExportMarker(text) === sessionId + ? { mode: Number(opened.mode & 0o777n), identity: storageFileIdentity(opened) } + : undefined; } catch { return undefined; } finally { diff --git a/apps/cli/src/process/owned-storage-replace.test.ts b/apps/cli/src/process/owned-storage-replace.test.ts index b0924553..922dc71f 100644 --- a/apps/cli/src/process/owned-storage-replace.test.ts +++ b/apps/cli/src/process/owned-storage-replace.test.ts @@ -16,7 +16,12 @@ import { join } from 'node:path'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { isCliError } from './errors.js'; -import { replaceFileForStorage } from './owned-storage.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 — @@ -26,6 +31,8 @@ describe('replaceFileForStorage', () => { let dir: string; const temps = (): string[] => readdirSync(dir).filter((name) => name.endsWith('.tmp')); const isRoot = typeof process.getuid === 'function' && process.getuid() === 0; + const identityOf = (path: string): StorageFileIdentity => + storageFileIdentity(lstatSync(path, { bigint: true })); beforeEach(() => { dir = mkdtempSync(join(tmpdir(), 'relavium-replace-')); @@ -37,36 +44,95 @@ describe('replaceFileForStorage', () => { it('replaces the file and leaves no temp beside it', () => { const path = join(dir, 'w.relavium.yaml'); writeFileSync(path, 'old'); - replaceFileForStorage(path, 'new', 0o644); + replaceFileForStorage(path, 'new', 0o644, identityOf(path)); expect(readFileSync(path, 'utf8')).toBe('new'); expect(temps()).toEqual([]); }); it.skipIf(process.platform === 'win32')( - 'replaces a link at the path instead of writing through it', + '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); - replaceFileForStorage(path, 'new', 0o644); - expect(lstatSync(path).isSymbolicLink()).toBe(false); - expect(readFileSync(path, 'utf8')).toBe('new'); + 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('removes its temp when the rename fails, and leaves what was there', () => { + it('refuses a directory at the path, removes its temp, and leaves what was there', () => { const path = join(dir, 'w.relavium.yaml'); - mkdirSync(path); // a directory: the rename onto it fails + mkdirSync(path); expect(() => { - replaceFileForStorage(path, 'new', 0o644); + replaceFileForStorage(path, 'new', 0o644, identityOf(path)); }).toThrow(); expect(statSync(path).isDirectory()).toBe(true); expect(temps()).toEqual([]); }); + 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': only its ctime 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.skipIf(process.platform === 'win32')( 'gives the new file exactly the old permission bits, whatever the umask would make of them', () => { @@ -74,7 +140,7 @@ describe('replaceFileForStorage', () => { writeFileSync(path, 'old'); for (const mode of [0o600, 0o666]) { chmodSync(path, mode); - replaceFileForStorage(path, 'new', mode); + replaceFileForStorage(path, 'new', mode, identityOf(path)); expect(statSync(path).mode & 0o777).toBe(mode); } }, @@ -86,7 +152,7 @@ describe('replaceFileForStorage', () => { chmodSync(path, 0o444); let caught: unknown; try { - replaceFileForStorage(path, 'new', 0o444); + replaceFileForStorage(path, 'new', 0o444, identityOf(path)); } catch (error) { caught = error; } diff --git a/apps/cli/src/process/owned-storage.ts b/apps/cli/src/process/owned-storage.ts index e2c07f47..093fa2de 100644 --- a/apps/cli/src/process/owned-storage.ts +++ b/apps/cli/src/process/owned-storage.ts @@ -1,11 +1,13 @@ import { randomUUID } from 'node:crypto'; import { accessSync, + type BigIntStats, chmodSync, closeSync, constants, fchmodSync, fsyncSync, + lstatSync, mkdirSync, openSync, renameSync, @@ -49,6 +51,34 @@ export function writeFileForStorage(path: string, content: string, force: boolea } } +/** + * A file's identity as `lstat` reports it: what {@link replaceFileForStorage} re-checks before it renames. A file + * put in its place has another device and inode; one rewritten in place has a new `ctime`, which, unlike `mtime`, + * no caller can set back. + */ +export interface StorageFileIdentity { + readonly dev: bigint; + readonly ino: bigint; + readonly ctimeNs: bigint; +} + +/** The identity of the file `stats` describes (from a `bigint` stat). */ +export function storageFileIdentity(stats: BigIntStats): StorageFileIdentity { + return { + dev: stats.dev, + ino: stats.ino, + ctimeNs: stats.ctimeNs, + }; +} + +/** 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, @@ -58,11 +88,34 @@ export function writeFileForStorage(path: string, content: string, force: boolea * 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. A swap in the microseconds between that last check and the rename is not detected: Node has no + * rename that compares first. */ -export function replaceFileForStorage(path: string, content: string, mode: number): void { +export function replaceFileForStorage( + path: string, + content: string, + mode: number, + expected: StorageFileIdentity, +): void { const temp = join(dirname(path), `.${basename(path)}.${randomUUID()}.tmp`); + const ensureUnchanged = (): void => { + const current = lstatSync(path, { bigint: true, throwIfNoEntry: false }); + if ( + current === undefined || + !current.isFile() || + !sameFile(storageFileIdentity(current), expected) + ) { + 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 @@ -70,6 +123,7 @@ export function replaceFileForStorage(path: string, content: string, mode: numbe fsyncSync(fd); closeSync(fd); fd = undefined; + ensureUnchanged(); renameSync(temp, path); } catch (error) { if (fd !== undefined) { @@ -88,6 +142,10 @@ export function replaceFileForStorage(path: string, content: string, mode: numbe } } +function sameFile(a: StorageFileIdentity, b: StorageFileIdentity): boolean { + return a.dev === b.dev && a.ino === b.ino && a.ctimeNs === b.ctimeNs; +} + /** Concrete native acquisition for the editor's private transcript directory. */ export async function mkdtempForStorage(prefix: string): Promise<string> { try { From bec3b99fa9bcd728466b01ebcf686d5bfc2fb81a Mon Sep 17 00:00:00 2001 From: Cemil ILIK <cemililik@outlook.com> Date: Fri, 6 Nov 2026 15:30:31 +0300 Subject: [PATCH 109/148] fix(cli): print run and step status on one line in status, list and logs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The status column is read back from history.db, and only the SQLite CHECK kept it clean; a file written with ignore_check_constraints got escapes and line breaks through. status's run and step status, list's --runs row and catalog [last: …], and logs's header now get the one-line floor every other history identifier gets (1a's history-12, the PR #97 review). A new forgeRunStatus test helper writes past the CHECK; removing any of the five sanitizers fails its test. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- apps/cli/src/commands/list.test.ts | 15 ++++++++++++++- apps/cli/src/commands/list.ts | 4 ++-- apps/cli/src/commands/logs.test.ts | 12 +++++++++++- apps/cli/src/commands/logs.ts | 3 ++- apps/cli/src/commands/status.test.ts | 16 +++++++++++++++- apps/cli/src/commands/status.ts | 4 ++-- apps/cli/src/test-support.ts | 18 +++++++++++++++++- 7 files changed, 63 insertions(+), 9 deletions(-) diff --git a/apps/cli/src/commands/list.test.ts b/apps/cli/src/commands/list.test.ts index e18b7d97..624ebba1 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'; @@ -104,6 +104,19 @@ describe('listCommand', () => { 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 diff --git a/apps/cli/src/commands/list.ts b/apps/cli/src/commands/list.ts index 809acafd..fd5956c8 100644 --- a/apps/cli/src/commands/list.ts +++ b/apps/cli/src/commands/list.ts @@ -155,7 +155,7 @@ function listRuns(deps: ListCommandDeps, homeDir: string): void { 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)} ${run.status.padEnd(9)} ${workflow} ${started}\n`, + `${sanitizeInline(run.id)} ${sanitizeInline(run.status).padEnd(9)} ${workflow} ${started}\n`, ); } } finally { @@ -251,7 +251,7 @@ function entryLine(entry: CatalogEntry, last: RunRecord | undefined | null): str 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 9ee21da7..df0c3fdb 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,16 @@ describe('logsCommand', () => { } }); + 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' }); diff --git a/apps/cli/src/commands/logs.ts b/apps/cli/src/commands/logs.ts index 010c4417..1a458c8d 100644 --- a/apps/cli/src/commands/logs.ts +++ b/apps/cli/src/commands/logs.ts @@ -91,8 +91,9 @@ function writeSkippedNote(io: CliIo, skipped: readonly SkippedRunEvent[]): void /** 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 ${run.id} — ${run.status} (${events.length} event${events.length === 1 ? '' : 's'})\n`, + `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 diff --git a/apps/cli/src/commands/status.test.ts b/apps/cli/src/commands/status.test.ts index 9845477a..73109765 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,20 @@ 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('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' }); diff --git a/apps/cli/src/commands/status.ts b/apps/cli/src/commands/status.ts index 2cb69f8b..ffdfca8f 100644 --- a/apps/cli/src/commands/status.ts +++ b/apps/cli/src/commands/status.ts @@ -296,7 +296,7 @@ function renderRun(io: CliIo, status: ActiveRunStatus): void { // 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)} — ${status.run.status} (workflow ${sanitizeInline(status.run.workflowId)})\n`, + `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"); @@ -329,7 +329,7 @@ function renderSteps(io: CliIo, status: ActiveRunStatus, finished: boolean): voi const attempt = step.attemptNumber > 1 ? ` (attempt ${step.attemptNumber})` : ''; const shown = shownStepStatus(step, finished, status.decidedGateNodes); io.writeOut( - ` ${shown.padEnd(9)} ${sanitizeInline(step.nodeId)} [${sanitizeInline(step.nodeType)}]${attempt}\n`, + ` ${sanitizeInline(shown).padEnd(9)} ${sanitizeInline(step.nodeId)} [${sanitizeInline(step.nodeType)}]${attempt}\n`, ); } } diff --git a/apps/cli/src/test-support.ts b/apps/cli/src/test-support.ts index 08136ae0..1833dfe2 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'; @@ -246,6 +246,22 @@ export async function seedRun(db: Db, opts: SeedRunOptions): Promise<string> { } /** Emit the `paused`-state events for {@link seedRun} — a human/budget gate park, or (no gate) a media-job park. */ +/** + * 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<T>( items: readonly T[], From d61488d8573c36c7b8e12129eb662ae95b25fb6c Mon Sep 17 00:00:00 2001 From: Cemil ILIK <cemililik@outlook.com> Date: Fri, 6 Nov 2026 15:30:44 +0300 Subject: [PATCH 110/148] ci(repo): say what release.yml's dispatch and environment comments actually guarantee A dispatched run of this workflow never publishes, but a dispatch runs the workflow file at its ref, so an edited copy elsewhere is bounded only by where NPM_TOKEN lives. The publish job's comment said "It sets no job environment" above `environment: release`; the environment does not exist yet, so GitHub creates it unprotected and it gates nothing until MVP-A23. Comments only (PR #97 review). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- .github/workflows/release.yml | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8ef218ec..8857ff2c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -31,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 @@ -229,7 +231,8 @@ jobs: if: ${{ github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') }} runs-on: ubuntu-latest # 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 sets no job environment. + # 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: From e37f911ceb5760b391885d07ed928f3d5f00b1ee Mon Sep 17 00:00:00 2001 From: Cemil ILIK <cemililik@outlook.com> Date: Fri, 6 Nov 2026 15:30:44 +0300 Subject: [PATCH 111/148] docs(core): say a run-path pre-flight tool cannot succeed, not that it certainly fails one way A refused tool's first call fails policy or argument checks before it can reach tool_unavailable, so "a certain tool_unavailable" overstated it. Comment only (PR #97 review). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- packages/core/src/validate-catalog.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/core/src/validate-catalog.ts b/packages/core/src/validate-catalog.ts index 255d8127..6f18708b 100644 --- a/packages/core/src/validate-catalog.ts +++ b/packages/core/src/validate-catalog.ts @@ -246,8 +246,9 @@ 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 is a certain `tool_unavailable` at its first call, partway - * through a run that may already have spent money; refusing before the run starts is the louder signal. A `$ref`'d + * `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. * From 126488c2c25ba9574d94e3c4502a46b3f922498d Mon Sep 17 00:00:00 2001 From: Cemil ILIK <cemililik@outlook.com> Date: Fri, 6 Nov 2026 15:30:45 +0300 Subject: [PATCH 112/148] docs(repo): record the PR #97 review, its fixes, rulings and recorded lows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four reviews of the checkpoint PR #97 were verified at 077650a8 by six agents with probes; none of their highs held, eight mediums did, and the maintainer ruled on 2026-11-06 that they are fixed before merge. - ADR-0118 (Proposed): a remembered grant holds an HMAC-SHA256 of the exact argv under a per-installation keychain key and an argument-free label, never the command, so no credential in a command reaches config; grants match element by element. ADR-0119: the interrupted-turn marker follows ADR-0095 §4's memory policy (carried under window, not sent under none). ADR-0120: an approval preview escapes controls and labels masks rather than stripping them. - ADR-0079: a 2026-11-06 note on the TTL rule, the foreign-key claim and the run's own unfenced terminal; database-schema.md and commands.md now describe reconcile's TTL rule, effect_needs_attention and the outbox re-check; effect-journal.md §8 covers reconcile. - commands.md: the gate card's Enter, Esc and deadline behaviour; list's masking is the human listing's; outputs name the finite shape list. chat-session.md: the keyless interactive chat-resume and --fixture exceptions, and /export's re-check. built-in-tools.md: the whole userinfo stays public (A4); invoke_agent and read_media are outside the pre-flight. - deferred-tasks.md: A4 states the reversal against main and its trigger (before v0.2.0); the ReDoS entry names the cloned-repository vector; history-12 is fixed; 1b's liveness-1 is narrowed; a new PR #97 section lists what was fixed and the lows recorded. current.md records PR #97. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- ...s-run-ownership-lease-and-fencing-token.md | 15 ++ ...e-the-allowlist-asks-instead-of-failing.md | 67 ++++++--- ...ted-turn-stays-as-a-content-free-marker.md | 26 +++- ...rows-and-a-tool-call-shows-what-it-does.md | 15 +- docs/reference/cli/chat-session.md | 6 +- docs/reference/cli/commands.md | 8 +- docs/reference/shared-core/built-in-tools.md | 10 +- docs/reference/shared-core/database-schema.md | 11 +- docs/reference/shared-core/effect-journal.md | 4 + docs/roadmap/current.md | 2 +- docs/roadmap/deferred-tasks.md | 137 ++++++++++++++++-- 11 files changed, 240 insertions(+), 61 deletions(-) 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 0403d03c..adb579bd 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 @@ -84,6 +84,21 @@ All contention is on the resume path, which is already async. > 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. That closes the window the 2026-11-05 note describes: a reconcile inside it no +> longer settles the starting run, which takes its lease and runs. Past one TTL, a run with no live lease is still +> treated as crashed. 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/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 index 22acb72f..31697286 100644 --- 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 @@ -1,9 +1,8 @@ # 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: a remembered grant is free-form text, so §2 - no longer calls it secret-incapable and withholds Remember for a command the credential masking would mask) -- **Related**: [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) +- **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 @@ -72,8 +71,8 @@ expanded before it can be approved. - **Once.** This invocation only. - **Session.** This exact command string for the rest of this session instance. It is held in memory and not persisted, like ADR-0057's `[a]`. -- **Remember.** Persist an exact-string grant for this project root (§2). It is - not offered for a command that may carry a credential (§2). +- **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 @@ -103,21 +102,29 @@ pre-approved is denied. That denial returns to the model under ADR-0117. 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 that carries no argument: the program, the argument count and the + date it was remembered, for the revocation view. + + 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. That primitive's secret-free guarantee covers its typed, non-secret - keys. A command string is free-form, so the guarantee does not cover a grant. -- **What a grant contains.** Only an exact command string, never a glob. "Remember - `npm test`" never authorizes `npm test -- --exec=…`. -- **What cannot be remembered.** Remember is not offered for a command in which - the [credential masking](../reference/shared-core/built-in-tools.md#finite-diagnostic-credential-masking) - that tool previews use finds anything to mask. The card then offers Once, - Session and Deny, and says why. When Remember is offered, the card shows the - exact string it would store. The masking recognizes a finite set of credential - shapes, so a credential it does not recognize can still be remembered, and it - is then stored in plaintext in operator configuration. That is a recorded - limit, not a guarantee; the approval-path security sitting examines it. + written. A grant holds only a digest and an argument-free label, so the + primitive's secret-free property covers it: no command text reaches the file. - **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. @@ -134,10 +141,14 @@ contract lives with the approval-preview contract (phase 2.6 ADR row 9). - **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. Allowlist entries and remembered - grants match exact strings, and globs stay opt-in and config-authored. +- **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. + 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. @@ -153,6 +164,12 @@ accepted. - **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. @@ -166,7 +183,8 @@ accepted. - A command the user is willing to run can run, with the exact argv on screen. The config edit and restart are no longer needed. - A remembered grant serves one operator on one tree and never travels with the - repository. + repository, and it holds no command text, so no credential in a command + reaches the configuration. - `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. @@ -180,9 +198,10 @@ accepted. decision. - Operator config gains a growing, per-root grant list. It needs bounds, strict parsing and a revocation view (2.6.G). -- A remembered grant is a plaintext command string. Remember is withheld when - the credential masking finds anything, but a credential shape it does not - recognize can still be stored (§2). +- 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, argument count and date. - 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 index 52958506..2d2f8e69 100644 --- 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 @@ -1,7 +1,7 @@ # ADR-0119: An interrupted turn stays in the session as a content-free marker - **Status**: Proposed -- **Date**: 2026-11-05 +- **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 @@ -86,6 +86,22 @@ 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 @@ -98,8 +114,10 @@ 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. -Provider adapters may need consecutive user-role messages merged. That is -`@relavium/llm`'s concern, behind the seam. The exact message shape lives in +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 @@ -155,6 +173,8 @@ Provider adapters may need consecutive user-role messages merged. That is 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 index 904fdd52..2e46cd61 100644 --- 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 @@ -1,7 +1,7 @@ # ADR-0120: The toolbelt grows, and a tool call shows what it does - **Status**: Proposed -- **Date**: 2026-11-05 +- **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 @@ -93,11 +93,22 @@ Exact arguments, bounds and texts live in `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 never hides 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. So in an approval card + and its full view, a terminal control or bidi character in a target, field or + diff line renders as a visible, styled escape (for example `<U+202E>`), never + stripped; transcript rows may strip. A span the secret masking replaces shows + as a labelled mask, and the card states how many spans were masked; the full + view does not unmask. 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. - **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 is what lands. + 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 diff --git a/docs/reference/cli/chat-session.md b/docs/reference/cli/chat-session.md index 3c63f47c..0f87b452 100644 --- a/docs/reference/cli/chat-session.md +++ b/docs/reference/cli/chat-session.md @@ -53,8 +53,8 @@ A session **binds one agent for its whole lifetime** — there is no mid-session - 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_<PROVIDER>_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 '<id>' — store one with `relavium provider set-key <id>` or set RELAVIUM_<ID>_API_KEY.`` What happens next depends on the surface: - - `chat`, `agent run` and a non-interactive `chat-resume` refuse with exit `2`. - - An **interactive** `chat-resume` opens anyway, because `/models` can move the conversation to a provider that has a key. 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. + - `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. @@ -109,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 <sessionId>` (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 where workflows live — `<project>/.relavium/workflows/<id>.relavium.yaml`, where `<project>/.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 `<cwd>/.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 <id>` ``) (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 `<slug>`, `<slug>-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. 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 — `<project>/.relavium/workflows/<id>.relavium.yaml`, where `<project>/.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 `<cwd>/.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 <id>` ``) (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 `<slug>`, `<slug>-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 takes the next free id; a swap in the microseconds 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. | diff --git a/docs/reference/cli/commands.md b/docs/reference/cli/commands.md index 6d3970cb..478fa444 100644 --- a/docs/reference/cli/commands.md +++ b/docs/reference/cli/commands.md @@ -200,17 +200,17 @@ 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 <digest>` 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 secret shapes in the original text, 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 <runId> --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. +- **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 <runId> --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_<PROVIDER>_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 <digest>` (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 <runId> --gate <gateId>`); with `--json` they are on the NDJSON event line, otherwise the plain/TUI renderer prints them inline (`paused at gate <gateId> (<type>)`, also echoed in the final summary). (`relavium status`, `relavium logs <runId>`, 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 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 <runId>` for a run that ended; for a paused run, `relavium gate <runId>` when a human gate is pending, `relavium budget resume <runId> --gate <gateId>` for a budget gate (which `gate` declines), and `relavium status <runId>` 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 <runId>`, 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_<PROVIDER>_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 <runId>`. 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. +> **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 <runId>` for a run that ended; for a paused run, `relavium gate <runId>` when a human gate is pending, `relavium budget resume <runId> --gate <gateId>` for a budget gate (which `gate` declines), and `relavium status <runId>` 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 <runId>`, 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_<PROVIDER>_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, so approving takes a deliberate choice; Esc at the optional rejection reason keeps the rejection without one. 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 decides the gate while its card is open, the card closes and stderr says the answer was not recorded; under `--json`/CI/no-TTY there is no prompt and the run exits `3`, resumable later by `relavium gate <runId>`. 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: <reason>)` rather than hidden. 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 secret shapes masked: a name is free authored text, and `/export` writes a session's title there. 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: <reason>)` 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). @@ -639,7 +639,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`**, 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` — `internal`, or `budget_exceeded` for a run with a recorded budget rejection ([sse-event-schema.md](../contracts/sse-event-schema.md#durable-budget-authorization)). It leaves alone a run parked at a gate and 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). `chat` and `agent run` run on `AgentSession` and do not reconcile. One reachable `5` comes from that reconcile: in the instant between a run's durable `run:started` and its lease acquire, a concurrent start can settle the run. 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, 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 <runId>` shows whether the held terminal has landed, or, in the race 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`. +> 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 (exit `7`; [effect-journal.md](../shared-core/effect-journal.md#8-needs_attention)), `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 (an older binary, or a clock 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 <runId>` 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/shared-core/built-in-tools.md b/docs/reference/shared-core/built-in-tools.md index e5fadbfd..bc67d930 100644 --- a/docs/reference/shared-core/built-in-tools.md +++ b/docs/reference/shared-core/built-in-tools.md @@ -40,7 +40,9 @@ A tool declaration in YAML has a `type` discriminator: > 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. +> 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). @@ -144,9 +146,9 @@ otherwise differs from that backstop by design: its authority also ends at `"`, `]`, 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 text after it stays -public unless another rule reads it (`https://user:pa,ss@host`; 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 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. diff --git a/docs/reference/shared-core/database-schema.md b/docs/reference/shared-core/database-schema.md index 618d248f..bf819725 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,7 +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 reachable case is `reconcile()` (which the CLI calls at `run`/`gate` start and on Home open) 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. 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. +- **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 — an older binary, or a clock 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/effect-journal.md b/docs/reference/shared-core/effect-journal.md index 7f266ecc..00a6e939 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 diff --git a/docs/roadmap/current.md b/docs/roadmap/current.md index dd2d7abc..c318af64 100644 --- a/docs/roadmap/current.md +++ b/docs/roadmap/current.md @@ -39,7 +39,7 @@ evidence summary, the recorded Wave 3 splits and the process amendments. | 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 `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 | +| 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, which the maintainer ruled are fixed before it merges, 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 diff --git a/docs/roadmap/deferred-tasks.md b/docs/roadmap/deferred-tasks.md index b5b83779..9105795f 100644 --- a/docs/roadmap/deferred-tasks.md +++ b/docs/roadmap/deferred-tasks.md @@ -997,15 +997,21 @@ names, unless the entry names another. **Redaction:** -- [ ] **A URL password that holds a `,` or `/` keeps its tail after it public (the PR #96 review's A4).** *Medium as - reviewed; pre-existing, not a regression.* Core's authority scan ends at either, so `https://user:pa,ss@host` and - `https://user:pa/ss@host` keep `ss` visible in `toolInput`, summaries and the journal digest input; 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. **Trigger:** a design that merges a new reading's ranges with the base rules' on the - original text, so it provably only adds masks, with a superset oracle validated on a known-bad version. *(medium as - reviewed · packages/core/src/tools/bounding.ts, 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 @@ -1577,9 +1583,11 @@ next group below, and the separate security sitting's two lows (at `ab5e46e1`) a `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"). **Re-check before the v0.2.0 release:** either tell users in the - release notes to finish or cancel 0.1.x runs first, or treat a run with no lease row whose last event is younger - than the lease TTL as live, as an ADR-0079 amendment. *(low · packages/core/src/engine/engine.ts)* + 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 @@ -1755,10 +1763,12 @@ names. 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)* -- [ ] **Run and step status strings print raw.** *Low.* *(history-12, security-1)* `status` and `list --runs` +- [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. **Re-check:** sanitize the status strings too, or validate - them in `fromRunRow` and the step reader. *(low · apps/cli/src/commands/status.ts, apps/cli/src/commands/list.ts)* + 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 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`. @@ -1887,6 +1897,95 @@ names. 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, and by the maintainer's rulings of 2026-11-06 the +PR is fixed before it merges, so it 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, and the drain read the open runs before the outbox (it now reads the outbox first and again +under its claim); reconcile now leaves a run younger than one lease TTL alone, which also narrows 1b's liveness-1; +a crashed run with an unresolved external effect is settled as `effect_needs_attention`; a bare Enter on an +approval, review or budget-amount card rejected nothing and approved instead; a card whose gate a deadline decided +stayed open and dropped the later answer silently; the Windows readiness budget; the URL-password reversal against +`main` is recorded (A4 above); the ADR-0118, ADR-0119 and ADR-0120 drafts. **Fixed (low):** Esc at the rejection +reason cancelled the run; the card promised `auto-reject` where the run fails; `/export`'s own-export rewrite now +re-checks the file before it replaces it; history-12; the doc and comment items. 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`, `api-key`, `token` or `password` 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)* + ## Wave 3 NI — recorded low findings (2026-10-25) Round 1 of the native-identity increment at `fc5af701` accepted it with these lows @@ -2632,7 +2731,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` and `relavium chat` 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 7.6 s in that review, 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 From 13979d60aaa69d4d785e2c311723107b287a03ff Mon Sep 17 00:00:00 2001 From: Cemil ILIK <cemililik@outlook.com> Date: Fri, 6 Nov 2026 19:26:27 +0300 Subject: [PATCH 113/148] test(repo): pin the usage permanent inventory at 447 cases bec3b99f added a forged-status case to logs.test.ts, one of the files the usage controls' permanent cohort collects, so the actual collection is 447 cases and the pinned 446 stopped full CI. The pin, its unit test and the README count now say 447; the native-child inventory is unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- tools/usage-coverage-controls/README.md | 5 +++-- tools/usage-coverage-controls/workload.mjs | 2 +- tools/usage-coverage-controls/workload.test.mjs | 4 ++-- 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/tools/usage-coverage-controls/README.md b/tools/usage-coverage-controls/README.md index a8e285bb..0fd44a29 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 447 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, and one `logs` case pins its header's one-line floor (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 d4b5bed2..8125ebdc 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: 447, 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 d1f5f3fa..1ae2239d 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: 447 }), runtime: { nativeChildren: 71, readinessTimeoutChildren: 1 }, }; assert.doesNotThrow(() => assertPermanentWorkload(result)); - for (const count of [0, 440, 445, 447]) { + for (const count of [0, 440, 446, 448]) { assert.throws( () => assertPermanentWorkload({ ...result, tests: Array.from({ length: count }) }), /Permanent case inventory drift/, From 775fe8dd9106fc3eaf3dc6dd6b4579e8e2b927e1 Mon Sep 17 00:00:00 2001 From: Cemil ILIK <cemililik@outlook.com> Date: Fri, 6 Nov 2026 19:57:49 +0300 Subject: [PATCH 114/148] fix(core): bound reconcile's TTL grace on both sides and pin the own-run guard again D64 round 1 of the PR #97 correction found: - A crashed run whose start lies in the reconciler's future (a clock that moved back) was never settled until the clock caught up. The grace now holds only within one lease TTL on either side of now. - The TTL rule made "reconcile() never takes over a run THIS engine is executing" hollow: its run was younger than one TTL, so the guard in #claimForReconcile was never reached and could be deleted with every test green. The test now runs reconcile with the clock past one TTL. - Three documented edges had no test: a start just ahead of the clock (kept) and more than a TTL ahead (settled), a store that does not report the start (no grace), and effect_needs_attention taking precedence over budget_exceeded. Each now has one. reconcile()'s docblock and the start-window comment describe the current rules. Mutating the two-sided bound, the absent-start rule, the precedence or the own-run guard fails a test. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- packages/core/src/engine/engine.test.ts | 92 ++++++++++++++++++++++ packages/core/src/engine/engine.ts | 32 +++++--- packages/core/src/engine/run-lease.test.ts | 10 ++- 3 files changed, 121 insertions(+), 13 deletions(-) diff --git a/packages/core/src/engine/engine.test.ts b/packages/core/src/engine/engine.test.ts index af0f56db..1b6a9826 100644 --- a/packages/core/src/engine/engine.test.ts +++ b/packages/core/src/engine/engine.test.ts @@ -6298,6 +6298,98 @@ describe('WorkflowEngine — internal failures and handle-side controls', () => 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, 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 diff --git a/packages/core/src/engine/engine.ts b/packages/core/src/engine/engine.ts index d2ad3f69..3a8f909e 100644 --- a/packages/core/src/engine/engine.ts +++ b/packages/core/src/engine/engine.ts @@ -1080,10 +1080,11 @@ class RunExecution { // // 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 it must list, drain and acquire inside the few microtask turns between the two commits — - // reachable only if this process is descheduled right there. The outcome is measured (the CLI's - // `history/reconcile.test.ts`): the reconciler 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 + // 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 @@ -6345,12 +6346,14 @@ export class WorkflowEngine { /** * 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{internal}`, or `budget_exceeded` when ordered gate history already records a budget rejection, - * continuing that run's `sequenceNumber`. Returns the reconciled events. Resumable runs (parked at a gate) - * are left for `resume`. Also left alone, for a later reconcile: 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, and every run while the outbox exists but cannot be read — so in those cases a crashed run can - * linger until a later start. + * `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 @@ -6597,10 +6600,15 @@ export class WorkflowEngine { return held === undefined || held.some((event) => event.runId === runId); } - /** Whether `run` started less than one lease TTL ago by this host's clock (a start in its future counts as recent). */ + /** + * 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; - return this.#nowMs() - run.startedAtMs < RUN_LEASE_TTL_MS; + const age = this.#nowMs() - run.startedAtMs; + return age > -RUN_LEASE_TTL_MS && age < RUN_LEASE_TTL_MS; } /** diff --git a/packages/core/src/engine/run-lease.test.ts b/packages/core/src/engine/run-lease.test.ts index 251db9a5..d9a7cb30 100644 --- a/packages/core/src/engine/run-lease.test.ts +++ b/packages/core/src/engine/run-lease.test.ts @@ -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(); From c842f01676506bcd1b4b9a84dc7f086e9fa5494f Mon Sep 17 00:00:00 2001 From: Cemil ILIK <cemililik@outlook.com> Date: Fri, 6 Nov 2026 19:57:49 +0300 Subject: [PATCH 115/148] fix(cli): keep the own-export identity check sound on coarse filesystem clocks D64 round 1 found that the identity (dev, ino, ctime) assumed a fine-grained ctime. On a Linux kernel before 6.13, ext4, xfs, btrfs and tmpfs take ctime from a coarse clock and ext4 reuses a freed inode, so a change inside one tick looked unchanged, and the new tests failed there about 99% of the time. The identity carries size and mtime again, so a change of a different length is caught whatever the clock, and the comments and the /export docs name the remaining gap: a same-length change within one coarse tick. The tests now let one clock tick pass between the check and the change. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- apps/cli/src/chat/export-project.test.ts | 2 ++ apps/cli/src/chat/export.ts | 3 ++- .../src/process/owned-storage-replace.test.ts | 10 +++++++- apps/cli/src/process/owned-storage.ts | 24 +++++++++++++++---- 4 files changed, 32 insertions(+), 7 deletions(-) diff --git a/apps/cli/src/chat/export-project.test.ts b/apps/cli/src/chat/export-project.test.ts index 5b56edea..3078cecd 100644 --- a/apps/cli/src/chat/export-project.test.ts +++ b/apps/cli/src/chat/export-project.test.ts @@ -159,6 +159,8 @@ describe('exportSessionToProject (MVP-A3)', () => { // 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(); + // Past one tick of a coarse filesystem clock, so the swapped-in file's times differ from the export's. + Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 30); fsHooks.onOpen = (path) => { if (!path.endsWith('.tmp')) return; fsHooks.onOpen = undefined; diff --git a/apps/cli/src/chat/export.ts b/apps/cli/src/chat/export.ts index de496883..9a360491 100644 --- a/apps/cli/src/chat/export.ts +++ b/apps/cli/src/chat/export.ts @@ -139,7 +139,8 @@ const WORKFLOW_ID_GRAMMAR = /^[a-z0-9]+(?:-[a-z0-9]+)*$/u; * 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 takes the next free id instead. A swap in the microseconds between - * the last check and the rename is not detected ({@link replaceFileForStorage}). + * the last check and the rename, or a same-length change within one tick of a coarse filesystem clock, is not + * detected ({@link replaceFileForStorage}). */ export function exportSessionToProject(opts: ExportToProjectOptions): ExportResult { const loaded = loadSession(opts.store, opts.sessionId); diff --git a/apps/cli/src/process/owned-storage-replace.test.ts b/apps/cli/src/process/owned-storage-replace.test.ts index 922dc71f..7e90f444 100644 --- a/apps/cli/src/process/owned-storage-replace.test.ts +++ b/apps/cli/src/process/owned-storage-replace.test.ts @@ -33,6 +33,13 @@ describe('replaceFileForStorage', () => { const isRoot = typeof process.getuid === 'function' && process.getuid() === 0; const identityOf = (path: string): StorageFileIdentity => storageFileIdentity(lstatSync(path, { bigint: true })); + /** + * Let the filesystem clock tick between the check and the change. A Linux kernel before 6.13 stamps ctime from a + * coarse clock (a few ms), and ext4 reuses a freed inode, so a change inside one tick would look unchanged. + */ + const pastOneClockTick = (): void => { + Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 30); + }; beforeEach(() => { dir = mkdtempSync(join(tmpdir(), 'relavium-replace-')); @@ -95,7 +102,7 @@ describe('replaceFileForStorage', () => { [ 'rewritten in place to the same length', (path: string) => { - writeFileSync(path, 'new'); // as long as 'old': only its ctime tells + writeFileSync(path, 'new'); // as long as 'old': only its times tell }, ], [ @@ -109,6 +116,7 @@ describe('replaceFileForStorage', () => { const path = join(dir, 'w.relavium.yaml'); writeFileSync(path, 'old'); const checked = identityOf(path); + pastOneClockTick(); apply(path); const before = (() => { try { diff --git a/apps/cli/src/process/owned-storage.ts b/apps/cli/src/process/owned-storage.ts index 093fa2de..f4a25375 100644 --- a/apps/cli/src/process/owned-storage.ts +++ b/apps/cli/src/process/owned-storage.ts @@ -53,12 +53,17 @@ export function writeFileForStorage(path: string, content: string, force: boolea /** * A file's identity as `lstat` reports it: what {@link replaceFileForStorage} re-checks before it renames. A file - * put in its place has another device and inode; one rewritten in place has a new `ctime`, which, unlike `mtime`, - * no caller can set back. + * rewritten in place, or deleted and recreated, gets a new `ctime`, which, unlike `mtime`, no caller can set back; + * a recreated file usually gets a new inode too, though ext4 reuses a freed one. `ctime` is only as fine as the + * filesystem's clock: a few milliseconds on a Linux kernel before 6.13, a second on HFS+. So a change of a + * different length is caught by `size` whatever the clock, while a same-length change inside one clock tick of the + * caller's check is not detected. */ export interface StorageFileIdentity { readonly dev: bigint; readonly ino: bigint; + readonly size: bigint; + readonly mtimeNs: bigint; readonly ctimeNs: bigint; } @@ -67,6 +72,8 @@ export function storageFileIdentity(stats: BigIntStats): StorageFileIdentity { return { dev: stats.dev, ino: stats.ino, + size: stats.size, + mtimeNs: stats.mtimeNs, ctimeNs: stats.ctimeNs, }; } @@ -93,8 +100,9 @@ export class StorageTargetChangedError extends Error { * 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. A swap in the microseconds between that last check and the rename is not detected: Node has no - * rename that compares first. + * writer can hit. Not detected: a swap in the microseconds between that last check and the rename (Node has no + * rename that compares first), and a same-length change within one tick of a coarse filesystem clock + * ({@link StorageFileIdentity}). */ export function replaceFileForStorage( path: string, @@ -143,7 +151,13 @@ export function replaceFileForStorage( } function sameFile(a: StorageFileIdentity, b: StorageFileIdentity): boolean { - return a.dev === b.dev && a.ino === b.ino && a.ctimeNs === b.ctimeNs; + return ( + a.dev === b.dev && + a.ino === b.ino && + a.size === b.size && + a.mtimeNs === b.mtimeNs && + a.ctimeNs === b.ctimeNs + ); } /** Concrete native acquisition for the editor's private transcript directory. */ From 6b32bfe5338c98c7f81666ec8f8400a0e0e9db63 Mon Sep 17 00:00:00 2001 From: Cemil ILIK <cemililik@outlook.com> Date: Fri, 6 Nov 2026 19:57:49 +0300 Subject: [PATCH 116/148] test(cli): pin the logs run-id floor and keep reconcile tests testing their subject D64 round 1 found: - The logs header's run-id sanitizer had no test, though the record said it did. A forged-id case now pins it; removing the sanitizer fails it. - run-history-projection-reconcile's suspension test reconciled within one lease TTL, so the TTL rule, not the resumable skip it is about, decided it. Its recovery host now starts a TTL later. - emitPausedState's JSDoc sat above forgeRunStatus. Comments: the gate card's Enter default is safe for a bare Enter only, since Clack's y/n and h/j/k/l hotkeys still answer; Esc or Ctrl-C at the rejection reason keeps the rejection; run.ts's reconcile summary names the current codes and the TTL rule. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- apps/cli/src/commands/drive.ts | 5 +++-- apps/cli/src/commands/logs.test.ts | 11 +++++++++++ apps/cli/src/commands/run.ts | 6 +++--- .../engine/run-history-projection-reconcile.test.ts | 6 ++++-- apps/cli/src/gate/clack-prompter.ts | 6 ++++-- apps/cli/src/history/reconcile.test.ts | 4 ++-- apps/cli/src/test-support.ts | 2 +- 7 files changed, 28 insertions(+), 12 deletions(-) diff --git a/apps/cli/src/commands/drive.ts b/apps/cli/src/commands/drive.ts index b6b92de3..3028c601 100644 --- a/apps/cli/src/commands/drive.ts +++ b/apps/cli/src/commands/drive.ts @@ -581,8 +581,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; at the optional rejection reason either key 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( diff --git a/apps/cli/src/commands/logs.test.ts b/apps/cli/src/commands/logs.test.ts index df0c3fdb..0637160c 100644 --- a/apps/cli/src/commands/logs.test.ts +++ b/apps/cli/src/commands/logs.test.ts @@ -49,6 +49,17 @@ 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'); diff --git a/apps/cli/src/commands/run.ts b/apps/cli/src/commands/run.ts index 09afc2a2..3a8802a2 100644 --- a/apps/cli/src/commands/run.ts +++ b/apps/cli/src/commands/run.ts @@ -474,9 +474,9 @@ async function runOwned( // **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` (`internal`, or `budget_exceeded` after a recorded budget - // rejection). It leaves alone a run parked at a gate 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 + // 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 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 8eb13ce6..1bfc1d86 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/gate/clack-prompter.ts b/apps/cli/src/gate/clack-prompter.ts index cd4b0cb3..99d7c912 100644 --- a/apps/cli/src/gate/clack-prompter.ts +++ b/apps/cli/src/gate/clack-prompter.ts @@ -107,7 +107,8 @@ export function createClackGatePrompter(deps: ClackPromptDeps = defaultDeps): Ga return signal?.aborted || deps.isCancel(value) ? null : inputDecision(value); } - // A bare Enter rejects: approving takes a deliberate choice, as at the MCP consent prompt. + // 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', @@ -127,7 +128,8 @@ export function createClackGatePrompter(deps: ClackPromptDeps = defaultDeps): Ga ...(signal === undefined ? {} : { signal }), }); if (signal?.aborted) return null; - // The reason is optional, so Esc here skips it: the rejection already chosen stands. + // The reason is optional, so Esc or Ctrl-C here skips it (Clack reports both as a cancel): the rejection + // already chosen stands. return rejectionDecision(deps.isCancel(comment) ? undefined : comment); }, }; diff --git a/apps/cli/src/history/reconcile.test.ts b/apps/cli/src/history/reconcile.test.ts index 7706f157..2f46e654 100644 --- a/apps/cli/src/history/reconcile.test.ts +++ b/apps/cli/src/history/reconcile.test.ts @@ -269,8 +269,8 @@ describe('reconcileInterruptedRuns (2.6.G liveness, #229)', () => { }); it('…and a reconciler that claims the run anyway still leaves one terminal', async () => { - // A reconciler without the TTL grace (an older binary, or one whose clock runs 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 + // 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 diff --git a/apps/cli/src/test-support.ts b/apps/cli/src/test-support.ts index 1833dfe2..5ec9c09d 100644 --- a/apps/cli/src/test-support.ts +++ b/apps/cli/src/test-support.ts @@ -245,7 +245,6 @@ export async function seedRun(db: Db, opts: SeedRunOptions): Promise<string> { return opts.runId; } -/** Emit the `paused`-state events for {@link seedRun} — a human/budget gate park, or (no gate) a media-job park. */ /** * 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. @@ -273,6 +272,7 @@ async function emitInOrder<T>( ); } +/** 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, opts: SeedRunOptions, From 0bd181456c063f042ab3697e1a1d7ed1de71f3b3 Mon Sep 17 00:00:00 2001 From: Cemil ILIK <cemililik@outlook.com> Date: Fri, 6 Nov 2026 19:57:49 +0300 Subject: [PATCH 117/148] test(repo): pin the usage permanent inventory at 448 cases The forged-id logs case is a second new case in logs.test.ts, one of the permanent cohort's files; the pin, its test and the README say 448. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- tools/usage-coverage-controls/README.md | 4 ++-- tools/usage-coverage-controls/workload.mjs | 2 +- tools/usage-coverage-controls/workload.test.mjs | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/tools/usage-coverage-controls/README.md b/tools/usage-coverage-controls/README.md index 0fd44a29..be757269 100644 --- a/tools/usage-coverage-controls/README.md +++ b/tools/usage-coverage-controls/README.md @@ -70,9 +70,9 @@ 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 447 cases and 71 joined native children, including Wave 2's two DB readiness +cohort pins 448 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, and one `logs` case pins its header's one-line floor (PR #97 review); they add no +rendering, and two `logs` cases pin its header's one-line floor (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 diff --git a/tools/usage-coverage-controls/workload.mjs b/tools/usage-coverage-controls/workload.mjs index 8125ebdc..3e5c5ce7 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: 447, nativeChildren: 71 }); +export const permanentWorkload = Object.freeze({ cases: 448, 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 1ae2239d..c83807f5 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: 447 }), + tests: Array.from({ length: 448 }), runtime: { nativeChildren: 71, readinessTimeoutChildren: 1 }, }; assert.doesNotThrow(() => assertPermanentWorkload(result)); - for (const count of [0, 440, 446, 448]) { + for (const count of [0, 440, 447, 449]) { assert.throws( () => assertPermanentWorkload({ ...result, tests: Array.from({ length: count }) }), /Permanent case inventory drift/, From 738238956301dff3cfc52e2a76e51a248d266bd3 Mon Sep 17 00:00:00 2001 From: Cemil ILIK <cemililik@outlook.com> Date: Fri, 6 Nov 2026 19:57:49 +0300 Subject: [PATCH 118/148] docs(repo): fix ADR-0118's grant label and record D64 round 1 of the PR #97 correction MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The round's one confirmed medium: ADR-0118 (Proposed) stored the program, argv[0], verbatim in a remembered grant's label, while argv[0] is free text (a model's whole command string, or `!PGPASSWORD=… psql`'s first token). The label now carries argv[0]'s base name only when it matches a conservative grammar, else `(unnamed program)`. The Session answer matches the exact argv element by element too, and §2 states what a grant does not bind: the files the program reads, which a model in auto can edit. Also from the round: ADR-0120 orders the approval escape before any strip and before ADR-0114's pipeline; ADR-0079's note and the reference docs agree on what the TTL rule closes (a reconciler with the rule) and leaves (a development build without it, a clock more than a TTL ahead), and on the two-sided bound; sse-event-schema.md and execution-model.md state the reconcile code precedence; commands.md drops the exit-7 gloss on a reconciled run, names Ctrl-C at the rejection reason and narrows the Enter and deadline-notice claims. deferred-tasks.md labels the PR #97 fixes with the register's severities, keeps M8 open, notes that B2/B3 were recorded rather than fixed, corrects the ReDoS timing and records the round's lows; current.md says six mediums were fixed and two resolved in the record. Refs: ADR-0118 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- docs/architecture/execution-model.md | 7 +- ...s-run-ownership-lease-and-fencing-token.md | 9 ++- ...e-the-allowlist-asks-instead-of-failing.md | 37 +++++++--- ...rows-and-a-tool-call-shows-what-it-does.md | 13 ++-- docs/reference/cli/chat-session.md | 2 +- docs/reference/cli/commands.md | 4 +- docs/reference/contracts/sse-event-schema.md | 5 +- docs/reference/shared-core/database-schema.md | 2 +- docs/roadmap/current.md | 2 +- docs/roadmap/deferred-tasks.md | 67 +++++++++++++++---- 10 files changed, 108 insertions(+), 40 deletions(-) diff --git a/docs/architecture/execution-model.md b/docs/architecture/execution-model.md index 86912464..d14a4630 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 adb579bd..8092adb9 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 @@ -87,9 +87,12 @@ All contention is on the resume path, which is already async. > **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. That closes the window the 2026-11-05 note describes: a reconcile inside it no -> longer settles the starting run, which takes its lease and runs. Past one TTL, a run with no live lease is still -> treated as crashed. The same rule covers a run started by a version that takes no lease at all (relavium 0.1.x): +> 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 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 index 31697286..41482a55 100644 --- 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 @@ -69,8 +69,9 @@ expanded before it can be approved. **The card's answers:** - **Once.** This invocation only. -- **Session.** This exact command string for the rest of this session - instance. It is held in memory and not persisted, like ADR-0057's `[a]`. +- **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 @@ -109,8 +110,12 @@ pre-approved is denied. That denial returns to the model under ADR-0117. 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 that carries no argument: the program, the argument count and the - date it was remembered, for the revocation view. + - a label for the revocation view: the program, the argument count and the + date it was remembered. The program is `argv[0]`'s base name only when that + name matches a conservative grammar (`^[A-Za-z0-9._+-]{1,64}$`, so no `=`, + `:`, `/`, `@` or whitespace); otherwise the label says `(unnamed program)`. + `argv[0]` is free text — the model's `command` string, or the first token of + `!PGPASSWORD=… psql` — and can itself be a credential. 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`" @@ -123,8 +128,19 @@ pre-approved is denied. That denial returns to the model under ADR-0117. - **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 an argument-free label, so the - primitive's secret-free property covers it: no command text reaches the file. + written. A grant holds only a digest and a label limited to a program name + that cannot carry a credential, so the primitive's secret-free property + covers it: no command text beyond such a name 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. @@ -183,8 +199,8 @@ accepted. - A command the user is willing to run can run, with the exact argv on screen. 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, so no credential in a command - reaches the configuration. + repository, and it holds no command text beyond a program name the label + grammar admits, so no credential in a command reaches the configuration. - `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. @@ -201,7 +217,10 @@ accepted. - 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, argument count and date. + 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/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 index 2e46cd61..91dfaff0 100644 --- 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 @@ -89,16 +89,19 @@ Exact arguments, bounds and texts live in 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.** 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`). +- **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 never hides 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. So in an approval card and its full view, a terminal control or bidi character in a target, field or diff line renders as a visible, styled escape (for example `<U+202E>`), never - stripped; transcript rows may strip. A span the secret masking replaces shows + stripped; transcript rows may strip. The order is fixed: secret masking on the + original text, then this escape, which turns every control into printable + text, and only then ADR-0114's span pipeline, whose strip then finds nothing + to remove. The diff's two sides go through the same escape. A span the secret masking replaces shows as a labelled mask, and the card states how many spans were masked; the full view does not unmask. Approving approves the bytes the host will write, masked spans included, and the card says so. Whether a write whose changed diff --git a/docs/reference/cli/chat-session.md b/docs/reference/cli/chat-session.md index 0f87b452..69b6a1e9 100644 --- a/docs/reference/cli/chat-session.md +++ b/docs/reference/cli/chat-session.md @@ -109,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 <sessionId>` (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 where workflows live — `<project>/.relavium/workflows/<id>.relavium.yaml`, where `<project>/.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 `<cwd>/.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 <id>` ``) (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 `<slug>`, `<slug>-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 takes the next free id; a swap in the microseconds 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).** | +| `/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 — `<project>/.relavium/workflows/<id>.relavium.yaml`, where `<project>/.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 `<cwd>/.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 <id>` ``) (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 `<slug>`, `<slug>-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 takes the next free id; a swap in the microseconds between that check and the replace, or a same-length change within one tick of a coarse filesystem clock (a second on HFS+), 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. | diff --git a/docs/reference/cli/commands.md b/docs/reference/cli/commands.md index 478fa444..a2f50d38 100644 --- a/docs/reference/cli/commands.md +++ b/docs/reference/cli/commands.md @@ -206,7 +206,7 @@ printf 'api_key=%s\n' "$API_KEY" | relavium run ./workflows/code-review.relavium - `--allow-mcp-stdio <digest>` (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 <runId> --gate <gateId>`); with `--json` they are on the NDJSON event line, otherwise the plain/TUI renderer prints them inline (`paused at gate <gateId> (<type>)`, also echoed in the final summary). (`relavium status`, `relavium logs <runId>`, 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 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 <runId>` for a run that ended; for a paused run, `relavium gate <runId>` when a human gate is pending, `relavium budget resume <runId> --gate <gateId>` for a budget gate (which `gate` declines), and `relavium status <runId>` 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 <runId>`, 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_<PROVIDER>_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, so approving takes a deliberate choice; Esc at the optional rejection reason keeps the rejection without one. 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 decides the gate while its card is open, the card closes and stderr says the answer was not recorded; under `--json`/CI/no-TTY there is no prompt and the run exits `3`, resumable later by `relavium gate <runId>`. 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. +> **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 <runId>` for a run that ended; for a paused run, `relavium gate <runId>` when a human gate is pending, `relavium budget resume <runId> --gate <gateId>` for a budget gate (which `gate` declines), and `relavium status <runId>` 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 <runId>`, 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_<PROVIDER>_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 or Ctrl-C at the optional rejection reason keeps the rejection without one. 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 and stderr says the answer was not recorded; under `--json`/CI/no-TTY there is no prompt and the run exits `3`, resumable later by `relavium gate <runId>`. 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` @@ -639,7 +639,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`**, 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 (exit `7`; [effect-journal.md](../shared-core/effect-journal.md#8-needs_attention)), `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 (an older binary, or a clock 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 <runId>` 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`. +> 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 <runId>` 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 <runId>` 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/contracts/sse-event-schema.md b/docs/reference/contracts/sse-event-schema.md index 24115ab5..cd2709ba 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/shared-core/database-schema.md b/docs/reference/shared-core/database-schema.md index bf819725..70d6e90b 100644 --- a/docs/reference/shared-core/database-schema.md +++ b/docs/reference/shared-core/database-schema.md @@ -1365,7 +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 — an older binary, or a clock 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. +- **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/roadmap/current.md b/docs/roadmap/current.md index c318af64..7b633ace 100644 --- a/docs/roadmap/current.md +++ b/docs/roadmap/current.md @@ -39,7 +39,7 @@ evidence summary, the recorded Wave 3 splits and the process amendments. | 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 `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, which the maintainer ruled are fixed before it merges, 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 | +| 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 diff --git a/docs/roadmap/deferred-tasks.md b/docs/roadmap/deferred-tasks.md index 9105795f..299993b4 100644 --- a/docs/roadmap/deferred-tasks.md +++ b/docs/roadmap/deferred-tasks.md @@ -1767,7 +1767,7 @@ names. 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 test. *(low · + 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 @@ -1902,19 +1902,22 @@ names. 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, and by the maintainer's rulings of 2026-11-06 the -PR is fixed before it merges, so it 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). +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, and the drain read the open runs before the outbox (it now reads the outbox first and again -under its claim); reconcile now leaves a run younger than one lease TTL alone, which also narrows 1b's liveness-1; -a crashed run with an unresolved external effect is settled as `effect_needs_attention`; a bare Enter on an -approval, review or budget-amount card rejected nothing and approved instead; a card whose gate a deadline decided -stayed open and dropped the later answer silently; the Windows readiness budget; the URL-password reversal against -`main` is recorded (A4 above); the ADR-0118, ADR-0119 and ADR-0120 drafts. **Fixed (low):** Esc at the rejection -reason cancelled the run; the card promised `auto-reject` where the run fails; `/export`'s own-export rewrite now -re-checks the file before it replaces it; history-12; the doc and comment items. The lows below were not fixed. +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; 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 @@ -1986,6 +1989,42 @@ re-checks the file before it replaces it; history-12; the doc and comment items. 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, +coarse-clock-safe identity tests with `size` and `mtime` back in the identity, 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)* +- [ ] **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. *(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:** on `StorageTargetChangedError`, + check the file once more and retry against its fresh identity when it is still this session's. *(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)* + ## Wave 3 NI — recorded low findings (2026-10-25) Round 1 of the native-identity increment at `fc5af701` accepted it with these lows @@ -2735,8 +2774,8 @@ so directly: these "should remain visible rather than disappear behind the green (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` and `relavium chat` 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 7.6 s in that review, and the cost doubles with each character, so a repository can hang - those commands with no input from the user. *(medium for that vector · + `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.** From 5bf9849630b1a6a62d5a67e0e72153819804994b Mon Sep 17 00:00:00 2001 From: Cemil ILIK <cemililik@outlook.com> Date: Fri, 6 Nov 2026 20:17:55 +0300 Subject: [PATCH 119/148] fix(cli): compare the own export's bytes too before replacing it A review of the correction asked for an identity that catches a same-length rewrite inside one tick of a coarse filesystem clock, and for tests that do not depend on that clock. The stat fields alone cannot: ctime is only as fine as the filesystem's clock (a few ms on a Linux kernel before 6.13, a second on HFS+), and ext4 reuses a freed inode. StorageFileIdentity now carries the SHA-256 of the file's bytes, which /export already reads when it recognizes its own file; replaceFileForStorage re-reads and compares them at both checks. The 30 ms clock waits are gone from the tests, and a new case pins the hash: a file whose every stat field matches but whose bytes differ is refused. Removing the hash comparison fails it. The remaining gap is the moment between the last check and the rename. Also a docstring for drive.ts's closeCardDecidedElsewhere. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- apps/cli/src/chat/export-project.test.ts | 2 -- apps/cli/src/chat/export.ts | 10 +++--- apps/cli/src/commands/drive.ts | 5 +++ .../src/process/owned-storage-replace.test.ts | 26 ++++++++------ apps/cli/src/process/owned-storage.ts | 35 ++++++++++--------- 5 files changed, 45 insertions(+), 33 deletions(-) diff --git a/apps/cli/src/chat/export-project.test.ts b/apps/cli/src/chat/export-project.test.ts index 3078cecd..5b56edea 100644 --- a/apps/cli/src/chat/export-project.test.ts +++ b/apps/cli/src/chat/export-project.test.ts @@ -159,8 +159,6 @@ describe('exportSessionToProject (MVP-A3)', () => { // 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(); - // Past one tick of a coarse filesystem clock, so the swapped-in file's times differ from the export's. - Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 30); fsHooks.onOpen = (path) => { if (!path.endsWith('.tmp')) return; fsHooks.onOpen = undefined; diff --git a/apps/cli/src/chat/export.ts b/apps/cli/src/chat/export.ts index 9a360491..39287e8c 100644 --- a/apps/cli/src/chat/export.ts +++ b/apps/cli/src/chat/export.ts @@ -138,9 +138,8 @@ const WORKFLOW_ID_GRAMMAR = /^[a-z0-9]+(?:-[a-z0-9]+)*$/u; * `/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 takes the next free id instead. A swap in the microseconds between - * the last check and the rename, or a same-length change within one tick of a coarse filesystem clock, is not - * detected ({@link replaceFileForStorage}). + * after the check is left alone, and the export takes the next free id instead. 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); @@ -215,11 +214,12 @@ function ownExport(path: string, sessionId: string): OwnExport | undefined { if (!opened.isFile() || opened.dev !== linked.dev || opened.ino !== linked.ino) return undefined; if (opened.size > BigInt(MAX_SOURCE_CHARS * 4)) return undefined; - const text = readFileSync(fd, 'utf8'); + 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) } + ? { mode: Number(opened.mode & 0o777n), identity: storageFileIdentity(opened, bytes) } : undefined; } catch { return undefined; diff --git a/apps/cli/src/commands/drive.ts b/apps/cli/src/commands/drive.ts index 3028c601..57aff7c8 100644 --- a/apps/cli/src/commands/drive.ts +++ b/apps/cli/src/commands/drive.ts @@ -136,6 +136,11 @@ export async function driveRun(deps: DriveRunDeps): Promise<RunOutcome | undefin decided?: string; } | undefined; + /** + * Close the open card when `decidedBy`, not this card, decided its gate: matched by gate id, or by node for a legacy + * resumed companion that names no gate. Records how it was decided for the stderr notice; our own decisions + * (`decidedBy` of `cli`) are left alone. + */ const closeCardDecidedElsewhere = ( gateId: string | undefined, nodeId: string, diff --git a/apps/cli/src/process/owned-storage-replace.test.ts b/apps/cli/src/process/owned-storage-replace.test.ts index 7e90f444..cd77bd3d 100644 --- a/apps/cli/src/process/owned-storage-replace.test.ts +++ b/apps/cli/src/process/owned-storage-replace.test.ts @@ -31,15 +31,9 @@ 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 })); - /** - * Let the filesystem clock tick between the check and the change. A Linux kernel before 6.13 stamps ctime from a - * coarse clock (a few ms), and ext4 reuses a freed inode, so a change inside one tick would look unchanged. - */ - const pastOneClockTick = (): void => { - Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 30); - }; + storageFileIdentity(lstatSync(path, { bigint: true }), readFileSync(path)); beforeEach(() => { dir = mkdtempSync(join(tmpdir(), 'relavium-replace-')); @@ -102,7 +96,7 @@ describe('replaceFileForStorage', () => { [ 'rewritten in place to the same length', (path: string) => { - writeFileSync(path, 'new'); // as long as 'old': only its times tell + writeFileSync(path, 'new'); // as long as 'old': on a coarse clock only the bytes' hash tells }, ], [ @@ -116,7 +110,6 @@ describe('replaceFileForStorage', () => { const path = join(dir, 'w.relavium.yaml'); writeFileSync(path, 'old'); const checked = identityOf(path); - pastOneClockTick(); apply(path); const before = (() => { try { @@ -141,6 +134,19 @@ describe('replaceFileForStorage', () => { }); } + 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'); + const checked = { ...identityOf(path), sha256: '0'.repeat(64) }; + 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', () => { diff --git a/apps/cli/src/process/owned-storage.ts b/apps/cli/src/process/owned-storage.ts index f4a25375..f7db1aed 100644 --- a/apps/cli/src/process/owned-storage.ts +++ b/apps/cli/src/process/owned-storage.ts @@ -1,4 +1,4 @@ -import { randomUUID } from 'node:crypto'; +import { createHash, randomUUID } from 'node:crypto'; import { accessSync, type BigIntStats, @@ -10,6 +10,7 @@ import { lstatSync, mkdirSync, openSync, + readFileSync, renameSync, unlinkSync, writeFileSync, @@ -52,12 +53,10 @@ export function writeFileForStorage(path: string, content: string, force: boolea } /** - * A file's identity as `lstat` reports it: what {@link replaceFileForStorage} re-checks before it renames. A file - * rewritten in place, or deleted and recreated, gets a new `ctime`, which, unlike `mtime`, no caller can set back; - * a recreated file usually gets a new inode too, though ext4 reuses a freed one. `ctime` is only as fine as the - * filesystem's clock: a few milliseconds on a Linux kernel before 6.13, a second on HFS+. So a change of a - * different length is caught by `size` whatever the clock, while a same-length change inside one clock tick of the - * caller's check is not detected. + * 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; @@ -65,16 +64,19 @@ export interface StorageFileIdentity { 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` describes (from a `bigint` stat). */ -export function storageFileIdentity(stats: BigIntStats): StorageFileIdentity { +/** 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'), }; } @@ -98,11 +100,9 @@ export class StorageTargetChangedError extends Error { * * 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. Not detected: a swap in the microseconds between that last check and the rename (Node has no - * rename that compares first), and a same-length change within one tick of a coarse filesystem clock - * ({@link StorageFileIdentity}). + * 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. 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, @@ -111,12 +111,13 @@ export function replaceFileForStorage( 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 = lstatSync(path, { bigint: true, throwIfNoEntry: false }); if ( current === undefined || !current.isFile() || - !sameFile(storageFileIdentity(current), expected) + !sameFile(storageFileIdentity(current, readFileSync(path)), expected) ) { throw new StorageTargetChangedError(); } @@ -150,13 +151,15 @@ export function replaceFileForStorage( } } +/** Whether two identities name the same file with the same bytes: every field equal. */ function sameFile(a: StorageFileIdentity, b: StorageFileIdentity): boolean { return ( a.dev === b.dev && a.ino === b.ino && a.size === b.size && a.mtimeNs === b.mtimeNs && - a.ctimeNs === b.ctimeNs + a.ctimeNs === b.ctimeNs && + a.sha256 === b.sha256 ); } From ca845100881af15197f8a6c721f97ec346d14ae1 Mon Sep 17 00:00:00 2001 From: Cemil ILIK <cemililik@outlook.com> Date: Fri, 6 Nov 2026 20:17:55 +0300 Subject: [PATCH 120/148] docs(repo): say the own-export check compares the file's bytes chat-session.md's /export row and the correction's round-1 record now say the re-check compares the bytes as well as the stat, so a same-length rewrite is caught and only the moment before the rename remains. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- docs/reference/cli/chat-session.md | 2 +- docs/roadmap/deferred-tasks.md | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/reference/cli/chat-session.md b/docs/reference/cli/chat-session.md index 69b6a1e9..cc1ed06b 100644 --- a/docs/reference/cli/chat-session.md +++ b/docs/reference/cli/chat-session.md @@ -109,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 <sessionId>` (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 where workflows live — `<project>/.relavium/workflows/<id>.relavium.yaml`, where `<project>/.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 `<cwd>/.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 <id>` ``) (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 `<slug>`, `<slug>-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 takes the next free id; a swap in the microseconds between that check and the replace, or a same-length change within one tick of a coarse filesystem clock (a second on HFS+), 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).** | +| `/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 — `<project>/.relavium/workflows/<id>.relavium.yaml`, where `<project>/.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 `<cwd>/.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 <id>` ``) (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 `<slug>`, `<slug>-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 takes the next free id; 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. | diff --git a/docs/roadmap/deferred-tasks.md b/docs/roadmap/deferred-tasks.md index 299993b4..1257cd4f 100644 --- a/docs/roadmap/deferred-tasks.md +++ b/docs/roadmap/deferred-tasks.md @@ -1992,7 +1992,8 @@ B2's missing-vertex fail-closed as one-line fixes; they were recorded below inst **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, -coarse-clock-safe identity tests with `size` and `mtime` back in the identity, ADR-0118's Session answer and its +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 From 0208b23ada41421100c56f974aaf0c106b0153ca Mon Sep 17 00:00:00 2001 From: Cemil ILIK <cemililik@outlook.com> Date: Fri, 6 Nov 2026 21:54:48 +0300 Subject: [PATCH 121/148] fix(cli): let Ctrl-C at the rejection reason cancel the run again The PR #97 correction made Esc at the optional rejection reason keep the rejection (B5). Clack reports Esc and Ctrl-C as one cancel, so Ctrl-C there recorded the rejection too and the workflow went on down its rejection branch, where on `relavium run` Ctrl-C cancels the run. The bound card now watches its own input for Ctrl-C while the reason prompt runs (Clack's `\x03` alias) and returns null for it, as at the card; Esc still keeps the rejection. Without a watcher a cancel there is taken as Ctrl-C. Real-Clack tests drive both keys through the bound prompter; seven mutations of the change each fail a test. Refs: ADR-0047 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- apps/cli/src/gate/clack-prompter.test.ts | 40 ++++++++++- apps/cli/src/gate/clack-prompter.ts | 67 ++++++++++++++++--- apps/cli/src/process/native-prompt-io.test.ts | 46 +++++++++++++ 3 files changed, 144 insertions(+), 9 deletions(-) diff --git a/apps/cli/src/gate/clack-prompter.test.ts b/apps/cli/src/gate/clack-prompter.test.ts index 50602f6c..8ff6baa9 100644 --- a/apps/cli/src/gate/clack-prompter.test.ts +++ b/apps/cli/src/gate/clack-prompter.test.ts @@ -125,13 +125,51 @@ describe('createClackGatePrompter', () => { expect(d).toBeNull(); }); + /** 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<ClackPromptDeps['watchInterrupt']> => + 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) }), + 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( diff --git a/apps/cli/src/gate/clack-prompter.ts b/apps/cli/src/gate/clack-prompter.ts index 99d7c912..a3ad7cb7 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'; @@ -34,6 +36,51 @@ export interface ClackPromptDeps { }) => Promise<string | symbol>; /** 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?: <T>(prompt: () => Promise<T>) => Promise<InterruptWatched<T>>; +} + +/** A prompt's answer and whether Ctrl-C ended it. */ +export interface InterruptWatched<T> { + 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, +): <T>(prompt: () => Promise<T>) => Promise<InterruptWatched<T>> { + 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<T>(prompt: () => Promise<T>): Promise<InterruptWatched<T>> { + return { value: await prompt(), interrupted: true }; } const defaultDeps: ClackPromptDeps = { @@ -53,6 +100,7 @@ export function createBoundClackGatePrompter(io: CliIo): GatePrompter { confirm: (options) => confirm({ ...options, ...streams }), text: (options) => text({ ...options, ...streams }), isCancel, + watchInterrupt: watchInterruptOn(streams.input), }); } @@ -122,15 +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 }), - }); + const reason = await (deps.watchInterrupt ?? assumeInterrupted)(() => + deps.text({ + message: 'Reason for rejection (optional)', + placeholder: '', + ...(signal === undefined ? {} : { signal }), + }), + ); if (signal?.aborted) return null; - // The reason is optional, so Esc or Ctrl-C here skips it (Clack reports both as a cancel): the rejection - // already chosen stands. - return rejectionDecision(deps.isCancel(comment) ? undefined : comment); + 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(); }, }; } diff --git a/apps/cli/src/process/native-prompt-io.test.ts b/apps/cli/src/process/native-prompt-io.test.ts index 31c23379..7bd19534 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(); From 105c75a86bb64faeb639c8b4c9c0605a54ef7b10 Mon Sep 17 00:00:00 2001 From: Cemil ILIK <cemililik@outlook.com> Date: Fri, 6 Nov 2026 21:54:48 +0300 Subject: [PATCH 122/148] docs(repo): say Ctrl-C at the rejection reason cancels the run commands.md said Esc or Ctrl-C at the optional reason keeps the rejection; only Esc does now. deferred-tasks.md's PR #97 fixed list notes it beside B5. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- docs/reference/cli/commands.md | 2 +- docs/roadmap/deferred-tasks.md | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/reference/cli/commands.md b/docs/reference/cli/commands.md index a2f50d38..24c6a700 100644 --- a/docs/reference/cli/commands.md +++ b/docs/reference/cli/commands.md @@ -206,7 +206,7 @@ printf 'api_key=%s\n' "$API_KEY" | relavium run ./workflows/code-review.relavium - `--allow-mcp-stdio <digest>` (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 <runId> --gate <gateId>`); with `--json` they are on the NDJSON event line, otherwise the plain/TUI renderer prints them inline (`paused at gate <gateId> (<type>)`, also echoed in the final summary). (`relavium status`, `relavium logs <runId>`, 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 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 <runId>` for a run that ended; for a paused run, `relavium gate <runId>` when a human gate is pending, `relavium budget resume <runId> --gate <gateId>` for a budget gate (which `gate` declines), and `relavium status <runId>` 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 <runId>`, 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_<PROVIDER>_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 or Ctrl-C at the optional rejection reason keeps the rejection without one. 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 and stderr says the answer was not recorded; under `--json`/CI/no-TTY there is no prompt and the run exits `3`, resumable later by `relavium gate <runId>`. 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. +> **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 <runId>` for a run that ended; for a paused run, `relavium gate <runId>` when a human gate is pending, `relavium budget resume <runId> --gate <gateId>` for a budget gate (which `gate` declines), and `relavium status <runId>` 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 <runId>`, 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_<PROVIDER>_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 and stderr says the answer was not recorded; under `--json`/CI/no-TTY there is no prompt and the run exits `3`, resumable later by `relavium gate <runId>`. 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` diff --git a/docs/roadmap/deferred-tasks.md b/docs/roadmap/deferred-tasks.md index 1257cd4f..de6019f3 100644 --- a/docs/roadmap/deferred-tasks.md +++ b/docs/roadmap/deferred-tasks.md @@ -1914,7 +1914,8 @@ the Windows readiness budget; the ADR-0119 and ADR-0120 drafts. **Resolved in th 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; the card promised +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. From 471a33eb01599515ca5a5dcd7c8c7d35db0c1bea Mon Sep 17 00:00:00 2001 From: Cemil ILIK <cemililik@outlook.com> Date: Fri, 6 Nov 2026 21:57:28 +0300 Subject: [PATCH 123/148] test(core): pin a start just inside reconcile's future-skew bound The ahead-of-clock table checked 1 s and TTL + 1 s, so a lower bound narrowed to half a TTL still passed. A start TTL - 1 s ahead must stay unreconciled; with the bound halved the case now fails. Refs: ADR-0079 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- packages/core/src/engine/engine.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/core/src/engine/engine.test.ts b/packages/core/src/engine/engine.test.ts index 1b6a9826..836fa833 100644 --- a/packages/core/src/engine/engine.test.ts +++ b/packages/core/src/engine/engine.test.ts @@ -6304,6 +6304,7 @@ describe('WorkflowEngine — internal failures and handle-side controls', () => 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(); From 82db354e24b6405918876ca7f8c95639ff91d21d Mon Sep 17 00:00:00 2001 From: Cemil ILIK <cemililik@outlook.com> Date: Fri, 6 Nov 2026 21:57:29 +0300 Subject: [PATCH 124/148] docs(repo): keep export-2's re-check inside the changed-file contract The recorded re-check said to retry against the file's fresh identity when it still names the session. An editor's save keeps the session's marker, so that retry would write over the edit; the re-check now asks to prevent the duplicate without writing over a changed file. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- docs/roadmap/deferred-tasks.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/roadmap/deferred-tasks.md b/docs/roadmap/deferred-tasks.md index de6019f3..ea02c50d 100644 --- a/docs/roadmap/deferred-tasks.md +++ b/docs/roadmap/deferred-tasks.md @@ -2013,9 +2013,9 @@ tick of a coarse filesystem clock is caught and its tests no longer depend on th 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:** on `StorageTargetChangedError`, - check the file once more and retry against its fresh identity when it is still this session's. *(low · - apps/cli/src/chat/export.ts)* + 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)* From aeb7e4c8d4fe7572ee7c01fc45281c948c333bd2 Mon Sep 17 00:00:00 2001 From: Cemil ILIK <cemililik@outlook.com> Date: Fri, 6 Nov 2026 22:41:32 +0300 Subject: [PATCH 125/148] fix(cli): compare the own export's stat fields before reading it ensureUnchanged hashed the file through a plain readFileSync before it compared anything, so a FIFO swapped in after its lstat blocked the process, a larger file was read whole, and a permission change failed the export instead of moving it on. It now compares every stat field first and reads only the same regular file, through a no-follow, non-blocking descriptor whose fstat must match, and no more than its size plus one byte, as /export's own read does. The directory test reached replaceFileForStorage again (its identity read threw EISDIR first), the forged-hash test now hashes the real bytes, and new tests refuse a FIFO and an unreadable file put at the path after the check (D64 round 2: export-1, export-2, export-3, security-2). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- .../src/process/owned-storage-replace.test.ts | 53 ++++++++++++-- apps/cli/src/process/owned-storage.ts | 72 ++++++++++++++----- 2 files changed, 102 insertions(+), 23 deletions(-) diff --git a/apps/cli/src/process/owned-storage-replace.test.ts b/apps/cli/src/process/owned-storage-replace.test.ts index cd77bd3d..bc0d78bf 100644 --- a/apps/cli/src/process/owned-storage-replace.test.ts +++ b/apps/cli/src/process/owned-storage-replace.test.ts @@ -1,3 +1,4 @@ +import { execFileSync } from 'node:child_process'; import { chmodSync, lstatSync, @@ -69,16 +70,56 @@ describe('replaceFileForStorage', () => { }, ); - it('refuses a directory at the path, removes its temp, and leaves what was there', () => { + 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, identityOf(path)); - }).toThrow(); + 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 opening it, so it never blocks', + () => { + 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([]); + }, + ); + + 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', @@ -139,7 +180,11 @@ describe('replaceFileForStorage', () => { // 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'); - const checked = { ...identityOf(path), sha256: '0'.repeat(64) }; + // 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); diff --git a/apps/cli/src/process/owned-storage.ts b/apps/cli/src/process/owned-storage.ts index f7db1aed..48ee311c 100644 --- a/apps/cli/src/process/owned-storage.ts +++ b/apps/cli/src/process/owned-storage.ts @@ -6,11 +6,12 @@ import { closeSync, constants, fchmodSync, + fstatSync, fsyncSync, lstatSync, mkdirSync, openSync, - readFileSync, + readSync, renameSync, unlinkSync, writeFileSync, @@ -101,8 +102,10 @@ export class StorageTargetChangedError extends Error { * 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. Not detected: a swap in the moment between that last check and the rename (Node has - * no rename that compares first). + * 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 without + * being followed, blocked on or read whole. 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, @@ -113,14 +116,8 @@ export function replaceFileForStorage( 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 = lstatSync(path, { bigint: true, throwIfNoEntry: false }); - if ( - current === undefined || - !current.isFile() || - !sameFile(storageFileIdentity(current, readFileSync(path)), expected) - ) { - throw new StorageTargetChangedError(); - } + const current = identityIfUnchanged(path, expected); + if (current?.sha256 !== expected.sha256) throw new StorageTargetChangedError(); }; let fd: number | undefined; try { @@ -151,15 +148,52 @@ export function replaceFileForStorage( } } -/** Whether two identities name the same file with the same bytes: every field equal. */ -function sameFile(a: StorageFileIdentity, b: StorageFileIdentity): boolean { +/** + * 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, and no more of them than `expected.size` plus one (to see growth), as `/export`'s own read does. + */ +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; the `fstat` check below is its swap guard there. + 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 ( - a.dev === b.dev && - a.ino === b.ino && - a.size === b.size && - a.mtimeNs === b.mtimeNs && - a.ctimeNs === b.ctimeNs && - a.sha256 === b.sha256 + stats.dev === expected.dev && + stats.ino === expected.ino && + stats.size === expected.size && + stats.mtimeNs === expected.mtimeNs && + stats.ctimeNs === expected.ctimeNs ); } From 02a64f4454b5e1f961d240842f736bd99f6d1f94 Mon Sep 17 00:00:00 2001 From: Cemil ILIK <cemililik@outlook.com> Date: Fri, 6 Nov 2026 22:41:32 +0300 Subject: [PATCH 126/148] fix(cli): give the logs skipped-row note's seq the one-line floor run_events.seq is an integer column with no CHECK, so a crafted history.db can store text with terminal controls there, and the note for rows a newer binary wrote printed it raw to stderr. Each number now passes sanitizeInline, pinned by a forged-seq test (D64 round 2: sinks-1, history-12's class). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- apps/cli/src/commands/logs.test.ts | 23 +++++++++++++++++++++++ apps/cli/src/commands/logs.ts | 7 +++++-- 2 files changed, 28 insertions(+), 2 deletions(-) diff --git a/apps/cli/src/commands/logs.test.ts b/apps/cli/src/commands/logs.test.ts index 0637160c..68bdc0cc 100644 --- a/apps/cli/src/commands/logs.test.ts +++ b/apps/cli/src/commands/logs.test.ts @@ -152,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 1a458c8d..a21e4297 100644 --- a/apps/cli/src/commands/logs.ts +++ b/apps/cli/src/commands/logs.ts @@ -79,8 +79,11 @@ 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. - const shown = skipped.slice(0, MAX_REPORTED_SKIPS).map((row) => row.sequenceNumber); + // 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( From f23d4ac1aad009c624dca73c1a7ae43bc28079ea Mon Sep 17 00:00:00 2001 From: Cemil ILIK <cemililik@outlook.com> Date: Fri, 6 Nov 2026 22:41:32 +0300 Subject: [PATCH 127/148] test(repo): pin the usage permanent inventory at 449 cases The forged-seq logs case is a third new case in logs.test.ts, one of the permanent cohort's files; the pin, its test and the README say 449. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- tools/usage-coverage-controls/README.md | 6 +++--- tools/usage-coverage-controls/workload.mjs | 2 +- tools/usage-coverage-controls/workload.test.mjs | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/tools/usage-coverage-controls/README.md b/tools/usage-coverage-controls/README.md index be757269..db8a1371 100644 --- a/tools/usage-coverage-controls/README.md +++ b/tools/usage-coverage-controls/README.md @@ -70,10 +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 448 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, and two `logs` cases pin its header's one-line floor (PR #97 review); 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 3e5c5ce7..930a2910 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: 448, 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 c83807f5..067027e7 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: 448 }), + tests: Array.from({ length: 449 }), runtime: { nativeChildren: 71, readinessTimeoutChildren: 1 }, }; assert.doesNotThrow(() => assertPermanentWorkload(result)); - for (const count of [0, 440, 447, 449]) { + for (const count of [0, 440, 448, 450]) { assert.throws( () => assertPermanentWorkload({ ...result, tests: Array.from({ length: count }) }), /Permanent case inventory drift/, From bc55186f65bf955c7bc6bcf95b4dddfc6f061bac Mon Sep 17 00:00:00 2001 From: Cemil ILIK <cemililik@outlook.com> Date: Fri, 6 Nov 2026 22:41:32 +0300 Subject: [PATCH 128/148] fix(cli): say no answer from the terminal was recorded The decided-elsewhere notice said "your answer was not recorded" even when the user gave none, its most common case: an idle card a deadline closes. It now says no answer from this terminal was recorded. The resolveGateInline and GatePrompter contracts still said either key at the rejection reason keeps the rejection; Ctrl-C there cancels the run since 0208b23a (D64 round 2: gates-1, gates-3). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- apps/cli/src/commands/drive.test.ts | 4 ++-- apps/cli/src/commands/drive.ts | 8 ++++---- apps/cli/src/gate/prompter.ts | 5 +++-- 3 files changed, 9 insertions(+), 8 deletions(-) diff --git a/apps/cli/src/commands/drive.test.ts b/apps/cli/src/commands/drive.test.ts index 2e220c7f..481728e9 100644 --- a/apps/cli/src/commands/drive.test.ts +++ b/apps/cli/src/commands/drive.test.ts @@ -106,7 +106,7 @@ 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, and says the late answer was not recorded', async () => { + it('closes an open card when its deadline decides the gate, and says no answer from the terminal was recorded', 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. const host = createInMemoryHost(); @@ -167,7 +167,7 @@ describe('driveRun — interactive gate', () => { expect(outcome).toBe('completed'); expect(signals[0]?.aborted).toBe(true); expect(err()).toMatch( - /Gate \S+ was already decided \(approved by its deadline\); your answer was not recorded\./u, + /Gate \S+ was already decided \(approved by its deadline\); no answer from this terminal was recorded\./u, ); // g2 was answered on its own card, which is not "decided elsewhere". expect(err().match(/was already decided/gu)).toHaveLength(1); diff --git a/apps/cli/src/commands/drive.ts b/apps/cli/src/commands/drive.ts index 57aff7c8..69ee0d0b 100644 --- a/apps/cli/src/commands/drive.ts +++ b/apps/cli/src/commands/drive.ts @@ -587,8 +587,8 @@ 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 card with - * Ctrl-C / ESC at its approve or input step; at the optional rejection reason either key keeps the rejection) - * cooperatively cancels the whole run; otherwise the decision is applied to the in-memory + * 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( @@ -602,14 +602,14 @@ async function resolveGateInline( ): Promise<boolean> { let decision: Awaited<ReturnType<GatePrompter['prompt']>>; let reported = false; - /** Say, once, that the card's answer was not recorded because something else decided the gate first. */ + /** 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)}); ` + - 'your answer was not recorded.\n', + 'no answer from this terminal was recorded.\n', ); }; try { diff --git a/apps/cli/src/gate/prompter.ts b/apps/cli/src/gate/prompter.ts index 72f560ae..ddff9b0e 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. */ From 4f2598907a5d3f5f8d85846213216ea130ca1a09 Mon Sep 17 00:00:00 2001 From: Cemil ILIK <cemililik@outlook.com> Date: Fri, 6 Nov 2026 22:41:32 +0300 Subject: [PATCH 129/148] docs(repo): fix ADR-0118's label and ADR-0120's escape after D64 round 2 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` could be stored. The label now names only an argv[0] that is a bare name resolving on the PATH, and nothing is taken from free text. ADR-0120's approval escape covered controls and bidi characters only, so a tag-character or zero-width run could hide content in an approved diff. It now covers every default-ignorable and format code point; ADR-0118's card follows it and the zero-width deferred entry notes it. Also: the glossary and vision no longer promise an auto-reject timeout, commands.md words the notice as the CLI does, and deferred-tasks.md records round 2 (gates-2 recorded; D3 and ReDoS wording corrected). Refs: ADR-0118, ADR-0120 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- ...e-the-allowlist-asks-instead-of-failing.md | 33 ++++++++++----- ...rows-and-a-tool-call-shows-what-it-does.md | 22 ++++++---- docs/glossary.md | 4 +- docs/reference/cli/commands.md | 2 +- docs/roadmap/deferred-tasks.md | 40 +++++++++++++++---- docs/vision.md | 2 +- 6 files changed, 73 insertions(+), 30 deletions(-) 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 index 41482a55..602578ea 100644 --- 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 @@ -62,7 +62,10 @@ not pre-approved.** - the working directory; - why the command is not pre-approved. -Everything is terminal-safe text. The card never offers an approving answer for +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 or an invisible code point in an argument shows as a visible escape, +never hidden. The card never offers an approving answer for a command it does not show in full. A command too long for the screen must be expanded before it can be approved. @@ -111,11 +114,19 @@ pre-approved is denied. That denial returns to the model under ADR-0117. - 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 `argv[0]`'s base name only when that - name matches a conservative grammar (`^[A-Za-z0-9._+-]{1,64}$`, so no `=`, - `:`, `/`, `@` or whitespace); otherwise the label says `(unnamed program)`. - `argv[0]` is free text — the model's `command` string, or the first token of - `!PGPASSWORD=… psql` — and can itself be a credential. + 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/…/<secret>` + 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`" @@ -128,9 +139,9 @@ pre-approved is denied. That denial returns to the model under ADR-0117. - **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 limited to a program name - that cannot carry a credential, so the primitive's secret-free property - covers it: no command text beyond such a name reaches the file. + 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 @@ -199,8 +210,8 @@ accepted. - A command the user is willing to run can run, with the exact argv on screen. 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 a program name the label - grammar admits, so no credential in a command reaches the configuration. + repository, and it holds no command text beyond the name of an executable + found on the `PATH`, so no credential in a command reaches the configuration. - `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. 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 index 91dfaff0..bb495e93 100644 --- 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 @@ -95,13 +95,21 @@ Exact arguments, bounds and texts live in target is one line (`sanitizeInline`). - **An approval preview never hides 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. So in an approval card - and its full view, a terminal control or bidi character in a target, field or - diff line renders as a visible, styled escape (for example `<U+202E>`), never - stripped; transcript rows may strip. The order is fixed: secret masking on the - original text, then this escape, which turns every control into printable - text, and only then ADR-0114's span pipeline, whose strip then finds nothing - to remove. The diff's two sides go through the same escape. A span the secret masking replaces shows + bidi override would render exactly like the original. An invisible code point + hides content the same way: a tag-character run (U+E0000–U+E007F) can spell a + whole instruction in zero columns, and a zero-width space inside a compared + literal stops it matching. So in an approval card and its full view, a terminal + control, a bidi character, and every default-ignorable (`\p{Default_Ignorable_Code_Point}`) + or format (`\p{Cf}`) code point in a target, field or diff line renders as a + visible, styled escape (for example `<U+202E>` or `<U+E0049>`), never stripped + and never left invisible. That covers zero-width spaces and joiners, the soft + hyphen, variation selectors and the tag block, including the joiners a + transcript keeps for emoji: an approval shows `<U+FE0F>` rather than hide a + code point. Both are Unicode properties the JavaScript regular-expression + engine knows, so no table is kept by hand. Transcript rows may strip. The order + is fixed: secret masking on the original text, then this escape, which turns + every control and every invisible code point into printable text, and only + then ADR-0114's span pipeline, whose strip then finds nothing to remove. The diff's two sides go through the same escape. A span the secret masking replaces shows as a labelled mask, and the card states how many spans were masked; the full view does not unmask. Approving approves the bytes the host will write, masked spans included, and the card says so. Whether a write whose changed diff --git a/docs/glossary.md b/docs/glossary.md index 383bb465..9ce7c029 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/commands.md b/docs/reference/cli/commands.md index 24c6a700..746eb57c 100644 --- a/docs/reference/cli/commands.md +++ b/docs/reference/cli/commands.md @@ -206,7 +206,7 @@ printf 'api_key=%s\n' "$API_KEY" | relavium run ./workflows/code-review.relavium - `--allow-mcp-stdio <digest>` (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 <runId> --gate <gateId>`); with `--json` they are on the NDJSON event line, otherwise the plain/TUI renderer prints them inline (`paused at gate <gateId> (<type>)`, also echoed in the final summary). (`relavium status`, `relavium logs <runId>`, 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 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 <runId>` for a run that ended; for a paused run, `relavium gate <runId>` when a human gate is pending, `relavium budget resume <runId> --gate <gateId>` for a budget gate (which `gate` declines), and `relavium status <runId>` 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 <runId>`, 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_<PROVIDER>_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 and stderr says the answer was not recorded; under `--json`/CI/no-TTY there is no prompt and the run exits `3`, resumable later by `relavium gate <runId>`. 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. +> **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 <runId>` for a run that ended; for a paused run, `relavium gate <runId>` when a human gate is pending, `relavium budget resume <runId> --gate <gateId>` for a budget gate (which `gate` declines), and `relavium status <runId>` 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 <runId>`, 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_<PROVIDER>_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 and stderr says no answer from the terminal was recorded; under `--json`/CI/no-TTY there is no prompt and the run exits `3`, resumable later by `relavium gate <runId>`. 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` diff --git a/docs/roadmap/deferred-tasks.md b/docs/roadmap/deferred-tasks.md index ea02c50d..817b09a4 100644 --- a/docs/roadmap/deferred-tasks.md +++ b/docs/roadmap/deferred-tasks.md @@ -1959,9 +1959,10 @@ B2's missing-vertex fail-closed as one-line fixes; they were recorded below inst - [ ] **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`, `api-key`, `token` or `password` 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)* + `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)* @@ -2003,8 +2004,10 @@ tick of a coarse filesystem clock is caught and its tests no longer depend on th 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)* -- [ ] **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. *(low · apps/cli/src/commands/drive.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 @@ -2027,6 +2030,24 @@ tick of a coarse filesystem clock is caught and its tests no longer depend on th 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)* + ## Wave 3 NI — recorded low findings (2026-10-25) Round 1 of the native-identity increment at `fc5af701` accepted it with these lows @@ -2774,8 +2795,8 @@ so directly: these "should remain visible rather than disappear behind the green 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. **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` and `relavium chat` 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 + 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)* @@ -3250,7 +3271,10 @@ 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). **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)* + 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 diff --git a/docs/vision.md b/docs/vision.md index da3fd274..716f9210 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. From cbe390643227b0274b2293fc0aa617beda8073ff Mon Sep 17 00:00:00 2001 From: Cemil ILIK <cemililik@outlook.com> Date: Sat, 7 Nov 2026 00:15:06 +0300 Subject: [PATCH 130/148] fix(cli): prompt no later gate after a card closes under the user Closing a card because a deadline or another terminal 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 outright, and the engine records it as the user's own decision. After such a close the run's later gates are now left for the command its summary names, and the notice says so. The card also tells its own decision by what it returned rather than by a `decidedBy` of `cli`, which another terminal's `relavium gate` shares: a foreign `cli` decision now closes the card, and a prompter whose decisions carry another `decidedBy` no longer reads them as foreign. losing-reject now parks on its second gate (D64 round 3: gates-1, gates-2, gates-4). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- .../drive-gate-queued-decisions.test.ts | 22 ++- apps/cli/src/commands/drive.test.ts | 178 +++++++++++++++++- apps/cli/src/commands/drive.ts | 28 ++- 3 files changed, 204 insertions(+), 24 deletions(-) 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 7a178763..a9909fba 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 481728e9..cab76c39 100644 --- a/apps/cli/src/commands/drive.test.ts +++ b/apps/cli/src/commands/drive.test.ts @@ -30,7 +30,7 @@ workflow: - { from: g, to: out } `; -// g has an approve deadline and g2 none, so the run continues after the deadline decides g. +// 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 @@ -45,6 +45,22 @@ workflow: - { 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<WorkflowEngine['start']>; @@ -106,9 +122,10 @@ 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, and says no answer from the terminal was recorded', async () => { + 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. + // 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({ @@ -164,18 +181,161 @@ describe('driveRun — interactive gate', () => { }); await lifetime.finish(); - expect(outcome).toBe('completed'); + 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\./u, + /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, ); - // g2 was answered on its own card, which is not "decided elsewhere". expect(err().match(/was already decided/gu)).toHaveLength(1); expect(order.slice(0, 2)).toEqual(['err', 'resume']); - const decidedG = events.filter((e) => e.type === 'human_gate:resumed' && e.nodeId === 'g'); - expect(decidedG).toEqual([ - expect.objectContaining({ decision: 'approved', decidedBy: 'timeout' }), + // 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 another terminal decides its gate as `cli`, 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<GateDecision | null>((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'); + const decided = events.filter((e) => e.type === 'human_gate:resumed'); + expect(decided).toEqual([expect.objectContaining({ decision: 'approved', decidedBy: 'cli' })]); + }); + + it('says so when another terminal decides the gate otherwise 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<GateDecision | null>((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('passes the built decision through to the engine (the resumed event carries it)', async () => { diff --git a/apps/cli/src/commands/drive.ts b/apps/cli/src/commands/drive.ts index 69ee0d0b..65b0b7ff 100644 --- a/apps/cli/src/commands/drive.ts +++ b/apps/cli/src/commands/drive.ts @@ -12,6 +12,7 @@ import { } from '@relavium/core'; import type { ErrorCode, + GateDecision, HumanGatePausedEvent, RunDurability, RunEvent, @@ -20,7 +21,6 @@ import type { import type { GatePrompter } from '../gate/prompter.js'; import { budgetIdentifier, budgetPromptContext, type BudgetPromptContext } from '../gate/budget.js'; -import { DECIDED_BY_CLI } from '../gate/decision.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'; @@ -133,13 +133,17 @@ export async function driveRun(deps: DriveRunDeps): Promise<RunOutcome | undefin | { readonly gate: HumanGatePausedEvent; readonly controller: AbortController; + /** The decision this card returned, once it has: the resumed event that carries it is the card's own. */ + answered?: { readonly decision: string; readonly decidedBy: string }; decided?: string; } | undefined; /** - * Close the open card when `decidedBy`, not this card, decided its gate: matched by gate id, or by node for a legacy - * resumed companion that names no gate. Records how it was decided for the stderr notice; our own decisions - * (`decidedBy` of `cli`) are left alone. + * Close the open card when `decidedBy`, not this card, decided its gate: matched by gate id when the resumed event + * names one (a budget companion), otherwise by node (every human-gate resume, and a legacy companion). Records how + * it was decided for the stderr notice. The card's own decision is the one it returned, matched by its decision and + * `decidedBy`: before the card answers, any decision for its gate came from elsewhere, even one by `cli` (another + * terminal's `relavium gate`). */ const closeCardDecidedElsewhere = ( gateId: string | undefined, @@ -148,8 +152,9 @@ export async function driveRun(deps: DriveRunDeps): Promise<RunOutcome | undefin decidedBy: string, ): void => { const card = openCard; - if (card === undefined || decidedBy === DECIDED_BY_CLI) return; - // A legacy resumed companion names the node, not the gate. + 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(); @@ -431,6 +436,9 @@ export async function driveRun(deps: DriveRunDeps): Promise<RunOutcome | undefin signal: controller.signal, runPrompt: (invoke) => 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, @@ -446,6 +454,9 @@ export async function driveRun(deps: DriveRunDeps): Promise<RunOutcome | undefin handledGates.add(gate.gateId); gates.delete(gate.gateId); } else inlineRefused = true; + // A card closed under the user can leave keys in flight that the next card would take as its answer + // (Clack's `y` approves outright), so this run's later gates are left for the command its summary names. + if (card.decided !== undefined) inlineRefused = true; } catch (error) { recordFault(error); requestCancellation(); @@ -568,6 +579,8 @@ interface InlineGateLifecycle { ) => ReturnType<GatePrompter['prompt']>; /** 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<void>, @@ -609,7 +622,7 @@ async function resolveGateInline( reported = true; io.writeErr( `Gate ${sanitizeInline(event.gateId)} was already decided (${sanitizeInline(decided)}); ` + - 'no answer from this terminal was recorded.\n', + 'no answer from this terminal was recorded. Later gates in this run are not prompted here.\n', ); }; try { @@ -624,6 +637,7 @@ 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) { From 3b8e550daf4813ec428d1e78b46e1e876b9ee54a Mon Sep 17 00:00:00 2001 From: Cemil ILIK <cemililik@outlook.com> Date: Sat, 7 Nov 2026 00:15:06 +0300 Subject: [PATCH 131/148] test(cli): pin the own-export swap at open and the legacy card's Enter A new race test swaps another file with the same bytes, a directory and a link in between the replace's last lstat and its open, which only the checks on the opened descriptor can see. The legacy budget card's confirm is pinned to default to Reject. The FIFO test's name and the read's comment now say what they pin (D64 round 3: export-1, export-2, gates-3). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- apps/cli/src/gate/clack-prompter.test.ts | 2 + .../src/process/owned-storage-race.test.ts | 119 ++++++++++++++++++ .../src/process/owned-storage-replace.test.ts | 2 +- apps/cli/src/process/owned-storage.ts | 4 +- 4 files changed, 125 insertions(+), 2 deletions(-) create mode 100644 apps/cli/src/process/owned-storage-race.test.ts diff --git a/apps/cli/src/gate/clack-prompter.test.ts b/apps/cli/src/gate/clack-prompter.test.ts index 8ff6baa9..72aff899 100644 --- a/apps/cli/src/gate/clack-prompter.test.ts +++ b/apps/cli/src/gate/clack-prompter.test.ts @@ -351,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/process/owned-storage-race.test.ts b/apps/cli/src/process/owned-storage-race.test.ts new file mode 100644 index 00000000..18896ee7 --- /dev/null +++ b/apps/cli/src/process/owned-storage-race.test.ts @@ -0,0 +1,119 @@ +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, +})); +vi.mock('node:fs', async (original) => { + const native = await original<typeof import('node:fs')>(); + return { + ...native, + lstatSync: (...args: Parameters<typeof native.lstatSync>) => { + const result = native.lstatSync(...args); + const [path] = args; + if (typeof path === 'string') fsHooks.afterLstat?.(path); + return result; + }, + }; +}); + +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; + rmSync(dir, { recursive: true, force: true }); + }); + + /** 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 without following 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 index bc0d78bf..c686ec48 100644 --- a/apps/cli/src/process/owned-storage-replace.test.ts +++ b/apps/cli/src/process/owned-storage-replace.test.ts @@ -84,7 +84,7 @@ describe('replaceFileForStorage', () => { }); it.skipIf(process.platform === 'win32')( - 'refuses a FIFO put at the path after the check without opening it, so it never blocks', + 'refuses a FIFO put at the path after the check, before its lstat lets anything open it', () => { const path = join(dir, 'w.relavium.yaml'); writeFileSync(path, 'old'); diff --git a/apps/cli/src/process/owned-storage.ts b/apps/cli/src/process/owned-storage.ts index 48ee311c..9d630099 100644 --- a/apps/cli/src/process/owned-storage.ts +++ b/apps/cli/src/process/owned-storage.ts @@ -151,7 +151,9 @@ export function replaceFileForStorage( /** * 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, and no more of them than `expected.size` plus one (to see growth), as `/export`'s own read does. + * 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, From 37acc0fae80137b081e037f064faf4d6da6efe83 Mon Sep 17 00:00:00 2001 From: Cemil ILIK <cemililik@outlook.com> Date: Sat, 7 Nov 2026 00:15:06 +0300 Subject: [PATCH 132/148] fix(cli): give gate list's ids and status's attempt the one-line floor gate list printed the run, gate and node ids it reads from history.db raw, and status printed an attempt number that SQLite kept as text but JavaScript reads as a number past 1 (' 2\r'). The ids pass sanitizeInline and only a safe integer attempt is shown, each pinned by a forged-value test (D64 round 3: sinks-1, sinks-2). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- apps/cli/src/commands/gate-list.test.ts | 15 +++++++++++++++ apps/cli/src/commands/gate-list.ts | 6 +++++- apps/cli/src/commands/status.test.ts | 16 ++++++++++++++++ apps/cli/src/commands/status.ts | 6 +++++- 4 files changed, 41 insertions(+), 2 deletions(-) diff --git a/apps/cli/src/commands/gate-list.test.ts b/apps/cli/src/commands/gate-list.test.ts index 9d79f623..84cc4ddf 100644 --- a/apps/cli/src/commands/gate-list.test.ts +++ b/apps/cli/src/commands/gate-list.test.ts @@ -28,6 +28,21 @@ 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('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 9fde071b..f4a634e1 100644 --- a/apps/cli/src/commands/gate-list.ts +++ b/apps/cli/src/commands/gate-list.ts @@ -153,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/status.test.ts b/apps/cli/src/commands/status.test.ts index 73109765..1a79a474 100644 --- a/apps/cli/src/commands/status.test.ts +++ b/apps/cli/src/commands/status.test.ts @@ -41,6 +41,22 @@ describe('statusCommand', () => { expect(text).not.toContain('\nforged row'); }); + 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' }); diff --git a/apps/cli/src/commands/status.ts b/apps/cli/src/commands/status.ts index ffdfca8f..7e6193e7 100644 --- a/apps/cli/src/commands/status.ts +++ b/apps/cli/src/commands/status.ts @@ -326,7 +326,11 @@ function renderSteps(io: CliIo, status: ActiveRunStatus, finished: boolean): voi io.writeOut(finished ? ' (no node ran)\n' : ' (no node activity recorded yet)\n'); } for (const step of status.steps) { - const attempt = step.attemptNumber > 1 ? ` (attempt ${step.attemptNumber})` : ''; + // A history.db value: text that JavaScript reads as a number past 1 (' 2\r') 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`, From 15708649af201ad8eab5ddf72f6b8742e1b5b915 Mon Sep 17 00:00:00 2001 From: Cemil ILIK <cemililik@outlook.com> Date: Sat, 7 Nov 2026 00:15:06 +0300 Subject: [PATCH 133/148] test(core): pin reconcile's per-run outbox re-read and its claim release A re-read that skipped every run whenever the outbox held any entry, or one that leaked its claim on that path, passed every test. One test now settles a crashed run beside another run's held entry, and the re-read test asserts the claim is gone (D64 round 3: reconcile-1, reconcile-2). Refs: ADR-0079 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- packages/core/src/engine/engine.test.ts | 27 +++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/packages/core/src/engine/engine.test.ts b/packages/core/src/engine/engine.test.ts index 836fa833..48ddb7ae 100644 --- a/packages/core/src/engine/engine.test.ts +++ b/packages/core/src/engine/engine.test.ts @@ -6477,10 +6477,37 @@ describe('WorkflowEngine — internal failures and handle-side controls', () => 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'); From 2d7f07147249bafeec638c5d1cc410fc32b6bf2f Mon Sep 17 00:00:00 2001 From: Cemil ILIK <cemililik@outlook.com> Date: Sat, 7 Nov 2026 00:15:06 +0300 Subject: [PATCH 134/148] docs(repo): close ADR-0120's approval escape and record D64 round 3 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ADR-0120's escape still let U+2028 and U+2029 through, which JavaScript reads as line breaks and a terminal draws as nothing, so an approved edit could end a `//` comment unseen. 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; confusables are named out of scope. The card draws plain, with no markdown parse (ADR-0114 §5). ADR-0118's card names its masked spans, and its Positive consequence states the one credential the label rule cannot refuse. commands.md says later gates are not prompted after a card closes under the user; deferred-tasks.md records round 3. Refs: ADR-0118, ADR-0120 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- ...e-the-allowlist-asks-instead-of-failing.md | 10 +++-- ...rows-and-a-tool-call-shows-what-it-does.md | 45 ++++++++++++------- docs/reference/cli/commands.md | 2 +- docs/roadmap/deferred-tasks.md | 21 +++++++++ 4 files changed, 58 insertions(+), 20 deletions(-) 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 index 602578ea..76eec22a 100644 --- 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 @@ -64,9 +64,10 @@ 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 or an invisible code point in an argument shows as a visible escape, -never hidden. The card never offers an approving answer for -a command it does not show in full. A command too long for the screen must be +control, a line separator, or an invisible or blank code point in an argument +shows as a visible escape, never hidden. The one thing the card does not show is +a secret span, which ADR-0120 masks, labels and counts. Masked spans aside, the +card never offers an approving answer for a command it does not show in full. A command too long for the screen must be expanded before it can be approved. **The card's answers:** @@ -211,7 +212,8 @@ accepted. 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 no credential in a command reaches the configuration. + 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. 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 index bb495e93..cf4ad7c1 100644 --- 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 @@ -95,21 +95,36 @@ Exact arguments, bounds and texts live in target is one line (`sanitizeInline`). - **An approval preview never hides 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. An invisible code point - hides content the same way: a tag-character run (U+E0000–U+E007F) can spell a - whole instruction in zero columns, and a zero-width space inside a compared - literal stops it matching. So in an approval card and its full view, a terminal - control, a bidi character, and every default-ignorable (`\p{Default_Ignorable_Code_Point}`) - or format (`\p{Cf}`) code point in a target, field or diff line renders as a - visible, styled escape (for example `<U+202E>` or `<U+E0049>`), never stripped - and never left invisible. That covers zero-width spaces and joiners, the soft - hyphen, variation selectors and the tag block, including the joiners a - transcript keeps for emoji: an approval shows `<U+FE0F>` rather than hide a - code point. Both are Unicode properties the JavaScript regular-expression - engine knows, so no table is kept by hand. Transcript rows may strip. The order - is fixed: secret masking on the original text, then this escape, which turns - every control and every invisible code point into printable text, and only - then ADR-0114's span pipeline, whose strip then finds nothing to remove. The diff's two sides go through the same escape. A span the secret masking replaces shows + 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 `<U+202E>`, `<U+2028>` or `<U+E0049>`), never stripped and + never left invisible. The set is closed: + - 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 `<U+FE0F>` rather than hide a code point; + - U+2800, the braille blank, which draws as a space. + + A TAB shows as a styled tab mark instead, so tab-indented code stays readable + and a tab is still told from spaces; the line diff splits on LF and CR LF, so + a CR LF ending is not escaped, while a CR anywhere else is. The properties are + ones the JavaScript regular-expression engine knows, so no table is kept by + hand. The escape does not tell confusable letters apart (a Cyrillic `а` for a + Latin `a`): they 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 span the secret masking replaces shows as a labelled mask, and the card states how many spans were masked; the full view does not unmask. Approving approves the bytes the host will write, masked spans included, and the card says so. Whether a write whose changed diff --git a/docs/reference/cli/commands.md b/docs/reference/cli/commands.md index 746eb57c..252c85af 100644 --- a/docs/reference/cli/commands.md +++ b/docs/reference/cli/commands.md @@ -206,7 +206,7 @@ printf 'api_key=%s\n' "$API_KEY" | relavium run ./workflows/code-review.relavium - `--allow-mcp-stdio <digest>` (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 <runId> --gate <gateId>`); with `--json` they are on the NDJSON event line, otherwise the plain/TUI renderer prints them inline (`paused at gate <gateId> (<type>)`, also echoed in the final summary). (`relavium status`, `relavium logs <runId>`, 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 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 <runId>` for a run that ended; for a paused run, `relavium gate <runId>` when a human gate is pending, `relavium budget resume <runId> --gate <gateId>` for a budget gate (which `gate` declines), and `relavium status <runId>` 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 <runId>`, 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_<PROVIDER>_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 and stderr says no answer from the terminal was recorded; under `--json`/CI/no-TTY there is no prompt and the run exits `3`, resumable later by `relavium gate <runId>`. 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. +> **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 <runId>` for a run that ended; for a paused run, `relavium gate <runId>` when a human gate is pending, `relavium budget resume <runId> --gate <gateId>` for a budget gate (which `gate` declines), and `relavium status <runId>` 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 <runId>`, 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_<PROVIDER>_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 <runId>`. 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` diff --git a/docs/roadmap/deferred-tasks.md b/docs/roadmap/deferred-tasks.md index 817b09a4..46a73f1f 100644 --- a/docs/roadmap/deferred-tasks.md +++ b/docs/roadmap/deferred-tasks.md @@ -2048,6 +2048,27 @@ entry's commands, the glossary's and the vision's auto-reject timeout). Recorded 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 +another terminal's `relavium gate` 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)* + ## Wave 3 NI — recorded low findings (2026-10-25) Round 1 of the native-identity increment at `fc5af701` accepted it with these lows From cdb41caee29ff955fa69858878c6de993770fd4e Mon Sep 17 00:00:00 2001 From: Cemil ILIK <cemililik@outlook.com> Date: Sat, 7 Nov 2026 00:42:10 +0300 Subject: [PATCH 135/148] fix(cli): clear a gate's pause when it resumes cbe39064 stopped inline prompting after a card closed under the user, but the run:paused that parked the gate was never cleared, so every later event read it as a reason to depart: the loop released the input, the engine answered continue, and the live view was re-mounted, stacking one frame per event and drawing no streamed text. A resumed gate now clears it; the engine announces a fresh run:paused for any gate still pending when it is next idle. Tests pin one re-mount after a close, and the card's docblock states the recorded late-answer limit (D64 round 4: gates-1, gates-2). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- apps/cli/src/commands/drive.test.ts | 4 ++++ apps/cli/src/commands/drive.ts | 9 +++++++-- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/apps/cli/src/commands/drive.test.ts b/apps/cli/src/commands/drive.test.ts index cab76c39..b02369e6 100644 --- a/apps/cli/src/commands/drive.test.ts +++ b/apps/cli/src/commands/drive.test.ts @@ -188,6 +188,9 @@ describe('driveRun — interactive gate', () => { ); 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'); @@ -246,6 +249,7 @@ describe('driveRun — interactive gate', () => { 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' })]); }); diff --git a/apps/cli/src/commands/drive.ts b/apps/cli/src/commands/drive.ts index 65b0b7ff..79208209 100644 --- a/apps/cli/src/commands/drive.ts +++ b/apps/cli/src/commands/drive.ts @@ -126,8 +126,9 @@ export async function driveRun(deps: DriveRunDeps): Promise<RunOutcome | undefin let inlineTerminating = false; let activePrompt: AbortController | undefined; /** - * The gate card on screen. A decision for its gate that this card did not make — a deadline's — closes it, so - * the user's later answer is never silently dropped by the engine's idempotent resume. + * The gate card on screen. A decision for its gate that this card did not make — a deadline's — closes it and says + * so, so an answer typed after that is not silently dropped by the engine's idempotent resume. One that lands in + * the moment between the deadline's claim and its event still can be (recorded in deferred-tasks.md). */ let openCard: | { @@ -320,6 +321,10 @@ export async function driveRun(deps: DriveRunDeps): Promise<RunOutcome | undefin } else if (event.type === 'human_gate:paused') { if (!handledGates.has(event.gateId)) gates.set(event.gateId, event); } else if (event.type === 'human_gate:resumed') { + // The pause that parked this gate is over. Kept, it would make every later event attempt a departure once + // inline prompting stops (a card closed under the user), re-mounting the live view each time; the engine + // announces a fresh `run:paused` for any gate still pending when it is next idle. + pause = undefined; // Legacy resumed companions identify the node, not a gate id. for (const [id, pending] of gates) { if (pending.nodeId === event.nodeId) { From e979f3df238a213f0bd33aad151bdb69315ecd84 Mon Sep 17 00:00:00 2001 From: Cemil ILIK <cemililik@outlook.com> Date: Sat, 7 Nov 2026 00:42:10 +0300 Subject: [PATCH 136/148] test(cli): pin that the own-export check refuses what it never opens A directory, link or FIFO already at the path is refused on its lstat with no open of the path; dropping that pre-check now fails three tests. On Windows, which has no O_NOFOLLOW, the pre-check is what keeps a link from being followed, and the comment says so. The FIFO test's name says what it pins (D64 round 4: export-1). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- .../src/process/owned-storage-race.test.ts | 35 +++++++++++++++++++ .../src/process/owned-storage-replace.test.ts | 2 +- apps/cli/src/process/owned-storage.ts | 3 +- 3 files changed, 38 insertions(+), 2 deletions(-) diff --git a/apps/cli/src/process/owned-storage-race.test.ts b/apps/cli/src/process/owned-storage-race.test.ts index 18896ee7..17575ca6 100644 --- a/apps/cli/src/process/owned-storage-race.test.ts +++ b/apps/cli/src/process/owned-storage-race.test.ts @@ -1,3 +1,4 @@ +import { execFileSync } from 'node:child_process'; import { linkSync, lstatSync, @@ -28,6 +29,7 @@ import { */ const fsHooks = vi.hoisted(() => ({ afterLstat: undefined as ((path: string) => void) | undefined, + opened: [] as string[], })); vi.mock('node:fs', async (original) => { const native = await original<typeof import('node:fs')>(); @@ -39,6 +41,11 @@ vi.mock('node:fs', async (original) => { if (typeof path === 'string') fsHooks.afterLstat?.(path); return result; }, + openSync: (...args: Parameters<typeof native.openSync>) => { + const [path] = args; + if (typeof path === 'string') fsHooks.opened.push(path); + return native.openSync(...args); + }, }; }); @@ -51,9 +58,37 @@ describe('replaceFileForStorage — a swap between its last lstat and its open', }); 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'); diff --git a/apps/cli/src/process/owned-storage-replace.test.ts b/apps/cli/src/process/owned-storage-replace.test.ts index c686ec48..52f9d9c9 100644 --- a/apps/cli/src/process/owned-storage-replace.test.ts +++ b/apps/cli/src/process/owned-storage-replace.test.ts @@ -84,7 +84,7 @@ describe('replaceFileForStorage', () => { }); it.skipIf(process.platform === 'win32')( - 'refuses a FIFO put at the path after the check, before its lstat lets anything open it', + 'refuses a FIFO put at the path after the check, without blocking', () => { const path = join(dir, 'w.relavium.yaml'); writeFileSync(path, 'old'); diff --git a/apps/cli/src/process/owned-storage.ts b/apps/cli/src/process/owned-storage.ts index 9d630099..0d670e27 100644 --- a/apps/cli/src/process/owned-storage.ts +++ b/apps/cli/src/process/owned-storage.ts @@ -163,7 +163,8 @@ function identityIfUnchanged( if (linked === undefined || !linked.isFile() || !sameStat(linked, expected)) return undefined; let fd: number; try { - // `O_NOFOLLOW` is absent on Windows; the `fstat` check below is its swap guard there. + // `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 From d538599437cf0f22e59a81e329243fffdd824065 Mon Sep 17 00:00:00 2001 From: Cemil ILIK <cemililik@outlook.com> Date: Sat, 7 Nov 2026 00:42:10 +0300 Subject: [PATCH 137/148] fix(cli): give gate's notices and gate list's empty line the one-line floor gate list's no-pending-gate line and gate's "already settled" and "already resolved" notices printed a run or gate id that names a history.db row raw. They pass sanitizeInline now, as the budget twin's notice already did. A legitimate (attempt N) is pinned, and the attempt guard's comment names text SQLite actually keeps as text (D64 round 4: sinks-1, sinks-2, sinks-3). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- apps/cli/src/commands/gate-list.test.ts | 10 ++++++++++ apps/cli/src/commands/gate-list.ts | 2 +- apps/cli/src/commands/gate.ts | 4 ++-- apps/cli/src/commands/status.test.ts | 13 +++++++++++++ apps/cli/src/commands/status.ts | 3 ++- 5 files changed, 28 insertions(+), 4 deletions(-) diff --git a/apps/cli/src/commands/gate-list.test.ts b/apps/cli/src/commands/gate-list.test.ts index 84cc4ddf..ee4c0b1f 100644 --- a/apps/cli/src/commands/gate-list.test.ts +++ b/apps/cli/src/commands/gate-list.test.ts @@ -43,6 +43,16 @@ describe('gateListCommand', () => { 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 f4a634e1..2275ec6c 100644 --- a/apps/cli/src/commands/gate-list.ts +++ b/apps/cli/src/commands/gate-list.ts @@ -145,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; } diff --git a/apps/cli/src/commands/gate.ts b/apps/cli/src/commands/gate.ts index 9aabcafa..f7c6f733 100644 --- a/apps/cli/src/commands/gate.ts +++ b/apps/cli/src/commands/gate.ts @@ -623,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; @@ -723,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/status.test.ts b/apps/cli/src/commands/status.test.ts index 1a79a474..0f0f14b9 100644 --- a/apps/cli/src/commands/status.test.ts +++ b/apps/cli/src/commands/status.test.ts @@ -41,6 +41,19 @@ describe('statusCommand', () => { 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. diff --git a/apps/cli/src/commands/status.ts b/apps/cli/src/commands/status.ts index 7e6193e7..3d3a154d 100644 --- a/apps/cli/src/commands/status.ts +++ b/apps/cli/src/commands/status.ts @@ -326,7 +326,8 @@ function renderSteps(io: CliIo, status: ActiveRunStatus, finished: boolean): voi io.writeOut(finished ? ' (no node ran)\n' : ' (no node activity recorded yet)\n'); } for (const step of status.steps) { - // A history.db value: text that JavaScript reads as a number past 1 (' 2\r') must not reach the terminal raw. + // 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})` From 5410bbe506663e1b5c45eb14f5a5581b5abb120b Mon Sep 17 00:00:00 2001 From: Cemil ILIK <cemililik@outlook.com> Date: Sat, 7 Nov 2026 00:42:10 +0300 Subject: [PATCH 138/148] test(core): pin that reconcile leaves a young run before any claim Moving the TTL check under the claim passed every test, though a claim held even briefly could refuse a just-started run's own first acquire. The young-run test now records lease acquires and expects none (D64 round 4: reconcile-1). Refs: ADR-0079 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- packages/core/src/engine/engine.test.ts | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/packages/core/src/engine/engine.test.ts b/packages/core/src/engine/engine.test.ts index 48ddb7ae..c15df215 100644 --- a/packages/core/src/engine/engine.test.ts +++ b/packages/core/src/engine/engine.test.ts @@ -6285,9 +6285,22 @@ describe('WorkflowEngine — internal failures and handle-side controls', () => 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: young, executor: new StubExecutor() }).reconcile(), + 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 }); From 882a2ff86577b73e29362f8bb51cd4c17f99321d Mon Sep 17 00:00:00 2001 From: Cemil ILIK <cemililik@outlook.com> Date: Sat, 7 Nov 2026 00:42:10 +0300 Subject: [PATCH 139/148] docs(repo): show line-end whitespace and endings in ADR-0120's approvals ADR-0120's closed escape still drew a trailing ASCII space as nothing, and its line diff dropped each line's ending, so a whitespace-only or ending-only change (a Makefile value with a trailing space, a CR LF after `set -e`) rendered as an unchanged line. Line-end whitespace now shows a mark per character, and a changed or mixed ending shows as `<CR LF>` or `<LF>`. The blank-drawing list names U+16FE4 and U+1D159 and is owned by the security review; the drawing is plain spans with no markdown parse. ADR-0118's card and Positive consequence state the masked spans and the delimited argv elements. deferred-tasks.md records round 4. Refs: ADR-0118, ADR-0120 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- ...e-the-allowlist-asks-instead-of-failing.md | 8 +++++--- ...rows-and-a-tool-call-shows-what-it-does.md | 19 ++++++++++++++----- docs/roadmap/deferred-tasks.md | 16 ++++++++++++++++ 3 files changed, 35 insertions(+), 8 deletions(-) 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 index 76eec22a..381a538d 100644 --- 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 @@ -56,9 +56,10 @@ not pre-approved.** | Approved earlier in this session (§1, "Session") | Denied | Runs | Runs | | Anything else | Denied | **Card** | **Card** | -**The card shows the command exactly.** It shows: +**The card shows the command exactly, masked secret spans aside.** It shows: -- the argv, one argument per element, so spaces and quotes are unambiguous; +- 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. @@ -208,7 +209,8 @@ accepted. ### Positive -- A command the user is willing to run can run, with the exact argv on screen. +- 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 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 index cf4ad7c1..cb308e06 100644 --- 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 @@ -112,11 +112,19 @@ Exact arguments, bounds and texts live in - 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 `<U+FE0F>` rather than hide a code point; - - U+2800, the braille blank, which draws as a space. + - 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; the line diff splits on LF and CR LF, so - a CR LF ending is not escaped, while a CR anywhere else is. The properties are + 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. The line diff compares each line with its ending: where a line's + ending differs between the two sides, or from the file's other lines, it shows + as a styled `<CR LF>` or `<LF>` mark, and a CR anywhere else is escaped. A + change to whitespace or to a line ending alone therefore never renders as an + unchanged line. The properties are ones the JavaScript regular-expression engine knows, so no table is kept by hand. The escape does not tell confusable letters apart (a Cyrillic `а` for a Latin `a`): they are visible, and they stay as they are. Transcript rows may @@ -139,8 +147,9 @@ Exact arguments, bounds and texts live in 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 renders a `ToolPreview` through - ADR-0114's span pipeline, which writes no escape sequences. + 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 diff --git a/docs/roadmap/deferred-tasks.md b/docs/roadmap/deferred-tasks.md index 46a73f1f..e8261d8b 100644 --- a/docs/roadmap/deferred-tasks.md +++ b/docs/roadmap/deferred-tasks.md @@ -2069,6 +2069,22 @@ Positive consequence, and ADR-0120's plain drawing, against ADR-0114 §5. Record 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)* + ## Wave 3 NI — recorded low findings (2026-10-25) Round 1 of the native-identity increment at `fc5af701` accepted it with these lows From 90bb0d9393ae201e3366014d73e51ad46563e6a8 Mon Sep 17 00:00:00 2001 From: Cemil ILIK <cemililik@outlook.com> Date: Sat, 7 Nov 2026 01:09:24 +0300 Subject: [PATCH 140/148] test(cli): pin the own export's stat times and gate's resolved-id notice Dropping the size, mtime and ctime comparison passed every test, and a chmod made after recognition would then be undone by the rewrite; a test now changes only the mode bits and expects the file left alone. selectGate's already-resolved notice is pinned on one line. The replace docblock says what Windows does with a link swapped in after the lstat, and the card-ownership comment and test names no longer say another terminal's decision reaches this run (D64 round 5: export-1, export-2, sinks-1, gates-1). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- apps/cli/src/commands/drive.test.ts | 4 ++-- apps/cli/src/commands/drive.ts | 4 ++-- apps/cli/src/commands/gate.test.ts | 11 +++++++++++ .../src/process/owned-storage-replace.test.ts | 17 +++++++++++++++++ apps/cli/src/process/owned-storage.ts | 7 ++++--- 5 files changed, 36 insertions(+), 7 deletions(-) diff --git a/apps/cli/src/commands/drive.test.ts b/apps/cli/src/commands/drive.test.ts index b02369e6..7c9e7cc5 100644 --- a/apps/cli/src/commands/drive.test.ts +++ b/apps/cli/src/commands/drive.test.ts @@ -202,7 +202,7 @@ describe('driveRun — interactive gate', () => { ); }); - it('closes the card when another terminal decides its gate as `cli`, before the card answered', async () => { + 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(); @@ -254,7 +254,7 @@ describe('driveRun — interactive gate', () => { expect(decided).toEqual([expect.objectContaining({ decision: 'approved', decidedBy: 'cli' })]); }); - it('says so when another terminal decides the gate otherwise after the card answered, before its resume', async () => { + 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); diff --git a/apps/cli/src/commands/drive.ts b/apps/cli/src/commands/drive.ts index 79208209..5e861e9e 100644 --- a/apps/cli/src/commands/drive.ts +++ b/apps/cli/src/commands/drive.ts @@ -143,8 +143,8 @@ export async function driveRun(deps: DriveRunDeps): Promise<RunOutcome | undefin * Close the open card when `decidedBy`, not this card, decided its gate: matched by gate id when the resumed event * names one (a budget companion), otherwise by node (every human-gate resume, and a legacy companion). Records how * it was decided for the stderr notice. The card's own decision is the one it returned, matched by its decision and - * `decidedBy`: before the card answers, any decision for its gate came from elsewhere, even one by `cli` (another - * terminal's `relavium gate`). + * `decidedBy`: before the card answers, any decision for its gate came from elsewhere. `decidedBy` alone cannot + * tell, since every CLI decision is marked `cli` and a prompter may mark its own otherwise. */ const closeCardDecidedElsewhere = ( gateId: string | undefined, diff --git a/apps/cli/src/commands/gate.test.ts b/apps/cli/src/commands/gate.test.ts index a47ad330..2d793a67 100644 --- a/apps/cli/src/commands/gate.test.ts +++ b/apps/cli/src/commands/gate.test.ts @@ -1676,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/process/owned-storage-replace.test.ts b/apps/cli/src/process/owned-storage-replace.test.ts index 52f9d9c9..2f326b63 100644 --- a/apps/cli/src/process/owned-storage-replace.test.ts +++ b/apps/cli/src/process/owned-storage-replace.test.ts @@ -99,6 +99,23 @@ describe('replaceFileForStorage', () => { }, ); + it.skipIf(process.platform === 'win32')( + 'refuses a file whose permission bits changed after the check, so the rewrite never undoes them', + () => { + // Same bytes, same size and times: only the ctime a chmod moves tells, and the file stays readable. + const path = join(dir, 'w.relavium.yaml'); + writeFileSync(path, 'old'); + chmodSync(path, 0o644); + const checked = identityOf(path); + chmodSync(path, 0o600); + expect(() => { + replaceFileForStorage(path, 'new', 0o644, checked); + }).toThrow(StorageTargetChangedError); + expect(statSync(path).mode & 0o777).toBe(0o600); + expect(readFileSync(path, 'utf8')).toBe('old'); + }, + ); + it.skipIf(process.platform === 'win32' || isRoot)( 'refuses a file made unreadable after the check as changed, rather than failing the export', () => { diff --git a/apps/cli/src/process/owned-storage.ts b/apps/cli/src/process/owned-storage.ts index 0d670e27..3a48418f 100644 --- a/apps/cli/src/process/owned-storage.ts +++ b/apps/cli/src/process/owned-storage.ts @@ -103,9 +103,10 @@ export class StorageTargetChangedError extends Error { * 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 without - * being followed, blocked on or read whole. Not detected: a swap in the moment between that last check and the - * rename (Node has no rename that compares first). + * 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, From 673a5ea90000ef7950973e13b6bd84fbf876197f Mon Sep 17 00:00:00 2001 From: Cemil ILIK <cemililik@outlook.com> Date: Sat, 7 Nov 2026 01:09:24 +0300 Subject: [PATCH 141/148] docs(repo): keep ADR-0120's masks from hiding code and state CR LF files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Secret masking matches shapes a model can fill: `Bearer $(curl …|sh)` was masked like a token, so an approval could hide the payload. A masked span holding anything but credential characters is now flagged, and approving waits until the user reveals it with a deliberate key. A new file written entirely in CR LF looked like its LF twin; the card now states a side's endings whenever they are not all LF. The escape notation escapes a literal `<U+`, and visible right-to-left text and the hand-kept blank list are stated. ADR-0118 draws each argument on its own line and names the reveal. chat-session.md words /export's move past a changed own export; deferred-tasks.md records round 5. Refs: ADR-0118, ADR-0120 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- ...e-the-allowlist-asks-instead-of-failing.md | 7 ++- ...rows-and-a-tool-call-shows-what-it-does.md | 44 ++++++++++++------- docs/reference/cli/chat-session.md | 2 +- docs/roadmap/deferred-tasks.md | 15 ++++++- 4 files changed, 49 insertions(+), 19 deletions(-) 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 index 381a538d..e642eecc 100644 --- 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 @@ -67,8 +67,11 @@ 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 in an argument shows as a visible escape, never hidden. The one thing the card does not show is -a secret span, which ADR-0120 masks, labels and counts. Masked spans aside, the -card never offers an approving answer for a command it does not show in full. A command too long for the screen must be +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:** 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 index cb308e06..d9d1b538 100644 --- 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 @@ -104,7 +104,9 @@ Exact arguments, bounds and texts live in 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 `<U+202E>`, `<U+2028>` or `<U+E0049>`), never stripped and - never left invisible. The set is closed: + never left invisible. A literal `<U+` already in the text is escaped too (its + `<` shows as `<U+003C>`), so the notation stays unambiguous where styling is + off. The set is closed: - 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 @@ -120,24 +122,36 @@ Exact arguments, bounds and texts live in 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. The line diff compares each line with its ending: where a line's - ending differs between the two sides, or from the file's other lines, it shows - as a styled `<CR LF>` or `<LF>` mark, and a CR anywhere else is escaped. A - change to whitespace or to a line ending alone therefore never renders as an - unchanged line. The properties are - ones the JavaScript regular-expression engine knows, so no table is kept by - hand. The escape does not tell confusable letters apart (a Cyrillic `а` for a - Latin `a`): they are visible, and they stay as they are. Transcript rows may + 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 `<CR LF>` or `<LF>` 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 span the secret masking replaces shows - as a labelled mask, and the card states how many spans were masked; the full - view does not unmask. 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. + 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. - **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 diff --git a/docs/reference/cli/chat-session.md b/docs/reference/cli/chat-session.md index cc1ed06b..ea808eb5 100644 --- a/docs/reference/cli/chat-session.md +++ b/docs/reference/cli/chat-session.md @@ -109,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 <sessionId>` (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 where workflows live — `<project>/.relavium/workflows/<id>.relavium.yaml`, where `<project>/.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 `<cwd>/.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 <id>` ``) (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 `<slug>`, `<slug>-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 takes the next free id; 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).** | +| `/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 — `<project>/.relavium/workflows/<id>.relavium.yaml`, where `<project>/.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 `<cwd>/.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 <id>` ``) (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 `<slug>`, `<slug>-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. | diff --git a/docs/roadmap/deferred-tasks.md b/docs/roadmap/deferred-tasks.md index e8261d8b..7bf6b7c9 100644 --- a/docs/roadmap/deferred-tasks.md +++ b/docs/roadmap/deferred-tasks.md @@ -2052,7 +2052,7 @@ entry's commands, the glossary's and the vision's auto-reject timeout). Recorded 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 -another terminal's `relavium gate` shares. ADR-0120's escape still let U+2028 and U+2029 through, which JavaScript reads as line breaks and a +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 @@ -2085,6 +2085,19 @@ ADR-0118's card and Positive wording, and three comments. Recorded: 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)* + ## Wave 3 NI — recorded low findings (2026-10-25) Round 1 of the native-identity increment at `fc5af701` accepted it with these lows From c461cd3a7277378734c0d95f16b4505df7c25d59 Mon Sep 17 00:00:00 2001 From: Cemil ILIK <cemililik@outlook.com> Date: Sat, 7 Nov 2026 01:59:43 +0300 Subject: [PATCH 142/148] test(repo): pin gate.test.ts at 76 cases in the secrets controls 90bb0d93 added a selectGate case to gate.test.ts, the file the gate-consumed-stdin-prompt-authority control declares, so its declared workload drifted from 75 to 76 and the secrets controls' second shard stopped. The control and its provenance test say 76. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- tools/causal-controls/provenance.test.mjs | 2 +- tools/secrets-controls/mutations.mjs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/tools/causal-controls/provenance.test.mjs b/tools/causal-controls/provenance.test.mjs index 440c2cbe..f3d8bfb5 100644 --- a/tools/causal-controls/provenance.test.mjs +++ b/tools/causal-controls/provenance.test.mjs @@ -290,7 +290,7 @@ test('secrets workload corrections retain independent complete-count and reader- for (const [id, file, expectedTests] of [ ['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', 75], + ['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/secrets-controls/mutations.mjs b/tools/secrets-controls/mutations.mjs index 604ba6c7..4191d695 100644 --- a/tools/secrets-controls/mutations.mjs +++ b/tools/secrets-controls/mutations.mjs @@ -699,7 +699,7 @@ export const mutations = [ ' const mayPrompt = args.secretStdin !== true;', ' const mayPrompt = true;', ['apps/cli/src/commands/gate.test.ts'], - 75, + 76, 'refuses unapproved stdio after a real read despite stale TTY, without prompting or writing a grant', 'SECRETS_CAUSAL_RESUME_CONSUMED_STDIN_CONSENT', ), From 2cc865c7e1ca7eecb67cb478ce46b44b2ba40d45 Mon Sep 17 00:00:00 2001 From: Cemil ILIK <cemililik@outlook.com> Date: Sat, 7 Nov 2026 02:21:56 +0300 Subject: [PATCH 143/148] docs(repo): state ADR-0120's approval escape as a starting set Rounds 2 to 5 of the PR #97 correction each found a further way an approval preview could hide content, each a medium against the draft's claim that a preview never hides what it approves. By the maintainer's ruling of 2026-11-07 the draft now claims only the rules it states: they are a starting set, and their completeness is for the mandatory security review before MVP-3 builds the card. The Positive consequence says the same, and deferred-tasks.md records the ruling. Refs: ADR-0120 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- ...-grows-and-a-tool-call-shows-what-it-does.md | 17 +++++++++++++---- docs/roadmap/deferred-tasks.md | 7 +++++++ 2 files changed, 20 insertions(+), 4 deletions(-) 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 index d9d1b538..79792850 100644 --- 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 @@ -93,7 +93,7 @@ Exact arguments, bounds and texts live in 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 never hides what it approves.** Stripping is right for a +- **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 @@ -152,6 +152,14 @@ Exact arguments, bounds and texts live in 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 @@ -209,9 +217,10 @@ Exact arguments, bounds and texts live in - 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 as what it will do. In - particular, every write shows its diff, and every `auto` write leaves a - trace. +- 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 diff --git a/docs/roadmap/deferred-tasks.md b/docs/roadmap/deferred-tasks.md index 7bf6b7c9..651c158d 100644 --- a/docs/roadmap/deferred-tasks.md +++ b/docs/roadmap/deferred-tasks.md @@ -2097,6 +2097,13 @@ resolved-id notice, `/export`'s "next free id" wording, and the card-ownership c - [ ] **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)* ## Wave 3 NI — recorded low findings (2026-10-25) From d20d414b178410526e92b73408f89fa92d70aaf5 Mon Sep 17 00:00:00 2001 From: Cemil ILIK <cemililik@outlook.com> Date: Sat, 7 Nov 2026 02:23:25 +0300 Subject: [PATCH 144/148] docs(repo): say the secrets controls pin 76 gate cases c461cd3a moved the gate-consumed-stdin-prompt-authority pin to 76 in the control and its provenance test; the README still said 75. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- tools/secrets-controls/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tools/secrets-controls/README.md b/tools/secrets-controls/README.md index 6bd2f9d5..d26ced3f 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, 85 run-command cases, three run-consent cases, 75 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 From 69e95d784776e79ec27d68a5432b9a530fd09374 Mon Sep 17 00:00:00 2001 From: Cemil ILIK <cemililik@outlook.com> Date: Sat, 7 Nov 2026 04:26:14 +0300 Subject: [PATCH 145/148] fix(cli): start a gate card's cleanup notice only when the card is aborted driveRun awaited the whole inline gate under the 250 ms cleanup timer, so every card a person took longer than that to answer printed "Run cleanup is still pending" under it on stderr, which was false and threw off Clack's redraws (a stale Approve/Reject line above the live one). The timer now starts when the card's controller aborts: a cancellation, a terminal or a decision made elsewhere, the case it exists for. Tests pin no notice for a slow answer and the notice for a closed card that ignores its abort. Pre-existing since d5ed56f4; fixed here by the maintainer's ruling (D64 round 6: gates-2). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- apps/cli/src/commands/drive.test.ts | 69 +++++++++++++++++++++++++++++ apps/cli/src/commands/drive.ts | 32 ++++++++----- 2 files changed, 91 insertions(+), 10 deletions(-) diff --git a/apps/cli/src/commands/drive.test.ts b/apps/cli/src/commands/drive.test.ts index 7c9e7cc5..58b8e481 100644 --- a/apps/cli/src/commands/drive.test.ts +++ b/apps/cli/src/commands/drive.test.ts @@ -342,6 +342,75 @@ describe('driveRun — interactive gate', () => { 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<GateDecision | null>((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<GateDecision | null>((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 5e861e9e..5f54dc16 100644 --- a/apps/cli/src/commands/drive.ts +++ b/apps/cli/src/commands/drive.ts @@ -212,19 +212,30 @@ export async function driveRun(deps: DriveRunDeps): Promise<RunOutcome | undefin lifetime.failure(new CliError('output_unavailable', '')); } }; - const joined = async <T>(work: Promise<T>): Promise<T> => { - 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 <T>(work: Promise<T>, stopped?: AbortSignal): Promise<T> => { + let timer: ReturnType<typeof setTimeout> | 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<never> => { @@ -454,6 +465,7 @@ export async function driveRun(deps: DriveRunDeps): Promise<RunOutcome | undefin io, recorded?.nodeId === gate.nodeId ? recorded.context : undefined, ), + controller.signal, ); if (handled) { handledGates.add(gate.gateId); From 010b21cffed511a978201adaef9ff72e60d0fd46 Mon Sep 17 00:00:00 2001 From: Cemil ILIK <cemililik@outlook.com> Date: Sat, 7 Nov 2026 04:26:14 +0300 Subject: [PATCH 146/148] test(cli): pin each own-export stat field without the filesystem clock Round 5's chmod test needed ctime to move between two chmods a few microseconds apart, which a coarse-clock filesystem does not do. Each stat field is now forged one at a time against the real bytes, so every comparison is pinned on any clock. The race test's name no longer says the link is not followed, and the export docblock says how it moves on past a changed own export (D64 round 6: export-1, export-2, export-3). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- apps/cli/src/chat/export.ts | 5 +++-- .../src/process/owned-storage-race.test.ts | 2 +- .../src/process/owned-storage-replace.test.ts | 19 +++++++++---------- 3 files changed, 13 insertions(+), 13 deletions(-) diff --git a/apps/cli/src/chat/export.ts b/apps/cli/src/chat/export.ts index 39287e8c..113cde24 100644 --- a/apps/cli/src/chat/export.ts +++ b/apps/cli/src/chat/export.ts @@ -138,8 +138,9 @@ const WORKFLOW_ID_GRAMMAR = /^[a-z0-9]+(?:-[a-z0-9]+)*$/u; * `/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 takes the next free id instead. A swap in the moment between the - * last check and the rename is not detected ({@link replaceFileForStorage}). + * 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); diff --git a/apps/cli/src/process/owned-storage-race.test.ts b/apps/cli/src/process/owned-storage-race.test.ts index 17575ca6..815e8a43 100644 --- a/apps/cli/src/process/owned-storage-race.test.ts +++ b/apps/cli/src/process/owned-storage-race.test.ts @@ -136,7 +136,7 @@ describe('replaceFileForStorage — a swap between its last lstat and its open', }); it.skipIf(process.platform === 'win32')( - 'refuses a link put there without following it, rather than failing on the open', + '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'); diff --git a/apps/cli/src/process/owned-storage-replace.test.ts b/apps/cli/src/process/owned-storage-replace.test.ts index 2f326b63..120a6667 100644 --- a/apps/cli/src/process/owned-storage-replace.test.ts +++ b/apps/cli/src/process/owned-storage-replace.test.ts @@ -99,22 +99,21 @@ describe('replaceFileForStorage', () => { }, ); - it.skipIf(process.platform === 'win32')( - 'refuses a file whose permission bits changed after the check, so the rewrite never undoes them', - () => { - // Same bytes, same size and times: only the ctime a chmod moves tells, and the file stays readable. + 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'); - chmodSync(path, 0o644); - const checked = identityOf(path); - chmodSync(path, 0o600); + const real = identityOf(path); + const checked = { ...real, [field]: real[field] + 1n }; expect(() => { replaceFileForStorage(path, 'new', 0o644, checked); }).toThrow(StorageTargetChangedError); - expect(statSync(path).mode & 0o777).toBe(0o600); 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', From 38b7223bf42261b20a796097f3499d43233f7b8d Mon Sep 17 00:00:00 2001 From: Cemil ILIK <cemililik@outlook.com> Date: Sat, 7 Nov 2026 04:26:14 +0300 Subject: [PATCH 147/148] docs(core): say the re-read under the claim keeps a repair off a held run reconcile()'s comment still called the drain-first order what protects a completed run; since the re-read under the repair's claim, a repair that ran first would skip the run, and draining first only lands the held terminal in the same pass (D64 round 6: reconcile-2). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- packages/core/src/engine/engine.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/core/src/engine/engine.ts b/packages/core/src/engine/engine.ts index 3a8f909e..981368f2 100644 --- a/packages/core/src/engine/engine.ts +++ b/packages/core/src/engine/engine.ts @@ -6356,11 +6356,11 @@ export class WorkflowEngine { * 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 From 72dee66a4b313914e01b25bf067a3fc9ce69f634 Mon Sep 17 00:00:00 2001 From: Cemil ILIK <cemililik@outlook.com> Date: Sat, 7 Nov 2026 04:26:15 +0300 Subject: [PATCH 148/148] docs(repo): say a card closed under the user parks the run's later gates commands.md still said a losing rejection does not stop later gates, which cbe39064 changed: a decision made elsewhere that closes an open card leaves the later gates for out-of-band resume. ADR-0118's argument escape and ADR-0120's set no longer claim more than the starting set promises. deferred-tasks.md records round 6 (D64 round 6: gates-1, docs-3, docs-4). Refs: ADR-0118, ADR-0120 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- ...e-the-allowlist-asks-instead-of-failing.md | 5 +++-- ...rows-and-a-tool-call-shows-what-it-does.md | 2 +- docs/reference/cli/commands.md | 6 ++++-- docs/roadmap/deferred-tasks.md | 21 +++++++++++++++++++ 4 files changed, 29 insertions(+), 5 deletions(-) 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 index e642eecc..0dbe0b8c 100644 --- 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 @@ -65,8 +65,9 @@ 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 in an argument -shows as a visible escape, never hidden. The one thing the card does not show is +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 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 index 79792850..d7a29e31 100644 --- 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 @@ -106,7 +106,7 @@ Exact arguments, bounds and texts live in escape (for example `<U+202E>`, `<U+2028>` or `<U+E0049>`), never stripped and never left invisible. A literal `<U+` already in the text is escaped too (its `<` shows as `<U+003C>`), so the notation stays unambiguous where styling is - off. The set is closed: + 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 diff --git a/docs/reference/cli/commands.md b/docs/reference/cli/commands.md index 252c85af..c6db618f 100644 --- a/docs/reference/cli/commands.md +++ b/docs/reference/cli/commands.md @@ -421,8 +421,10 @@ relavium budget resume <runId> [--gate <gateId>] --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). diff --git a/docs/roadmap/deferred-tasks.md b/docs/roadmap/deferred-tasks.md index 651c158d..db0e3d86 100644 --- a/docs/roadmap/deferred-tasks.md +++ b/docs/roadmap/deferred-tasks.md @@ -2105,6 +2105,27 @@ resolved-id notice, `/export`'s "next free id" wording, and the card-ownership c 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) Round 1 of the native-identity increment at `fc5af701` accepted it with these lows