Four defects that all reduce to the same thing: the engine trusted a name it had no business trusting. _broadcast is now a reserved namespace, not a repo. coord-send guarded retraction with a sender check, but that guard only covered the door it was nailed to: `coord-done --repo _broadcast <file>` walked in the side entrance and archived a broadcast out of the queue - a full unauthenticated retract of an announcement for every repo that had not read it yet. The rule reserves the whole `_` prefix rather than one literal, so a later `_seen` or `_config` cannot reopen the hole, and it is enforced in every CLI: a rule held in three of four places is not a rule. The pwd fallback is gone. It existed so the CLI would work anywhere, but "anywhere" includes every global surface: a session in ~/repos is not a repo, and basename(pwd) silently handed it the identity "repos". That is not hypothetical - mail was delivered under exactly that name. git toplevel or an explicit --from/--repo are now the only two sources. The write paths refuse and say so; the read path declines silently, because the hook runs it at every session start and must never fail a session. Two checkouts with the same directory name still share one mailbox - re-keying identity would break every existing mailbox and the readable `--to <repo>` addressing. Instead the first git-derived read records the claiming path in <repo>/.origin, and a read from elsewhere is warned about in the injection. A warning, not a refusal: the same repo moved or re-cloned is the ordinary case. The warning goes in the injection because the hook discards stderr, and a warning nobody can see is not a warning. The collision is live in this tree: claude-code-100x is nested inside a repo of the same name. Broadcast delivery is recorded only after the injection is written. Marking inside the read loop left a window where the seen set said "delivered" while the operator saw nothing, and the hook runs under `timeout: 10`, so the window was reachable. A lost broadcast is unrecoverable by design - the seen set is delivery history and retraction deliberately leaves it alone - so the failure mode has to be redelivery, never loss. The hook stops resolving identity altogether. It was the fourth copy of the rule and the only one that runs in production, so passing --repo bypassed the engine's guards exactly where they mattered and suppressed the collision check along with them. It is now the pure wrapper the boundary rule always claimed it was, pinned by two behavioral tests rather than by reading the source. Selftest 93 -> 116; three node tests cover the hook. BREAKING CHANGE: coord-send and coord-done exit 2 outside a git repo instead of naming themselves after the working directory. Pass --from/--repo to choose an identity explicitly. Repo names beginning with _ are refused everywhere. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01U6EixQo6hpoRCVtiAXdnFs
69 lines
3.4 KiB
JavaScript
69 lines
3.4 KiB
JavaScript
// Node wrapper (marketplace convention: node --test) around the bash
|
|
// selftest, which owns every mailbox assertion. The selftest runs against a
|
|
// throwaway mailbox (mktemp) and exits non-zero on any failing check.
|
|
import { test } from 'node:test';
|
|
import assert from 'node:assert';
|
|
import { execFileSync } from 'node:child_process';
|
|
import { mkdtempSync, mkdirSync, writeFileSync, existsSync } from 'node:fs';
|
|
import { tmpdir } from 'node:os';
|
|
import { basename, dirname, join } from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
|
|
const root = join(dirname(fileURLToPath(import.meta.url)), '..');
|
|
const hook = join(root, 'hooks', 'scripts', 'session-start.mjs');
|
|
|
|
test('coord bash selftest passes', () => {
|
|
execFileSync('bash', [join(root, 'scripts', 'coord-selftest.sh')], { encoding: 'utf8' });
|
|
});
|
|
|
|
// The engine refuses to invent an identity from the cwd, but the hook is the
|
|
// FOURTH place repo identity is derived, and a rule enforced in three of four
|
|
// places is not a rule: as long as the hook resolved the name itself and passed
|
|
// --repo, the engine's guard was bypassed on the only path that runs in
|
|
// production. These two tests pin the hook as a pure wrapper - it must not
|
|
// resolve identity at all, so the engine's rules apply where they matter.
|
|
function runHook(cwd, mailbox) {
|
|
const out = execFileSync('node', [hook], {
|
|
cwd,
|
|
env: { ...process.env, CLAUDE_COORD_DIR: mailbox },
|
|
encoding: 'utf8',
|
|
});
|
|
return JSON.parse(out);
|
|
}
|
|
|
|
function seedMailbox(mailbox, repo, body) {
|
|
mkdirSync(join(mailbox, repo, 'inbox'), { recursive: true });
|
|
writeFileSync(join(mailbox, repo, 'inbox', '20260101T000000Z-1-from-someone.md'),
|
|
`---\nfrom: someone\nto: ${repo}\nsubject: seeded\ndate: 2026-01-01T00:00:00Z\n---\n${body}\n`);
|
|
}
|
|
|
|
test('hook does not invent a repo identity from the working directory', () => {
|
|
const mailbox = mkdtempSync(join(tmpdir(), 'coord-mb-'));
|
|
const nonGit = mkdtempSync(join(tmpdir(), 'coord-nogit-'));
|
|
// A mailbox that happens to carry the cwd's basename. A hook that falls back
|
|
// to basename(cwd) reads it; a hook that leaves identity to the engine does
|
|
// not. This is the ~/repos case that delivered mail as the repo "repos".
|
|
seedMailbox(mailbox, basename(nonGit), 'CWD-IDENTITY-LEAK');
|
|
|
|
const parsed = runHook(nonGit, mailbox);
|
|
assert.equal(parsed.continue, true);
|
|
const ctx = parsed.hookSpecificOutput?.additionalContext ?? '';
|
|
assert.ok(!ctx.includes('CWD-IDENTITY-LEAK'),
|
|
'hook read a mailbox named after the cwd outside any git repo');
|
|
});
|
|
|
|
test('hook lets the engine derive identity, so the mailbox claim is recorded', () => {
|
|
const mailbox = mkdtempSync(join(tmpdir(), 'coord-mb-'));
|
|
const repoDir = mkdtempSync(join(tmpdir(), 'coord-repo-'));
|
|
execFileSync('git', ['-C', repoDir, 'init', '-q'], { stdio: 'ignore' });
|
|
seedMailbox(mailbox, basename(repoDir), 'GIT-IDENTITY-OK');
|
|
|
|
const parsed = runHook(repoDir, mailbox);
|
|
const ctx = parsed.hookSpecificOutput?.additionalContext ?? '';
|
|
assert.ok(ctx.includes('GIT-IDENTITY-OK'), 'hook did not deliver the pending message');
|
|
// .origin is written only when coord-inbox.sh resolved the repo itself. Its
|
|
// presence is the observable proof that the hook stopped overriding identity,
|
|
// and its absence is why the collision warning would never fire in production.
|
|
assert.ok(existsSync(join(mailbox, basename(repoDir), '.origin')),
|
|
'engine never derived the identity: the hook passed --repo and suppressed the claim');
|
|
});
|