mirror of
https://github.com/garrytan/gbrain.git
synced 2026-08-14 08:53:22 +00:00
* fix(bootstrap): harden create-repo-first repo adoption `gbrain bootstrap repo` adopts an empty, private, personally-owned GitHub repo the human created (create-repo-first), instead of only ever creating one. This hardens the existing adoption branch: - Empty-only adoption + pending_repo_url proof: a non-empty origin is refused (ORIGIN_NOT_EMPTY) unless it matches this workspace's pending marker (our own interrupted push). Never adopts a user's existing project from a git-ancestry guess, and never silently no-ops without pushing. - Repo-local git identity is set on the adopt path too (fresh-machine commits). - repo_url is recorded only AFTER a successful push (pending marker before); a failed push no longer looks "done" to `bootstrap status`. - Pre-push secret scan also covers an already-committed tree; ls-files failure fails closed. - assertOriginMatches binds BOTH the fetch URL and a configured push URL to the verified-private repo, so a foreign pushurl can't leak the workspace. - disposition: 'created' | 'adopted' | 'reused' replaces the overloaded flag. - Hook push-gate: the no-daemon session-end / recovery push is deferred until the repo phase records repo_url AND the current origin still matches it, so nothing is published to an unverified or redirected remote. Adds ORIGIN_NOT_EMPTY / REMOTE_CHECK_FAILED error codes. * docs(bootstrap): lead with the repo, document create-repo-first README (Claude Code + Codex) now opens with "the folder you open becomes your agent's private repo" and adds a "prefer to make the repo yourself?" callout for the create-repo-first path (empty, personal-account repo). Updates the bootstrap guide, the Claude Code MCP note, and the KEY_FILES / AGENT_BOOTSTRAP_PLAN invariants to describe adoption instead of "foreign origins refused". * v0.45.1.0 fix(bootstrap): create-repo-first repo adoption + hardening Bumps VERSION/package.json to 0.45.1.0, adds the CHANGELOG entry, refreshes the runbook + template-repo version stamps, and regenerates the llms bundle. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs(todos): file P2 follow-up — index-blob secret scan for bootstrap pushes * ci(gitleaks): run the free CLI instead of the license-gated v2 action gitleaks-action@v2 now enforces a paid GITLEAKS_LICENSE and fails the job ("missing gitleaks license") for accounts it can't validate over the API — blocking every PR's merge gate. Replace it with the open-source gitleaks CLI (pinned 8.30.1, checksum-verified against the release's own checksums file), scanning the PR/push commit range with the committed .gitleaks.toml allowlist. Same secret-scan coverage, no license wall. * v0.45.2.0 chore(release): re-bump 0.45.1.0 -> 0.45.2.0 Re-target the release version at the user's request. Updates VERSION, package.json, the CHANGELOG header + self-repair block, the runbook + template-repo version stamps, the TODOS follow-up reference, and the llms bundle. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(perf): raise entity-card ratio ceiling 50x -> 100x (CI flake) The RATIO GUARD asserted entity p99 <= 50x max(getPage p50, 1ms). On a fast runner getPage p50 floors to 1ms and a normal entity p99 (~50ms) reads as ~52x, tripping the gate even though absolute p99 (52ms) is well under the 100ms budget — a p99 tail divided by a sub-ms median. At the 1ms floor, 50x also made the ratio STRICTER than the test's own 100ms absolute budget. Raise the ceiling to 100x: still far below the >=200x O(N)-regression signal the guard exists to catch, and consistent with (never stricter than) the absolute budget. --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
940 lines
41 KiB
TypeScript
940 lines
41 KiB
TypeScript
/**
|
|
* `gbrain hook <event>` (agent-bootstrap D5, A3, A9, G3, G4, G15, B3, B4,
|
|
* ENG-1, S3#2, S3#7, S3#8): in-process stdin→stdout contract against an
|
|
* in-process IPC server (never a spawned serve — the hook-under-serve e2e is
|
|
* a separate task). Fail-open everywhere, heartbeat schema allowlist, digest
|
|
* section allowlist + cap, corpus redaction/dedup/retention, parser drift.
|
|
*/
|
|
import { describe, test, expect, beforeEach, afterEach } from 'bun:test';
|
|
import {
|
|
copyFileSync, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync,
|
|
rmSync, utimesSync, writeFileSync,
|
|
} from 'node:fs';
|
|
import net from 'node:net';
|
|
import { execFileSync } from 'node:child_process';
|
|
import { join } from 'node:path';
|
|
import { tmpdir } from 'node:os';
|
|
import {
|
|
runHook,
|
|
heartbeatPath,
|
|
hookStatusPath,
|
|
readHeartbeatTail,
|
|
HEARTBEAT_ALLOWED_KEYS,
|
|
HEARTBEAT_MAX_LINES,
|
|
DIGEST_MEMORY_CAP_BYTES,
|
|
memoryDigest,
|
|
type HookHeartbeatEntry,
|
|
} from '../src/commands/hook.ts';
|
|
import {
|
|
ensureIpcSecret,
|
|
resolveSocketPath,
|
|
startResolveIpcServer,
|
|
type TurnContextRequest,
|
|
} from '../src/core/context/resolve-ipc.ts';
|
|
import { CLAUDE_HOOK_OUTPUT_CAP_CHARS } from '../src/core/bootstrap/host-specs.ts';
|
|
import { writeReceipt } from '../src/core/bootstrap/format.ts';
|
|
import type { RepoReceipt } from '../src/core/bootstrap/repo.ts';
|
|
|
|
const FIXTURE = join(import.meta.dir, 'fixtures', 'conversation-formats', 'claude-code.jsonl');
|
|
const ENV_KEYS = ['GBRAIN_HOME', 'DATABASE_URL', 'GBRAIN_DATABASE_URL', 'GBRAIN_SOURCE', 'GBRAIN_HOOKS'] as const;
|
|
|
|
let tmp: string;
|
|
let saved: Record<string, string | undefined>;
|
|
let servers: net.Server[] = [];
|
|
|
|
beforeEach(() => {
|
|
tmp = mkdtempSync(join(tmpdir(), 'gb-hk-'));
|
|
saved = {};
|
|
for (const k of ENV_KEYS) {
|
|
saved[k] = process.env[k];
|
|
delete process.env[k];
|
|
}
|
|
process.env.GBRAIN_HOME = tmp; // config/heartbeat/corpus all under tmp/.gbrain
|
|
});
|
|
|
|
afterEach(() => {
|
|
for (const s of servers) {
|
|
try { s.close(); } catch { /* noop */ }
|
|
}
|
|
servers = [];
|
|
for (const k of ENV_KEYS) {
|
|
if (saved[k] === undefined) delete process.env[k];
|
|
else process.env[k] = saved[k];
|
|
}
|
|
rmSync(tmp, { recursive: true, force: true });
|
|
});
|
|
|
|
const home = () => join(tmp, '.gbrain');
|
|
|
|
function writePgliteConfig(dataDir: string): void {
|
|
mkdirSync(home(), { recursive: true });
|
|
writeFileSync(join(home(), 'config.json'), JSON.stringify({ engine: 'pglite', database_path: dataDir }));
|
|
}
|
|
|
|
function collectStdout(): { io: { write: (s: string) => void }; get: () => string } {
|
|
let buf = '';
|
|
return { io: { write: (s: string) => { buf += s; } }, get: () => buf };
|
|
}
|
|
|
|
async function lastHeartbeat(): Promise<HookHeartbeatEntry | undefined> {
|
|
const tail = await readHeartbeatTail(1);
|
|
return tail[0];
|
|
}
|
|
|
|
/** In-process v2 IPC server with a canned turn_context block. */
|
|
async function startServer(opts: {
|
|
dataDir: string;
|
|
blockText?: string | null;
|
|
secretOverride?: string;
|
|
boundSourceId?: string;
|
|
onRequest?: (req: TurnContextRequest) => void;
|
|
}): Promise<void> {
|
|
mkdirSync(opts.dataDir, { recursive: true });
|
|
const secret = opts.secretOverride ?? ensureIpcSecret(opts.dataDir);
|
|
if (opts.secretOverride) ensureIpcSecret(opts.dataDir); // client still reads the file secret
|
|
const server = await startResolveIpcServer(
|
|
resolveSocketPath(opts.dataDir),
|
|
{
|
|
resolve: async () => null,
|
|
turn_context: async (req) => {
|
|
opts.onRequest?.(req);
|
|
if (opts.blockText == null) return null;
|
|
return { text: opts.blockText, pointers: [], factsCount: 0 };
|
|
},
|
|
},
|
|
{ secret, ...(opts.boundSourceId ? { boundSourceId: opts.boundSourceId } : {}) },
|
|
);
|
|
expect(server).not.toBeNull();
|
|
servers.push(server!);
|
|
}
|
|
|
|
// ── Dispatch + kill switch ──────────────────────────────────────────────────
|
|
|
|
describe('dispatch', () => {
|
|
test('GBRAIN_HOOKS=0 short-circuits every event: exit 0, no output, no heartbeat', async () => {
|
|
process.env.GBRAIN_HOOKS = '0';
|
|
for (const event of ['session-start', 'user-prompt', 'stop', 'session-end']) {
|
|
const out = collectStdout();
|
|
expect(await runHook([event], { ...out.io, stdin: '{}' })).toBe(0);
|
|
expect(out.get()).toBe('');
|
|
}
|
|
expect(existsSync(join(home(), 'integrations', 'hooks', 'heartbeat.jsonl'))).toBe(false);
|
|
});
|
|
|
|
test('unknown/missing event → usage + exit 1; --help → exit 0', async () => {
|
|
expect(await runHook(['no-such-event'], { stdin: '' })).toBe(1);
|
|
expect(await runHook([], { stdin: '' })).toBe(1);
|
|
const out = collectStdout();
|
|
expect(await runHook(['--help'], out.io)).toBe(0);
|
|
expect(out.get()).toContain('session-start');
|
|
});
|
|
});
|
|
|
|
// ── user-prompt [ENG-1, S3#8, A9] ───────────────────────────────────────────
|
|
|
|
describe('user-prompt', () => {
|
|
test('fail-open on missing config: exit 0, empty stdout, degraded heartbeat', async () => {
|
|
const out = collectStdout();
|
|
expect(await runHook(['user-prompt'], { ...out.io, stdin: JSON.stringify({ prompt: 'hello' }) })).toBe(0);
|
|
expect(out.get()).toBe('');
|
|
const hb = await lastHeartbeat();
|
|
expect(hb?.event).toBe('user-prompt');
|
|
expect(hb?.outcome).toBe('degraded');
|
|
expect(hb?.reason).toBe('no_pglite_path');
|
|
});
|
|
|
|
test('no stdin → degraded no_stdin, exit 0 empty', async () => {
|
|
const out = collectStdout();
|
|
expect(await runHook(['user-prompt'], { ...out.io, stdin: '' })).toBe(0);
|
|
expect(out.get()).toBe('');
|
|
expect((await lastHeartbeat())?.reason).toBe('no_stdin');
|
|
});
|
|
|
|
test('pull-mode when no serve has minted a secret: no_serve', async () => {
|
|
const dataDir = join(tmp, 'data');
|
|
mkdirSync(dataDir, { recursive: true });
|
|
writePgliteConfig(dataDir);
|
|
const out = collectStdout();
|
|
await runHook(['user-prompt'], { ...out.io, stdin: JSON.stringify({ prompt: 'hi' }) });
|
|
expect(out.get()).toBe('');
|
|
expect((await lastHeartbeat())?.reason).toBe('no_serve');
|
|
});
|
|
|
|
test('happy path: additionalContext JSON contract, ≤10000 chars [ENG-1]', async () => {
|
|
const dataDir = join(tmp, 'data');
|
|
writePgliteConfig(dataDir);
|
|
await startServer({ dataDir, blockText: 'CTX: alice-example runs widget-co' });
|
|
const out = collectStdout();
|
|
expect(
|
|
await runHook(['user-prompt'], {
|
|
...out.io,
|
|
stdin: JSON.stringify({ prompt: 'what does alice-example do?', session_id: 's-1' }),
|
|
}),
|
|
).toBe(0);
|
|
const payload = out.get().trim();
|
|
expect(payload.length).toBeGreaterThan(0);
|
|
expect(payload.length).toBeLessThanOrEqual(CLAUDE_HOOK_OUTPUT_CAP_CHARS);
|
|
const parsed = JSON.parse(payload);
|
|
expect(parsed).toEqual({
|
|
hookSpecificOutput: {
|
|
hookEventName: 'UserPromptSubmit',
|
|
additionalContext: 'CTX: alice-example runs widget-co',
|
|
},
|
|
});
|
|
const hb = await lastHeartbeat();
|
|
expect(hb?.outcome).toBe('ok');
|
|
expect(hb?.turns).toBe(1);
|
|
});
|
|
|
|
test('window = last 4 transcript turns + the current prompt', async () => {
|
|
const dataDir = join(tmp, 'data');
|
|
writePgliteConfig(dataDir);
|
|
let seen: TurnContextRequest | null = null;
|
|
await startServer({ dataDir, blockText: 'ok', onRequest: (r) => { seen = r; } });
|
|
// Confinement seam: fixture copied under a fake projects root.
|
|
const projRoot = join(tmp, 'projects');
|
|
mkdirSync(join(projRoot, 'p1'), { recursive: true });
|
|
const transcript = join(projRoot, 'p1', 'sess.jsonl');
|
|
copyFileSync(FIXTURE, transcript);
|
|
const out = collectStdout();
|
|
await runHook(['user-prompt'], {
|
|
...out.io,
|
|
stdin: JSON.stringify({ prompt: 'and now?', transcript_path: transcript, session_id: 's-2' }),
|
|
transcriptRoot: projRoot,
|
|
});
|
|
expect(seen).not.toBeNull();
|
|
const win = seen!.window;
|
|
expect(win).toHaveLength(5); // fixture has 5 turns → slice(-4) + prompt
|
|
expect(win[win.length - 1]).toEqual({ role: 'user', text: 'and now?' });
|
|
expect(win.map((t) => t.text).join(' ')).not.toContain('SIDECHAIN-ONLY-TEXT');
|
|
expect((await lastHeartbeat())?.turns).toBe(5);
|
|
});
|
|
|
|
test('cross-turn dedupe: previously-injected blocks ride priorContextText; channel defaults to claude-code', async () => {
|
|
const dataDir = join(tmp, 'data');
|
|
writePgliteConfig(dataDir);
|
|
let seen: TurnContextRequest | null = null;
|
|
await startServer({ dataDir, blockText: 'ok', onRequest: (r) => { seen = r; } });
|
|
// Real captured transcript (claude CLI 2.1.224, hook installed): two
|
|
// hook_additional_context attachments naming companies/acme-example.
|
|
const projRoot = join(tmp, 'projects');
|
|
mkdirSync(join(projRoot, 'p1'), { recursive: true });
|
|
const transcript = join(projRoot, 'p1', 'sess.jsonl');
|
|
copyFileSync(join(import.meta.dir, 'fixtures', 'hook-transcript.jsonl'), transcript);
|
|
const out = collectStdout();
|
|
await runHook(['user-prompt'], {
|
|
...out.io,
|
|
stdin: JSON.stringify({ prompt: 'more about Acme Example?', transcript_path: transcript, session_id: 's-3' }),
|
|
transcriptRoot: projRoot,
|
|
});
|
|
expect(seen).not.toBeNull();
|
|
// Dedupe input = ONLY the structured injections (both blocks, joined) —
|
|
// the serve suppresses re-volunteering companies/acme-example this turn.
|
|
expect(seen!.priorContextText).toContain('companies/acme-example');
|
|
expect(seen!.priorContextText).not.toContain('Reply with exactly'); // never raw turn text
|
|
// Feedback-loop attribution: default channel is claude-code (the only
|
|
// harness bootstrap registers hooks for today).
|
|
expect(seen!.channel).toBe('claude-code');
|
|
});
|
|
|
|
test('priorContextText is deduped and byte-capped: an injection-heavy session can never blow the IPC message cap', async () => {
|
|
const dataDir = join(tmp, 'data');
|
|
writePgliteConfig(dataDir);
|
|
let seen: TurnContextRequest | null = null;
|
|
await startServer({ dataDir, blockText: 'ok', onRequest: (r) => { seen = r; } });
|
|
const projRoot = join(tmp, 'projects');
|
|
mkdirSync(join(projRoot, 'p1'), { recursive: true });
|
|
const transcript = join(projRoot, 'p1', 'sess.jsonl');
|
|
// 60 injections: 50 identical (per-turn re-records of one block) + 10
|
|
// distinct 8KB blocks — raw join would be ~90KB+; the cap keeps ≤32KB
|
|
// of NEWEST distinct blocks.
|
|
const bigBlock = (i: number) =>
|
|
`## Brain pages mentioned this turn\n- **Page ${i}** → \`pages/p${i}\` — ${'x'.repeat(8000)}`;
|
|
const lines = [
|
|
...Array.from({ length: 50 }, () =>
|
|
JSON.stringify({ type: 'attachment', attachment: { type: 'hook_additional_context', content: ['## Brain pages mentioned this turn\n- **Dup** → `pages/dup` — same block every turn'] } })),
|
|
...Array.from({ length: 10 }, (_, i) =>
|
|
JSON.stringify({ type: 'attachment', attachment: { type: 'hook_additional_context', content: [bigBlock(i)] } })),
|
|
];
|
|
writeFileSync(transcript, lines.join('\n') + '\n');
|
|
const out = collectStdout();
|
|
await runHook(['user-prompt'], {
|
|
...out.io,
|
|
stdin: JSON.stringify({ prompt: 'more about Acme?', transcript_path: transcript, session_id: 's-cap' }),
|
|
transcriptRoot: projRoot,
|
|
});
|
|
expect(seen).not.toBeNull();
|
|
const prior = seen!.priorContextText!;
|
|
expect(Buffer.byteLength(prior, 'utf8')).toBeLessThanOrEqual(32 * 1024);
|
|
// Newest-first retention: the newest distinct block survives the cap...
|
|
expect(prior).toContain('pages/p9');
|
|
// ...and identical re-records collapsed to one occurrence.
|
|
expect(prior.split('pages/dup').length - 1).toBeLessThanOrEqual(1);
|
|
});
|
|
|
|
test('--harness codex flags the channel; unknown values fall back to the default', async () => {
|
|
const dataDir = join(tmp, 'data');
|
|
writePgliteConfig(dataDir);
|
|
const seen: TurnContextRequest[] = [];
|
|
await startServer({ dataDir, blockText: 'ok', onRequest: (r) => { seen.push(r); } });
|
|
const out = collectStdout();
|
|
await runHook(['user-prompt', '--harness', 'codex'], {
|
|
...out.io,
|
|
stdin: JSON.stringify({ prompt: 'hello Acme' }),
|
|
});
|
|
await runHook(['user-prompt', '--harness', 'vim'], {
|
|
...out.io,
|
|
stdin: JSON.stringify({ prompt: 'hello Acme' }),
|
|
});
|
|
expect(seen).toHaveLength(2);
|
|
expect(seen[0].channel).toBe('codex');
|
|
expect(seen[1].channel).toBe('claude-code'); // fail-open to the default
|
|
});
|
|
|
|
test('hook ∈ STARTUP_HOOK_SKIP_COMMANDS (source grep — maybeEmitUpdateMarker no-ops under NODE_ENV=test, so no runtime test can pin this)', () => {
|
|
const cliSrc = readFileSync(join(import.meta.dir, '..', 'src', 'cli.ts'), 'utf8');
|
|
const m = cliSrc.match(/const STARTUP_HOOK_SKIP_COMMANDS = new Set\(\[[\s\S]*?\]\);/);
|
|
expect(m).not.toBeNull();
|
|
// user-prompt fires once per user PROMPT: a stale update cache would
|
|
// otherwise spawn a detached check-update child per prompt.
|
|
expect(m![0]).toContain("'hook'");
|
|
});
|
|
|
|
test('confinement rejection aborts: heartbeat + exit 0 empty [S3#8]', async () => {
|
|
const dataDir = join(tmp, 'data');
|
|
writePgliteConfig(dataDir);
|
|
const outside = join(tmp, 'outside.jsonl');
|
|
writeFileSync(outside, '{}\n');
|
|
const out = collectStdout();
|
|
expect(
|
|
await runHook(['user-prompt'], {
|
|
...out.io,
|
|
stdin: JSON.stringify({ prompt: 'x', transcript_path: outside }),
|
|
transcriptRoot: join(tmp, 'projects-root'),
|
|
}),
|
|
).toBe(0);
|
|
expect(out.get()).toBe('');
|
|
expect((await lastHeartbeat())?.reason).toBe('transcript_outside_projects_dir');
|
|
});
|
|
|
|
test('unauthorized (server holds a different secret) degrades cleanly', async () => {
|
|
const dataDir = join(tmp, 'data');
|
|
writePgliteConfig(dataDir);
|
|
await startServer({ dataDir, blockText: 'x', secretOverride: 'not-the-file-secret' });
|
|
const out = collectStdout();
|
|
await runHook(['user-prompt'], { ...out.io, stdin: JSON.stringify({ prompt: 'hi' }) });
|
|
expect(out.get()).toBe('');
|
|
expect((await lastHeartbeat())?.reason).toBe('unauthorized');
|
|
});
|
|
|
|
test('source_mismatch when GBRAIN_SOURCE differs from the bound source [CX2-10]', async () => {
|
|
const dataDir = join(tmp, 'data');
|
|
writePgliteConfig(dataDir);
|
|
await startServer({ dataDir, blockText: 'x', boundSourceId: 'other-source' });
|
|
process.env.GBRAIN_SOURCE = 'mine';
|
|
const out = collectStdout();
|
|
await runHook(['user-prompt'], { ...out.io, stdin: JSON.stringify({ prompt: 'hi' }) });
|
|
expect(out.get()).toBe('');
|
|
expect((await lastHeartbeat())?.reason).toBe('source_mismatch');
|
|
});
|
|
|
|
test('stale (v1) serve without the protocol echo degrades LOUDLY [A9]', async () => {
|
|
const dataDir = join(tmp, 'data');
|
|
mkdirSync(dataDir, { recursive: true });
|
|
writePgliteConfig(dataDir);
|
|
ensureIpcSecret(dataDir);
|
|
// Raw v1-shaped server: answers everything as a resolve response.
|
|
const server = net.createServer((conn) => {
|
|
conn.on('data', () => conn.write(JSON.stringify({ ok: true, block: null }) + '\n'));
|
|
});
|
|
await new Promise<void>((r) => server.listen(resolveSocketPath(dataDir), r));
|
|
servers.push(server);
|
|
const out = collectStdout();
|
|
await runHook(['user-prompt'], { ...out.io, stdin: JSON.stringify({ prompt: 'hi' }) });
|
|
expect(out.get()).toBe('');
|
|
expect((await lastHeartbeat())?.reason).toBe('stale_serve');
|
|
});
|
|
|
|
test('oversized block is trimmed under the 10000-char stdout cap [ENG-1]', async () => {
|
|
const dataDir = join(tmp, 'data');
|
|
writePgliteConfig(dataDir);
|
|
await startServer({ dataDir, blockText: 'line of context\n'.repeat(700) }); // ~11K raw, ~12K escaped
|
|
const out = collectStdout();
|
|
await runHook(['user-prompt'], { ...out.io, stdin: JSON.stringify({ prompt: 'hi' }) });
|
|
const payload = out.get().trim();
|
|
expect(payload.length).toBeGreaterThan(0);
|
|
expect(payload.length).toBeLessThanOrEqual(CLAUDE_HOOK_OUTPUT_CAP_CHARS);
|
|
expect(JSON.parse(payload).hookSpecificOutput.hookEventName).toBe('UserPromptSubmit');
|
|
});
|
|
});
|
|
|
|
// ── session-start [A3, B3, B4, G3] ──────────────────────────────────────────
|
|
|
|
describe('session-start', () => {
|
|
test('digest respects the section allowlist [A3]', async () => {
|
|
const ws = join(tmp, 'ws');
|
|
mkdirSync(ws, { recursive: true });
|
|
writeFileSync(
|
|
join(ws, 'MEMORY.md'),
|
|
[
|
|
'# Memory',
|
|
'## Standing rules',
|
|
'- always verify before claiming done',
|
|
'## Security boundary',
|
|
'NEVER-IN-DIGEST: this section stays out of injected context',
|
|
'## Open commitments',
|
|
'- ship the follow-up to charlie-example',
|
|
'## Active context',
|
|
'- migrating widget-co notes',
|
|
'## Scratch',
|
|
'SCRATCH-NOT-ELIGIBLE',
|
|
].join('\n'),
|
|
);
|
|
const out = collectStdout();
|
|
expect(await runHook(['session-start'], { ...out.io, stdin: '', cwd: ws })).toBe(0);
|
|
const text = out.get();
|
|
expect(text).toContain('always verify before claiming done');
|
|
expect(text).toContain('charlie-example');
|
|
expect(text).toContain('migrating widget-co notes');
|
|
expect(text).not.toContain('NEVER-IN-DIGEST');
|
|
expect(text).not.toContain('SCRATCH-NOT-ELIGIBLE');
|
|
expect((await lastHeartbeat())?.outcome).toBe('ok');
|
|
});
|
|
|
|
test('digest capped at 3KB [A3]', () => {
|
|
const ws = join(tmp, 'ws');
|
|
mkdirSync(ws, { recursive: true });
|
|
const memPath = join(ws, 'MEMORY.md');
|
|
writeFileSync(memPath, '## Standing rules\n' + 'a very long standing rule line\n'.repeat(400));
|
|
const digest = memoryDigest(memPath);
|
|
expect(digest).not.toBeNull();
|
|
expect(Buffer.byteLength(digest!, 'utf8')).toBeLessThanOrEqual(DIGEST_MEMORY_CAP_BYTES + 4);
|
|
});
|
|
|
|
test('missing MEMORY.md: fail-open, exit 0, heartbeat ok', async () => {
|
|
const ws = join(tmp, 'empty-ws');
|
|
mkdirSync(ws, { recursive: true });
|
|
const out = collectStdout();
|
|
expect(await runHook(['session-start'], { ...out.io, stdin: '', cwd: ws })).toBe(0);
|
|
expect((await lastHeartbeat())?.outcome).toBe('ok');
|
|
});
|
|
|
|
test('B3 visible degradation: >50% trailing errors → doctor notice', async () => {
|
|
const hbPath = await heartbeatPath();
|
|
const lines: string[] = [];
|
|
for (let i = 0; i < 15; i++) {
|
|
lines.push(JSON.stringify({ ts: new Date().toISOString(), event: 'user-prompt', outcome: 'error', reason: 'exception:Error', duration_ms: 5 }));
|
|
}
|
|
for (let i = 0; i < 3; i++) {
|
|
lines.push(JSON.stringify({ ts: new Date().toISOString(), event: 'user-prompt', outcome: 'ok', duration_ms: 5 }));
|
|
}
|
|
writeFileSync(hbPath, lines.join('\n') + '\n');
|
|
const ws = join(tmp, 'ws');
|
|
mkdirSync(ws, { recursive: true });
|
|
const out = collectStdout();
|
|
await runHook(['session-start'], { ...out.io, stdin: '', cwd: ws });
|
|
expect(out.get()).toContain('run gbrain doctor');
|
|
});
|
|
|
|
test('degraded-only history does NOT trigger the notice (designed fallbacks)', async () => {
|
|
const hbPath = await heartbeatPath();
|
|
const lines = Array.from({ length: 20 }, () =>
|
|
JSON.stringify({ ts: new Date().toISOString(), event: 'user-prompt', outcome: 'degraded', reason: 'no_serve', duration_ms: 3 }));
|
|
writeFileSync(hbPath, lines.join('\n') + '\n');
|
|
const ws = join(tmp, 'ws');
|
|
mkdirSync(ws, { recursive: true });
|
|
const out = collectStdout();
|
|
await runHook(['session-start'], { ...out.io, stdin: '', cwd: ws });
|
|
expect(out.get()).not.toContain('run gbrain doctor');
|
|
});
|
|
|
|
test('last-session line + stale push-status surfaced [B4]', async () => {
|
|
const liveDir = join(home(), 'transcripts', 'live');
|
|
mkdirSync(liveDir, { recursive: true });
|
|
writeFileSync(join(liveDir, 'prev.txt'), JSON.stringify({ ts: '2026-08-07T09:00:00Z', session_id: 'prev', exchange: 'wrapped up the acme-example memo' }) + '\n');
|
|
const bootDir = join(home(), 'bootstrap');
|
|
mkdirSync(bootDir, { recursive: true });
|
|
writeFileSync(join(bootDir, 'push-status.json'), JSON.stringify({ ts: new Date(Date.now() - 3 * 24 * 3600 * 1000).toISOString(), ok: true }));
|
|
const ws = join(tmp, 'ws');
|
|
mkdirSync(ws, { recursive: true });
|
|
const out = collectStdout();
|
|
await runHook(['session-start'], { ...out.io, stdin: '', cwd: ws });
|
|
const text = out.get();
|
|
expect(text).toContain('Last session activity');
|
|
expect(text).toContain('acme-example memo');
|
|
expect(text).toContain('>48h ago');
|
|
});
|
|
|
|
test('parser-drift status file surfaced at session start [G3]', async () => {
|
|
const statusPath = await hookStatusPath();
|
|
writeFileSync(statusPath, JSON.stringify({ ts: new Date().toISOString(), error: 'parser_drift', bytes: 1234 }));
|
|
const ws = join(tmp, 'ws');
|
|
mkdirSync(ws, { recursive: true });
|
|
const out = collectStdout();
|
|
await runHook(['session-start'], { ...out.io, stdin: '', cwd: ws });
|
|
expect(out.get()).toContain('Hook alert: parser_drift');
|
|
});
|
|
});
|
|
|
|
// ── stop [G15] ──────────────────────────────────────────────────────────────
|
|
|
|
describe('stop', () => {
|
|
test('appends to the per-session buffer (sanitized id) and GCs stale buffers', async () => {
|
|
const liveDir = join(home(), 'transcripts', 'live');
|
|
mkdirSync(liveDir, { recursive: true });
|
|
const stale = join(liveDir, 'stale.txt');
|
|
writeFileSync(stale, 'old\n');
|
|
const old = (Date.now() - 8 * 24 * 3600 * 1000) / 1000;
|
|
utimesSync(stale, old, old);
|
|
|
|
expect(
|
|
await runHook(['stop'], {
|
|
stdin: JSON.stringify({ session_id: 'abc/../def', last_assistant_message: 'the widget-co answer' }),
|
|
}),
|
|
).toBe(0);
|
|
const files = readdirSync(liveDir);
|
|
expect(files).toContain('abc-..-def.txt'); // '/' sanitized, no traversal possible
|
|
expect(files).not.toContain('stale.txt');
|
|
const body = readFileSync(join(liveDir, 'abc-..-def.txt'), 'utf8');
|
|
expect(body).toContain('the widget-co answer');
|
|
expect((await lastHeartbeat())?.outcome).toBe('ok');
|
|
});
|
|
});
|
|
|
|
// ── session-end [S3#2, G3, G15, A6] ─────────────────────────────────────────
|
|
|
|
function seedTranscript(dir: string, name: string, lines: string[]): string {
|
|
mkdirSync(dir, { recursive: true });
|
|
const p = join(dir, name);
|
|
writeFileSync(p, lines.join('\n') + '\n');
|
|
return p;
|
|
}
|
|
|
|
const userLine = (text: string) =>
|
|
JSON.stringify({ type: 'user', isSidechain: false, message: { role: 'user', content: text } });
|
|
const assistantLine = (text: string) =>
|
|
JSON.stringify({ type: 'assistant', isSidechain: false, message: { role: 'assistant', content: [{ type: 'text', text }] } });
|
|
|
|
describe('session-end', () => {
|
|
test('corpus write redacts a planted secret at write time [S3#2]', async () => {
|
|
const projRoot = join(tmp, 'projects');
|
|
const planted = 'sk-' + 'FAKEfakeFAKEfake1234567890';
|
|
const transcript = seedTranscript(join(projRoot, 'p1'), 's.jsonl', [
|
|
userLine(`my key is ${planted} — keep it safe`),
|
|
assistantLine('I will not repeat that.'),
|
|
]);
|
|
const ws = join(tmp, 'ws');
|
|
mkdirSync(ws, { recursive: true });
|
|
expect(
|
|
await runHook(['session-end'], {
|
|
stdin: JSON.stringify({ session_id: 'sess-red', transcript_path: transcript, cwd: ws }),
|
|
transcriptRoot: projRoot,
|
|
}),
|
|
).toBe(0);
|
|
const corpus = join(home(), 'transcripts', 'corpus', 'sess-red.txt');
|
|
expect(existsSync(corpus)).toBe(true);
|
|
const body = readFileSync(corpus, 'utf8');
|
|
expect(body).not.toContain(planted);
|
|
expect(body).toContain('<REDACTED:openai>');
|
|
expect(body).toContain('I will not repeat that.');
|
|
const hb = await lastHeartbeat();
|
|
expect(hb?.outcome).toBe('ok');
|
|
expect(hb?.turns).toBe(2);
|
|
expect(hb?.bytes).toBeGreaterThan(0);
|
|
// COUNT only in telemetry — one planted secret, one redaction [S3#2, S3#7].
|
|
expect(hb?.redactions).toBe(1);
|
|
});
|
|
|
|
test('session-id-keyed dedup: resumed session overwrites its corpus file [A6]', async () => {
|
|
const projRoot = join(tmp, 'projects');
|
|
const ws = join(tmp, 'ws');
|
|
mkdirSync(ws, { recursive: true });
|
|
const t1 = seedTranscript(join(projRoot, 'p1'), 'a.jsonl', [userLine('first pass content')]);
|
|
await runHook(['session-end'], {
|
|
stdin: JSON.stringify({ session_id: 'sess-dup', transcript_path: t1, cwd: ws }),
|
|
transcriptRoot: projRoot,
|
|
});
|
|
const t2 = seedTranscript(join(projRoot, 'p1'), 'a.jsonl', [userLine('first pass content'), assistantLine('resumed pass content')]);
|
|
await runHook(['session-end'], {
|
|
stdin: JSON.stringify({ session_id: 'sess-dup', transcript_path: t2, cwd: ws }),
|
|
transcriptRoot: projRoot,
|
|
});
|
|
const corpusDir = join(home(), 'transcripts', 'corpus');
|
|
const files = readdirSync(corpusDir).filter((f) => f.startsWith('sess-dup'));
|
|
expect(files).toEqual(['sess-dup.txt']);
|
|
expect(readFileSync(join(corpusDir, 'sess-dup.txt'), 'utf8')).toContain('resumed pass content');
|
|
});
|
|
|
|
test('resumed session rewrite drops the stale .ingested/.in-progress sidecars so the sweep re-ingests', async () => {
|
|
const projRoot = join(tmp, 'projects');
|
|
const ws = join(tmp, 'ws');
|
|
mkdirSync(ws, { recursive: true });
|
|
|
|
// First pass: session-end writes the corpus file.
|
|
const t1 = seedTranscript(join(projRoot, 'p1'), 'r.jsonl', [userLine('first pass content')]);
|
|
await runHook(['session-end'], {
|
|
stdin: JSON.stringify({ session_id: 'sess-resume', transcript_path: t1, cwd: ws }),
|
|
transcriptRoot: projRoot,
|
|
});
|
|
const corpusDir = join(home(), 'transcripts', 'corpus');
|
|
const corpusFile = join(corpusDir, 'sess-resume.txt');
|
|
expect(existsSync(corpusFile)).toBe(true);
|
|
|
|
// Simulate the sweep having ingested it (completion sidecar) and a stale
|
|
// claim being left behind.
|
|
writeFileSync(corpusFile + '.ingested', JSON.stringify({ ingested_at: new Date().toISOString() }) + '\n');
|
|
writeFileSync(corpusFile + '.in-progress', JSON.stringify({ claimed_at: new Date().toISOString() }) + '\n');
|
|
expect(existsSync(corpusFile + '.ingested')).toBe(true);
|
|
|
|
// Second pass: the session resumes with appended content.
|
|
const t2 = seedTranscript(join(projRoot, 'p1'), 'r.jsonl', [
|
|
userLine('first pass content'),
|
|
assistantLine('appended resumed content'),
|
|
]);
|
|
await runHook(['session-end'], {
|
|
stdin: JSON.stringify({ session_id: 'sess-resume', transcript_path: t2, cwd: ws }),
|
|
transcriptRoot: projRoot,
|
|
});
|
|
|
|
// The corpus reflects the appended content …
|
|
expect(readFileSync(corpusFile, 'utf8')).toContain('appended resumed content');
|
|
// … and the stale sidecars are GONE so the next sweep re-processes it.
|
|
expect(existsSync(corpusFile + '.ingested')).toBe(false);
|
|
expect(existsSync(corpusFile + '.in-progress')).toBe(false);
|
|
// No torn tmp file left behind by the atomic write.
|
|
expect(readdirSync(corpusDir).filter((f) => f.includes('.tmp-'))).toEqual([]);
|
|
});
|
|
|
|
test('retention prune: corpus files past the default 30d window are removed [G15]', async () => {
|
|
const corpusDir = join(home(), 'transcripts', 'corpus');
|
|
mkdirSync(corpusDir, { recursive: true });
|
|
const oldFile = join(corpusDir, 'ancient.txt');
|
|
writeFileSync(oldFile, 'old corpus\n');
|
|
const old = (Date.now() - 40 * 24 * 3600 * 1000) / 1000;
|
|
utimesSync(oldFile, old, old);
|
|
const freshFile = join(corpusDir, 'fresh.txt');
|
|
writeFileSync(freshFile, 'fresh corpus\n');
|
|
|
|
const projRoot = join(tmp, 'projects');
|
|
const transcript = seedTranscript(join(projRoot, 'p1'), 's.jsonl', [userLine('hello')]);
|
|
const ws = join(tmp, 'ws');
|
|
mkdirSync(ws, { recursive: true });
|
|
await runHook(['session-end'], {
|
|
stdin: JSON.stringify({ session_id: 'sess-ret', transcript_path: transcript, cwd: ws }),
|
|
transcriptRoot: projRoot,
|
|
});
|
|
expect(existsSync(oldFile)).toBe(false);
|
|
expect(existsSync(freshFile)).toBe(true);
|
|
});
|
|
|
|
test('bytes>0 && turns==0 → parser_drift heartbeat error + status file [G3]', async () => {
|
|
const projRoot = join(tmp, 'projects');
|
|
const transcript = seedTranscript(join(projRoot, 'p1'), 'drift.jsonl', [
|
|
JSON.stringify({ type: 'summary', summary: 'format changed under us' }),
|
|
JSON.stringify({ type: 'summary', summary: 'no user/assistant lines at all' }),
|
|
]);
|
|
const ws = join(tmp, 'ws');
|
|
mkdirSync(ws, { recursive: true });
|
|
await runHook(['session-end'], {
|
|
stdin: JSON.stringify({ session_id: 'sess-drift', transcript_path: transcript, cwd: ws }),
|
|
transcriptRoot: projRoot,
|
|
});
|
|
const hb = await lastHeartbeat();
|
|
expect(hb?.outcome).toBe('error');
|
|
expect(hb?.reason).toBe('parser_drift');
|
|
expect(hb?.bytes).toBeGreaterThan(0);
|
|
expect(hb?.turns).toBe(0);
|
|
const status = JSON.parse(readFileSync(await hookStatusPath(), 'utf8'));
|
|
expect(status.error).toBe('parser_drift');
|
|
// No corpus file for a drift session.
|
|
expect(existsSync(join(home(), 'transcripts', 'corpus', 'sess-drift.txt'))).toBe(false);
|
|
});
|
|
|
|
test('confinement rejection: degraded heartbeat, no corpus write [S3#8]', async () => {
|
|
const outside = join(tmp, 'outside.jsonl');
|
|
writeFileSync(outside, userLine('outside content') + '\n');
|
|
const ws = join(tmp, 'ws');
|
|
mkdirSync(ws, { recursive: true });
|
|
await runHook(['session-end'], {
|
|
stdin: JSON.stringify({ session_id: 'sess-out', transcript_path: outside, cwd: ws }),
|
|
transcriptRoot: join(tmp, 'projects'),
|
|
});
|
|
expect((await lastHeartbeat())?.reason).toBe('transcript_outside_projects_dir');
|
|
expect(existsSync(join(home(), 'transcripts', 'corpus', 'sess-out.txt'))).toBe(false);
|
|
});
|
|
|
|
test('GCs this session\'s stop buffer [G15]', async () => {
|
|
const liveDir = join(home(), 'transcripts', 'live');
|
|
mkdirSync(liveDir, { recursive: true });
|
|
writeFileSync(join(liveDir, 'sess-gc.txt'), '{"ts":"x"}\n');
|
|
const projRoot = join(tmp, 'projects');
|
|
const transcript = seedTranscript(join(projRoot, 'p1'), 's.jsonl', [userLine('bye')]);
|
|
const ws = join(tmp, 'ws');
|
|
mkdirSync(ws, { recursive: true });
|
|
await runHook(['session-end'], {
|
|
stdin: JSON.stringify({ session_id: 'sess-gc', transcript_path: transcript, cwd: ws }),
|
|
transcriptRoot: projRoot,
|
|
});
|
|
expect(existsSync(join(liveDir, 'sess-gc.txt'))).toBe(false);
|
|
});
|
|
});
|
|
|
|
// ── heartbeat [S3#7] ────────────────────────────────────────────────────────
|
|
|
|
describe('heartbeat', () => {
|
|
test('schema allowlist: every key on every line is allowlisted, never text payloads', async () => {
|
|
const ws = join(tmp, 'ws');
|
|
mkdirSync(ws, { recursive: true });
|
|
await runHook(['session-start'], { stdin: '', cwd: ws, write: () => {} });
|
|
await runHook(['user-prompt'], { stdin: JSON.stringify({ prompt: 'secret prompt text' }), write: () => {} });
|
|
await runHook(['stop'], { stdin: JSON.stringify({ session_id: 's', prompt: 'secret prompt text' }) });
|
|
const raw = readFileSync(await heartbeatPath(), 'utf8');
|
|
const allowed = new Set<string>(HEARTBEAT_ALLOWED_KEYS);
|
|
const lines = raw.split('\n').filter((l) => l.trim());
|
|
expect(lines.length).toBe(3);
|
|
for (const line of lines) {
|
|
for (const key of Object.keys(JSON.parse(line))) {
|
|
expect(allowed.has(key)).toBe(true);
|
|
}
|
|
}
|
|
// The prompt text itself never lands in telemetry.
|
|
expect(raw).not.toContain('secret prompt text');
|
|
});
|
|
|
|
test('per-event writes are append-only below the compaction threshold', async () => {
|
|
const p = await heartbeatPath();
|
|
const seed = JSON.stringify({ ts: 't', event: 'stop', outcome: 'ok', duration_ms: 1 });
|
|
// Above the nominal cap but below the 2x compaction trigger: the hot path
|
|
// must stay a single O_APPEND write (no read-modify-write per prompt).
|
|
writeFileSync(p, Array.from({ length: HEARTBEAT_MAX_LINES + 100 }, () => seed).join('\n') + '\n');
|
|
await runHook(['stop'], { stdin: JSON.stringify({ session_id: 'cap' }) });
|
|
const lines = readFileSync(p, 'utf8').split('\n').filter((l) => l.trim());
|
|
expect(lines.length).toBe(HEARTBEAT_MAX_LINES + 101);
|
|
expect(JSON.parse(lines[lines.length - 1]).event).toBe('stop');
|
|
});
|
|
|
|
test('file compacted to HEARTBEAT_MAX_LINES once it exceeds 2x the cap', async () => {
|
|
const p = await heartbeatPath();
|
|
const seed = JSON.stringify({ ts: 't', event: 'stop', outcome: 'ok', duration_ms: 1 });
|
|
writeFileSync(p, Array.from({ length: 2 * HEARTBEAT_MAX_LINES + 100 }, () => seed).join('\n') + '\n');
|
|
await runHook(['stop'], { stdin: JSON.stringify({ session_id: 'cap' }) });
|
|
const lines = readFileSync(p, 'utf8').split('\n').filter((l) => l.trim());
|
|
expect(lines.length).toBe(HEARTBEAT_MAX_LINES);
|
|
// The newest entry survived the compaction.
|
|
expect(JSON.parse(lines[lines.length - 1]).event).toBe('stop');
|
|
});
|
|
|
|
test('readHeartbeatTail returns the last n entries oldest→newest', async () => {
|
|
const p = await heartbeatPath();
|
|
const mk = (i: number) => JSON.stringify({ ts: `t${i}`, event: 'stop', outcome: 'ok', duration_ms: i });
|
|
writeFileSync(p, [mk(1), mk(2), mk(3), mk(4)].join('\n') + '\n');
|
|
const tail = await readHeartbeatTail(2);
|
|
expect(tail.map((e) => e.duration_ms)).toEqual([3, 4]);
|
|
});
|
|
});
|
|
|
|
// ── bootstrap-workspace push gate [G4] ──────────────────────────────────────
|
|
//
|
|
// The initialized agent.json manifest is THE security boundary: `gbrain hook
|
|
// <event>` run inside an arbitrary git repo must never spawn the workspace
|
|
// push (which commits). Spawns are observed via the io.spawnPush seam.
|
|
|
|
function initGitRepoWithDirtyTree(dir: string): void {
|
|
mkdirSync(dir, { recursive: true });
|
|
execFileSync('git', ['init', '-q'], { cwd: dir });
|
|
writeFileSync(join(dir, 'untracked-work.txt'), 'unsaved work\n');
|
|
}
|
|
|
|
function gitStatus(dir: string): string {
|
|
return execFileSync('git', ['-C', dir, 'status', '--porcelain'], { encoding: 'utf8' });
|
|
}
|
|
|
|
const INITIALIZED_MANIFEST = {
|
|
format_version: 1,
|
|
initialized: true,
|
|
agent_name: 'test-agent',
|
|
created_by: 'test',
|
|
created_at: '2026-01-01T00:00:00.000Z',
|
|
source_id: 'workspace',
|
|
};
|
|
|
|
/** Simulate a COMPLETED repo phase: a receipt for this workspace carrying a
|
|
* recorded repo_url. Without this the no-daemon push is deferred (a
|
|
* create-repo-first install must not push to an unverified-privacy origin). */
|
|
function markRepoPhaseComplete(repo: string): void {
|
|
const toplevel = execFileSync('git', ['-C', repo, 'rev-parse', '--show-toplevel'], { encoding: 'utf8' }).trim();
|
|
const repoUrl = 'https://github.com/alice/boot-repo';
|
|
// The push gate binds to the recorded repo: origin must resolve to repo_url.
|
|
try {
|
|
execFileSync('git', ['-C', repo, 'remote', 'remove', 'origin'], { stdio: 'ignore' });
|
|
} catch {
|
|
/* no origin yet */
|
|
}
|
|
execFileSync('git', ['-C', repo, 'remote', 'add', 'origin', repoUrl]);
|
|
mkdirSync(join(home(), 'bootstrap'), { recursive: true });
|
|
writeReceipt(home(), {
|
|
receipt_version: 1,
|
|
workspace_dir: toplevel,
|
|
source_id: 'workspace',
|
|
agent_name: 'test-agent',
|
|
created_at: '2026-01-01T00:00:00.000Z',
|
|
created_by: 'test',
|
|
brain_created_by_bootstrap: false,
|
|
created_paths: [],
|
|
registrations: [],
|
|
repo_url: repoUrl,
|
|
} as RepoReceipt);
|
|
}
|
|
|
|
describe('bootstrap push gate [G4]', () => {
|
|
test('git repo + dirty tree + NO agent.json: session-start and session-end never spawn a push, repo untouched', async () => {
|
|
const repo = join(tmp, 'plain-repo');
|
|
initGitRepoWithDirtyTree(repo);
|
|
const before = gitStatus(repo);
|
|
const spawned: string[] = [];
|
|
const io = { spawnPush: (root: string) => { spawned.push(root); } };
|
|
|
|
const startOut = collectStdout();
|
|
expect(await runHook(['session-start'], { ...startOut.io, ...io, stdin: '', cwd: repo })).toBe(0);
|
|
expect(startOut.get()).not.toContain('Unpushed work');
|
|
|
|
const endOut = collectStdout();
|
|
expect(
|
|
await runHook(['session-end'], {
|
|
...endOut.io,
|
|
...io,
|
|
stdin: JSON.stringify({ session_id: 'sess-plain', cwd: repo }),
|
|
}),
|
|
).toBe(0);
|
|
|
|
expect(spawned).toEqual([]);
|
|
expect(gitStatus(repo)).toBe(before); // repo untouched — nothing staged/committed
|
|
});
|
|
|
|
test('initialized:false (template clone) manifest: same untouched guarantee', async () => {
|
|
const repo = join(tmp, 'template-repo');
|
|
initGitRepoWithDirtyTree(repo);
|
|
writeFileSync(
|
|
join(repo, 'agent.json'),
|
|
JSON.stringify({ ...INITIALIZED_MANIFEST, initialized: false }, null, 2) + '\n',
|
|
);
|
|
const before = gitStatus(repo);
|
|
const spawned: string[] = [];
|
|
const io = { spawnPush: (root: string) => { spawned.push(root); } };
|
|
|
|
const startOut = collectStdout();
|
|
await runHook(['session-start'], { ...startOut.io, ...io, stdin: '', cwd: repo });
|
|
expect(startOut.get()).not.toContain('Unpushed work');
|
|
await runHook(['session-end'], {
|
|
...io,
|
|
write: () => {},
|
|
stdin: JSON.stringify({ session_id: 'sess-template', cwd: repo }),
|
|
});
|
|
|
|
expect(spawned).toEqual([]);
|
|
expect(gitStatus(repo)).toBe(before);
|
|
});
|
|
|
|
test('initialized bootstrap workspace + dirty tree: session-start prints the recovery note and spawns the detached push', async () => {
|
|
const repo = join(tmp, 'boot-repo');
|
|
initGitRepoWithDirtyTree(repo);
|
|
writeFileSync(join(repo, 'agent.json'), JSON.stringify(INITIALIZED_MANIFEST, null, 2) + '\n');
|
|
markRepoPhaseComplete(repo); // repo phase done → push is allowed
|
|
const spawned: string[] = [];
|
|
|
|
const out = collectStdout();
|
|
expect(
|
|
await runHook(['session-start'], {
|
|
...out.io,
|
|
spawnPush: (root: string) => { spawned.push(root); },
|
|
stdin: '',
|
|
cwd: repo,
|
|
}),
|
|
).toBe(0);
|
|
|
|
expect(out.get()).toContain('Unpushed work from a previous session detected');
|
|
expect(spawned).toHaveLength(1);
|
|
// The spawned root is the git toplevel of the workspace (macOS may prefix
|
|
// /private on tmpdir paths — compare via git itself).
|
|
const toplevel = execFileSync('git', ['-C', repo, 'rev-parse', '--show-toplevel'], { encoding: 'utf8' }).trim();
|
|
expect(spawned[0]).toBe(toplevel);
|
|
expect((await lastHeartbeat())?.reason).toBe('push_spawned');
|
|
});
|
|
|
|
test('initialized workspace: session-end also spawns the backstop push', async () => {
|
|
const repo = join(tmp, 'boot-repo-end');
|
|
initGitRepoWithDirtyTree(repo);
|
|
writeFileSync(join(repo, 'agent.json'), JSON.stringify(INITIALIZED_MANIFEST, null, 2) + '\n');
|
|
markRepoPhaseComplete(repo); // repo phase done → push is allowed
|
|
const spawned: string[] = [];
|
|
await runHook(['session-end'], {
|
|
write: () => {},
|
|
spawnPush: (root: string) => { spawned.push(root); },
|
|
stdin: JSON.stringify({ session_id: 'sess-boot-end', cwd: repo }),
|
|
});
|
|
expect(spawned).toHaveLength(1);
|
|
});
|
|
|
|
test('create-repo-first BEFORE the repo phase (no repo_url yet): session-start defers the push, never publishes to an unverified origin', async () => {
|
|
const repo = join(tmp, 'boot-repo-pending');
|
|
initGitRepoWithDirtyTree(repo);
|
|
writeFileSync(join(repo, 'agent.json'), JSON.stringify(INITIALIZED_MANIFEST, null, 2) + '\n');
|
|
// NB: no markRepoPhaseComplete — the repo phase has not run yet.
|
|
const spawned: string[] = [];
|
|
const out = collectStdout();
|
|
await runHook(['session-start'], {
|
|
...out.io,
|
|
spawnPush: (root: string) => { spawned.push(root); },
|
|
stdin: '',
|
|
cwd: repo,
|
|
});
|
|
expect(spawned).toEqual([]); // deferred, not spawned
|
|
expect((await lastHeartbeat())?.reason).toBe('push_deferred_repo_pending');
|
|
});
|
|
|
|
test('create-repo-first BEFORE the repo phase (no repo_url yet): session-end defers the backstop push', async () => {
|
|
const repo = join(tmp, 'boot-repo-pending-end');
|
|
initGitRepoWithDirtyTree(repo);
|
|
writeFileSync(join(repo, 'agent.json'), JSON.stringify(INITIALIZED_MANIFEST, null, 2) + '\n');
|
|
const spawned: string[] = [];
|
|
await runHook(['session-end'], {
|
|
write: () => {},
|
|
spawnPush: (root: string) => { spawned.push(root); },
|
|
stdin: JSON.stringify({ session_id: 'sess-boot-end-pending', cwd: repo }),
|
|
});
|
|
expect(spawned).toEqual([]); // deferred until `gbrain bootstrap repo`
|
|
});
|
|
});
|
|
|
|
// ── user-prompt deadline degradation [D5/ENG-1] ─────────────────────────────
|
|
|
|
describe('user-prompt deadline', () => {
|
|
test('IPC server that accepts but never responds → exit 0, empty stdout, heartbeat reason deadline', async () => {
|
|
const dataDir = join(tmp, 'data');
|
|
mkdirSync(dataDir, { recursive: true });
|
|
writePgliteConfig(dataDir);
|
|
ensureIpcSecret(dataDir); // client finds the secret, so it proceeds to the socket
|
|
// A black-hole server: accepts the connection, never writes a byte.
|
|
const blackHole = net.createServer(() => { /* accept and stall */ });
|
|
await new Promise<void>((r) => blackHole.listen(resolveSocketPath(dataDir), r));
|
|
servers.push(blackHole);
|
|
|
|
const out = collectStdout();
|
|
expect(
|
|
await runHook(['user-prompt'], {
|
|
...out.io,
|
|
stdin: JSON.stringify({ prompt: 'hello?' }),
|
|
// Injected deadline seam (below the 600ms IPC client timeout) so the
|
|
// test pins the DEADLINE path, not the client-timeout path, without
|
|
// an 800ms wall-clock wait.
|
|
userPromptDeadlineMs: 250,
|
|
}),
|
|
).toBe(0);
|
|
expect(out.get()).toBe(''); // late writes are suppressed post-deadline
|
|
const hb = await lastHeartbeat();
|
|
expect(hb?.event).toBe('user-prompt');
|
|
expect(hb?.outcome).toBe('degraded');
|
|
expect(hb?.reason).toBe('deadline');
|
|
});
|
|
});
|