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 .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -35,3 +35,6 @@ GITHUB_TOKEN=

# Optional: Venice uncensored inference (OpenAI-compatible). https://venice.ai
VENICE_API_KEY=
# Opt in only when the local Claude Code configuration is trusted as a separate
# execution authority. Session reuse is disabled by default.
T3MP3ST_TRUST_CLAUDE_SESSION=0
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,13 @@ Slow local agents can be given more room with `T3MP3ST_LOCAL_AGENT_TIMEOUT_MS`
for each CLI call, `T3MP3ST_TASK_TIMEOUT_MS` for mission tasks, and
`T3MP3ST_GENERAL_TIMEOUT_MS` for planning requests. Values are milliseconds.

Claude Code session reuse is disabled by default because a resumed coding-agent
session may retain provider-side context and ambient tool authority outside the
Arsenal receipt boundary. Operators who explicitly trust their local Claude
Code configuration as a separate execution authority may set
`T3MP3ST_TRUST_CLAUDE_SESSION=1`; reuse remains isolated to one task and the
choice must not be treated as an Arsenal-enforced sandbox.

Or run it **fully offline** on your own model — no key, no cloud. Defaults to Ollama; point it at any OpenAI-compatible server (LM Studio, vLLM, llama.cpp):

```bash
Expand Down
58 changes: 57 additions & 1 deletion src/__tests__/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,20 @@
* Basic test suite for core functionality.
*/

import { describe, it, expect, beforeEach } from 'vitest';
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { Arsenal, BUILTIN_TOOLS, successResult, failResult, createToolContext } from '../arsenal/index.js';
import { FRONTIER_ARSENAL_MILESTONE, SAFE_COMMANDS, TOOL_ADAPTERS, adaptersForFamily, summarizeToolCatalog } from '../arsenal/catalog.js';
import { createKnowledgeBase, createEvasionEngine, CVE_DATABASE, MITRE_TECHNIQUES } from '../stubs/index.js';
import { AGENT_PROMPT_PACKS, FOREFRONT_PRESSURE_LANES, OPERATOR_RUNBOOKS, forefrontPressureForFamily, promptPacksForFamily, runbookForFamily } from '../resources/index.js';
import { OpGeneral } from '../general/index.js';
import { LLMBackbone } from '../llm/index.js';

// Scoped to this file's new per-operator-isolation test (PR #149 review follow-up) — every other
// test here uses provider: 'mock', which never touches this module.
vi.mock('../agent/local-agents.js', () => ({ localAgentChat: vi.fn() }));
import { localAgentChat } from '../agent/local-agents.js';
const localAgentCli = vi.mocked(localAgentChat);

describe('Arsenal', () => {
let arsenal: Arsenal;

Expand Down Expand Up @@ -465,6 +471,56 @@ describe('Wedged-dispatch timeout backstop', () => {
else process.env.T3MP3ST_TASK_TIMEOUT_MS = prev;
}
}, 15000);

// PR #149 review follow-up: spawnOperator() used to hand every operator the SAME shared
// this.llm, so LocalAgentAdapter's Claude Code session-resume state (added for --resume) leaked
// across operators — one operator's session could get resumed with a different operator's
// unrelated task content. Each operator now gets its own LLMBackbone from the same config.
it('spawnOperator() gives each operator its OWN LLMBackbone, not a shared one (no cross-operator Claude session leakage)', async () => {
const mod = await import('../index.js');
const command = new mod.TempestCommand({
name: 'Isolation Op',
llm: { provider: 'local-agent', model: 'claude' },
});

const opA = command.spawnOperator('Recon-Iso', 'recon');
const opB = command.spawnOperator('Scanner-Iso', 'scanner');

expect((opA as any).llm).not.toBe((opB as any).llm);
expect((opA as any).llm).not.toBe(command.llm);

localAgentCli.mockResolvedValueOnce(JSON.stringify({ result: 'a1', session_id: 'sess-A' }));
await (opA as any).llm.chat([{ role: 'user', content: 'hello A' }]);

// Operator B has never talked to Claude before — its first call must NOT resume A's session.
localAgentCli.mockResolvedValueOnce(JSON.stringify({ result: 'b1', session_id: 'sess-B' }));
await (opB as any).llm.chat([{ role: 'user', content: 'hello B' }]);

expect(localAgentCli.mock.calls.at(-1)?.[2]).toMatchObject({ sessionId: undefined });
});

// PR #149 review, round 3 (jmagly): cross-OPERATOR isolation isn't enough — the SAME operator
// running a second task must not resume the first task's session either. The old session belongs
// to a task (and potentially a target) that never earned a place in the new task's context;
// PackBoard is the one sanctioned channel for carrying anything across a task boundary.
it('one operator running TWO SEQUENTIAL tasks does not resume task 1\'s session for task 2 (production-shaped)', async () => {
const mod = await import('../index.js');
const command = new mod.TempestCommand({
name: 'Task Boundary Op',
llm: { provider: 'local-agent', model: 'claude' },
});
const op = command.spawnOperator('Recon-Task-Boundary', 'recon');

localAgentCli.mockResolvedValueOnce(JSON.stringify({ result: 'task1', session_id: 'sess-task-1' }));
await (op as any).llm.chat([{ role: 'system', content: 'TASK-1' }, { role: 'user', content: 'do task 1' }]);
expect(localAgentCli.mock.calls.at(-1)?.[2]).toMatchObject({ sessionId: undefined });

// AgentLoop.run() builds a brand-new messages array per task — simulate that exactly.
localAgentCli.mockResolvedValueOnce(JSON.stringify({ result: 'task2', session_id: 'sess-task-2' }));
await (op as any).llm.chat([{ role: 'system', content: 'TASK-2' }, { role: 'user', content: 'do task 2' }]);

expect(localAgentCli.mock.calls.at(-1)?.[2]).toMatchObject({ sessionId: undefined });
});
});

describe('Codex account provider', () => {
Expand Down
118 changes: 118 additions & 0 deletions src/__tests__/local-agent-path-resolution.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -284,3 +284,121 @@ describe('spawn call-sites use the resolved path (issue #78 — detected-but-uns
})).resolves.toBe('--no-tools --model openai-codex/gpt-5|long planning prompt');
});
});

/**
* REGRESSION guard (Tier 2 — #139 follow-up, session resume): confirmed empirically against the
* real Claude Code CLI that an unknown/expired --resume id is a hard, fast failure (nonzero exit,
* "No conversation found with session ID: ..." on stderr, no JSON on stdout) — the CLI never falls
* back to a fresh session on its own. localAgentChat does that fallback itself, once. These tests
* drive the REAL function (not mocked) through a fake CLI script so the retry wiring is pinned,
* not just the higher-level LocalAgentAdapter call-shape covered in local-agent-tool-calling.test.ts.
*/
const FAKE_CLI_RESUME_STALE = `#!/bin/sh
cat >/dev/null 2>&1
case "$*" in
*--resume*) echo "No conversation found with session ID: fake" >&2; exit 1 ;;
*) echo '{"result":"fresh ok","session_id":"new-session-123"}' ;;
esac
`;

// Echoes exactly what it received on stdin back to stdout on the fresh (non-resume) path — lets a
// test prove WHICH prompt string a fresh-session retry actually received, not just that it succeeded.
const FAKE_CLI_ECHO_ON_FRESH = `#!/bin/sh
prompt=$(cat)
case "$*" in
*--resume*) echo "No conversation found with session ID: fake" >&2; exit 1 ;;
*) printf '%s' "$prompt" ;;
esac
`;

// A --resume failure that is NOT the stale-session error. If a fallback retry incorrectly fired,
// the fresh (non-resume) branch below would resolve successfully — so a test asserting this
// REJECTS proves no retry happened, not just that the final error text matches.
const FAKE_CLI_NONSTALE_RESUME_ERROR = `#!/bin/sh
cat >/dev/null 2>&1
case "$*" in
*--resume*) echo "connection refused" >&2; exit 1 ;;
*) echo '{"result":"should not be reached — a retry fired for a non-stale error","session_id":"x"}' ;;
esac
`;

describe('localAgentChat — stale Claude session fallback (Tier 2)', () => {
it('retries WITHOUT --resume when the resumed session is stale, and succeeds', async () => {
const home = scratch();
putExe(join(home, '.local', 'bin'), 'claude', FAKE_CLI_RESUME_STALE);
process.env.T3MP3ST_AGENT_HOME = home;
process.env.PATH = '/usr/bin:/bin';

const out = await localAgentChat('claude', 'ping', { sessionId: 'stale-uuid', timeoutMs: 4000 });
expect(JSON.parse(out).result).toBe('fresh ok');
});

it('never adds --resume when no sessionId is supplied (no unnecessary retry path)', async () => {
const home = scratch();
putExe(join(home, '.local', 'bin'), 'claude', FAKE_CLI_RESUME_STALE);
process.env.T3MP3ST_AGENT_HOME = home;
process.env.PATH = '/usr/bin:/bin';

const out = await localAgentChat('claude', 'ping', { timeoutMs: 4000 });
expect(JSON.parse(out).result).toBe('fresh ok');
});

it('a genuine failure with no session in play still rejects (no unnecessary-retry regression)', async () => {
const home = scratch();
putExe(join(home, '.local', 'bin'), 'claude', '#!/bin/sh\ncat >/dev/null 2>&1\necho "boom" >&2\nexit 1\n');
process.env.T3MP3ST_AGENT_HOME = home;
process.env.PATH = '/usr/bin:/bin';

await expect(localAgentChat('claude', 'ping', { timeoutMs: 4000 })).rejects.toThrow(/boom/);
});

it('a non-stale failure with a session in play propagates without a fallback retry (narrowed trigger, PR #149 review)', async () => {
const home = scratch();
putExe(join(home, '.local', 'bin'), 'claude', FAKE_CLI_NONSTALE_RESUME_ERROR);
process.env.T3MP3ST_AGENT_HOME = home;
process.env.PATH = '/usr/bin:/bin';

// If the (old, unnarrowed) fallback fired here, this would RESOLVE with the fresh branch's
// success text instead of rejecting — a rejection proves no retry happened.
await expect(localAgentChat('claude', 'ping', { sessionId: 'whatever', timeoutMs: 4000 }))
.rejects.toThrow(/connection refused/);
});

it('the specific stale-session error still triggers exactly one fallback retry', async () => {
const home = scratch();
putExe(join(home, '.local', 'bin'), 'claude', FAKE_CLI_RESUME_STALE);
process.env.T3MP3ST_AGENT_HOME = home;
process.env.PATH = '/usr/bin:/bin';

const out = await localAgentChat('claude', 'ping', { sessionId: 'stale-uuid', timeoutMs: 4000 });
expect(JSON.parse(out).result).toBe('fresh ok');
});

// PR #149 review (jmagly): the fresh-session retry has no history at all, so it must receive the
// FULL transcript (fallbackPrompt) — never the delta sized for the (failed) resumed attempt.
it('the fresh-session retry receives fallbackPrompt, not the delta prompt of the failed resumed attempt', async () => {
const home = scratch();
putExe(join(home, '.local', 'bin'), 'claude', FAKE_CLI_ECHO_ON_FRESH);
process.env.T3MP3ST_AGENT_HOME = home;
process.env.PATH = '/usr/bin:/bin';

const out = await localAgentChat('claude', 'DELTA-ONLY-CONTENT', {
sessionId: 'stale-uuid',
fallbackPrompt: 'FULL-TRANSCRIPT-CONTENT',
timeoutMs: 4000,
});

expect(out).toBe('FULL-TRANSCRIPT-CONTENT');
expect(out).not.toContain('DELTA-ONLY-CONTENT');
});

it('falls back to the (delta) prompt itself when no fallbackPrompt is supplied', async () => {
const home = scratch();
putExe(join(home, '.local', 'bin'), 'claude', FAKE_CLI_ECHO_ON_FRESH);
process.env.T3MP3ST_AGENT_HOME = home;
process.env.PATH = '/usr/bin:/bin';

const out = await localAgentChat('claude', 'ONLY-PROMPT-CONTENT', { sessionId: 'stale-uuid', timeoutMs: 4000 });
expect(out).toBe('ONLY-PROMPT-CONTENT');
});
});
Loading
Loading