fix(trekendsession): Phase 3 re-reads the arguments instead of splicing the label into the shell line

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Kjell Tore Guttormsen 2026-10-26 16:12:44 +01:00
commit 89975a1fd6
Signed by: ktg
SSH key fingerprint: SHA256:JakMjO6FTBBzN0Bhfj9saOoEjaFxlSdYuZQQpM/lF9Q
5 changed files with 223 additions and 15 deletions

View file

@ -11,9 +11,12 @@
import { test } from 'node:test';
import { strict as assert } from 'node:assert';
import { readFileSync, readdirSync } from 'node:fs';
import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { dirname, join } from 'node:path';
import { spawnSync } from 'node:child_process';
import { fileURLToPath } from 'node:url';
import { childEnv } from '../helpers/child-env.mjs';
const HERE = dirname(fileURLToPath(import.meta.url));
const ROOT = join(HERE, '..', '..');
@ -125,3 +128,108 @@ test('trekendsession Phase 4 — validator call is runtime Bash (no eager prefix
'Phase 4 must not use <angle> placeholders in commands — zsh parses <x> as input redirection',
);
});
// ---------------------------------------------------------------
// DJ5 (fix round 4.5b): Phase 3 splices no label into its shell line.
// The block used to end in '{project_dir}' '{next_brief_path}'
// '{next_label}', filled in by the model: a label holding a ' closed the
// quote and the rest ran as shell. Phase 3 now re-reads $ARGUMENTS through
// the quoted heredoc on stdin, parses it with parseArgs and re-derives the
// project directory itself. The runtime tests run the block as Claude Code
// would after it substitutes ${CLAUDE_PLUGIN_ROOT} and $ARGUMENTS.
// ---------------------------------------------------------------
const ARGS_DELIM = 'VOYAGE_ARGS_EOF_7F3C9E';
const PROJECT_REL = join('.claude', 'projects', '2026-01-01-demo');
const NEXT_BRIEF = '.claude/projects/2026-01-01-demo/next-brief.md';
/** The one runtime ```bash block of Phase 3 (the atomic-write block). */
function phase3Block() {
const phase3 = extractPhase(readCommand(), '## Phase 3 ');
const blocks = phase3.split('\n```bash\n').slice(1).map((b) => b.split('\n```')[0])
.filter((b) => b.includes('atomic-write.mjs'));
assert.equal(blocks.length, 1, `Phase 3 must hold one \`\`\`bash block that runs atomic-write.mjs; found ${blocks.length}`);
return blocks[0];
}
/** True when `bin` can be started from PATH. */
function onPath(bin) {
const r = spawnSync(bin, ['-c', 'exit 0'], { env: childEnv(), encoding: 'utf8' });
return r.error === undefined && r.status === 0;
}
/**
* Run the Phase 3 block with `shell` in a fresh fixture project, with
* `argText` standing where Claude Code puts $ARGUMENTS. Returns the result,
* what the run wrote, and whether a PWNED file appeared; the fixture is gone
* on return.
*/
function runPhase3(shell, argText) {
const block = phase3Block();
assert.ok(block.includes('$ARGUMENTS'),
'Phase 3 does not re-read $ARGUMENTS: its values still come from text the model splices into the shell line');
const script = block.split('${CLAUDE_PLUGIN_ROOT}').join(ROOT).split('$ARGUMENTS').join(argText);
const fixture = mkdtempSync(join(tmpdir(), 'trekendsession-dj5-'));
try {
mkdirSync(join(fixture, PROJECT_REL), { recursive: true });
writeFileSync(join(fixture, PROJECT_REL, 'brief.md'), '# demo brief\n');
const r = spawnSync(shell, ['-c', script], { cwd: fixture, env: childEnv(), encoding: 'utf8' });
const stateFile = join(fixture, PROJECT_REL, '.session-state.local.json');
const promptFile = join(fixture, PROJECT_REL, 'NEXT-SESSION-PROMPT.local.md');
return {
r,
pwned: ['PWNED', 'PWNED2'].filter((f) => existsSync(join(fixture, f))),
state: existsSync(stateFile) ? JSON.parse(readFileSync(stateFile, 'utf8')) : null,
prompt: existsSync(promptFile) ? readFileSync(promptFile, 'utf8') : null,
};
} finally {
rmSync(fixture, { recursive: true, force: true });
}
}
/** Assert one run wrote `label` whole and ran nothing from it. */
function assertLabelKept(shell, label) {
const { r, pwned, state, prompt } = runPhase3(shell, `${NEXT_BRIEF} ${label}`);
const how = `${shell}, label ${JSON.stringify(label)}: exit ${r.status}\n${r.stdout}${r.stderr}`;
assert.deepEqual(pwned, [], `the label ran as shell under ${how}`);
assert.equal(r.status, 0, `Phase 3 did not run cleanly under ${how}`);
assert.ok(state, `no .session-state.local.json was written under ${how}`);
assert.equal(state.next_session_label, label, `the written label is not the typed one under ${how}`);
assert.equal(state.next_session_brief_path, NEXT_BRIEF, `the written brief path is not the typed one under ${how}`);
assert.equal(state.project, PROJECT_REL, `the project directory was not re-derived under ${how}`);
assert.ok(prompt && prompt.includes(`\n# ${label}\n`), `NEXT-SESSION-PROMPT.local.md does not carry the label under ${how}`);
}
test('[DJ5] Phase 3 has no quoted placeholder for the label, brief or project', () => {
const block = phase3Block();
const placeholders = block.match(/\{[a-z][a-z0-9]*(?:[_ -][a-z0-9]+)*\}/g) || [];
assert.deepEqual(placeholders, [], 'Phase 3 still has the model fill {placeholders} into its shell line');
assert.doesNotMatch(block, /'\{/, 'Phase 3 still quotes a placeholder as a shell word');
const lines = block.split('\n');
const open = lines.findIndex((l) => l.trimEnd().endsWith(`<<'${ARGS_DELIM}'`));
assert.ok(open !== -1, `Phase 3 does not re-read the arguments through the quoted heredoc <<'${ARGS_DELIM}'`);
assert.equal(lines[open + 1], '$ARGUMENTS', 'the line inside the Phase 3 heredoc is not $ARGUMENTS alone');
const filled = lines.filter((l) => l.trim() !== '');
assert.ok(lines[open + 2] === ARGS_DELIM && filled[filled.length - 1] === ARGS_DELIM,
`the closing line ${ARGS_DELIM} does not follow $ARGUMENTS as the block's last non-empty line`);
assert.equal(lines.filter((l, i) => i !== open + 1 && l.includes('$ARGUMENTS')).length, 0,
'a line outside the Phase 3 heredoc carries $ARGUMENTS');
assert.match(block, /import \{ parseArgs \} from '\$\{CLAUDE_PLUGIN_ROOT\}\/lib\/parsers\/arg-parser\.mjs'/,
'Phase 3 does not import parseArgs at runtime from the plugin root');
});
test('[DJ5] Phase 3 run with an injection label creates no file and keeps the label whole', (t) => {
// The step's label, and the same with a command substitution: under bash,
// and under zsh (the shell the Bash tool runs) when zsh is on PATH.
const labels = ["a'; touch PWNED; echo 'b", "a'; touch PWNED; echo 'b $(touch PWNED2)"];
for (const label of labels) assertLabelKept('bash', label);
if (onPath('zsh')) {
for (const label of labels) assertLabelKept('zsh', label);
} else {
t.diagnostic('zsh is not on PATH: the zsh half of this test was not run');
}
});
test("[DJ5] a label with an apostrophe is kept", () => {
assertLabelKept('bash', "Fix the user's login");
});