Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -17,3 +17,6 @@ dist/
.relayflow/
.relayflowd/
.relayflowd-*/

# Legacy drive-local snapshots; the preparing launcher no longer executes them.
.drive-gate/
3 changes: 3 additions & 0 deletions ops/BACKLOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -238,6 +238,9 @@ complete. Recorded here so they are tracked rather than lost — found by tick
message.
- **F8b** — `validateKernelRetry` names an authoring rule as if the kernel
imposed it — a doc claim the kernel does not make.
Scope: `packages/sdk/src/compile.ts`. Use `validateAuthoringRetryDefaults`
for the authoring validator and its call site; remove the old identifier.
Verify: ["node", "--input-type=module", "-e", "import assert from 'node:assert/strict'; import {readFileSync} from 'node:fs'; const source = readFileSync('packages/sdk/src/compile.ts', 'utf8'); assert(!source.includes('validateKernelRetry')); assert(source.includes('function validateAuthoringRetryDefaults(')); assert(source.includes('validateAuthoringRetryDefaults(step['));"]
- **F9** — `probeTrigger` catches every error with no classification, so a
broken probe environment is indistinguishable from a bad trigger.
- **F10** — small load-bearing boundary details a reader will trip over.
Expand Down
87 changes: 87 additions & 0 deletions ops/DRIVE-LOCAL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
# Local drive packages

Launch from a trusted checkout with the SDK dependencies installed:

```sh
node scripts/run-drive-local.mjs
```

Run on the branch that should receive the diff, from a clean checkout. The
flow leaves delivery to the operator. It does not commit or merge. The wrapper
builds the SDK for the local launcher, pins the original HEAD and branch, and
submits commands through the existing local launcher. The daemon journals those
pins before implementation starts. Running the YAML template directly refuses
the snapshot step because its pinned inputs are missing.

`gate-snapshot` runs first. It extracts the package helper, verifier and SDK
picker source with `git --no-replace-objects show <head>:<path>`, then compiles
that picker in a temporary directory outside the checkout. Its own extraction
script also comes from that Git ref. Neither working-tree helper files nor
ignored SDK dist supply gate inputs. The installed TypeScript compiler is a
trusted toolchain dependency; no compiler package is copied into the temp dir.

Selection records the same ref in the work package as `head`. Subsequent checks
compare that field to the submitted pin, so changing package metadata cannot
repin it after a commit. The ref supplies the gate-input integrity claim; there
is no SHA256SUMS file beside the scripts. The launcher removes the temporary
directory when its run returns.

Selection uses the SDK backlog picker. A locally executable entry must name
repository paths committed at HEAD in backticks and declare at least one acceptance
command on an indented `Verify:` line. Each command is a JSON argv array,
executed at the repository root with a two-minute bound. For example:

```text
- **Fix the value** Update `src/value.txt` to contain exactly "fixed".
Verify: ["node", "-e", "require('node:assert/strict').equal(require('node:fs').readFileSync('src/value.txt','utf8'),'fixed')"]
```

Multiple `Verify:` lines mean all commands must pass. Commands come from the
backlog before implementation; the agent cannot substitute its own acceptance
checks in package.json. Entries with no executable checks are skipped with
`missing_executable_checks`, alongside the existing unbounded/stale scope
reasons. The old F8b entry now carries a source assertion for its declared
rename. That assertion also allows an already-completed package to pass
without manufacturing another edit.

The submitted scope step executes the helpers extracted from Git and refuses changes
to its checkout sources and backlog. It checks the working tree and
index against the selected HEAD, including untracked non-ignored files and
both sides of renames. Scope uses exact paths or directory descendants, never
string-prefix siblings. Backlog, verifier and gate changes fail even when a
package names a containing directory. Symlink changes are refused. Ignored
build/runtime artifacts are excluded from this Git diff boundary; it is not an
OS filesystem sandbox. Moving HEAD or branch fails against the submitted pins,
even if the agent updates the ignored package metadata to match.
Pre-existing unresolved symlinks under directory scopes are refused: creating
a file through a dangling symlink can write outside the checkout.

Verification reconstructs the selected work from the unchanged backlog and
compares its scope and commands to package.json. Each package check must pass
before the SDK regression suite runs. An unchanged implementation whose DoD
is unmet fails. HEAD/branch changes fail, and out-of-scope changes produced by
an acceptance command fail too. A failed scope or verification step prevents
the dependent report step from running.

The scope command runs again after the SDK build and suite, before reporting.
Reports include tracked changes against HEAD and non-ignored untracked paths.

The execution contract has one owner for each kind of data:

| Input or policy | Owner and validation |
| --- | --- |
| Gate inputs | Package helper, verifier, extraction script and picker source come from the pinned Git commit, with replacement objects disabled. The picker is built during `gate-snapshot` before selection or implementation. |
| HEAD, branch, backlog hash | Preparing launcher pins the original commit/branch and the hash of its committed backlog in submitted commands. The package records the commit as `head`; no adjacent ref or checksum file is authority. |
| Package metadata | Private atomic JSON artifact for the agent; scope, verify and report reconstruct its fields from the picker built from the pinned ref and the pinned backlog. It cannot redefine the original HEAD. |
| Allowed paths and protected paths | `local-work-verification.mjs` extracted from the pinned ref; index and working tree checked separately against the original HEAD, including non-ignored untracked files. |
| Acceptance argv | Parsed from the pinned backlog; every command must succeed, with scope rechecked after execution. |
| Acceptance scripts and dependencies | **Open blocker:** arbitrary argv can load mutable source from implementation scope. |

**A same-user agent can still write to the temporary execution directory.**
This meets the narrower bar that gate inputs come from a pinned Git ref rather
than files implementation edits. It does not make extracted runtime files
immutable, isolate processes, or prevent arbitrary Git-storage tampering.

The remaining acceptance-input decision is documented in
`runtime-evidence/drive-threads-0909-decisions.md`. This flow is not ready for
unattended use until those inputs have an explicit enforced ownership contract.
4 changes: 4 additions & 0 deletions ops/RUNTIME-STATUS.md
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,10 @@ Its runtime behavior was not tested or inferred from a successful v2 check.
## Work package execution and delivery

Scaffold commit: `89f2f1d31f262420c2c5f613efa3f50c70153bfa`.
The command and receipts below describe that historical scaffold. The current
local drive uses `node scripts/run-drive-local.mjs` to capture gate inputs before
submission; see [DRIVE-LOCAL.md](DRIVE-LOCAL.md) for its remaining acceptance-input
blocker.
From a clean work branch at that commit, after the setup above:

```sh
Expand Down
67 changes: 67 additions & 0 deletions ops/drive-local-flow.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import assert from 'node:assert/strict';
import { spawnSync } from 'node:child_process';
import { chmodSync, existsSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { join, resolve } from 'node:path';
import test from 'node:test';
import { fixture, packagePath } from './local-work-test-fixture.mjs';

const quote = text => "'" + text.replaceAll("'", "'\\''") + "'";
for (const scenario of ['outside edit', 'verifier edit', 'unchanged package', 'HEAD repin', 'forged snapshot']) {
test(`drive-local journals failure and blocks reporting for ${scenario}`, t => {
const f = fixture(t);
const wrapper = resolve('testdata/preflight/wrapper-session.mjs');
const cli = join(f.root, '.relayflow/agent.mjs');
f.put('.relayflow/agent.mjs', `#!/usr/bin/env node
import { receiveWrapperRequest } from ${JSON.stringify(wrapper)};
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import { execFileSync } from 'node:child_process';
const request = await receiveWrapperRequest();
if (request) {
${scenario === 'outside edit' ? `writeFileSync(${JSON.stringify(join(f.root, 'outside.txt'))}, 'changed');` : ''}
${scenario === 'verifier edit' ? `writeFileSync(${JSON.stringify(join(f.root, 'ops/local-work-package.mjs'))}, 'process.exit(0)');` : ''}
${scenario === 'HEAD repin' ? `
const git = (...args) => execFileSync('git', args, {cwd: ${JSON.stringify(f.root)}, encoding: 'utf8'}).trim();
writeFileSync(${JSON.stringify(join(f.root, 'outside.txt'))}, 'changed');
git('add', 'outside.txt');
git('-c', 'user.name=Fixture', '-c', 'user.email=fixture@example.test', '-c', 'commit.gpgsign=false', 'commit', '-qm', 'outside');
const path = ${JSON.stringify(join(f.root, packagePath))};
const pkg = JSON.parse(readFileSync(path, 'utf8'));
pkg.head = git('rev-parse', 'HEAD');
writeFileSync(path, JSON.stringify(pkg));` : ''}
${scenario === 'forged snapshot' ? `
const directory = ${JSON.stringify(join(f.root, '.drive-gate'))};
mkdirSync(directory, {recursive: true});
writeFileSync(directory + '/local-work-package.mjs', 'process.exit(0);');
const sums = execFileSync('shasum', ['-a', '256', '.drive-gate/local-work-package.mjs'], {cwd: ${JSON.stringify(f.root)}});
writeFileSync(directory + '/SHA256SUMS', sums);` : ''}
console.log('DONE');
}
`);
// A scripted worker controls the edit while using the real worker protocol,
// stream pin, submitted flow commands, daemon and journal.
chmodSync(cli, 0o755);
const spec = structuredClone(f.preparedFlow);
for (const step of spec.steps) {
if (step.type === 'agent') step.cli = cli;
else step.command = `cd ${quote(f.root)}\n${step.command}`;
}
const path = join(f.root, '.relayflow/flow.json');
writeFileSync(path, JSON.stringify(spec));
const result = spawnSync(process.execPath, ['scripts/run-local-workflow.mjs', path], {
encoding: 'utf8', timeout: 20000,
});
assert.equal(result.error, undefined, result.stderr);
assert.equal(result.status, 1, result.stderr + result.stdout);
const dataDir = result.stdout.match(/^LOCAL_DATA_DIR=(.+)$/m)?.[1];
assert(dataDir, result.stderr);
t.after(() => rmSync(dataDir, { recursive: true, force: true }));
const journal = readFileSync(join(dataDir, 'journal.jsonl'), 'utf8').trim().split('\n').map(JSON.parse);
const failedStep = ['unchanged package', 'forged snapshot'].includes(scenario) ? 'verify' : 'scope';
const completion = journal.find(e => e.entry_type === 'step.completed' && e.step_id === failedStep);
assert.equal(completion?.payload.verification.verdict, 'fail', JSON.stringify(journal));
assert.equal(completion.payload.completionReason, 'retries_exhausted');
assert.equal(completion.payload.verification.detail, 'exit code was 1');
assert(!journal.some(e => e.entry_type === 'step.attempt.started' && e.step_id === 'report'));
assert(!existsSync(join(dataDir, 'relayflowd.sock')));
});
}
49 changes: 49 additions & 0 deletions ops/local-work-gate.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
// Pin the Git ref before submission; gate-snapshot extracts only its Git objects.
// The temporary execution directory is not a same-user filesystem sandbox.
import assert from 'node:assert/strict';
import { execFileSync } from 'node:child_process';
import { createHash } from 'node:crypto';
import { mkdtempSync, rmSync } from 'node:fs';
import { join, resolve } from 'node:path';
import { tmpdir } from 'node:os';
import { fileURLToPath, pathToFileURL } from 'node:url';

export const shellQuote = value => "'" + value.replaceAll("'", "'\\''") + "'";
export function prepareLocalDrive(flow, { root = process.cwd(),
compiler = fileURLToPath(new URL('../packages/sdk/node_modules/typescript/bin/tsc', import.meta.url)) } = {}) {
assert.equal(flow.name, 'drive-local', 'NOT_LOCAL_DRIVE');
const git = (...args) => execFileSync('git', ['--no-replace-objects', ...args], { cwd: root, encoding: 'utf8' });
const head = git('rev-parse', 'HEAD').trim();
const baseline = {
head,
branch: git('branch', '--show-current').trim(),
backlogSha256: createHash('sha256').update(git('show', `${head}:ops/BACKLOG.md`)).digest('hex'),
};
assert(baseline.branch && baseline.branch !== 'main', 'LOCAL_DRIVE_REFUSED: use a work branch');
const snapshot = git('show', `${head}:ops/local-work-snapshot.mjs`);
const directory = mkdtempSync(join(tmpdir(), 'drive-gate-'));
const dispose = () => rmSync(directory, { recursive: true, force: true });
try {
const picker = pathToFileURL(join(directory, 'dist/backlog-picker.js')).href;
const prefix = `GIT_NO_REPLACE_OBJECTS=1 DRIVE_GATE_BASELINE=${shellQuote(JSON.stringify(baseline))} ` +
`DRIVE_GATE_PICKER=${shellQuote(picker)} ${shellQuote(process.execPath)} ` +
shellQuote(join(directory, 'local-work-package.mjs'));
const commands = Object.fromEntries(['select', 'scope', 'verify', 'report']
.map(operation => [operation, `${prefix} ${operation}`]));
const prepared = structuredClone(flow);
const snapshotStep = prepared.steps.find(step => step.id === 'gate-snapshot');
assert.equal(snapshotStep?.command, 'node ops/local-work-snapshot.mjs', 'DRIVE_SNAPSHOT_CHANGED');
snapshotStep.command = `${shellQuote(process.execPath)} --input-type=module --eval ${shellQuote(snapshot)} ` +
`local-drive-snapshot ${shellQuote(head)} ${shellQuote(directory)} ${shellQuote(resolve(compiler))}`;
const operations = { select: 'select', 'initial-scope': 'scope', scope: 'scope',
verify: 'verify', 'final-scope': 'scope', report: 'report' };
for (const [id, operation] of Object.entries(operations)) {
const step = prepared.steps.find(candidate => candidate.id === id);
assert(step?.type === 'deterministic', `DRIVE_STEP_CHANGED: ${id}`);
const call = `node ops/local-work-package.mjs ${operation}`;
assert.equal(step.command.split(call).length, 2, `DRIVE_COMMAND_CHANGED: ${id}`);
step.command = step.command.replace(call, commands[operation]);
}
return { flow: prepared, commands, baseline, directory, dispose };
} catch (error) { dispose(); throw error; }
}
55 changes: 55 additions & 0 deletions ops/local-work-gate.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import assert from 'node:assert/strict';
import { spawnSync } from 'node:child_process';
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import test from 'node:test';
import { entry, fixture, packagePath, pass, fail } from './local-work-test-fixture.mjs';

test('committing outside scope and repinning metadata cannot replace the submitted HEAD', t => {
const f = fixture(t);
pass(f.run('select'));
f.put('outside.txt', 'committed outside scope');
f.git('add', 'outside.txt');
f.git('-c', 'user.name=Fixture', '-c', 'user.email=fixture@example.test',
'-c', 'commit.gpgsign=false', 'commit', '-qm', 'outside scope');
const pkg = JSON.parse(readFileSync(join(f.root, packagePath), 'utf8'));
pkg.head = f.git('rev-parse', 'HEAD').toString().trim();
f.put(packagePath, JSON.stringify(pkg));
for (const operation of ['scope', 'verify', 'report']) fail(f.run(operation), /HEAD_MOVED/);
});

test('forged legacy checkout snapshot and compiled picker cannot replace the Git-extracted judge', t => {
const f = fixture(t);
pass(f.run('select'));
for (const path of ['.drive-gate/local-work-package.mjs', '.drive-gate/local-work-verification.mjs',
'.drive-gate/backlog-picker.js', 'packages/sdk/dist/backlog-picker.js']) {
f.put(path, 'process.exit(0);\n');
}
pass(spawnSync('sh', ['-c', 'shasum -a 256 .drive-gate/*.mjs .drive-gate/*.js > .drive-gate/SHA256SUMS'],
{ cwd: f.root, encoding: 'utf8' }));
pass(f.scope());
fail(f.run('verify'), /PACKAGE_CHECK_FAILED/);
f.put('src/value.txt', 'fixed');
pass(f.run('verify'));
pass(f.run('report'));
});

test('raw template refuses selection before implementation without captured gate inputs', t => {
const f = fixture(t);
const env = { ...process.env };
delete env.DRIVE_GATE_PICKER;
delete env.DRIVE_GATE_BASELINE;
fail(spawnSync(process.execPath, ['ops/local-work-package.mjs', 'select'],
{ cwd: f.root, encoding: 'utf8', env }), /LOCAL_DRIVE_NOT_PREPARED/);
});

for (const title of ['Same title', 'Regex [a].* (b) + $']) {
test(`selection advances past rejected duplicate title: ${title}`, t => {
const f = fixture(t, entry(title, 'missing/value.txt') + entry(title));
const result = f.run('select');
pass(result);
assert(result.stdout.includes(`SKIPPED [stale_scope: missing/value.txt] ${title}`));
const pkg = JSON.parse(readFileSync(join(f.root, packagePath), 'utf8'));
assert.deepEqual(pkg.filesInScope, ['src/value.txt']);
});
}
Loading
Loading