From 84c78a2d3745c3190ac05dcd02a0d72646fdeaa2 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN Date: Thu, 1 Oct 2026 15:04:07 -0400 Subject: [PATCH 01/51] feat(checks): add bounded candidate source seams TASK-6e4ff870-a386-4a11-a59c-444ebc23732e --- docs/TEST_INTEGRITY.md | 39 +++++++++++ src/checks/candidate-execution-fence-init.mjs | 3 + src/checks/candidate-execution-fence.mjs | 6 ++ src/checks/candidate-input-snapshot.mjs | 69 +++++++++++++++++++ src/checks/candidate-source-bootstrap.mjs | 16 +++++ src/checks/candidate-source-execution.mjs | 15 ++++ src/checks/service.mjs | 15 ++++ test/fleet/candidate-execution-fence.test.mjs | 5 ++ .../candidate-input-consumption.test.mjs | 10 +++ test/fleet/candidate-snapshot-policy.test.mjs | 7 ++ .../fleet/candidate-source-bootstrap.test.mjs | 12 ++++ .../fleet/candidate-source-execution.test.mjs | 9 +++ test/fleet/candidate-source-parent.test.mjs | 8 +++ 13 files changed, 214 insertions(+) create mode 100644 src/checks/candidate-execution-fence-init.mjs create mode 100644 src/checks/candidate-execution-fence.mjs create mode 100644 src/checks/candidate-input-snapshot.mjs create mode 100644 src/checks/candidate-source-bootstrap.mjs create mode 100644 src/checks/candidate-source-execution.mjs create mode 100644 test/fleet/candidate-execution-fence.test.mjs create mode 100644 test/fleet/candidate-input-consumption.test.mjs create mode 100644 test/fleet/candidate-snapshot-policy.test.mjs create mode 100644 test/fleet/candidate-source-bootstrap.test.mjs create mode 100644 test/fleet/candidate-source-execution.test.mjs create mode 100644 test/fleet/candidate-source-parent.test.mjs diff --git a/docs/TEST_INTEGRITY.md b/docs/TEST_INTEGRITY.md index 44c659d..bed22bc 100644 --- a/docs/TEST_INTEGRITY.md +++ b/docs/TEST_INTEGRITY.md @@ -2715,3 +2715,42 @@ test_integrity_note: did_relax_any_assertion: false if_true_explain_spec_basis: "" ``` + +# Candidate source B2 frozen-input and fence boundaries (2026-10-01) + +```yaml +test_integrity_note: + change_type: new_tests + scenarios: + - id: SCN-candidate-bootstrap-one-shot + given: "An issued, nonce-bound native admission" + when: "It is opened, replayed, expired, foreign, or malformed" + then: "Only one bounded private bootstrap opens; every invalid case refuses before child start and exposes no authority handle" + - id: SCN-candidate-input-consumption + given: "A frozen project-local source snapshot" + when: "The original source changes before the fixed Node child reads its cwd" + then: "The child observes the captured bytes; snapshot drift refuses" + - id: SCN-candidate-execution-boundary + given: "A fixed command and explicit bounded environment" + when: "Ambient PATH/HOME/cache fallback or output overflow is attempted" + then: "Execution refuses or remains non-success" + - id: SCN-candidate-fence-closure + given: "A private PID1 namespace observation" + when: "Descendant closure is absent or uncertain" + then: "The outcome is UNKNOWN/INCOMPLETE, never terminal PASS" + observable_outcomes: + - "No DB, ControlPlane, writer, receipt adapter, or authority FD reaches the child" + - "Product input links and special nodes refuse; frozen product S stays under 64MiB/file and 512MiB total" + - "Artifact output remains M/artifactManifest; receipt-adapter A is absent and unclaimed" + determinism_controls: + - "Temporary source trees, fixed Node executable, explicit empty ambient environment, bounded streams, and injected observations" + - "No live policy registration, installation, network, cache, or provider process" + anti_cheat_rationale: + prevents: + - "Reading mutated live source instead of captured bytes" + - "Replaying a private admission or relabeling fixture output as a receipt" + - "Treating PGID emptiness, zero exit, or Chromium metadata as descendant/binary proof" + relaxation: + did_relax_any_assertion: false + if_true_explain_spec_basis: "" +``` diff --git a/src/checks/candidate-execution-fence-init.mjs b/src/checks/candidate-execution-fence-init.mjs new file mode 100644 index 0000000..bbadb03 --- /dev/null +++ b/src/checks/candidate-execution-fence-init.mjs @@ -0,0 +1,3 @@ +// Private init entrypoint. It is never a CLI surface: bootstrap is supplied only on inherited FD3 and acknowledgement only on FD4. +import { closeSync, readFileSync, writeFileSync } from 'node:fs'; +export function readPrivateFenceBootstrapV2() { const frame = readFileSync(3, 'utf8'); if (Buffer.byteLength(frame, 'utf8') > 65_536) throw new Error('CANDIDATE_FENCE_FRAME_TOO_LARGE'); const value = JSON.parse(frame); if (!value?.nonce || !value?.generation) throw new Error('CANDIDATE_FENCE_FRAME_INVALID'); writeFileSync(4, JSON.stringify({ nonce: value.nonce, generation: value.generation })); closeSync(3); closeSync(4); return Object.freeze({ nonce: value.nonce, generation: value.generation, admissionFdsClosed: true }); } diff --git a/src/checks/candidate-execution-fence.mjs b/src/checks/candidate-execution-fence.mjs new file mode 100644 index 0000000..4990a2f --- /dev/null +++ b/src/checks/candidate-execution-fence.mjs @@ -0,0 +1,6 @@ +import { TorchError } from '../kernel/errors.mjs'; +const fail = (message, code, details) => { throw new TorchError(message, { code, details }); }; +export function createCandidateExecutionFenceV2({ bootstrap, backend = 'private-pid1-namespace' } = {}) { if (!bootstrap?.transport?.closesBeforeCandidateSpawn || backend !== 'private-pid1-namespace') fail('Candidate fence has no supported private bootstrap', 'CANDIDATE_FENCE_UNAVAILABLE'); return Object.freeze({ schema: 'torch.dev/candidate-execution-fence/v2alpha1', backend, nonce: bootstrap.nonce, closed: false }); } +/** A direct exit or empty PGID is deliberately insufficient: missing observed PID1/reap proof is UNKNOWN. */ +export function observeCandidateExecutionFenceV2({ fence, observation = {} } = {}) { if (!fence || fence.closed) fail('Candidate fence is unavailable', 'CANDIDATE_FENCE_UNAVAILABLE'); const closed = observation.privatePid1 === true && observation.namespaceObserved === true && observation.descendantsReaped === true && observation.admissionFdsClosed === true; return Object.freeze({ schema: 'torch.dev/candidate-terminal-proof/v2alpha1', outcome: closed ? 'terminal' : 'unknown', terminalProof: closed, reason: closed ? null : 'descendant-closure-unobserved', observation: Object.freeze({ privatePid1: observation.privatePid1 === true, namespaceObserved: observation.namespaceObserved === true, descendantsReaped: observation.descendantsReaped === true, admissionFdsClosed: observation.admissionFdsClosed === true }) }); } +export function closeCandidateExecutionFenceV2(fence) { if (!fence || fence.closed) fail('Candidate fence is already closed', 'CANDIDATE_FENCE_UNAVAILABLE'); return Object.freeze({ ...fence, closed: true }); } diff --git a/src/checks/candidate-input-snapshot.mjs b/src/checks/candidate-input-snapshot.mjs new file mode 100644 index 0000000..20f2fe8 --- /dev/null +++ b/src/checks/candidate-input-snapshot.mjs @@ -0,0 +1,69 @@ +import { createHash, randomUUID } from 'node:crypto'; +import { closeSync, constants, fstatSync, lstatSync, mkdirSync, openSync, readSync, readdirSync, writeSync } from 'node:fs'; +import { isAbsolute, join, relative, resolve } from 'node:path'; +import { TorchError } from '../kernel/errors.mjs'; + +export const CANDIDATE_PRODUCT_INPUT_LIMITS_V2 = Object.freeze({ maxRecords: 8192, maxPathUtf8Bytes: 4096, maxFileBytes: 67_108_864, maxTotalBytes: 536_870_912, maxFrameBytes: 1_048_576, chunkBytes: 65_536 }); +const fail = (message, code, details) => { throw new TorchError(message, { code, details }); }; +const order = (a, b) => Buffer.compare(Buffer.from(a, 'utf8'), Buffer.from(b, 'utf8')); +const bytes = (value) => Buffer.byteLength(value, 'utf8'); +function pathOf(value, limits) { + if (typeof value !== 'string' || !value || isAbsolute(value) || value.includes('\\') || value.includes('\0') || bytes(value) > limits.maxPathUtf8Bytes || value.split('/').some((part) => !part || part === '.' || part === '..' || part === '.git')) fail('Candidate snapshot path is invalid', 'CANDIDATE_SNAPSHOT_PATH_INVALID', { path: value }); + return value; +} +function normalizeLimits(value = {}) { + if (!value || typeof value !== 'object' || Array.isArray(value) || Object.keys(value).some((key) => !Object.hasOwn(CANDIDATE_PRODUCT_INPUT_LIMITS_V2, key))) fail('Candidate snapshot limits are invalid', 'CANDIDATE_SNAPSHOT_LIMIT_INVALID'); + const result = {}; + for (const [key, maximum] of Object.entries(CANDIDATE_PRODUCT_INPUT_LIMITS_V2)) { const v = value[key] ?? maximum; if (!Number.isInteger(v) || v < 1 || v > maximum) fail('Candidate snapshot limit is invalid', 'CANDIDATE_SNAPSHOT_LIMIT_INVALID', { key, value: v }); result[key] = v; } + return Object.freeze(result); +} +function regular(path) { const stat = lstatSync(path); if (stat.isSymbolicLink()) fail('Candidate snapshot rejects links', 'CANDIDATE_SNAPSHOT_LINK_REJECTED', { path }); if (!stat.isFile() && !stat.isDirectory()) fail('Candidate snapshot rejects special files', 'CANDIDATE_SNAPSHOT_SPECIAL_REJECTED', { path }); return stat; } +function files(root, paths, limits) { + const result = []; + const visit = (rel) => { const absolute = join(root, rel); const stat = regular(absolute); if (stat.isDirectory()) for (const name of readdirSync(absolute).sort(order)) visit(`${rel}/${name}`); else { if (stat.size > limits.maxFileBytes) fail('Candidate snapshot file cap exceeded', 'CANDIDATE_SNAPSHOT_LIMIT_EXCEEDED', { path: rel }); result.push({ path: rel, size: stat.size, executable: Boolean(stat.mode & 0o111) }); } }; + for (const path of [...new Set(paths.map((p) => pathOf(p, limits)))].sort(order)) visit(path); + if (!result.length || result.length > limits.maxRecords) fail('Candidate snapshot record cap exceeded', 'CANDIDATE_SNAPSHOT_LIMIT_EXCEEDED'); + return result.sort((a, b) => order(a.path, b.path)); +} +function copyAndHash(source, destination, expected, limits) { + let inFd; let outFd; + try { + inFd = openSync(source, constants.O_RDONLY | constants.O_NOFOLLOW); const before = fstatSync(inFd); + if (!before.isFile() || before.size !== expected.size || before.size > limits.maxFileBytes) fail('Candidate snapshot source drifted', 'CANDIDATE_SNAPSHOT_DRIFT', { path: expected.path }); + outFd = openSync(destination, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL, expected.executable ? 0o500 : 0o400); + const hash = createHash('sha256'); const buffer = Buffer.alloc(limits.chunkBytes); let total = 0; + for (;;) { const count = readSync(inFd, buffer, 0, buffer.length, null); if (!count) break; total += count; if (total > limits.maxFileBytes) fail('Candidate snapshot file cap exceeded', 'CANDIDATE_SNAPSHOT_LIMIT_EXCEEDED'); hash.update(buffer.subarray(0, count)); let offset = 0; while (offset < count) offset += writeSync(outFd, buffer, offset, count - offset); } + const after = fstatSync(inFd); if (total !== before.size || after.size !== before.size) fail('Candidate snapshot source changed while read', 'CANDIDATE_SNAPSHOT_DRIFT', { path: expected.path }); + return { path: expected.path, bytes: total, sha256: hash.digest('hex'), executable: expected.executable }; + } finally { if (inFd !== undefined) closeSync(inFd); if (outFd !== undefined) closeSync(outFd); } +} +function hashRegularFile(path, expected, limits) { + let fd; + try { + fd = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW); const before = fstatSync(fd); + if (!before.isFile() || before.size !== expected.bytes || before.size > limits.maxFileBytes) fail('Candidate snapshot drifted', 'CANDIDATE_SNAPSHOT_DRIFT', { path: expected.path }); + const hash = createHash('sha256'); const buffer = Buffer.alloc(limits.chunkBytes); let total = 0; + for (;;) { const count = readSync(fd, buffer, 0, buffer.length, null); if (!count) break; total += count; if (total > limits.maxFileBytes) fail('Candidate snapshot cap exceeded', 'CANDIDATE_SNAPSHOT_LIMIT_EXCEEDED'); hash.update(buffer.subarray(0, count)); } + const after = fstatSync(fd); if (total !== before.size || after.size !== before.size) fail('Candidate snapshot drifted while read', 'CANDIDATE_SNAPSHOT_DRIFT', { path: expected.path }); + return { path: expected.path, bytes: total, sha256: hash.digest('hex'), executable: expected.executable }; + } finally { if (fd !== undefined) closeSync(fd); } +} +/** Private caller seam: callers obtain inputRoot from the returned opaque snapshot, never from a policy selector. */ +export function captureCandidateInputSnapshotV2({ sourceRoot, destinationRoot, paths, limits: requestedLimits = {}, id = randomUUID() } = {}) { + const limits = normalizeLimits(requestedLimits); if (typeof sourceRoot !== 'string' || typeof destinationRoot !== 'string' || !Array.isArray(paths)) fail('Candidate snapshot request is invalid', 'CANDIDATE_SNAPSHOT_INPUT_INVALID'); + const source = resolve(sourceRoot); const destination = resolve(destinationRoot); if (source === destination || !relative(source, destination).startsWith('..')) fail('Candidate snapshot destination must be outside source', 'CANDIDATE_SNAPSHOT_INPUT_INVALID'); + const listed = files(source, paths, limits); mkdirSync(destination, { recursive: false, mode: 0o700 }); let totalBytes = 0; const records = []; + for (const item of listed) { const target = join(destination, item.path); mkdirSync(resolve(target, '..'), { recursive: true, mode: 0o700 }); const record = copyAndHash(join(source, item.path), target, item, limits); totalBytes += record.bytes; if (totalBytes > limits.maxTotalBytes) fail('Candidate snapshot total cap exceeded', 'CANDIDATE_SNAPSHOT_LIMIT_EXCEEDED'); records.push(record); } + const manifest = Object.freeze({ schema: 'torch.dev/candidate-input-manifest/v2alpha1', id, files: Object.freeze(records), totalBytes, digest: createHash('sha256').update(JSON.stringify(records)).digest('hex') }); + return Object.freeze({ schema: 'torch.dev/candidate-input-snapshot/v2alpha1', inputRoot: destination, manifest, limits }); +} +export function verifyCandidateInputSnapshotV2(snapshot = {}) { + if (snapshot?.schema !== 'torch.dev/candidate-input-snapshot/v2alpha1' || !snapshot.manifest?.files || typeof snapshot.inputRoot !== 'string') fail('Candidate snapshot is malformed', 'CANDIDATE_SNAPSHOT_INVALID'); + const listed = files(snapshot.inputRoot, snapshot.manifest.files.map((file) => file.path), snapshot.limits); + const observed = listed.map((file) => hashRegularFile(join(snapshot.inputRoot, file.path), { ...file, bytes: file.size }, snapshot.limits)); + if (JSON.stringify(observed) !== JSON.stringify(snapshot.manifest.files) + || createHash('sha256').update(JSON.stringify(observed)).digest('hex') !== snapshot.manifest.digest) { + fail('Candidate snapshot content drifted', 'CANDIDATE_SNAPSHOT_DRIFT'); + } + return Object.freeze({ verified: true, digest: snapshot.manifest.digest, files: observed.length, totalBytes: snapshot.manifest.totalBytes }); +} diff --git a/src/checks/candidate-source-bootstrap.mjs b/src/checks/candidate-source-bootstrap.mjs new file mode 100644 index 0000000..ccf0af2 --- /dev/null +++ b/src/checks/candidate-source-bootstrap.mjs @@ -0,0 +1,16 @@ +import { randomBytes } from 'node:crypto'; +import { TorchError } from '../kernel/errors.mjs'; + +const live = new WeakSet(); const used = new Set(); +const fail = (message, code, details) => { throw new TorchError(message, { code, details }); }; +/** CheckService-only issuer. The returned object is intentionally not serializable and carries no DB/adapter/writer capability. */ +export function issueCandidateNativeAdmissionV2({ projectId, areaId, checkId, runId, generation, policyDigest, engineDigest, expiresAt } = {}) { + for (const [key, value] of Object.entries({ projectId, areaId, checkId, runId, generation, policyDigest, engineDigest, expiresAt })) if (typeof value !== 'string' || !value) fail('Candidate native admission is incomplete', 'CANDIDATE_NATIVE_ADMISSION_INVALID', { key }); + const admission = Object.freeze({ schema: 'torch.dev/candidate-native-admission/v2alpha1', projectId, areaId, checkId, runId, generation, policyDigest, engineDigest, expiresAt, nonce: randomBytes(32).toString('base64url') }); live.add(admission); return admission; +} +export function openCandidateSourceBootstrapV2(admission, { now = () => new Date() } = {}) { + if (!live.has(admission) || used.has(admission.nonce)) fail('Candidate native admission is unavailable or replayed', 'CANDIDATE_NATIVE_ADMISSION_REPLAY'); + if (Number.isNaN(Date.parse(admission.expiresAt)) || Date.parse(admission.expiresAt) <= now().getTime()) fail('Candidate native admission expired', 'CANDIDATE_NATIVE_ADMISSION_EXPIRED'); + used.add(admission.nonce); live.delete(admission); + return Object.freeze({ schema: 'torch.dev/candidate-bootstrap/v2alpha1', nonce: admission.nonce, runId: admission.runId, generation: admission.generation, policyDigest: admission.policyDigest, engineDigest: admission.engineDigest, expiresAt: admission.expiresAt, transport: Object.freeze({ inputFd: 3, acknowledgementFd: 4, closesBeforeCandidateSpawn: true }) }); +} diff --git a/src/checks/candidate-source-execution.mjs b/src/checks/candidate-source-execution.mjs new file mode 100644 index 0000000..30151d8 --- /dev/null +++ b/src/checks/candidate-source-execution.mjs @@ -0,0 +1,15 @@ +import { spawnSync } from 'node:child_process'; +import { isAbsolute } from 'node:path'; +import { TorchError } from '../kernel/errors.mjs'; +import { verifyCandidateInputSnapshotV2 } from './candidate-input-snapshot.mjs'; +const fail = (message, code, details) => { throw new TorchError(message, { code, details }); }; +export const CANDIDATE_EXECUTION_LIMITS_V2 = Object.freeze({ timeoutMs: 300000, maxOutputBytes: 1048576, maxArgs: 64, maxArgBytes: 4096 }); +function bound(value, limit, name) { if (typeof value !== 'string' || Buffer.byteLength(value, 'utf8') > limit) fail('Candidate execution value is invalid', 'CANDIDATE_EXECUTION_INVALID', { name }); return value; } +export function runCandidateSourceExecutionV2({ command, args = [], inputSnapshot, environment = {}, executor = spawnSync, limits = CANDIDATE_EXECUTION_LIMITS_V2 } = {}) { + if (!isAbsolute(command) || inputSnapshot?.schema !== 'torch.dev/candidate-input-snapshot/v2alpha1' || typeof executor !== 'function' || !Array.isArray(args) || args.length > limits.maxArgs || Object.keys(environment).some((key) => ['PATH', 'HOME', 'NODE_PATH', 'npm_config_cache'].includes(key))) fail('Candidate execution request is invalid', 'CANDIDATE_EXECUTION_INVALID'); + verifyCandidateInputSnapshotV2(inputSnapshot); + const safeArgs = args.map((arg) => bound(arg, limits.maxArgBytes, 'arg')); const env = Object.freeze(Object.fromEntries(Object.entries(environment).sort())); + let raw; try { raw = executor(command, safeArgs, { cwd: inputSnapshot.inputRoot, env, encoding: 'buffer', stdio: ['ignore', 'pipe', 'pipe'], timeout: limits.timeoutMs, maxBuffer: limits.maxOutputBytes, killSignal: 'SIGTERM' }); } catch (error) { raw = { status: null, error }; } + const stdout = Buffer.from(raw?.stdout ?? ''); const stderr = Buffer.from(raw?.stderr ?? ''); const overflow = stdout.length > limits.maxOutputBytes || stderr.length > limits.maxOutputBytes || raw?.error?.code === 'ENOBUFS'; const timeout = raw?.error?.code === 'ETIMEDOUT' || raw?.timedOut === true; + return Object.freeze({ schema: 'torch.dev/candidate-source-execution/v2alpha1', terminal: Object.freeze({ kind: timeout ? 'timeout' : overflow ? 'overflow' : raw?.signal ? 'signal' : raw?.error ? 'error' : 'exit', exitStatus: Number.isInteger(raw?.status) ? raw.status : null, signal: raw?.signal ?? null, overflow, timeout, success: !timeout && !overflow && !raw?.signal && !raw?.error && raw?.status === 0 }), output: Object.freeze({ stdout: stdout.subarray(0, limits.maxOutputBytes), stderr: stderr.subarray(0, limits.maxOutputBytes) }) }); +} diff --git a/src/checks/service.mjs b/src/checks/service.mjs index fcc3458..422200f 100644 --- a/src/checks/service.mjs +++ b/src/checks/service.mjs @@ -84,6 +84,19 @@ function captureGeneratedArtifacts({ generatedDirectory, commit, checkId, defini }; } +// B2 admission boundary only. Kernel owns policy parsing/registration and the parent; +// this service must never synthesize either from cwd, environment, or caller JSON. +function assertCandidateNativeAdmissionAvailable(definition) { + if (!Object.hasOwn(definition ?? {}, 'candidate_execution_policy')) return; + if (!definition.candidate_execution_policy) { + throw new TorchError('Candidate execution policy is not registered', { code: 'CANDIDATE_SNAPSHOT_POLICY_UNDECLARED' }); + } + // A registered policy alone is not the genuine parent issuer or receipt adapter. + throw new TorchError('Candidate native parent bootstrap is unavailable', { + code: 'CANDIDATE_PARENT_NATIVE_BOOTSTRAP_UNAVAILABLE', + }); +} + export class CheckService { constructor({ repositoryRoot, controlPlane, resourceService = null, @@ -330,6 +343,8 @@ export class CheckService { if (missing.length) throw new TorchError('Prepared check requires resource leases', { code: 'CHECK_BLOCKED', details: missing.map((resourceId) => ({ code: 'RESOURCE_LEASE_REQUIRED', resourceId })), }); + // Deliberately before snapshot verification, state mutation, artifacts, or child spawn. + assertCandidateNativeAdmissionAvailable(currentDefinition); verifyCheckSnapshot(item.snapshot, { stateRoot: this.stateRoot }); const claimed = this.controlPlane.database.prepare(`UPDATE prepared_checks SET state = 'running', runner_json = ? WHERE id = ? AND project_id = ? AND area_id = ? AND state = 'prepared'`) diff --git a/test/fleet/candidate-execution-fence.test.mjs b/test/fleet/candidate-execution-fence.test.mjs new file mode 100644 index 0000000..7b7590c --- /dev/null +++ b/test/fleet/candidate-execution-fence.test.mjs @@ -0,0 +1,5 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { createCandidateExecutionFenceV2, observeCandidateExecutionFenceV2 } from '../../src/checks/candidate-execution-fence.mjs'; +const bootstrap = { nonce: 'n', transport: { closesBeforeCandidateSpawn: true } }; +test('SCN-candidate-fence-closure: double-fork/escaped-descendant uncertainty is UNKNOWN, never terminal proof', () => { const fence = createCandidateExecutionFenceV2({ bootstrap }); const unknown = observeCandidateExecutionFenceV2({ fence, observation: { privatePid1: true, namespaceObserved: true, admissionFdsClosed: true, descendantsReaped: false } }); assert.equal(unknown.outcome, 'unknown'); assert.equal(unknown.terminalProof, false); const terminal = observeCandidateExecutionFenceV2({ fence, observation: { privatePid1: true, namespaceObserved: true, admissionFdsClosed: true, descendantsReaped: true } }); assert.equal(terminal.terminalProof, true); }); diff --git a/test/fleet/candidate-input-consumption.test.mjs b/test/fleet/candidate-input-consumption.test.mjs new file mode 100644 index 0000000..3aac9fb --- /dev/null +++ b/test/fleet/candidate-input-consumption.test.mjs @@ -0,0 +1,10 @@ +import assert from 'node:assert/strict'; +import { chmodSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import test from 'node:test'; +import { captureCandidateInputSnapshotV2, verifyCandidateInputSnapshotV2 } from '../../src/checks/candidate-input-snapshot.mjs'; +import { runCandidateSourceExecutionV2 } from '../../src/checks/candidate-source-execution.mjs'; +function roots() { const root = mkdtempSync(join(tmpdir(), 'torch-b2-source-')); mkdirSync(join(root, 'src')); writeFileSync(join(root, 'src', 'input.txt'), 'captured'); return { root, snapshot: `${root}-snapshot` }; } +test('SCN-candidate-input-consumption: child execution seam receives frozen cwd bytes after original source mutation', () => { const { root, snapshot: destination } = roots(); const input = captureCandidateInputSnapshotV2({ sourceRoot: root, destinationRoot: destination, paths: ['src'] }); writeFileSync(join(root, 'src', 'input.txt'), 'mutated'); const result = runCandidateSourceExecutionV2({ command: process.execPath, args: [], inputSnapshot: input, executor: (_command, _args, execution) => ({ status: 0, stdout: readFileSync(join(execution.cwd, 'src', 'input.txt')) }) }); assert.equal(result.terminal.success, true); assert.equal(result.output.stdout.toString(), 'captured'); assert.equal(verifyCandidateInputSnapshotV2(input).verified, true); }); +test('SCN-candidate-input-drift: frozen-input mutation refuses rather than relabeling evidence', () => { const { root, snapshot: destination } = roots(); const input = captureCandidateInputSnapshotV2({ sourceRoot: root, destinationRoot: destination, paths: ['src'] }); const file = join(input.inputRoot, 'src', 'input.txt'); chmodSync(file, 0o600); writeFileSync(file, 'drift'); assert.throws(() => verifyCandidateInputSnapshotV2(input), { code: 'CANDIDATE_SNAPSHOT_DRIFT' }); }); diff --git a/test/fleet/candidate-snapshot-policy.test.mjs b/test/fleet/candidate-snapshot-policy.test.mjs new file mode 100644 index 0000000..61c2352 --- /dev/null +++ b/test/fleet/candidate-snapshot-policy.test.mjs @@ -0,0 +1,7 @@ +import assert from 'node:assert/strict'; +import { mkdtempSync, symlinkSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import test from 'node:test'; +import { captureCandidateInputSnapshotV2 } from '../../src/checks/candidate-input-snapshot.mjs'; +test('SCN-candidate-snapshot-policy: product links and S caps refuse before a snapshot is admitted', () => { const root = mkdtempSync(join(tmpdir(), 'torch-b2-policy-')); writeFileSync(join(root, 'file'), 'x'); symlinkSync('file', join(root, 'link')); assert.throws(() => captureCandidateInputSnapshotV2({ sourceRoot: root, destinationRoot: `${root}-snapshot`, paths: ['link'] }), { code: 'CANDIDATE_SNAPSHOT_LINK_REJECTED' }); }); diff --git a/test/fleet/candidate-source-bootstrap.test.mjs b/test/fleet/candidate-source-bootstrap.test.mjs new file mode 100644 index 0000000..95116dc --- /dev/null +++ b/test/fleet/candidate-source-bootstrap.test.mjs @@ -0,0 +1,12 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { issueCandidateNativeAdmissionV2, openCandidateSourceBootstrapV2 } from '../../src/checks/candidate-source-bootstrap.mjs'; + +const issued = (expiresAt = '2030-01-01T00:00:00.000Z') => issueCandidateNativeAdmissionV2({ projectId: 'project', areaId: 'work-integration', checkId: 'test', runId: 'run', generation: 'g1', policyDigest: 'a'.repeat(64), engineDigest: 'b'.repeat(64), expiresAt }); +test('SCN-candidate-bootstrap-one-shot: only issued native admission opens once and supplies no authority handles', () => { + assert.throws(() => openCandidateSourceBootstrapV2({}), { code: 'CANDIDATE_NATIVE_ADMISSION_REPLAY' }); + const admission = issued(); const bootstrap = openCandidateSourceBootstrapV2(admission, { now: () => new Date('2029-01-01T00:00:00.000Z') }); + assert.equal(bootstrap.transport.closesBeforeCandidateSpawn, true); assert.deepEqual(Object.keys(bootstrap).sort(), ['engineDigest', 'expiresAt', 'generation', 'nonce', 'policyDigest', 'runId', 'schema', 'transport']); + assert.equal(Object.hasOwn(bootstrap, 'controlPlane'), false); assert.throws(() => openCandidateSourceBootstrapV2(admission, { now: () => new Date('2029-01-01T00:00:00.000Z') }), { code: 'CANDIDATE_NATIVE_ADMISSION_REPLAY' }); +}); +test('SCN-candidate-bootstrap-expiry: expired admission refuses before any child protocol exists', () => assert.throws(() => openCandidateSourceBootstrapV2(issued('2020-01-01T00:00:00.000Z')), { code: 'CANDIDATE_NATIVE_ADMISSION_EXPIRED' })); diff --git a/test/fleet/candidate-source-execution.test.mjs b/test/fleet/candidate-source-execution.test.mjs new file mode 100644 index 0000000..11b5f0e --- /dev/null +++ b/test/fleet/candidate-source-execution.test.mjs @@ -0,0 +1,9 @@ +import assert from 'node:assert/strict'; +import { mkdtempSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import test from 'node:test'; +import { captureCandidateInputSnapshotV2 } from '../../src/checks/candidate-input-snapshot.mjs'; +import { runCandidateSourceExecutionV2 } from '../../src/checks/candidate-source-execution.mjs'; +function snapshot() { const source = mkdtempSync(join(tmpdir(), 'torch-b2-execution-')); writeFileSync(join(source, 'input'), 'x'); return captureCandidateInputSnapshotV2({ sourceRoot: source, destinationRoot: `${source}-snapshot`, paths: ['input'] }); } +test('SCN-candidate-execution-boundary: ambient PATH/HOME fallback and output overflow cannot become success', () => { const frozen = snapshot(); assert.throws(() => runCandidateSourceExecutionV2({ command: process.execPath, inputSnapshot: frozen, environment: { PATH: '/bin' } }), { code: 'CANDIDATE_EXECUTION_INVALID' }); const observed = runCandidateSourceExecutionV2({ command: process.execPath, inputSnapshot: frozen, limits: { timeoutMs: 1000, maxOutputBytes: 4, maxArgs: 64, maxArgBytes: 4096 }, executor: () => ({ status: 0, stdout: Buffer.from('xxxxxxxx') }) }); assert.equal(observed.terminal.overflow, true); assert.equal(observed.terminal.success, false); }); diff --git a/test/fleet/candidate-source-parent.test.mjs b/test/fleet/candidate-source-parent.test.mjs new file mode 100644 index 0000000..b1c426b --- /dev/null +++ b/test/fleet/candidate-source-parent.test.mjs @@ -0,0 +1,8 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { issueCandidateNativeAdmissionV2, openCandidateSourceBootstrapV2 } from '../../src/checks/candidate-source-bootstrap.mjs'; +test('SCN-candidate-parent-capability: bootstrap frame carries only a bound tuple, never roots, env, writers, adapters, or receipt authority', () => { + const ticket = issueCandidateNativeAdmissionV2({ projectId: 'p', areaId: 'a', checkId: 'c', runId: 'r', generation: 'g', policyDigest: 'a'.repeat(64), engineDigest: 'b'.repeat(64), expiresAt: '2030-01-01T00:00:00.000Z' }); + const frame = openCandidateSourceBootstrapV2(ticket, { now: () => new Date('2029-01-01T00:00:00.000Z') }); + for (const forbidden of ['projectId', 'areaId', 'checkId', 'inputRoot', 'cwd', 'environment', 'writer', 'adapter', 'receipt']) assert.equal(Object.hasOwn(frame, forbidden), false); +}); From ea36a28d14ff332f24bc64b34ab94c79dac01514 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN Date: Thu, 1 Oct 2026 15:07:14 -0400 Subject: [PATCH 02/51] TASK-6e4ff870-a386-4a11-a59c-444ebc23732e: bound fence bootstrap reads --- src/checks/candidate-execution-fence-init.mjs | 29 +++++++++++++++++-- 1 file changed, 27 insertions(+), 2 deletions(-) diff --git a/src/checks/candidate-execution-fence-init.mjs b/src/checks/candidate-execution-fence-init.mjs index bbadb03..cef9072 100644 --- a/src/checks/candidate-execution-fence-init.mjs +++ b/src/checks/candidate-execution-fence-init.mjs @@ -1,3 +1,28 @@ // Private init entrypoint. It is never a CLI surface: bootstrap is supplied only on inherited FD3 and acknowledgement only on FD4. -import { closeSync, readFileSync, writeFileSync } from 'node:fs'; -export function readPrivateFenceBootstrapV2() { const frame = readFileSync(3, 'utf8'); if (Buffer.byteLength(frame, 'utf8') > 65_536) throw new Error('CANDIDATE_FENCE_FRAME_TOO_LARGE'); const value = JSON.parse(frame); if (!value?.nonce || !value?.generation) throw new Error('CANDIDATE_FENCE_FRAME_INVALID'); writeFileSync(4, JSON.stringify({ nonce: value.nonce, generation: value.generation })); closeSync(3); closeSync(4); return Object.freeze({ nonce: value.nonce, generation: value.generation, admissionFdsClosed: true }); } +import { closeSync, readSync, writeFileSync } from 'node:fs'; + +const MAX_BOOTSTRAP_FRAME_BYTES = 65_536; + +function readBoundedFrame(fd) { + const chunks = []; + let total = 0; + const buffer = Buffer.allocUnsafe(8_192); + while (true) { + const bytesRead = readSync(fd, buffer, 0, buffer.length, null); + if (bytesRead === 0) break; + total += bytesRead; + if (total > MAX_BOOTSTRAP_FRAME_BYTES) throw new Error('CANDIDATE_FENCE_FRAME_TOO_LARGE'); + chunks.push(Buffer.from(buffer.subarray(0, bytesRead))); + } + return Buffer.concat(chunks, total).toString('utf8'); +} + +export function readPrivateFenceBootstrapV2() { + const frame = readBoundedFrame(3); + const value = JSON.parse(frame); + if (!value?.nonce || !value?.generation) throw new Error('CANDIDATE_FENCE_FRAME_INVALID'); + writeFileSync(4, JSON.stringify({ nonce: value.nonce, generation: value.generation })); + closeSync(3); + closeSync(4); + return Object.freeze({ nonce: value.nonce, generation: value.generation, admissionFdsClosed: true }); +} From 0665bb305b10efd9e7242eed2484ecf97646a0f3 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN Date: Thu, 1 Oct 2026 15:32:38 -0400 Subject: [PATCH 03/51] TASK-6e4ff870-a386-4a11-a59c-444ebc23732e: correct candidate source boundaries --- docs/TEST_INTEGRITY.md | 41 +++++++++ src/checks/candidate-execution-fence.mjs | 37 ++++++-- src/checks/candidate-input-snapshot.mjs | 31 ++++++- src/checks/candidate-source-bootstrap.mjs | 89 ++++++++++++++++--- src/checks/candidate-source-execution.mjs | 68 +++++++++++--- test/fleet/candidate-execution-fence.test.mjs | 14 ++- .../candidate-input-consumption.test.mjs | 30 +++++-- test/fleet/candidate-snapshot-policy.test.mjs | 11 ++- .../fleet/candidate-source-bootstrap.test.mjs | 32 +++++-- .../fleet/candidate-source-execution.test.mjs | 17 ++-- test/fleet/candidate-source-parent.test.mjs | 16 ++-- 11 files changed, 327 insertions(+), 59 deletions(-) diff --git a/docs/TEST_INTEGRITY.md b/docs/TEST_INTEGRITY.md index bed22bc..74b4b62 100644 --- a/docs/TEST_INTEGRITY.md +++ b/docs/TEST_INTEGRITY.md @@ -2754,3 +2754,44 @@ test_integrity_note: did_relax_any_assertion: false if_true_explain_spec_basis: "" ``` + +# Candidate source B2 authenticated-attempt spec correction (2026-10-01) + +```yaml +test_integrity_note: + change_type: spec_correction + scenarios: + - id: SCN-candidate-bootstrap-one-shot + given: "A CheckService-issued opaque registered-plan handle" + when: "A foreign, cloned, expired, replayed, or already-closed handle is used" + then: "No FD3 admission opens; the only accepted acknowledgement is the exact FD4 binding frame and it closes the original linear handle" + - id: SCN-candidate-input-consumption + given: "A frozen, bounded product snapshot and a fixed Node E binding" + when: "The original product bytes change before execution" + then: "An actual spawned child reads the frozen bytes; post-capture drift refuses before spawn" + - id: SCN-candidate-execution-boundary + given: "An opaque attempt with fixed command, arguments, explicit environment, and output cap" + when: "The child exits zero after exceeding the cap" + then: "The parent records overflow as non-success without accepting an injected executor or caller-selected limit" + - id: SCN-candidate-fence-closure + given: "A closed opaque attempt" + when: "The backend has not recorded namespace identity, FD closure, and descendant reaping" + then: "Terminal proof is UNKNOWN; fabricated observations and cloned handles cannot create proof" + observable_outcomes: + - "Only the exact FD4 acknowledgement closes a one-shot opaque admission" + - "Child stdout proves frozen-byte consumption, not same-process test reads" + - "Zero exit plus output overflow is never success" + - "Missing backend fence evidence is never TerminalProof" + determinism_controls: + - "Temporary source trees, fixed absolute Node runtime, explicit empty child environment, bounded streams" + - "No injected executor, caller-provided command, root, environment, limit, or terminal observation" + anti_cheat_rationale: + prevents: + - "Arbitrary tuple issuance or a fabricated bootstrap literal" + - "Boolean caller observations minting terminal proof" + - "Cloned or reused handles reopening an admission" + - "Same-process snapshot reads impersonating child consumption" + relaxation: + did_relax_any_assertion: false + if_true_explain_spec_basis: "" +``` diff --git a/src/checks/candidate-execution-fence.mjs b/src/checks/candidate-execution-fence.mjs index 4990a2f..6bd72ab 100644 --- a/src/checks/candidate-execution-fence.mjs +++ b/src/checks/candidate-execution-fence.mjs @@ -1,6 +1,33 @@ import { TorchError } from '../kernel/errors.mjs'; -const fail = (message, code, details) => { throw new TorchError(message, { code, details }); }; -export function createCandidateExecutionFenceV2({ bootstrap, backend = 'private-pid1-namespace' } = {}) { if (!bootstrap?.transport?.closesBeforeCandidateSpawn || backend !== 'private-pid1-namespace') fail('Candidate fence has no supported private bootstrap', 'CANDIDATE_FENCE_UNAVAILABLE'); return Object.freeze({ schema: 'torch.dev/candidate-execution-fence/v2alpha1', backend, nonce: bootstrap.nonce, closed: false }); } -/** A direct exit or empty PGID is deliberately insufficient: missing observed PID1/reap proof is UNKNOWN. */ -export function observeCandidateExecutionFenceV2({ fence, observation = {} } = {}) { if (!fence || fence.closed) fail('Candidate fence is unavailable', 'CANDIDATE_FENCE_UNAVAILABLE'); const closed = observation.privatePid1 === true && observation.namespaceObserved === true && observation.descendantsReaped === true && observation.admissionFdsClosed === true; return Object.freeze({ schema: 'torch.dev/candidate-terminal-proof/v2alpha1', outcome: closed ? 'terminal' : 'unknown', terminalProof: closed, reason: closed ? null : 'descendant-closure-unobserved', observation: Object.freeze({ privatePid1: observation.privatePid1 === true, namespaceObserved: observation.namespaceObserved === true, descendantsReaped: observation.descendantsReaped === true, admissionFdsClosed: observation.admissionFdsClosed === true }) }); } -export function closeCandidateExecutionFenceV2(fence) { if (!fence || fence.closed) fail('Candidate fence is already closed', 'CANDIDATE_FENCE_UNAVAILABLE'); return Object.freeze({ ...fence, closed: true }); } + +function fail(message, code, details) { + throw new TorchError(message, { code, details }); +} + +/** + * Fence observations are owned by the backend, never submitted by the caller. + * Until the backend has recorded a PID1 namespace identity, FD closure, and + * namespace-wide reaping, every attempt is UNKNOWN. A process-group exit never + * creates TerminalProof. + */ +export function createCandidateExecutionFenceRuntimeV2({ stateForAttempt, backendObservation } = {}) { + if (typeof stateForAttempt !== 'function' || typeof backendObservation !== 'function') throw new TypeError('candidate fence runtime is incomplete'); + const closed = new WeakSet(); + function close(attempt) { + if (!stateForAttempt(attempt) || closed.has(attempt)) fail('Candidate fence is unavailable', 'CANDIDATE_FENCE_UNAVAILABLE'); + closed.add(attempt); + } + function terminal(attempt) { + if (!stateForAttempt(attempt) || !closed.has(attempt)) fail('Candidate fence is unavailable', 'CANDIDATE_FENCE_UNAVAILABLE'); + const observed = backendObservation(attempt); + const qualified = observed?.backend === 'private-pid1-namespace' + && Number.isSafeInteger(observed.namespacePid) && observed.namespacePid > 0 + && typeof observed.namespaceIdentity === 'string' && observed.namespaceIdentity.length > 0 + && observed.admissionFdsClosed === true && observed.descendantsReaped === true && observed.escapedDescendants === false; + return Object.freeze({ + schema: 'torch.dev/candidate-terminal-proof/v2alpha1', outcome: qualified ? 'terminal' : 'unknown', terminalProof: qualified, + reason: qualified ? null : 'descendant-closure-unobserved', + }); + } + return Object.freeze({ close, terminal }); +} diff --git a/src/checks/candidate-input-snapshot.mjs b/src/checks/candidate-input-snapshot.mjs index 20f2fe8..e5dd562 100644 --- a/src/checks/candidate-input-snapshot.mjs +++ b/src/checks/candidate-input-snapshot.mjs @@ -49,7 +49,7 @@ function hashRegularFile(path, expected, limits) { } finally { if (fd !== undefined) closeSync(fd); } } /** Private caller seam: callers obtain inputRoot from the returned opaque snapshot, never from a policy selector. */ -export function captureCandidateInputSnapshotV2({ sourceRoot, destinationRoot, paths, limits: requestedLimits = {}, id = randomUUID() } = {}) { +function captureCandidateInputSnapshotV2({ sourceRoot, destinationRoot, paths, limits: requestedLimits = {}, id = randomUUID() } = {}) { const limits = normalizeLimits(requestedLimits); if (typeof sourceRoot !== 'string' || typeof destinationRoot !== 'string' || !Array.isArray(paths)) fail('Candidate snapshot request is invalid', 'CANDIDATE_SNAPSHOT_INPUT_INVALID'); const source = resolve(sourceRoot); const destination = resolve(destinationRoot); if (source === destination || !relative(source, destination).startsWith('..')) fail('Candidate snapshot destination must be outside source', 'CANDIDATE_SNAPSHOT_INPUT_INVALID'); const listed = files(source, paths, limits); mkdirSync(destination, { recursive: false, mode: 0o700 }); let totalBytes = 0; const records = []; @@ -57,7 +57,7 @@ export function captureCandidateInputSnapshotV2({ sourceRoot, destinationRoot, p const manifest = Object.freeze({ schema: 'torch.dev/candidate-input-manifest/v2alpha1', id, files: Object.freeze(records), totalBytes, digest: createHash('sha256').update(JSON.stringify(records)).digest('hex') }); return Object.freeze({ schema: 'torch.dev/candidate-input-snapshot/v2alpha1', inputRoot: destination, manifest, limits }); } -export function verifyCandidateInputSnapshotV2(snapshot = {}) { +function verifyCandidateInputSnapshotV2(snapshot = {}) { if (snapshot?.schema !== 'torch.dev/candidate-input-snapshot/v2alpha1' || !snapshot.manifest?.files || typeof snapshot.inputRoot !== 'string') fail('Candidate snapshot is malformed', 'CANDIDATE_SNAPSHOT_INVALID'); const listed = files(snapshot.inputRoot, snapshot.manifest.files.map((file) => file.path), snapshot.limits); const observed = listed.map((file) => hashRegularFile(join(snapshot.inputRoot, file.path), { ...file, bytes: file.size }, snapshot.limits)); @@ -67,3 +67,30 @@ export function verifyCandidateInputSnapshotV2(snapshot = {}) { } return Object.freeze({ verified: true, digest: snapshot.manifest.digest, files: observed.length, totalBytes: snapshot.manifest.totalBytes }); } + +/** + * The public boundary accepts only a service-issued attempt. Source roots, + * paths, destinations, and S limits live in the service's private state. + */ +export function createCandidateInputSnapshotRuntimeV2({ stateForAttempt } = {}) { + if (typeof stateForAttempt !== 'function') throw new TypeError('stateForAttempt is required'); + const snapshots = new WeakMap(); + function capture(attempt) { + const state = stateForAttempt(attempt); + if (!state?.input) fail('Candidate attempt has no frozen input authority', 'CANDIDATE_SNAPSHOT_ADMISSION_REQUIRED'); + const snapshot = captureCandidateInputSnapshotV2(state.input); + snapshots.set(attempt, snapshot); + return Object.freeze({ digest: snapshot.manifest.digest, files: snapshot.manifest.files.length, totalBytes: snapshot.manifest.totalBytes }); + } + function verify(attempt) { + const snapshot = snapshots.get(attempt); + if (!snapshot) fail('Candidate snapshot is unavailable', 'CANDIDATE_SNAPSHOT_ADMISSION_REQUIRED'); + return verifyCandidateInputSnapshotV2(snapshot); + } + function cwd(attempt) { + const snapshot = snapshots.get(attempt); + if (!snapshot) fail('Candidate snapshot is unavailable', 'CANDIDATE_SNAPSHOT_ADMISSION_REQUIRED'); + return snapshot.inputRoot; + } + return Object.freeze({ capture, verify, cwd }); +} diff --git a/src/checks/candidate-source-bootstrap.mjs b/src/checks/candidate-source-bootstrap.mjs index ccf0af2..02c5c64 100644 --- a/src/checks/candidate-source-bootstrap.mjs +++ b/src/checks/candidate-source-bootstrap.mjs @@ -1,16 +1,81 @@ -import { randomBytes } from 'node:crypto'; +import { createHash, randomBytes } from 'node:crypto'; import { TorchError } from '../kernel/errors.mjs'; -const live = new WeakSet(); const used = new Set(); -const fail = (message, code, details) => { throw new TorchError(message, { code, details }); }; -/** CheckService-only issuer. The returned object is intentionally not serializable and carries no DB/adapter/writer capability. */ -export function issueCandidateNativeAdmissionV2({ projectId, areaId, checkId, runId, generation, policyDigest, engineDigest, expiresAt } = {}) { - for (const [key, value] of Object.entries({ projectId, areaId, checkId, runId, generation, policyDigest, engineDigest, expiresAt })) if (typeof value !== 'string' || !value) fail('Candidate native admission is incomplete', 'CANDIDATE_NATIVE_ADMISSION_INVALID', { key }); - const admission = Object.freeze({ schema: 'torch.dev/candidate-native-admission/v2alpha1', projectId, areaId, checkId, runId, generation, policyDigest, engineDigest, expiresAt, nonce: randomBytes(32).toString('base64url') }); live.add(admission); return admission; +const ADMISSION_SCHEMA = 'torch.dev/candidate-source-parent-admission/v2alpha1'; +const ACK_SCHEMA = 'torch.dev/candidate-source-parent-binding/v2alpha1'; +const SHA256 = /^[a-f0-9]{64}$/; +const MAX_FRAME_BYTES = 64 * 1024; + +function fail(message, code, details) { + throw new TorchError(message, { code, details }); } -export function openCandidateSourceBootstrapV2(admission, { now = () => new Date() } = {}) { - if (!live.has(admission) || used.has(admission.nonce)) fail('Candidate native admission is unavailable or replayed', 'CANDIDATE_NATIVE_ADMISSION_REPLAY'); - if (Number.isNaN(Date.parse(admission.expiresAt)) || Date.parse(admission.expiresAt) <= now().getTime()) fail('Candidate native admission expired', 'CANDIDATE_NATIVE_ADMISSION_EXPIRED'); - used.add(admission.nonce); live.delete(admission); - return Object.freeze({ schema: 'torch.dev/candidate-bootstrap/v2alpha1', nonce: admission.nonce, runId: admission.runId, generation: admission.generation, policyDigest: admission.policyDigest, engineDigest: admission.engineDigest, expiresAt: admission.expiresAt, transport: Object.freeze({ inputFd: 3, acknowledgementFd: 4, closesBeforeCandidateSpawn: true }) }); + +function digest(bytes) { + return createHash('sha256').update(bytes).digest('hex'); +} + +function validString(value) { + return typeof value === 'string' && value.length > 0; +} + +/** + * Service-only closure factory. It has no public issuer that accepts caller + * project/check/run tuples: CheckService supplies an opaque registered-plan + * handle to stateForPlan and this module serializes only that captured state. + */ +export function createCandidateSourceBootstrapRuntimeV2({ stateForPlan, now = () => new Date() } = {}) { + if (typeof stateForPlan !== 'function') throw new TypeError('stateForPlan is required'); + const attempts = new WeakMap(); + + function create(plan) { + const state = stateForPlan(plan); + if (!state || typeof state !== 'object') fail('Registered candidate plan is unavailable', 'CANDIDATE_NATIVE_ADMISSION_REQUIRED'); + const required = ['admissionId', 'leaseId', 'checkId', 'policyBytes', 'policyDigest', 'engine', 'subject', 'artifactManifest', 'expiresAt']; + for (const key of required) if (!Object.hasOwn(state, key)) fail('Registered candidate plan is incomplete', 'CANDIDATE_NATIVE_ADMISSION_INVALID', { key }); + if (!Buffer.isBuffer(state.policyBytes) || state.policyBytes.length === 0 || state.policyBytes.length > 1024 * 1024 + || !SHA256.test(state.policyDigest) || digest(state.policyBytes) !== state.policyDigest + || !validString(state.admissionId) || !validString(state.leaseId) || !validString(state.checkId) + || !Number.isSafeInteger(state.runGeneration) || !Number.isSafeInteger(state.guardGeneration) + || Number.isNaN(Date.parse(state.expiresAt))) fail('Registered candidate plan is invalid', 'CANDIDATE_NATIVE_ADMISSION_INVALID'); + const handle = Object.freeze({}); + attempts.set(handle, { state, used: false, closed: false, nonce: randomBytes(32).toString('base64url') }); + return handle; + } + + function open(handle) { + const attempt = attempts.get(handle); + if (!attempt || attempt.used || attempt.closed) fail('Candidate admission is unavailable or replayed', 'CANDIDATE_NATIVE_ADMISSION_REPLAY'); + if (Date.parse(attempt.state.expiresAt) <= now().getTime()) { + attempt.closed = true; + fail('Candidate admission expired', 'CANDIDATE_NATIVE_ADMISSION_EXPIRED'); + } + attempt.used = true; + const { state } = attempt; + const frame = Buffer.from(JSON.stringify({ + schema: ADMISSION_SCHEMA, version: 2, admissionId: state.admissionId, leaseId: state.leaseId, + checkId: state.checkId, runGeneration: state.runGeneration, guardGeneration: state.guardGeneration, + policyBytesBase64: state.policyBytes.toString('base64'), policyDigest: state.policyDigest, + engine: state.engine, subject: state.subject, artifactManifest: state.artifactManifest, + receiptAdapterAdmission: null, + }), 'utf8'); + if (frame.length > MAX_FRAME_BYTES) fail('Candidate parent admission frame exceeds its bound', 'CANDIDATE_NATIVE_ADMISSION_INVALID'); + return Object.freeze({ frame, acknowledgement: Buffer.from(`${ACK_SCHEMA}\n${state.admissionId}\n${state.policyDigest}\n`, 'utf8'), nonce: attempt.nonce }); + } + + function acknowledgeAndClose(handle, acknowledgement) { + const attempt = attempts.get(handle); + if (!attempt || !attempt.used || attempt.closed || !Buffer.isBuffer(acknowledgement)) fail('Candidate acknowledgement is unavailable', 'CANDIDATE_NATIVE_ADMISSION_REPLAY'); + const expected = Buffer.from(`${ACK_SCHEMA}\n${attempt.state.admissionId}\n${attempt.state.policyDigest}\n`, 'utf8'); + if (!acknowledgement.equals(expected)) fail('Candidate parent acknowledgement mismatches admission', 'CANDIDATE_PARENT_ACK_MISMATCH'); + attempt.closed = true; + return Object.freeze({ admissionFdsClosed: true, receiptEligible: false }); + } + + function cancel(handle) { + const attempt = attempts.get(handle); + if (!attempt || attempt.used || attempt.closed) fail('Candidate admission is unavailable', 'CANDIDATE_NATIVE_ADMISSION_REPLAY'); + attempt.closed = true; + } + + return Object.freeze({ create, open, acknowledgeAndClose, cancel }); } diff --git a/src/checks/candidate-source-execution.mjs b/src/checks/candidate-source-execution.mjs index 30151d8..5f52511 100644 --- a/src/checks/candidate-source-execution.mjs +++ b/src/checks/candidate-source-execution.mjs @@ -1,15 +1,61 @@ import { spawnSync } from 'node:child_process'; -import { isAbsolute } from 'node:path'; import { TorchError } from '../kernel/errors.mjs'; -import { verifyCandidateInputSnapshotV2 } from './candidate-input-snapshot.mjs'; -const fail = (message, code, details) => { throw new TorchError(message, { code, details }); }; + export const CANDIDATE_EXECUTION_LIMITS_V2 = Object.freeze({ timeoutMs: 300000, maxOutputBytes: 1048576, maxArgs: 64, maxArgBytes: 4096 }); -function bound(value, limit, name) { if (typeof value !== 'string' || Buffer.byteLength(value, 'utf8') > limit) fail('Candidate execution value is invalid', 'CANDIDATE_EXECUTION_INVALID', { name }); return value; } -export function runCandidateSourceExecutionV2({ command, args = [], inputSnapshot, environment = {}, executor = spawnSync, limits = CANDIDATE_EXECUTION_LIMITS_V2 } = {}) { - if (!isAbsolute(command) || inputSnapshot?.schema !== 'torch.dev/candidate-input-snapshot/v2alpha1' || typeof executor !== 'function' || !Array.isArray(args) || args.length > limits.maxArgs || Object.keys(environment).some((key) => ['PATH', 'HOME', 'NODE_PATH', 'npm_config_cache'].includes(key))) fail('Candidate execution request is invalid', 'CANDIDATE_EXECUTION_INVALID'); - verifyCandidateInputSnapshotV2(inputSnapshot); - const safeArgs = args.map((arg) => bound(arg, limits.maxArgBytes, 'arg')); const env = Object.freeze(Object.fromEntries(Object.entries(environment).sort())); - let raw; try { raw = executor(command, safeArgs, { cwd: inputSnapshot.inputRoot, env, encoding: 'buffer', stdio: ['ignore', 'pipe', 'pipe'], timeout: limits.timeoutMs, maxBuffer: limits.maxOutputBytes, killSignal: 'SIGTERM' }); } catch (error) { raw = { status: null, error }; } - const stdout = Buffer.from(raw?.stdout ?? ''); const stderr = Buffer.from(raw?.stderr ?? ''); const overflow = stdout.length > limits.maxOutputBytes || stderr.length > limits.maxOutputBytes || raw?.error?.code === 'ENOBUFS'; const timeout = raw?.error?.code === 'ETIMEDOUT' || raw?.timedOut === true; - return Object.freeze({ schema: 'torch.dev/candidate-source-execution/v2alpha1', terminal: Object.freeze({ kind: timeout ? 'timeout' : overflow ? 'overflow' : raw?.signal ? 'signal' : raw?.error ? 'error' : 'exit', exitStatus: Number.isInteger(raw?.status) ? raw.status : null, signal: raw?.signal ?? null, overflow, timeout, success: !timeout && !overflow && !raw?.signal && !raw?.error && raw?.status === 0 }), output: Object.freeze({ stdout: stdout.subarray(0, limits.maxOutputBytes), stderr: stderr.subarray(0, limits.maxOutputBytes) }) }); + +function fail(message, code, details) { + throw new TorchError(message, { code, details }); +} + +function validRuntime(state) { + const runtime = state?.runtime; + if (!runtime || typeof runtime.command !== 'string' || !runtime.command.startsWith('/') || !Array.isArray(runtime.args) + || !runtime.args.every((arg) => typeof arg === 'string' && Buffer.byteLength(arg, 'utf8') <= CANDIDATE_EXECUTION_LIMITS_V2.maxArgBytes) + || runtime.args.length > CANDIDATE_EXECUTION_LIMITS_V2.maxArgs + || !runtime.environment || Object.keys(runtime.environment).some((key) => ['PATH', 'HOME', 'NODE_PATH', 'npm_config_cache'].includes(key))) { + fail('Candidate fixed runtime is invalid', 'CANDIDATE_EXECUTION_RUNTIME_INVALID'); + } + return runtime; +} + +/** + * Runs a fixed, CheckService-derived E binding. `run` takes only an opaque + * attempt; command, arguments, environment, executor, cwd and limits cannot + * be selected by callers. It is raw terminal evidence, never a receipt PASS. + */ +export function createCandidateSourceExecutionRuntimeV2({ stateForAttempt, snapshotRuntime } = {}) { + if (typeof stateForAttempt !== 'function' || !snapshotRuntime) throw new TypeError('candidate execution runtime is incomplete'); + const results = new WeakMap(); + function run(attempt) { + const state = stateForAttempt(attempt); + const runtime = validRuntime(state); + snapshotRuntime.verify(attempt); + const cwd = snapshotRuntime.cwd(attempt); + let raw; + try { + raw = spawnSync(runtime.command, runtime.args, { + cwd, env: Object.freeze({ ...runtime.environment }), encoding: 'buffer', + stdio: ['ignore', 'pipe', 'pipe'], timeout: CANDIDATE_EXECUTION_LIMITS_V2.timeoutMs, + maxBuffer: CANDIDATE_EXECUTION_LIMITS_V2.maxOutputBytes, killSignal: 'SIGTERM', + }); + } catch (error) { raw = { status: null, error }; } + const stdout = Buffer.from(raw?.stdout ?? ''); + const stderr = Buffer.from(raw?.stderr ?? ''); + const overflow = stdout.length > CANDIDATE_EXECUTION_LIMITS_V2.maxOutputBytes || stderr.length > CANDIDATE_EXECUTION_LIMITS_V2.maxOutputBytes || raw?.error?.code === 'ENOBUFS'; + const timeout = raw?.error?.code === 'ETIMEDOUT' || raw?.timedOut === true; + const observation = Object.freeze({ + schema: 'torch.dev/candidate-source-execution/v2alpha1', + terminal: Object.freeze({ kind: timeout ? 'timeout' : overflow ? 'overflow' : raw?.signal ? 'signal' : raw?.error ? 'error' : 'exit', exitStatus: Number.isInteger(raw?.status) ? raw.status : null, signal: raw?.signal ?? null, overflow, timeout, success: !timeout && !overflow && !raw?.signal && !raw?.error && raw?.status === 0 }), + output: Object.freeze({ stdout: stdout.subarray(0, CANDIDATE_EXECUTION_LIMITS_V2.maxOutputBytes), stderr: stderr.subarray(0, CANDIDATE_EXECUTION_LIMITS_V2.maxOutputBytes) }), + }); + snapshotRuntime.verify(attempt); + results.set(attempt, observation); + return observation; + } + function observation(attempt) { + const value = results.get(attempt); + if (!value) fail('Candidate execution has not run', 'CANDIDATE_EXECUTION_UNAVAILABLE'); + return value; + } + return Object.freeze({ run, observation }); } diff --git a/test/fleet/candidate-execution-fence.test.mjs b/test/fleet/candidate-execution-fence.test.mjs index 7b7590c..00d6ad1 100644 --- a/test/fleet/candidate-execution-fence.test.mjs +++ b/test/fleet/candidate-execution-fence.test.mjs @@ -1,5 +1,13 @@ import assert from 'node:assert/strict'; import test from 'node:test'; -import { createCandidateExecutionFenceV2, observeCandidateExecutionFenceV2 } from '../../src/checks/candidate-execution-fence.mjs'; -const bootstrap = { nonce: 'n', transport: { closesBeforeCandidateSpawn: true } }; -test('SCN-candidate-fence-closure: double-fork/escaped-descendant uncertainty is UNKNOWN, never terminal proof', () => { const fence = createCandidateExecutionFenceV2({ bootstrap }); const unknown = observeCandidateExecutionFenceV2({ fence, observation: { privatePid1: true, namespaceObserved: true, admissionFdsClosed: true, descendantsReaped: false } }); assert.equal(unknown.outcome, 'unknown'); assert.equal(unknown.terminalProof, false); const terminal = observeCandidateExecutionFenceV2({ fence, observation: { privatePid1: true, namespaceObserved: true, admissionFdsClosed: true, descendantsReaped: true } }); assert.equal(terminal.terminalProof, true); }); +import { createCandidateExecutionFenceRuntimeV2 } from '../../src/checks/candidate-execution-fence.mjs'; + +test('SCN-candidate-fence-closure: a closed opaque attempt without backend namespace/reaping evidence is UNKNOWN and clones cannot be reused', () => { + const states = new WeakMap(); const attempt = Object.freeze({}); states.set(attempt, { authenticated: true }); + const fence = createCandidateExecutionFenceRuntimeV2({ stateForAttempt: (value) => states.get(value), backendObservation: () => null }); + assert.throws(() => fence.close({}), { code: 'CANDIDATE_FENCE_UNAVAILABLE' }); + fence.close(attempt); const terminal = fence.terminal(attempt); + assert.equal(terminal.outcome, 'unknown'); assert.equal(terminal.terminalProof, false); + assert.throws(() => fence.terminal({ ...attempt }), { code: 'CANDIDATE_FENCE_UNAVAILABLE' }); + assert.throws(() => fence.close(attempt), { code: 'CANDIDATE_FENCE_UNAVAILABLE' }); +}); diff --git a/test/fleet/candidate-input-consumption.test.mjs b/test/fleet/candidate-input-consumption.test.mjs index 3aac9fb..6ab8ca0 100644 --- a/test/fleet/candidate-input-consumption.test.mjs +++ b/test/fleet/candidate-input-consumption.test.mjs @@ -1,10 +1,28 @@ import assert from 'node:assert/strict'; -import { chmodSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs'; +import { chmodSync, mkdirSync, mkdtempSync, realpathSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import test from 'node:test'; -import { captureCandidateInputSnapshotV2, verifyCandidateInputSnapshotV2 } from '../../src/checks/candidate-input-snapshot.mjs'; -import { runCandidateSourceExecutionV2 } from '../../src/checks/candidate-source-execution.mjs'; -function roots() { const root = mkdtempSync(join(tmpdir(), 'torch-b2-source-')); mkdirSync(join(root, 'src')); writeFileSync(join(root, 'src', 'input.txt'), 'captured'); return { root, snapshot: `${root}-snapshot` }; } -test('SCN-candidate-input-consumption: child execution seam receives frozen cwd bytes after original source mutation', () => { const { root, snapshot: destination } = roots(); const input = captureCandidateInputSnapshotV2({ sourceRoot: root, destinationRoot: destination, paths: ['src'] }); writeFileSync(join(root, 'src', 'input.txt'), 'mutated'); const result = runCandidateSourceExecutionV2({ command: process.execPath, args: [], inputSnapshot: input, executor: (_command, _args, execution) => ({ status: 0, stdout: readFileSync(join(execution.cwd, 'src', 'input.txt')) }) }); assert.equal(result.terminal.success, true); assert.equal(result.output.stdout.toString(), 'captured'); assert.equal(verifyCandidateInputSnapshotV2(input).verified, true); }); -test('SCN-candidate-input-drift: frozen-input mutation refuses rather than relabeling evidence', () => { const { root, snapshot: destination } = roots(); const input = captureCandidateInputSnapshotV2({ sourceRoot: root, destinationRoot: destination, paths: ['src'] }); const file = join(input.inputRoot, 'src', 'input.txt'); chmodSync(file, 0o600); writeFileSync(file, 'drift'); assert.throws(() => verifyCandidateInputSnapshotV2(input), { code: 'CANDIDATE_SNAPSHOT_DRIFT' }); }); +import { createCandidateInputSnapshotRuntimeV2 } from '../../src/checks/candidate-input-snapshot.mjs'; +import { createCandidateSourceExecutionRuntimeV2 } from '../../src/checks/candidate-source-execution.mjs'; + +function attemptWithInput() { + const root = mkdtempSync(join(tmpdir(), 'torch-b2-source-')); mkdirSync(join(root, 'src')); writeFileSync(join(root, 'src', 'input.txt'), 'captured'); + const states = new WeakMap(); const attempt = Object.freeze({}); + states.set(attempt, { input: { sourceRoot: root, destinationRoot: `${root}-snapshot`, paths: ['src'] }, runtime: { command: realpathSync(process.execPath), args: ['-e', 'process.stdout.write(require("node:fs").readFileSync("src/input.txt"))'], environment: {} } }); + return { root, attempt, states }; +} + +test('SCN-candidate-input-consumption: an actual fixed Node child reads frozen bytes after the original source mutates', () => { + const { root, attempt, states } = attemptWithInput(); + const snapshots = createCandidateInputSnapshotRuntimeV2({ stateForAttempt: (value) => states.get(value) }); + snapshots.capture(attempt); writeFileSync(join(root, 'src', 'input.txt'), 'mutated'); + const execution = createCandidateSourceExecutionRuntimeV2({ stateForAttempt: (value) => states.get(value), snapshotRuntime: snapshots }).run(attempt); + assert.equal(execution.terminal.success, true); assert.equal(execution.output.stdout.toString(), 'captured'); +}); + +test('SCN-candidate-input-drift: a post-capture mutation refuses before a real child can be launched', () => { + const { attempt, states } = attemptWithInput(); const snapshots = createCandidateInputSnapshotRuntimeV2({ stateForAttempt: (value) => states.get(value) }); + snapshots.capture(attempt); const file = join(snapshots.cwd(attempt), 'src', 'input.txt'); chmodSync(file, 0o600); writeFileSync(file, 'drift'); + assert.throws(() => createCandidateSourceExecutionRuntimeV2({ stateForAttempt: (value) => states.get(value), snapshotRuntime: snapshots }).run(attempt), { code: 'CANDIDATE_SNAPSHOT_DRIFT' }); +}); diff --git a/test/fleet/candidate-snapshot-policy.test.mjs b/test/fleet/candidate-snapshot-policy.test.mjs index 61c2352..19a8887 100644 --- a/test/fleet/candidate-snapshot-policy.test.mjs +++ b/test/fleet/candidate-snapshot-policy.test.mjs @@ -3,5 +3,12 @@ import { mkdtempSync, symlinkSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import test from 'node:test'; -import { captureCandidateInputSnapshotV2 } from '../../src/checks/candidate-input-snapshot.mjs'; -test('SCN-candidate-snapshot-policy: product links and S caps refuse before a snapshot is admitted', () => { const root = mkdtempSync(join(tmpdir(), 'torch-b2-policy-')); writeFileSync(join(root, 'file'), 'x'); symlinkSync('file', join(root, 'link')); assert.throws(() => captureCandidateInputSnapshotV2({ sourceRoot: root, destinationRoot: `${root}-snapshot`, paths: ['link'] }), { code: 'CANDIDATE_SNAPSHOT_LINK_REJECTED' }); }); +import { createCandidateInputSnapshotRuntimeV2 } from '../../src/checks/candidate-input-snapshot.mjs'; + +test('SCN-candidate-snapshot-policy: an opaque service attempt still refuses product links before capture', () => { + const root = mkdtempSync(join(tmpdir(), 'torch-b2-policy-')); writeFileSync(join(root, 'file'), 'x'); symlinkSync('file', join(root, 'link')); + const states = new WeakMap(); const attempt = Object.freeze({}); states.set(attempt, { input: { sourceRoot: root, destinationRoot: `${root}-snapshot`, paths: ['link'] } }); + const snapshots = createCandidateInputSnapshotRuntimeV2({ stateForAttempt: (value) => states.get(value) }); + assert.throws(() => snapshots.capture(attempt), { code: 'CANDIDATE_SNAPSHOT_LINK_REJECTED' }); + assert.throws(() => snapshots.capture(Object.freeze({})), { code: 'CANDIDATE_SNAPSHOT_ADMISSION_REQUIRED' }); +}); diff --git a/test/fleet/candidate-source-bootstrap.test.mjs b/test/fleet/candidate-source-bootstrap.test.mjs index 95116dc..f496762 100644 --- a/test/fleet/candidate-source-bootstrap.test.mjs +++ b/test/fleet/candidate-source-bootstrap.test.mjs @@ -1,12 +1,28 @@ import assert from 'node:assert/strict'; +import { createHash } from 'node:crypto'; import test from 'node:test'; -import { issueCandidateNativeAdmissionV2, openCandidateSourceBootstrapV2 } from '../../src/checks/candidate-source-bootstrap.mjs'; +import { createCandidateSourceBootstrapRuntimeV2 } from '../../src/checks/candidate-source-bootstrap.mjs'; -const issued = (expiresAt = '2030-01-01T00:00:00.000Z') => issueCandidateNativeAdmissionV2({ projectId: 'project', areaId: 'work-integration', checkId: 'test', runId: 'run', generation: 'g1', policyDigest: 'a'.repeat(64), engineDigest: 'b'.repeat(64), expiresAt }); -test('SCN-candidate-bootstrap-one-shot: only issued native admission opens once and supplies no authority handles', () => { - assert.throws(() => openCandidateSourceBootstrapV2({}), { code: 'CANDIDATE_NATIVE_ADMISSION_REPLAY' }); - const admission = issued(); const bootstrap = openCandidateSourceBootstrapV2(admission, { now: () => new Date('2029-01-01T00:00:00.000Z') }); - assert.equal(bootstrap.transport.closesBeforeCandidateSpawn, true); assert.deepEqual(Object.keys(bootstrap).sort(), ['engineDigest', 'expiresAt', 'generation', 'nonce', 'policyDigest', 'runId', 'schema', 'transport']); - assert.equal(Object.hasOwn(bootstrap, 'controlPlane'), false); assert.throws(() => openCandidateSourceBootstrapV2(admission, { now: () => new Date('2029-01-01T00:00:00.000Z') }), { code: 'CANDIDATE_NATIVE_ADMISSION_REPLAY' }); +function planState(expiresAt = '2030-01-01T00:00:00.000Z') { + const policyBytes = Buffer.from('policy\n'); + return { admissionId: 'admission-1', leaseId: 'lease-1', checkId: 'test', runGeneration: 1, guardGeneration: 1, policyBytes, policyDigest: createHash('sha256').update(policyBytes).digest('hex'), engine: { pre: {}, post: {} }, subject: {}, artifactManifest: { schema: 'torch.dev/artifact-manifest/v1alpha1', manifestSha256: 'a'.repeat(64) }, expiresAt }; +} + +test('SCN-candidate-bootstrap-one-shot: only a service-issued opaque plan opens once and its observed FD acknowledgement closes it', () => { + const states = new WeakMap(); const plan = Object.freeze({}); states.set(plan, planState()); + const runtime = createCandidateSourceBootstrapRuntimeV2({ stateForPlan: (value) => states.get(value), now: () => new Date('2029-01-01T00:00:00.000Z') }); + assert.throws(() => runtime.create(Object.freeze({})), { code: 'CANDIDATE_NATIVE_ADMISSION_REQUIRED' }); + const handle = runtime.create(plan); const clone = { ...handle }; + assert.throws(() => runtime.open(clone), { code: 'CANDIDATE_NATIVE_ADMISSION_REPLAY' }); + const channel = runtime.open(handle); + assert.equal(JSON.parse(channel.frame).receiptAdapterAdmission, null); + runtime.acknowledgeAndClose(handle, channel.acknowledgement); + assert.throws(() => runtime.open(handle), { code: 'CANDIDATE_NATIVE_ADMISSION_REPLAY' }); + assert.throws(() => runtime.acknowledgeAndClose(handle, channel.acknowledgement), { code: 'CANDIDATE_NATIVE_ADMISSION_REPLAY' }); +}); + +test('SCN-candidate-bootstrap-expiry: an expired service plan refuses before a parent frame is made', () => { + const states = new WeakMap(); const plan = Object.freeze({}); states.set(plan, planState('2020-01-01T00:00:00.000Z')); + const runtime = createCandidateSourceBootstrapRuntimeV2({ stateForPlan: (value) => states.get(value) }); + assert.throws(() => runtime.open(runtime.create(plan)), { code: 'CANDIDATE_NATIVE_ADMISSION_EXPIRED' }); }); -test('SCN-candidate-bootstrap-expiry: expired admission refuses before any child protocol exists', () => assert.throws(() => openCandidateSourceBootstrapV2(issued('2020-01-01T00:00:00.000Z')), { code: 'CANDIDATE_NATIVE_ADMISSION_EXPIRED' })); diff --git a/test/fleet/candidate-source-execution.test.mjs b/test/fleet/candidate-source-execution.test.mjs index 11b5f0e..c3b0025 100644 --- a/test/fleet/candidate-source-execution.test.mjs +++ b/test/fleet/candidate-source-execution.test.mjs @@ -1,9 +1,16 @@ import assert from 'node:assert/strict'; -import { mkdtempSync, writeFileSync } from 'node:fs'; +import { mkdtempSync, realpathSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import test from 'node:test'; -import { captureCandidateInputSnapshotV2 } from '../../src/checks/candidate-input-snapshot.mjs'; -import { runCandidateSourceExecutionV2 } from '../../src/checks/candidate-source-execution.mjs'; -function snapshot() { const source = mkdtempSync(join(tmpdir(), 'torch-b2-execution-')); writeFileSync(join(source, 'input'), 'x'); return captureCandidateInputSnapshotV2({ sourceRoot: source, destinationRoot: `${source}-snapshot`, paths: ['input'] }); } -test('SCN-candidate-execution-boundary: ambient PATH/HOME fallback and output overflow cannot become success', () => { const frozen = snapshot(); assert.throws(() => runCandidateSourceExecutionV2({ command: process.execPath, inputSnapshot: frozen, environment: { PATH: '/bin' } }), { code: 'CANDIDATE_EXECUTION_INVALID' }); const observed = runCandidateSourceExecutionV2({ command: process.execPath, inputSnapshot: frozen, limits: { timeoutMs: 1000, maxOutputBytes: 4, maxArgs: 64, maxArgBytes: 4096 }, executor: () => ({ status: 0, stdout: Buffer.from('xxxxxxxx') }) }); assert.equal(observed.terminal.overflow, true); assert.equal(observed.terminal.success, false); }); +import { createCandidateInputSnapshotRuntimeV2 } from '../../src/checks/candidate-input-snapshot.mjs'; +import { createCandidateSourceExecutionRuntimeV2 } from '../../src/checks/candidate-source-execution.mjs'; + +test('SCN-candidate-execution-boundary: only fixed E is run and actual zero-exit output overflow is not success', () => { + const root = mkdtempSync(join(tmpdir(), 'torch-b2-execution-')); writeFileSync(join(root, 'input'), 'x'); + const states = new WeakMap(); const attempt = Object.freeze({}); + states.set(attempt, { input: { sourceRoot: root, destinationRoot: `${root}-snapshot`, paths: ['input'] }, runtime: { command: realpathSync(process.execPath), args: ['-e', 'process.stdout.write("x".repeat(1048577))'], environment: {} } }); + const snapshots = createCandidateInputSnapshotRuntimeV2({ stateForAttempt: (value) => states.get(value) }); snapshots.capture(attempt); + const result = createCandidateSourceExecutionRuntimeV2({ stateForAttempt: (value) => states.get(value), snapshotRuntime: snapshots }).run(attempt); + assert.equal(result.terminal.overflow, true); assert.equal(result.terminal.success, false); +}); diff --git a/test/fleet/candidate-source-parent.test.mjs b/test/fleet/candidate-source-parent.test.mjs index b1c426b..bf3c7d4 100644 --- a/test/fleet/candidate-source-parent.test.mjs +++ b/test/fleet/candidate-source-parent.test.mjs @@ -1,8 +1,14 @@ import assert from 'node:assert/strict'; +import { createHash } from 'node:crypto'; import test from 'node:test'; -import { issueCandidateNativeAdmissionV2, openCandidateSourceBootstrapV2 } from '../../src/checks/candidate-source-bootstrap.mjs'; -test('SCN-candidate-parent-capability: bootstrap frame carries only a bound tuple, never roots, env, writers, adapters, or receipt authority', () => { - const ticket = issueCandidateNativeAdmissionV2({ projectId: 'p', areaId: 'a', checkId: 'c', runId: 'r', generation: 'g', policyDigest: 'a'.repeat(64), engineDigest: 'b'.repeat(64), expiresAt: '2030-01-01T00:00:00.000Z' }); - const frame = openCandidateSourceBootstrapV2(ticket, { now: () => new Date('2029-01-01T00:00:00.000Z') }); - for (const forbidden of ['projectId', 'areaId', 'checkId', 'inputRoot', 'cwd', 'environment', 'writer', 'adapter', 'receipt']) assert.equal(Object.hasOwn(frame, forbidden), false); +import { createCandidateSourceBootstrapRuntimeV2 } from '../../src/checks/candidate-source-bootstrap.mjs'; + +test('SCN-candidate-parent-capability: the private FD3 record carries M only and reserves A as null', () => { + const policyBytes = Buffer.from('policy\n'); const states = new WeakMap(); const plan = Object.freeze({}); + states.set(plan, { admissionId: 'admission-2', leaseId: 'lease-2', checkId: 'test', runGeneration: 1, guardGeneration: 2, policyBytes, policyDigest: createHash('sha256').update(policyBytes).digest('hex'), engine: { pre: {}, post: {} }, subject: {}, artifactManifest: { schema: 'torch.dev/artifact-manifest/v1alpha1', manifestSha256: 'a'.repeat(64) }, expiresAt: '2030-01-01T00:00:00.000Z' }); + const runtime = createCandidateSourceBootstrapRuntimeV2({ stateForPlan: (value) => states.get(value), now: () => new Date('2029-01-01T00:00:00.000Z') }); + const frame = JSON.parse(runtime.open(runtime.create(plan)).frame); + for (const forbidden of ['root', 'cwd', 'environment', 'writer', 'adapter', 'receipt', 'command', 'executor']) assert.equal(Object.hasOwn(frame, forbidden), false); + assert.equal(frame.receiptAdapterAdmission, null); + assert.deepEqual(Object.keys(frame.artifactManifest).sort(), ['manifestSha256', 'schema']); }); From a2fc8e99cf0d5087983b151cce0017420c5e3c0f Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN Date: Thu, 1 Oct 2026 16:24:37 -0400 Subject: [PATCH 04/51] docs: specify shared project knowledge and domain handbooks --- docs/PORTABLE_AGENT_FLEET_SPEC.md | 6 ++ docs/PROJECT_KNOWLEDGE_SPEC.md | 111 ++++++++++++++++++++++++++++++ docs/TODO.md | 18 +++++ 3 files changed, 135 insertions(+) create mode 100644 docs/PROJECT_KNOWLEDGE_SPEC.md diff --git a/docs/PORTABLE_AGENT_FLEET_SPEC.md b/docs/PORTABLE_AGENT_FLEET_SPEC.md index 3a55f0b..afbb483 100644 --- a/docs/PORTABLE_AGENT_FLEET_SPEC.md +++ b/docs/PORTABLE_AGENT_FLEET_SPEC.md @@ -2743,6 +2743,12 @@ and refuses to replace a launcher path not owned by that installation. ## 27. User interfaces +Project reference documentation, domain handbooks and provisional working memory +MUST follow [Project knowledge and domain institutional memory](PROJECT_KNOWLEDGE_SPEC.md). +This owner-approved extension requires a Git-backed shared KB, durable domain +ownership, compact agent entry points and a dedicated Console Knowledge view. +It is a development requirement, not a claim that the feature is implemented. + ### 27.1 CLI The target command surface includes: diff --git a/docs/PROJECT_KNOWLEDGE_SPEC.md b/docs/PROJECT_KNOWLEDGE_SPEC.md new file mode 100644 index 0000000..ed4e697 --- /dev/null +++ b/docs/PROJECT_KNOWLEDGE_SPEC.md @@ -0,0 +1,111 @@ +# Project knowledge and domain institutional memory + +Status: owner-approved development requirement, 2026-10-01. Not implemented. +The fleet remains manually paused; recording this requirement does not authorize +dispatch, new sessions, model calls, publication or automatic restart. + +## Purpose + +Give users and agents a shared, evolving project reference, while preserving +focused domain knowledge across session restarts, provider changes and agent +replacement. Documentation should explain how the project actually works, not +be a transcript archive, another backlog or a source of implicit authority. + +## Three connected layers + +1. **Project KB:** authoritative, Git-backed Markdown documenting architecture, + setup, interfaces, workflows, pipelines, troubleshooting and decisions. +2. **Domain handbook:** a compact entry point owned by the domain, linking to + relevant KB pages and recording domain conventions, procedures and pitfalls. + Prefer links over duplicated shared contracts. Knowledge belongs to the + durable domain identity, not a provider conversation or disposable session. +3. **Working memory:** current investigations, hypotheses, handoffs and unfinished + work. It is explicitly provisional; promotion to reference documentation + requires supporting evidence and the ordinary review/integration process. + +The existing backlog remains the sole task queue. Handbooks may link to tasks +and domain coordination summaries but must not become competing task ledgers. + +## Storage and adoption + +The repository is the source of truth; Console indexes and search results are +rebuildable projections, never independent authoritative copies. Documentation +must remain usable offline and without TORCH. Do not require a hosted service, +particular AI provider or external documentation platform. + +During init/analyze, discover existing documentation and propose a project-fit +navigation and ownership map. Reuse existing directories and conventions rather +than silently moving documents. For projects without conventions, a candidate +layout is `docs/knowledge/`, `docs/domains//` and a clearly separated +working-memory area. Exact paths and metadata format need a reviewed implementation +contract; the layout is not a mandate for every project. + +One accountable domain owns each reference page; collaborators and reviewers may +be named separately. Cross-domain interface documentation retains one owner and +review links to affected domains. Domain retirement, merge, split or reassignment +must preserve history and transfer responsibility, not delete institutional memory. + +## Agent use and maintenance + +Startup/resume briefs expose the compact domain index and essential references, +not the full KB. Agents retrieve relevant detail on demand and can discover +cross-domain references without loading unrelated documentation into every context. +Record which revision was supplied; warn about stale references where detectable. + +Completion of a behavior, interface or workflow change requires documentation +impact to be considered: update affected pages or record why no update is needed. +Do not make clerical documentation edits a permission to change another domain's +implementation. Route cross-domain documentation changes through existing ownership +and review mechanisms and notify affected owners. + +Reference pages expose owner, status (draft/experimental/reference/deprecated), +verification evidence and exact last-verified commit. A timestamp or successful +render does not prove accuracy. Branch/candidate documentation must not masquerade +as landed canonical behavior; represent divergence and unknown freshness honestly. +Flag broken links, missing owners and affected pages needing re-verification. +Do not automatically certify pages, mass-rewrite them or invent a freshness score. + +## Console Knowledge view + +Add a dedicated Knowledge screen, not another long Overview section. Provide +Read-the-Docs-style sidebar navigation, search, breadcrumbs, readable articles, +stable deep links, responsive/mobile navigation and keyboard accessibility. +Show owner, status, verified revision, related domains, tasks, pipeline definitions +and history. Keep handbook, reference and provisional memory visibly distinct. + +Offer bounded actions to ask the responsible domain and report outdated content +through existing durable conversations/intake. Show queued/delivered outcomes; +these actions must not wake agents while paused or silently grant edit authority. +Use the same renderer for live project documentation and labelled demo fixtures. +Do not bundle private project documents into the public marketing demo. + +## Safety and portability + +Render repository text as untrusted content: sanitize Markdown, block script/raw +HTML execution, unsafe URL schemes, path traversal and escaping symlinks. Restrict +indexing/serving to approved documentation roots. Exclude credentials and private +working-memory content according to explicit project visibility policy, including +search snippets and cached indexes. External links/imported instructions do not +override TORCH policy. KB content does not expand operational authority. + +## Required acceptance scenarios + +- Existing project docs are discovered and linked without moves or overwrite; + an empty project receives only a reviewed, project-fit plan. +- Restart or provider replacement inherits the same domain handbook; unrelated + KB bodies are not injected into its startup context. +- A shared interface change updates its single authoritative page and routes + affected-domain notification; unchanged pages are not falsely re-verified. +- A domain ownership change preserves document history and transfers maintenance + responsibility without creating two authoritative copies. +- Provisional claims and candidate-branch behavior remain distinct from verified + reference docs; missing evidence is visible rather than converted into confidence. +- Navigation, search, deep links, keyboard and mobile use work on a realistic + multi-domain project; actual browser inspection is required before UI acceptance. +- Unsafe Markdown, traversal, symlink escape and private search leakage fail closed. +- Questions/corrections are durable while paused; no provider launch occurs. +- Markdown remains readable without TORCH, and the public demo has no private docs + or live control-plane calls. + +Implementation slices are recorded in the existing authoritative TORCH backlog. +They remain proposed and unassigned for Session Manager triage after owner resume. diff --git a/docs/TODO.md b/docs/TODO.md index c8ffd17..787df51 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -1,5 +1,23 @@ # TORCH remaining work +## Owner-approved project knowledge requirement (2026-10-01) + +See [Project knowledge and domain institutional memory](PROJECT_KNOWLEDGE_SPEC.md). +The existing authoritative backlog holds the implementation slices below; this +section is an index, not a second queue. All are proposed/unassigned, and the +fleet remains manually paused until the owner explicitly requests resume. + +- `TASK-knowledge-contract`: project-fit Git-backed KB, domain handbooks and + provisional memory; metadata, visibility, evidence and ownership contract. +- `TASK-knowledge-agent-context`: documentation discovery, compact domain brief + references and provider-independent retrieval/inheritance without context bloat. +- `TASK-knowledge-maintenance`: documentation-impact workflow, verification + evidence, cross-domain notifications and ownership transfer without duplicate truth. +- `TASK-knowledge-console`: dedicated accessible Knowledge view, safe rendering, + search, history and durable ask-domain/report-outdated actions. +- `TASK-knowledge-qualification`: end-to-end inheritance/freshness/privacy/pause + scenarios and realistic browser acceptance, including isolated demo fixtures. + ## Authoritative current handoff (2026-09-30, after alpha.2 reinstall) TORCH is installed and healthy at `0.1.0-alpha.2`. Setup/restore and all seven From fcc28454a72c54d106e54fa8676c2cf1869aa932 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN Date: Thu, 1 Oct 2026 23:27:12 -0400 Subject: [PATCH 05/51] docs: plan adaptive company integration and reliability gates --- docs/ADAPTIVE_COMPANY_INTEGRATION_PLAN.md | 243 ++++++++++++++++++++++ docs/PORTABLE_AGENT_FLEET_SPEC.md | 6 + docs/TODO.md | 12 ++ 3 files changed, 261 insertions(+) create mode 100644 docs/ADAPTIVE_COMPANY_INTEGRATION_PLAN.md diff --git a/docs/ADAPTIVE_COMPANY_INTEGRATION_PLAN.md b/docs/ADAPTIVE_COMPANY_INTEGRATION_PLAN.md new file mode 100644 index 0000000..5723773 --- /dev/null +++ b/docs/ADAPTIVE_COMPANY_INTEGRATION_PLAN.md @@ -0,0 +1,243 @@ +# Adaptive software company: integration plan + +Status: owner-approved roadmap direction, 2026-10-01; implementation pending. +Applies to the Portable Agent Fleet. This document records the supplied adaptive +software company proposal and the agreed refinements. It does not certify the +current alpha as unattended or production-ready. The fleet remains manually +paused until the owner explicitly requests resume. + +## Product boundary + +TORCH supplies organization, ownership, intent, authority, durable work, +coordination, evidence, verification, integration and recovery. Coding harnesses +supply implementation intelligence, tools and temporary delegation. Start with +the smallest useful organization; do not require a fixed Engineering/Product/QA +taxonomy or a new permanent AI supervisor. Projects may use different roles. + +Preserve worktree isolation, one active implementation assignment per specialist, +revision-checked mutations, direct peer communication, resource leases, exact +candidate verification, serialized integration, independent review where required, +audits and separate release/deployment authority. KB/handbook/working-memory +requirements remain governed by [Project knowledge](PROJECT_KNOWLEDGE_SPEC.md). + +## Existing work: extend, do not replace + +This is a staged extension of current systems, not a rewrite. Reuse the current +identity, organization, backlog, messaging, runtime, checks, resource, integration, +delivery and schedule services. Owner-approved grants are already proposed as +`TASK-874ed1e4-9414-4a8f-8575-a6c5aceefc56`; do not build a competing grant system. +Link first-work intake, event-driven wakes, usage governor, hierarchy pilots, +starting-presence and provider update-path tasks instead of duplicating them. + +The earlier executor-outcome task is completed, but later pilot launches exposed +stdout-bound/terminal-outcome disagreement. A completed historical task is not +proof that the later installed runtime is ready for unattended operation. +Preserve those receipts and qualify the new regression explicitly. + +## Phase A: trustworthy runtime state before unattended coordination + +Lead domains: Provider Runtime, with Project Kernel and independent QA. + +1. Reproduce the installed output-bound failure using bounded deterministic + fixtures. Keep parser/transport bounds; do not treat a wrapper's observation + as a substitute for an authenticated runtime outcome. +2. Define startup reservation, process/session ownership, terminal outcome and + recovery contracts. Distinguish success, failure, unknown and physically stopped. +3. Publish guarded starting presence before launching. Coordinate manual, + scheduled and controller paths through the same exclusivity mechanism. +4. Add a supported, owner-attributed recovery operation with executor-stopped + evidence, revision/lease checks and audit. Never impersonate a specialist, + edit SQLite manually, infer death from a name or drop an unknown reservation. +5. Qualify manual pause across reboot, quota reset, schedule due, message arrival, + upgrade and event replay. Only explicit owner resume can clear this pause. + +Exit gate: oversized output, timeout, signal, malformed terminal data and a crash +between spawn and receipt cannot produce false success or a duplicate launch; +unknown outcomes remain recoverable and block unsafe actions. Actual installed +runtime qualification and independent QA are required, not just fixture tests. + +## Phase B: scoped capability authority migration + +Lead: Project Kernel; extend the existing owner-grants proposal. + +Identity identifies the participant; role describes responsibility; an approved +grant confers authority. Reporting edges and implementation ownership do not +confer management capabilities. Never interpret delegated user authority as +permission to impersonate the owner or act outside its explicit scope. + +Before coding, inventory identity-special business rules and their CLI, MCP, +Console, scheduled and internal entry points. Define a single authorization +service and a capability mapping for each action. Grant records must identify: + +- grant ID, issuer/delegation provenance, recipient and required capability; +- project/domain/task/path scope and operation constraints; +- approval/reference evidence and policy/grant revision; +- validity/revocation and whether further delegation is allowed; +- owner-reserved exclusions and independent-review conflict rules. + +Initially migrate the current Session Manager's permitted operations without +widening permissions. Support preview and explicit approval of broader grants. +No default wildcard capabilities, hierarchy inheritance or self-granting. +Evaluate the current grant immediately before effects; audit the actual actor, +grant, scope, relevant revisions and allow/deny reason. + +Exit gate: old permitted actions still work under equivalent grants; ungranted, +out-of-scope, revoked and stale actions fail across every entry point. A new lead +can be assigned a bounded capability without source changes. Independent review +cannot approve its own work merely because it also holds a leadership role. + +## Phase C: durable event contract + +Lead: Work & Integration, with Kernel/Runtime; extend existing event-driven wakes. + +Version events for work-ready/blocked, assignment completion, verification, +integration readiness, approval requests/decisions, pending messages, resource +release, runtime observations, schedule due and evidence-backed organization +pressure. Existing audit records are evidence, not automatically executable orders. + +Each event needs an ID, project, type/schema version, source entity and revision, +actor/cause, timestamp, correlation/causation IDs and deduplication key. Keep +payloads bounded and avoid credentials, private document bodies or full transcripts. + +Define emission and recovery at each state-change boundary. Use transactional +outbox semantics where one store supports them; when Git/files and SQLite cross +a boundary, specify a durable journal/reconciliation protocol rather than claiming +one atomic transaction. Preserve one authority for task state. Define cursor, +retention, replay and schema-upgrade behavior before enabling consumers. + +Exit gate: crash before/after state mutation or event delivery, duplicate delivery, +out-of-order delivery and replay cannot lose a significant transition or authorize +a stale action. Event delivery may be at-least-once; effects must be idempotent. + +## Phase D: small desired-state reconciler + +Lead: Work & Integration / Provider Runtime. No strategy or implementation logic. + +For each supported action, observe authoritative current state, compare with +approved policy and desired work, evaluate eligibility, reserve one bounded action, +recheck revisions/authority/pause/budget, execute and record the outcome. +Eligibility is allow, deny or unknown; unknown is not permission to retry. + +Begin with durable notices and read-only explanations. Then pilot wake of one +eligible identity for assigned work, followed by narrowly configured ready-work +self-claim and integration-authority notifications. Do not automatically approve, +land, release, deploy, reassign ownership or create identities just because an +event arrived. Preserve existing checks and authorization gates for each operation. + +Require persisted pause/backoff, launch reservations, concurrency limits, +bounded retry rules, actionable failures and periodic safety reconciliation. +Queue/backend/usage/model/auth unavailability must not create busy model loops. +Check budgets before launching, including uncertainty in subscription usage. +Use minimum useful wake context; a timer is a safety net, not another AI supervisor. + +Exit gate: duplicate events cannot duplicate execution; paused or uncertain work +is never restarted; missed events are found by reconciliation; stale observations +cannot override a newly revoked grant. Start with an owner-approved bounded pilot, +manual intervention metric and immediate pause/rollback path. + +## Phase E: native execution capabilities and repository evidence + +Runtime lead: Provider Runtime. Repository evidence lead: Project Kernel. + +Keep execution capabilities separate from lifecycle functions. Describe native +subagents/delegation, browser/computer use, artifacts, worktrees, background tasks, +checkpoints and review as advertised, locally available, qualified, unavailable +or unknown. Bind qualification to executable/version, host, account/config and +evidence; declarations alone do not prove availability. No model calls merely +to populate the inventory without approval. + +Temporary harness workers are accountable to the persistent TORCH specialist, +not new roster members. They must not acquire broader TORCH credentials, authority, +ownership or uncontrolled resource access. Keep delegation provenance and aggregate +available cost evidence. Coordinate native worktrees with TORCH-owned workspaces. +Native review is useful but not automatically independent QA. Avoid orchestration +squared and promote workers only through evidence-backed organizational proposals. + +Extend deterministic reconnaissance with bounded workspace/build/module graphs, +CODEOWNERS, co-change observations, entry points, schemas, deployment/process +boundaries and recurring cross-domain tasks. Give the Architect provenance, +coverage and uncertainty, not invented semantic conclusions. Do not execute +untrusted build tools or fetch history merely to analyze a repository silently. + +Exit gate: absent capabilities have honest fallbacks; helper delegation cannot +expand authority; organization proposals cite real evidence and remain advisory. + +## Phase F: owner-oriented onboarding and evidence UX + +Leads: Owner Console, Release/Self-host and Kernel. Extend first-work intake. + +Offer one guided path through prerequisites/runtime qualification, analysis, +small-team proposal and rationale, ownership/check gaps, explicit review, +installation/worktree preview, first-work intake and optional startup. Preserve +advanced CLI and diagnostics. Do not redefine the existing read-only `init` into +a mutating command without a documented compatibility decision and tests. + +"Company ready" must distinguish configured, launchable and actually operational. +Missing models/auth, unknown outcomes, unresolved ownership and unqualified checks +must remain visible. Separate consent for downloads, spending, persistent services, +agent startup and publication; onboarding cannot override a manual pause. + +Show conclusions, reason, evidence and exact artifacts progressively, with stable +links to commits, check conditions, receipts, decisions and discussions. Derive +implemented/verified/independently reviewed/integrated/released/live states from +their actual authorities. Surface unknown coverage and rejected/relaxed criteria; +green exit alone does not support a claimed business outcome or performance number. + +Exit gate: a fresh user can reach useful reviewed work without knowing SQLite, +runtime IDs or schedule unit internals; desktop/mobile/keyboard browser inspection +passes. Every important asserted status can be traced to exact evidence. + +## Phase G: adaptive organization and storage investigation + +Extend existing evolution/hierarchy pilots to evidence-backed contraction, +promotion and consolidation. Roles must justify recurring outcomes rather than +headcount. Record baseline/window, hypothesis, costs, success/stop criteria and +owner-reviewed pilot. Transfer authority, ownership, docs and unfinished work +safely; never retire an active/dirty/unknown worker. Keep one implementation owner. + +Measure bookkeeping friction before changing storage: dirty manager time, +administrative commits, collisions, checkpoint effort and integration delay +attributable to state. Compare current tracked records, dedicated state branch +and Git-intent/SQLite-operations alternatives against offline recovery, export, +backup and migration requirements. Do not dual-write authoritative task state or +move it until a separate reviewed decision and recovery tests justify the change. + +## Phase H: multi-project and release qualification + +Use separate controlled fixtures/pilots for a small web app, monorepo, compiled +systems project, graphics/game project and optional docs/data-heavy project. +Record init-to-useful-team, request-to-assignment, verified integration latency, +owner interventions, coordination volume, launch failures, false greens and +measured/estimated usage separately. Evaluate organization changes for actual benefit. +Do not run expensive/live-provider experiments without owner-approved budgets. + +Release gates include migrations/backup compatibility, crash-safe lock recovery, +model/auth preflight, repository relocation, provider update compatibility, +distribution provenance/signing decision, atomic upgrade/rollback, event replay +and platform-specific schedule qualification. Remote execution is optional and +needs a separate trust/deployment boundary, not an implicit requirement. + +## Integration and rollback discipline + +- Assign bounded slices through the existing backlog; agree shared contracts first. +- Add scenario-based fault and authority tests with Test Integrity Notes. Do not + weaken accepted scenarios or substitute simulated success for installed evidence. +- Land exact checked commits through the existing integration mechanism, not + concurrent direct pushes or a second queue. Keep runtime/parser/security work + independently qualified from marketing and UI changes. +- Ship additive, disabled-by-default policy/config first. Migrations need explicit + version/compatibility rules, backup/restore tests and an upgrade preview. +- Do not roll back a database by blindly activating old code. Define supported + data downgrade or fail closed while preserving the new store and receipts. +- Enable notifications before wakes; qualify one-domain pilot before broad fleet + activation. Fresh source is not automatically an installed runtime upgrade. +- Give the owner a report of configured/enabled/qualified state and remaining gates. + +## Backlog index + +The authoritative proposals use `TASK-company-` IDs: runtime-recovery, +authority-migration, event-contract, reconciler, native-capabilities, +repository-evidence, onboarding, evidence-ux, organization-experiments, +storage-study and multi-project-qualification. Existing tasks remain authoritative +for their previously scoped work. New proposals are unassigned until Manager +triage after explicit owner resume; this document is not another work queue. diff --git a/docs/PORTABLE_AGENT_FLEET_SPEC.md b/docs/PORTABLE_AGENT_FLEET_SPEC.md index afbb483..95552bf 100644 --- a/docs/PORTABLE_AGENT_FLEET_SPEC.md +++ b/docs/PORTABLE_AGENT_FLEET_SPEC.md @@ -2743,6 +2743,12 @@ and refuses to replace a launcher path not owned by that installation. ## 27. User interfaces +The owner-approved [Adaptive software company integration plan](ADAPTIVE_COMPANY_INTEGRATION_PLAN.md) +extends this architecture with scoped capabilities, durable events, bounded +reconciliation, native-runtime awareness, evidence UX and project-fit onboarding. +It preserves current invariants and requires staged migration and qualification; +it is not a claim that the adaptive-company upgrade is implemented. + Project reference documentation, domain handbooks and provisional working memory MUST follow [Project knowledge and domain institutional memory](PROJECT_KNOWLEDGE_SPEC.md). This owner-approved extension requires a Git-backed shared KB, durable domain diff --git a/docs/TODO.md b/docs/TODO.md index 787df51..948bab4 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -1,5 +1,17 @@ # TORCH remaining work +## Adaptive software company upgrade (owner-approved, 2026-10-01) + +The [integration plan](ADAPTIVE_COMPANY_INTEGRATION_PLAN.md) records scope, +existing-task reuse, phased contracts, exit gates and migration/rollback rules. +The existing authoritative backlog contains eleven linked `TASK-company-` +proposals, not a second queue. Runtime recovery gates unattended coordination; +authority comes from scoped revocable grants, not reporting relationships. +Existing grants, event-driven wakes, first-work intake, usage governance and +hierarchy pilots are reused rather than replaced. All new items are proposed +and unassigned. No automatic restart or implementation is authorized by this +roadmap addition; the fleet remains manually paused pending owner resume. + ## Owner-approved project knowledge requirement (2026-10-01) See [Project knowledge and domain institutional memory](PROJECT_KNOWLEDGE_SPEC.md). From 9caa6b7a05bc600f6b562ff98a0ade45651f6c50 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN Date: Thu, 1 Oct 2026 23:42:44 -0400 Subject: [PATCH 06/51] docs: prioritize compact dashboard cards and agent chat --- docs/CONSOLE_CHAT_AND_COMPACT_CARDS_SPEC.md | 77 +++++++++++++++++++++ docs/TODO.md | 9 +++ 2 files changed, 86 insertions(+) create mode 100644 docs/CONSOLE_CHAT_AND_COMPACT_CARDS_SPEC.md diff --git a/docs/CONSOLE_CHAT_AND_COMPACT_CARDS_SPEC.md b/docs/CONSOLE_CHAT_AND_COMPACT_CARDS_SPEC.md new file mode 100644 index 0000000..25d5552 --- /dev/null +++ b/docs/CONSOLE_CHAT_AND_COMPACT_CARDS_SPEC.md @@ -0,0 +1,77 @@ +# Priority Console upgrade: compact cards and agent Chat + +Status: owner-approved, high-priority development requirement. Not implemented. +These UI slices should lead the next development cycle after explicit owner resume. +They do not override runtime safety gates or the current manual fleet pause. + +## Compact cards and detail modal + +Make card-based views a scanning layer rather than full-document readers. Apply +a shared collapsed-height/max-height policy across data types, with consistent +heights within each grid/view and responsive size variants. Do not squeeze tables, +charts or conversations into an arbitrary universal card height. + +Show title, state, accountable agent, a two/three-line summary and important +blockers or decisions. Clamp overflowing ordinary content with an explicit +View details action. Critical warnings must remain identifiable and accessible; +provide a concise warning summary and full detail rather than clipping it silently. +Retain sensible quick actions such as approve/reject on collapsed cards, using +existing evidence/revision/preview confirmation and authority checks. + +Clicking the card's detail affordance opens an accessible modal with complete +content, evidence, linked conversations/tasks and contextual actions. Nested +action buttons must not accidentally open the modal or trigger another mutation. +Modal content may scroll. Preserve underlying view/filter/scroll and unsent input; +support keyboard access, focus containment/restoration, Escape, labelled headings, +mobile full-screen details and explicit stale/deleted-record states. Refresh must +not replace unsaved edits or apply an action against an obsolete revision. + +Start with Work and Needs Your Attention, then cover remaining card-based views +with the same pattern. Do not replace specialist views or create duplicate data. + +## Conversational Chat view + +Add Chat immediately below Overview in the sidebar. Default to the highest-level +approved project coordinator, currently Session Manager, not a permanently +hard-coded identity. Show an empty/explanatory state if none is configured; never +create or activate an agent to satisfy the screen. + +Provide a familiar chronological message stream, composer and selector for every +activated fleet identity. Persistently show Talking to…, identity, role, runtime +availability and a distinct visual accent. Colour is supplementary: labels, +avatars/initials and accessible contrast must also distinguish participants. + +Combine durable owner exchanges and publishable inter-agent exchanges involving +the selected agent, preserving sender, recipient, thread/context and causal links. +Clearly distinguish direct owner conversation from collapsible routine fleet +traffic; replying must explicitly target the selected agent or named thread and +never silently broadcast. Do not claim every harness transcript is available. +Reuse existing owner-agent-chat/messages and formal Conversations audit view, +not a parallel conversation database. Include links to tasks, approvals, images +and supporting evidence. No private chain-of-thought or indiscriminate terminal, +secret or private-file exposure. + +Stream publishable replies/progress only when the adapter supports it; otherwise +show honest durable-message updates. Expose queued/delivered/being-handled/failed +outcomes accurately: acknowledgement alone does not mean work is done. Preserve +drafts per recipient, history on navigation/reload, reconnect deduplication and +an explicit send destination. New arrivals must not force-scroll a user reading +history. Keep the composer usable on mobile and by keyboard. + +Messages can be saved while paused without waking an agent. Future wake-on-message +requires separately qualified reservations, budget, authority and owner-approved +policy. Casual chat does not bypass an explicit approval or grant new authority. +Use clearly labelled demo fixtures with no live APIs, not private fleet history. + +## Acceptance and delivery + +Use realistic long content, many senders, agent switches, interrupted delivery, +stale records and pending owner decisions. Inspect desktop/mobile/keyboard flows +in an actual browser and retain screenshots. Prove bounded collapsed layout, +complete details, action correctness, focus restoration, preserved filters/drafts, +safe refresh, accurate chat attribution and no launch on paused send. + +Existing backlog proposals remain authoritative. New high-priority slices cover +compact cards/details, Chat presentation and independent browser qualification. +Runtime wake safety remains a prerequisite for execution, not a reason to delay +safe read-only/chat-intake UI development. diff --git a/docs/TODO.md b/docs/TODO.md index 948bab4..10a1d9b 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -1,5 +1,14 @@ # TORCH remaining work +## Priority UI cycle: compact cards and agent Chat + +Owner-approved [Console UI spec](CONSOLE_CHAT_AND_COMPACT_CARDS_SPEC.md). +Prioritize these after explicit owner resume, ahead of broad company expansion: +`TASK-console-compact-details`, `TASK-console-agent-chat-ui` and +`TASK-console-chat-cards-qualification`. Extend the existing high-priority +`TASK-owner-agent-chat`; do not duplicate messaging or bypass paused wake policy. +Proposals remain unassigned. Fleet startup is not authorized by this addition. + ## Adaptive software company upgrade (owner-approved, 2026-10-01) The [integration plan](ADAPTIVE_COMPANY_INTEGRATION_PLAN.md) records scope, From 64bf8dc4050d322d122612f1734b72c013b72fe5 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN Date: Fri, 2 Oct 2026 21:07:21 -0400 Subject: [PATCH 07/51] fleet: record owner-approved resume and priority UI polish --- .torch/prompts/COMMON.md | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/.torch/prompts/COMMON.md b/.torch/prompts/COMMON.md index 72677a5..56986db 100644 --- a/.torch/prompts/COMMON.md +++ b/.torch/prompts/COMMON.md @@ -2,6 +2,32 @@ Repository state outranks conversation memory. Query ownership before crossing a domain boundary. +## Owner-approved fleet resume and priority polish cycle (2026-10-02) + +The owner explicitly said: "We got a reset. lets get torch as pollished as we can. +Spin them all back up." This lifts the earlier manual wind-down for the existing +seven identities. The arbiter controls physical launches and timer restoration; +automatic AI wakes remain disabled until their runtime safety gates qualify. +Do not interpret old pause messages as a new pause after this authorization. + +Session Manager may triage and assign the existing development roadmap within +approved ownership, one active implementation assignment per specialist, direct +peer coordination and independent QA. The earlier first-slice/audit-only limits +below are superseded for this development cycle. Preserve existing unfinished +assignments and resolve or explicitly block them rather than silently replacing +them to take new work. Prioritize compact cards/detail modals and coordinator-first +agent Chat, then broader polish; runtime recovery required for safe operation comes +first. Read docs/CONSOLE_CHAT_AND_COMPACT_CARDS_SPEC.md and the newer owner inbox. +Company/KB proposals remain roadmap requirements, not implemented capabilities. + +Reconcile the three prior working/unknown records through supported audited +recovery, with actual stopped-executor evidence. No peer impersonation, direct +SQLite mutation, silent guard removal or false PASS promotion. Current model/ +provider profiles stay unchanged. Existing checked integration and non-force +development push authority remains; no default-main/legacy rewrite, destructive +cleanup, extra identities, package publication or unreviewed deployment is granted. +Record honest progress and blockers; end completed turns without polling loops. + ## Fresh owner authorization: guarded integration and continued development (2026-10-01) The owner explicitly answered the arbiter's two held decisions: "yes do whatever From b4c9c3fe74fd8ad58fe34e6f544fc0a6b359ab21 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN Date: Fri, 2 Oct 2026 21:33:25 -0400 Subject: [PATCH 08/51] TASK-company-runtime-recovery forward MCP runtime directory --- docs/TEST_INTEGRITY.md | 24 ++++++++++++++++ src/adapters/codex.mjs | 21 ++++++++++++-- test/fleet/mcp-adapter.test.mjs | 50 +++++++++++++++++++++++++++++++++ 3 files changed, 92 insertions(+), 3 deletions(-) diff --git a/docs/TEST_INTEGRITY.md b/docs/TEST_INTEGRITY.md index a35e033..2b19a3b 100644 --- a/docs/TEST_INTEGRITY.md +++ b/docs/TEST_INTEGRITY.md @@ -2811,3 +2811,27 @@ test_integrity_note: did_relax_any_assertion: false if_true_explain_spec_basis: "" ``` + +```yaml +test_integrity_note: + change_type: new_tests + scenarios: + - id: SCN-codex-mcp-runtime-dir + given: "A Codex adapter receives an injected Linux parent environment with a valid XDG runtime directory, or deterministic missing, malformed, or unsupported-platform context" + when: "It creates or resumes an identity-bound Codex session with a TORCH MCP server" + then: "Both launch records name only XDG_RUNTIME_DIR in MCP env_vars for valid context; invalid context emits no env_vars, and no runtime value, DBUS, home, path, token, or general environment is serialized" + observable_outcomes: + - "Create and resume plans expose the exact single-name MCP env_vars allowlist only for valid parent context" + - "Invalid or unsupported context omits the allowlist without synthesizing a runtime path" + - "The serialized invocation never exposes parent environment values" + determinism_controls: + - "Narrow injected adapter env and platform inputs; no process-wide environment mutation, host service, provider, or native check" + anti_cheat_rationale: + prevents: + - "Hard-coded runtime UID or directory values" + - "Forwarding DBUS, secrets, home, PATH, or the complete parent environment" + - "Treating a mocked launch record as native runtime qualification" + relaxation: + did_relax_any_assertion: false + if_true_explain_spec_basis: "" +``` diff --git a/src/adapters/codex.mjs b/src/adapters/codex.mjs index 9ec7447..1f09450 100644 --- a/src/adapters/codex.mjs +++ b/src/adapters/codex.mjs @@ -31,15 +31,28 @@ function commandRecord(command, args, cwd) { return { command, args, cwd, mutatesRuntime: true }; } -function mcpArgs(mcp) { +function mcpRuntimeEnvironmentNames(env, platform) { + const runtimeDirectory = env?.XDG_RUNTIME_DIR; + if (platform !== 'linux' || typeof runtimeDirectory !== 'string' + || !/^\/run\/user\/[1-9]\d*$/.test(runtimeDirectory)) { + return []; + } + return ['XDG_RUNTIME_DIR']; +} + +function mcpArgs(mcp, { env, platform } = {}) { if (!mcp) return []; if (typeof mcp.name !== 'string' || !/^[a-z0-9_-]+$/.test(mcp.name) || typeof mcp.command !== 'string' || !Array.isArray(mcp.args)) { throw new TorchError('Codex MCP launch configuration is invalid', { code: 'INVALID_RUNTIME_INPUT' }); } + const environmentNames = mcpRuntimeEnvironmentNames(env, platform); return [ '-c', `mcp_servers.${mcp.name}.command=${JSON.stringify(mcp.command)}`, '-c', `mcp_servers.${mcp.name}.args=${JSON.stringify(mcp.args)}`, + ...(environmentNames.length + ? ['-c', `mcp_servers.${mcp.name}.env_vars=${JSON.stringify(environmentNames)}`] + : []), ]; } @@ -169,12 +182,14 @@ export function diagnoseCodexStartupFailure({ stdout, stderr } = {}) { export class CodexRuntimeAdapter { constructor({ env = process.env, + platform = process.platform, runner = null, executable = 'codex', nodeExecutable = process.execPath, } = {}) { this.name = 'codex'; this.env = env; + this.platform = platform; this.runner = runner; this.executable = executable; this.nodeExecutable = nodeExecutable; @@ -241,7 +256,7 @@ export class CodexRuntimeAdapter { const cwd = text(worktree, 'worktree'); const args = [ '--cd', cwd, ...launchPolicyArgs(launchPolicy ?? this.configuration.launchPolicy), - ...mcpArgs(mcp), + ...mcpArgs(mcp, { env: this.env, platform: this.platform }), ...((reasoning ?? this.configuration.reasoning) ? ['-c', `model_reasoning_effort=${JSON.stringify(text(reasoning ?? this.configuration.reasoning, 'reasoning'))}`] : []), 'exec', '--json', ...optionalModelArgs(model ?? this.configuration.model), @@ -266,7 +281,7 @@ export class CodexRuntimeAdapter { : ''; const args = [ '--cd', cwd, ...launchPolicyArgs(launchPolicy ?? this.configuration.launchPolicy), - ...mcpArgs(mcp), + ...mcpArgs(mcp, { env: this.env, platform: this.platform }), ...((reasoning ?? this.configuration.reasoning) ? ['-c', `model_reasoning_effort=${JSON.stringify(text(reasoning ?? this.configuration.reasoning, 'reasoning'))}`] : []), 'exec', 'resume', '--json', ...optionalModelArgs(model ?? this.configuration.model), sessionId, `${text(message, 'message')}${currentInstructions}`, diff --git a/test/fleet/mcp-adapter.test.mjs b/test/fleet/mcp-adapter.test.mjs index 4cd07be..089f674 100644 --- a/test/fleet/mcp-adapter.test.mjs +++ b/test/fleet/mcp-adapter.test.mjs @@ -242,6 +242,56 @@ test('SCN-codex-adapter: turns are resumable, identity-bound, durable, and capab control.close(); }); +test('SCN-codex-mcp-runtime-dir: only a valid Linux parent runtime directory is named for MCP inheritance', () => { + const context = fixture(); + const validParent = { + XDG_RUNTIME_DIR: '/run/user/1000', + DBUS_SESSION_BUS_ADDRESS: 'unix:path=/run/user/1000/bus', + HOME: '/home/fixture', PATH: '/usr/bin', TOKEN: 'not-forwarded', + }; + const adapter = createCodexAdapter({ env: validParent, platform: 'linux' }); + const mcp = adapter.configure({ repositoryRoot: context.root, areaId: context.worker, mcpEntry: MCP_SERVER }).mcp; + const create = adapter.createSession({ + areaId: context.worker, worktree: '/tmp/codex-runtime-dir', promptFile: '/tmp/prompt.md', + firstMessage: 'Start.', mcp, + }); + const resume = adapter.resumeSession({ + areaId: context.worker, runtimeSessionId: 'thread-runtime-dir', worktree: '/tmp/codex-runtime-dir', + message: 'Resume.', mcp, + }); + const expected = `mcp_servers.${mcp.name}.env_vars=[\"XDG_RUNTIME_DIR\"]`; + for (const launch of [create, resume]) { + assert.equal(launch.launch.args.includes(expected), true); + assert.equal(launch.launch.args.some((arg) => /env_vars=/.test(arg) && arg !== expected), false); + assert.equal(launch.launch.args.some((arg) => arg.includes(validParent.XDG_RUNTIME_DIR) + || arg.includes(validParent.DBUS_SESSION_BUS_ADDRESS) || arg.includes(validParent.HOME) + || arg.includes(validParent.PATH) || arg.includes(validParent.TOKEN)), false); + } + + for (const invalid of [ + { env: {}, platform: 'linux' }, + { env: { XDG_RUNTIME_DIR: '' }, platform: 'linux' }, + { env: { XDG_RUNTIME_DIR: 'relative/runtime' }, platform: 'linux' }, + { env: validParent, platform: 'darwin' }, + ]) { + const unsupported = createCodexAdapter(invalid); + const unsupportedMcp = unsupported.configure({ + repositoryRoot: context.root, areaId: context.worker, mcpEntry: MCP_SERVER, + }).mcp; + const createPlan = unsupported.createSession({ + areaId: context.worker, worktree: '/tmp/codex-runtime-dir', promptFile: '/tmp/prompt.md', + firstMessage: 'Start.', mcp: unsupportedMcp, + }); + const resumePlan = unsupported.resumeSession({ + areaId: context.worker, runtimeSessionId: 'thread-runtime-dir', worktree: '/tmp/codex-runtime-dir', + message: 'Resume.', mcp: unsupportedMcp, + }); + for (const plan of [createPlan, resumePlan]) { + assert.equal(plan.launch.args.some((arg) => /env_vars=/.test(arg)), false); + } + } +}); + test('SCN-mcp-stdio: an MCP host can initialize and list the TORCH tool surface over stdio', async () => { const context = fixture(); const child = spawn(process.execPath, [MCP_SERVER, '--root', context.root, '--area', context.worker], { From 25a3c7c1b5ebbd91c1122d6bde884c1ae78ac582 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN Date: Fri, 2 Oct 2026 22:27:25 -0400 Subject: [PATCH 09/51] fleet: prioritize operational reliability before console polish --- .torch/prompts/COMMON.md | 18 ++++++++++++++++++ docs/TODO.md | 22 ++++++++++++++++++++++ 2 files changed, 40 insertions(+) diff --git a/.torch/prompts/COMMON.md b/.torch/prompts/COMMON.md index 56986db..261d491 100644 --- a/.torch/prompts/COMMON.md +++ b/.torch/prompts/COMMON.md @@ -2,6 +2,24 @@ Repository state outranks conversation memory. Query ownership before crossing a domain boundary. +## Owner priority update: reliability before visual polish (2026-10-03 UTC) + +The owner requested that TORCH's operating defects be fixed first so the fleet +benefits from improved operation. This supersedes the UI-first ordering below, +not the existing resume authorization or safety boundaries. The authoritative +backlog priorities were changed through the revision-checked owner API. + +Sequence the smallest safe current-schema stopped-unknown recovery and CLI +qualification first; then truthful lifecycle/status and their source-safety +prerequisites; then qualified bounded continuation/event-driven wakes under +pause, budget and authority policy; then actionable operational attention. +Compact cards/detail modals and coordinator Chat remain high-priority work after +these reliability gates. Do not require the entire future company architecture +to deliver a safe minimal recovery. Reprioritization does not resolve blockers, +grant test consent, enable automatic wakes or launch agents. Preserve task owners, +dependencies and evidence; explicitly block/resequence existing assignments when +needed rather than replacing them silently or creating concurrent assignments. + ## Owner-approved fleet resume and priority polish cycle (2026-10-02) The owner explicitly said: "We got a reset. lets get torch as pollished as we can. diff --git a/docs/TODO.md b/docs/TODO.md index 10a1d9b..44ea4c8 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -1,5 +1,27 @@ # TORCH remaining work +## Current owner priority: reliability first (2026-10-03 UTC) + +This ordering supersedes the UI-first ordering below. The existing authoritative +backlog, not this index, controls task state and assignment. + +1. Safe current-schema runtime recovery (`TASK-company-runtime-recovery`) and + combined CLI qualification (`TASK-pilot-cli-suite-timeout`). +2. Truthful startup/session status (`TASK-pilot-starting-presence`) and its + existing Kernel reservation/Work candidate-safety prerequisites. +3. Persistent operations and bounded event-driven continuation + (`TASK-conversation-persistent-operations`, + `TASK-conversation-event-driven-wakes`), followed by actual scheduler + qualification (`TASK-conversation-scheduler-qualification`). +4. Specific actionable operational warnings (`TASK-console-attention-actions`). +5. Compact cards/detail modals and coordinator-first Chat remain high priority. + +The first four groups and incomplete technical prerequisites are urgent; +scheduler qualification is high. These priority-only changes preserve owners, +states, dependencies and evidence. Blocked remains blocked; automatic wakes stay +disabled until qualified and explicitly activated. Older pause/install/status +statements below are historical snapshots, not current runtime evidence. + ## Priority UI cycle: compact cards and agent Chat Owner-approved [Console UI spec](CONSOLE_CHAT_AND_COMPACT_CARDS_SPEC.md). From 11dabd2450f5cb40104557a684c3efbf763f3900 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN Date: Fri, 2 Oct 2026 23:49:59 -0400 Subject: [PATCH 10/51] runtime: recover stopped legacy sessions and add bounded continuation --- .torch/prompts/COMMON.md | 20 ++ docs/TEST_INTEGRITY.md | 57 ++++++ docs/TODO.md | 8 + .../2026-10-03-legacy-runtime-recovery.md | 93 ++++++++++ src/cli.mjs | 49 +++++ src/control-plane/service.mjs | 33 +++- src/runtime/continuation.mjs | 92 ++++++++++ src/runtime/lifecycle.mjs | 3 + src/runtime/stopped-executor-evidence.mjs | 106 +++++++++++ src/runtime/turn-guard.mjs | 31 ++++ test/fleet/legacy-runtime-recovery.test.mjs | 171 ++++++++++++++++++ 11 files changed, 662 insertions(+), 1 deletion(-) create mode 100644 docs/spec_changes/2026-10-03-legacy-runtime-recovery.md create mode 100644 src/runtime/continuation.mjs create mode 100644 src/runtime/stopped-executor-evidence.mjs create mode 100644 src/runtime/turn-guard.mjs create mode 100644 test/fleet/legacy-runtime-recovery.test.mjs diff --git a/.torch/prompts/COMMON.md b/.torch/prompts/COMMON.md index 261d491..c5c5a41 100644 --- a/.torch/prompts/COMMON.md +++ b/.torch/prompts/COMMON.md @@ -2,6 +2,26 @@ Repository state outranks conversation memory. Query ownership before crossing a domain boundary. +## Owner-requested restart and continuation repair (2026-10-03 UTC) + +The owner said "yeah fix the issue" after the arbiter verified that the fleet +had stopped making progress. The arbiter implemented a schema-compatible, +owner-only manual legacy recovery and a bounded deterministic continuation pilot. +These are source changes pending independent QA and installed-release acceptance, +not completed umbrella tasks. Read +docs/spec_changes/2026-10-03-legacy-runtime-recovery.md. The arbiter may apply exact +tested stopped-invocation recovery and resume the existing seven identities with +the source CLI. Preserve old unknown/failure receipts and existing unique commits. + +The source controller may launch only the explicitly approved existing roster, +under its daily turn cap, event deduplication and per-identity physical turn guard. +It does not grant agents permission to start providers or bypass budgets, resource +leases, approval boundaries or task ownership. Existing legacy manager-wake +configuration stays disabled; this source pilot is a distinct, bounded mechanism. +Manager should process current inbox and route real next work rather than leaving +it at a proposal-only loop. QA may independently review the exact new source and +scenario packet now; no fabricated QA acceptance is implied by owner implementation. + ## Owner priority update: reliability before visual polish (2026-10-03 UTC) The owner requested that TORCH's operating defects be fixed first so the fleet diff --git a/docs/TEST_INTEGRITY.md b/docs/TEST_INTEGRITY.md index a35e033..ee96a2a 100644 --- a/docs/TEST_INTEGRITY.md +++ b/docs/TEST_INTEGRITY.md @@ -2811,3 +2811,60 @@ test_integrity_note: did_relax_any_assertion: false if_true_explain_spec_basis: "" ``` +# Owner-requested legacy recovery and bounded continuation (2026-10-03) + +Implementation and behavioral basis: +[legacy runtime recovery](spec_changes/2026-10-03-legacy-runtime-recovery.md). +Existing assertions are unchanged; the new scenarios are in +`test/fleet/legacy-runtime-recovery.test.mjs`. + +```yaml +test_integrity_note: + change_type: new_tests + scenarios: + - id: SCN-legacy-runtime-recovery + given: "A stopped exact invocation and retained unknown identity" + when: "The owner recovers presence" + then: "One owner audit, offline presence and preserved task/session/unknown" + - id: SCN-legacy-runtime-recovery-refusal + given: "Live or mismatched invocation/process evidence" + when: "Proof is requested" + then: "Recovery refuses without mutation" + - id: SCN-legacy-runtime-recovery-cas + given: "Stale, expired, forged or newly live evidence" + when: "Recovery applies" + then: "Transaction refuses without success audit" + - id: SCN-legacy-runtime-recovery-authority + given: "A specialist or incomplete expected snapshot" + when: "Owner recovery is attempted" + then: "Authority/snapshot validation refuses" + - id: SCN-runtime-turn-guard + given: "A physically live executor reporting idle" + when: "A second turn tries to start" + then: "The executor mutex refuses overlap until terminal release" + - id: SCN-runtime-continuation + given: "Owner-enabled roster and a new durable signal" + when: "Turns repeat, hit a cap or are paused" + then: "Only new eligible signals launch within the owner daily cap" + - id: SCN-runtime-continuation-pause + given: "Two eligible turns" + when: "The owner pauses during the first" + then: "The second does not start and pause is retained" + - id: SCN-legacy-runtime-recovery-project + given: "Cloned project/session metadata in distinct checkouts" + when: "Proof crosses checkout boundaries" + then: "Recovery refuses and leaves the other identity untouched" + observable_outcomes: + - "Persisted identity, task/session, owner audit, launch count and refusal codes" + determinism_controls: + - "Hermetic repositories, fixed clock and OS-boundary fixtures; no live provider" + anti_cheat_rationale: + prevents: + - "Boolean stopped proof or stop-as-PASS" + - "Stale/cross-project mutations and peer impersonation" + - "Duplicate physical turns or unchanged-signal busy loops" + - "Losing pause or refunding interrupted attempts" + relaxation: + did_relax_any_assertion: false + if_true_explain_spec_basis: "" +``` diff --git a/docs/TODO.md b/docs/TODO.md index 44ea4c8..e422a66 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -22,6 +22,14 @@ states, dependencies and evidence. Blocked remains blocked; automatic wakes stay disabled until qualified and explicitly activated. Older pause/install/status statements below are historical snapshots, not current runtime evidence. +Owner-requested recovery/continuation implementation is described in +[the source pilot specification](spec_changes/2026-10-03-legacy-runtime-recovery.md). +Do not close the runtime-recovery, persistent-operations or event-wake umbrellas +from focused tests alone. Release/Fleet Operations still need installer-owned +permanent timer provisioning/removal, installed artifact qualification and restart +recovery of retained crashed turn/controller guards. The transient host pilot does +not survive reboot, and its daily turn cap is not a token/dollar usage governor. + ## Priority UI cycle: compact cards and agent Chat Owner-approved [Console UI spec](CONSOLE_CHAT_AND_COMPACT_CARDS_SPEC.md). diff --git a/docs/spec_changes/2026-10-03-legacy-runtime-recovery.md b/docs/spec_changes/2026-10-03-legacy-runtime-recovery.md new file mode 100644 index 0000000..405af54 --- /dev/null +++ b/docs/spec_changes/2026-10-03-legacy-runtime-recovery.md @@ -0,0 +1,93 @@ +# Manual owner recovery for retained unknown executor outcomes + +The owner requested fixing the stalled fleet. Legacy runtime records lacked a +recovery API: three physically stopped executors remained `working/unknown` and +correctly refused duplicate launches. This change adds an owner-only, previewed +`recover-runtime` operation without schema changes or automatic recovery. + +The owner explicitly selects a project-scoped systemd service invocation. Its +working directory and exact area argument must match this installation; its +terminal timestamp must match the retained identity update within five seconds +(systemd displays timestamps with one-second precision). Both service PIDs must +be zero, its cgroup absent, and no visible same-user native process may reference +the session or registered workspace. Physical state is checked again at apply. +Unreadable process visibility, an active service, foreign invocation or changed +identity refuses recovery. This is a **manual legacy operator binding**, not +cryptographic session provenance or protection against arbitrary same-user code. + +Evidence is process-local, branded, single-use and expires after sixty seconds. +The service transaction compares the exact state/runtime/session/update snapshot, +sets presence to offline and writes an owner-attributed audit. Session, current +task and the unknown outcome are preserved. No task, test or launch is completed; +no process is killed, lease removed, schema migrated or provider started. + +Preview and apply use the same implementation. Apply additionally requires the +session and update timestamp from the preview. Hosts/providers without verifiable +Linux/systemd evidence refuse this legacy recovery; no Boolean fallback exists. + +## Bounded continuation pilot + +`torch continuation enable --max-turns-per-day 12 --yes` approves only the +currently activated identity roster. `status` previews eligible identities; +`tick --limit 3 --yes` runs at most three normal guarded `up` operations. New +unacknowledged messages or assigned/in-progress task revision changes supply the +signal; an unchanged signal is not retried. Active/unknown identities, held turn +guards, detach, disabled policy and the daily UTC attempt cap prevent launch. +Attempts are charged before invocation, including failed/interrupted attempts. +The cap limits turns, **not dollars or token consumption**. Unknown quota remains +unknown. This controller makes no product/approval/assignment decisions. + +`torch continuation pause --yes` prevents subsequent launches. Policy and attempt +ledger are separate atomic files so an in-flight turn cannot overwrite a pause. +Pause does not kill an already-running turn. A controller lock serializes ticks; +a crashed lock is retained rather than expired automatically. Each executor has +a separate physical turn guard held through classification and final presence, +so an agent reporting idle cannot permit overlapping native turns. + +The first host pilot uses an explicitly installed transient user-systemd timer. +It survives this chat ending, but not a host reboot. Permanent installer-owned +provisioning/removal and installed artifact qualification remain existing backlog +work; this is not a claim that the alpha.3 installed package has been upgraded. + +```yaml +test_integrity_note: + change_type: new_tests + scenarios: + - id: SCN-legacy-runtime-recovery + given: "An exact retained working/unknown identity and stopped project invocation" + when: "The owner previews and applies recovery" + then: "Presence becomes offline once; session, task and unknown outcome persist with one owner audit" + - id: SCN-legacy-runtime-recovery-refusal + given: "Live, foreign, mismatched or incomplete stopped-executor evidence" + when: "Recovery evidence is requested" + then: "Recovery refuses without identity mutation" + - id: SCN-legacy-runtime-recovery-cas + given: "Issued proof followed by a changed identity, live process, expiration or forgery" + when: "Recovery is applied" + then: "Transactional recovery refuses and creates no success audit" + - id: SCN-legacy-runtime-recovery-authority + given: "A specialist actor or incomplete expected snapshot" + when: "Owner recovery is requested" + then: "Authority or snapshot validation refuses" + - id: SCN-runtime-turn-guard + given: "A physical executor holds an identity guard and reports idle early" + when: "Another executor attempts the same identity" + then: "It refuses until the first executor finishes and releases its own guard" + - id: SCN-runtime-continuation + given: "An explicitly enabled roster, a new durable signal and a fixed daily turn cap" + when: "Continuation ticks, repeats a signal, exhausts its cap or is paused" + then: "Only eligible new signals launch; deduplication, cap, owner authority and pause remain enforced" + observable_outcomes: + - "Durable presence, session and task state; owner-attributed audit; explicit refusal codes" + determinism_controls: + - "Hermetic Git/install fixtures, fixed clock and injected OS observation boundary" + anti_cheat_rationale: + prevents: + - "Caller Boolean as stopped proof" + - "Process stop promoted to successful task or test" + - "Stale snapshot overwrites or silent peer impersonation" + - "Expired, foreign or replayed proof" + relaxation: + did_relax_any_assertion: false + if_true_explain_spec_basis: "" +``` diff --git a/src/cli.mjs b/src/cli.mjs index e136d1d..0b7a0f7 100644 --- a/src/cli.mjs +++ b/src/cli.mjs @@ -70,6 +70,8 @@ import { } from './design/architect.mjs'; import { FleetEvolutionService } from './evolution/service.mjs'; import { HierarchyEvolutionService } from './evolution/hierarchy.mjs'; +import { issueStoppedExecutorEvidence } from './runtime/stopped-executor-evidence.mjs'; +import { configureContinuation, planContinuation, runContinuation } from './runtime/continuation.mjs'; import { ConvergenceService } from './convergence/service.mjs'; import { attachForge, detachForge, forgeStatus, planForgeAttach, planForgeDetach, @@ -95,6 +97,8 @@ Usage: torch install --restore [--dry-run] [--yes] [--json] torch setup [--parent ] [--dry-run] [--yes] [--json] torch up [--fresh] [--only ] [--dry-run] [--yes] [--json] + torch recover-runtime --area --unit --invocation [--expected-session --expected-updated-at