Skip to content

Commit e59522b

Browse files
committed
feat(test+sec): E2E faux-provider test + credentials mode self-heal
Two launch-readiness wins for pre.32: - New end-to-end test at src/agent/__test__/agent-e2e.test.ts uses pi-ai's exported registerFauxProvider (we already depend on it via the npm package) to script multi-turn assistant responses. Asserts our bundle composition routes a tool call cleanly, persists the session, fires the right lifecycle events, and doesn't lose anything in the message_update → reducer pipeline App.tsx depends on. Three tests, ~130 lines, no new mock infrastructure. - createAgent now accepts a configOverride escape hatch for tests so the E2E suite doesn't have to fake env vars or credentials. - src/auth/credentials.ts load() now stats the file mode on every read, warns to stderr and re-chmods to 0600 if it drifted. Audit #9: we enforced 0600 on write but never verified on read, so a user chmod or a permissive umask would silently leave tokens world-readable. Warn-and-heal is the friendly path (refusing to load would lock users out over a chmod they didn't realise mattered).
1 parent 493d7ce commit e59522b

4 files changed

Lines changed: 200 additions & 3 deletions

File tree

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
2+
import { tmpdir } from "node:os";
3+
import { join } from "node:path";
4+
import {
5+
type FauxProviderRegistration,
6+
fauxAssistantMessage,
7+
fauxText,
8+
fauxToolCall,
9+
registerFauxProvider,
10+
} from "@earendil-works/pi-ai";
11+
import { afterEach, beforeEach, describe, expect, it } from "vitest";
12+
import { createAgent } from "../agent.js";
13+
14+
/**
15+
* End-to-end coverage of our agent bundle wiring. Pi-mono already tests
16+
* the bare loop with its own e2e suite; this test exists to catch
17+
* regressions in OUR layer: the bundle composition, tool dispatch
18+
* through our buildTools, message persistence, hook lifecycle, and
19+
* (most importantly) that the streaming-events → reducer pipeline our
20+
* App.tsx depends on doesn't silently drop anything.
21+
*
22+
* Uses pi-ai's built-in `registerFauxProvider`, which is exported from
23+
* the npm package we already depend on. No new mock infrastructure.
24+
*/
25+
26+
describe("agent bundle end-to-end", () => {
27+
let cwd: string;
28+
let faux: FauxProviderRegistration;
29+
30+
beforeEach(() => {
31+
cwd = mkdtempSync(join(tmpdir(), "codebase-e2e-"));
32+
// Seed a file the agent's first scripted tool call will read.
33+
writeFileSync(join(cwd, "hello.txt"), "hello from the e2e harness\n");
34+
faux = registerFauxProvider({
35+
provider: "faux",
36+
models: [{ id: "faux-model", name: "Faux Model" }],
37+
});
38+
});
39+
40+
afterEach(() => {
41+
faux.unregister();
42+
rmSync(cwd, { recursive: true, force: true });
43+
});
44+
45+
it("runs a tool call and produces a final answer", async () => {
46+
// Turn 1: assistant says "let me check" and calls read_file.
47+
// Turn 2: assistant produces the final answer using the tool result.
48+
faux.setResponses([
49+
fauxAssistantMessage(
50+
[
51+
fauxText("Let me read that file."),
52+
fauxToolCall("read_file", { path: join(cwd, "hello.txt") }, { id: "call-1" }),
53+
],
54+
{ stopReason: "toolUse" },
55+
),
56+
fauxAssistantMessage("The file says: hello from the e2e harness."),
57+
]);
58+
59+
const bundle = createAgent({
60+
cwd,
61+
configOverride: { model: faux.getModel(), apiKey: "test-key", source: "explicit" },
62+
autoApprove: true,
63+
});
64+
65+
const events: string[] = [];
66+
bundle.subscribe((event) => {
67+
events.push(event.type);
68+
});
69+
70+
await bundle.agent.prompt("Read hello.txt and summarize.");
71+
72+
// Final state: user prompt → assistant w/ tool call → tool result → assistant answer.
73+
expect(bundle.agent.state.messages.length).toBe(4);
74+
expect(bundle.agent.state.messages[0].role).toBe("user");
75+
expect(bundle.agent.state.messages[1].role).toBe("assistant");
76+
expect(bundle.agent.state.messages[2].role).toBe("toolResult");
77+
expect(bundle.agent.state.messages[3].role).toBe("assistant");
78+
79+
const toolResult = bundle.agent.state.messages[2];
80+
if (toolResult.role !== "toolResult") throw new Error("expected toolResult");
81+
expect(toolResult.toolName).toBe("read_file");
82+
const resultText = toolResult.content.map((b) => (b.type === "text" ? b.text : "")).join("");
83+
expect(resultText).toContain("hello from the e2e harness");
84+
85+
// Lifecycle events fired in expected order (we don't assert on
86+
// every event, just the load-bearing ones).
87+
expect(events).toContain("agent_start");
88+
expect(events).toContain("message_start");
89+
expect(events).toContain("message_end");
90+
expect(events).toContain("tool_execution_start");
91+
expect(events).toContain("tool_execution_end");
92+
expect(events).toContain("agent_end");
93+
94+
// Tool-execution events bracket the call cleanly.
95+
const toolStart = events.indexOf("tool_execution_start");
96+
const toolEnd = events.indexOf("tool_execution_end");
97+
expect(toolStart).toBeGreaterThan(-1);
98+
expect(toolEnd).toBeGreaterThan(toolStart);
99+
100+
// Faux provider was called twice (one per assistant turn).
101+
expect(faux.state.callCount).toBe(2);
102+
});
103+
104+
it("session persists after a completed turn", async () => {
105+
faux.setResponses([fauxAssistantMessage("hello back")]);
106+
const bundle = createAgent({
107+
cwd,
108+
configOverride: { model: faux.getModel(), apiKey: "test-key", source: "explicit" },
109+
autoApprove: true,
110+
});
111+
112+
await bundle.agent.prompt("hi");
113+
114+
// Session file should exist now and have both messages.
115+
const stored = bundle.sessions.load(faux.getModel().id);
116+
expect(stored).not.toBeNull();
117+
if (!stored) return;
118+
expect(stored.messages.length).toBe(2);
119+
expect(stored.messages[0].role).toBe("user");
120+
expect(stored.messages[1].role).toBe("assistant");
121+
});
122+
123+
it("compaction monitor stays inactive when transcript is short", async () => {
124+
faux.setResponses([fauxAssistantMessage("short reply")]);
125+
const bundle = createAgent({
126+
cwd,
127+
configOverride: { model: faux.getModel(), apiKey: "test-key", source: "explicit" },
128+
autoApprove: true,
129+
});
130+
131+
expect(bundle.compactionMonitor.current().active).toBe(false);
132+
await bundle.agent.prompt("hi");
133+
// Short transcript can't trigger compaction; monitor must stay idle.
134+
expect(bundle.compactionMonitor.current().active).toBe(false);
135+
});
136+
});

src/agent/agent.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,13 @@ export interface CreateAgentOptions {
5454
* defeats the entire permission system.
5555
*/
5656
autoApprove?: boolean;
57+
/**
58+
* Test escape hatch. Skip resolveConfig() and inject a pre-built
59+
* model + apiKey + source. Used by the E2E harness with pi-ai's
60+
* faux provider so tests can run without env vars or credentials.
61+
* Production code never sets this.
62+
*/
63+
configOverride?: { model: ResolvedConfig["model"]; apiKey: string; source: ResolvedConfig["source"] };
5764
}
5865

5966
export interface AgentBundle {
@@ -81,7 +88,7 @@ export interface AgentBundle {
8188
}
8289

8390
export function createAgent(opts: CreateAgentOptions = {}): AgentBundle {
84-
const { model, apiKey, source } = resolveConfig();
91+
const { model, apiKey, source } = opts.configOverride ?? resolveConfig();
8592
const cwd = opts.cwd ?? process.cwd();
8693
const systemPrompt = opts.systemPrompt ?? buildSystemPrompt(cwd);
8794

src/auth/credentials.test.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,30 @@ describe("CredentialsStore", () => {
4343
}
4444
});
4545

46+
it("re-chmods to 0600 on load if the file was made world-readable", () => {
47+
if (process.platform === "win32") return; // no posix modes
48+
store.save({ accessToken: "x", scopes: [], source: "manual" });
49+
// Simulate user chmod or a permissive umask after save.
50+
require("node:fs").chmodSync(store.filePath, 0o644);
51+
expect(store.mode()).toBe(0o644);
52+
// Stderr warning is the spec for this branch; capture so we don't
53+
// pollute the test runner output.
54+
const origWrite = process.stderr.write.bind(process.stderr);
55+
let warned = "";
56+
process.stderr.write = ((chunk: string | Uint8Array) => {
57+
warned += typeof chunk === "string" ? chunk : Buffer.from(chunk).toString();
58+
return true;
59+
}) as typeof process.stderr.write;
60+
try {
61+
const creds = store.load();
62+
expect(creds).not.toBeNull();
63+
} finally {
64+
process.stderr.write = origWrite;
65+
}
66+
expect(warned).toContain("credentials file mode is 0644");
67+
expect(store.mode()).toBe(0o600);
68+
});
69+
4670
it("load returns null when no file exists", () => {
4771
expect(store.load()).toBeNull();
4872
});

src/auth/credentials.ts

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,14 @@
11
import { randomBytes } from "node:crypto";
2-
import { existsSync, mkdirSync, readFileSync, renameSync, statSync, unlinkSync, writeFileSync } from "node:fs";
2+
import {
3+
chmodSync,
4+
existsSync,
5+
mkdirSync,
6+
readFileSync,
7+
renameSync,
8+
statSync,
9+
unlinkSync,
10+
writeFileSync,
11+
} from "node:fs";
312
import { homedir } from "node:os";
413
import { dirname, join } from "node:path";
514

@@ -70,6 +79,27 @@ export class CredentialsStore {
7079

7180
load(): Credentials | null {
7281
if (!existsSync(this.path)) return null;
82+
// Mode invariant: tokens live behind 0600. We enforce on save, but a
83+
// user could chmod the file or have a permissive umask after the
84+
// fact — in that case quietly heal by re-chmod'ing AND emit a
85+
// stderr warning so they know. Anything stricter (e.g. refusing to
86+
// load) would lock people out of their own session over a chmod
87+
// they didn't realise mattered. Warn-and-heal is the friendly path.
88+
try {
89+
const currentMode = statSync(this.path).mode & 0o777;
90+
if (currentMode !== 0o600) {
91+
process.stderr.write(
92+
`[auth] credentials file mode is 0${currentMode.toString(8)} — should be 0600. Fixing.\n`,
93+
);
94+
try {
95+
chmodSync(this.path, 0o600);
96+
} catch {
97+
// non-fatal — Windows/ACL systems don't support chmod
98+
}
99+
}
100+
} catch {
101+
// stat failed — file race during read; let the readFileSync below surface it
102+
}
73103
let raw: string;
74104
try {
75105
raw = readFileSync(this.path, "utf8");
@@ -113,7 +143,7 @@ export class CredentialsStore {
113143
// Re-assert mode after rename — some platforms preserve the tmp's
114144
// mode but it's cheap insurance.
115145
try {
116-
require("node:fs").chmodSync(this.path, 0o600);
146+
chmodSync(this.path, 0o600);
117147
} catch {
118148
// non-fatal on systems that don't support chmod (Windows w/ ACLs)
119149
}

0 commit comments

Comments
 (0)