@botcord/daemon 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/activity-tracker.d.ts +43 -0
- package/dist/activity-tracker.js +110 -0
- package/dist/adapters/runtimes.d.ts +14 -0
- package/dist/adapters/runtimes.js +18 -0
- package/dist/agent-discovery.d.ts +81 -0
- package/dist/agent-discovery.js +181 -0
- package/dist/agent-workspace.d.ts +31 -0
- package/dist/agent-workspace.js +221 -0
- package/dist/config.d.ts +116 -0
- package/dist/config.js +180 -0
- package/dist/control-channel.d.ts +99 -0
- package/dist/control-channel.js +388 -0
- package/dist/cross-room.d.ts +23 -0
- package/dist/cross-room.js +55 -0
- package/dist/daemon-config-map.d.ts +61 -0
- package/dist/daemon-config-map.js +153 -0
- package/dist/daemon.d.ts +123 -0
- package/dist/daemon.js +349 -0
- package/dist/doctor.d.ts +89 -0
- package/dist/doctor.js +191 -0
- package/dist/gateway/channel-manager.d.ts +54 -0
- package/dist/gateway/channel-manager.js +292 -0
- package/dist/gateway/channels/botcord.d.ts +93 -0
- package/dist/gateway/channels/botcord.js +510 -0
- package/dist/gateway/channels/index.d.ts +2 -0
- package/dist/gateway/channels/index.js +1 -0
- package/dist/gateway/channels/sanitize.d.ts +20 -0
- package/dist/gateway/channels/sanitize.js +56 -0
- package/dist/gateway/dispatcher.d.ts +73 -0
- package/dist/gateway/dispatcher.js +431 -0
- package/dist/gateway/gateway.d.ts +87 -0
- package/dist/gateway/gateway.js +158 -0
- package/dist/gateway/index.d.ts +15 -0
- package/dist/gateway/index.js +15 -0
- package/dist/gateway/log.d.ts +9 -0
- package/dist/gateway/log.js +20 -0
- package/dist/gateway/router.d.ts +10 -0
- package/dist/gateway/router.js +48 -0
- package/dist/gateway/runtimes/claude-code.d.ts +30 -0
- package/dist/gateway/runtimes/claude-code.js +162 -0
- package/dist/gateway/runtimes/codex.d.ts +83 -0
- package/dist/gateway/runtimes/codex.js +272 -0
- package/dist/gateway/runtimes/gemini.d.ts +15 -0
- package/dist/gateway/runtimes/gemini.js +29 -0
- package/dist/gateway/runtimes/ndjson-stream.d.ts +43 -0
- package/dist/gateway/runtimes/ndjson-stream.js +169 -0
- package/dist/gateway/runtimes/probe.d.ts +17 -0
- package/dist/gateway/runtimes/probe.js +54 -0
- package/dist/gateway/runtimes/registry.d.ts +59 -0
- package/dist/gateway/runtimes/registry.js +94 -0
- package/dist/gateway/session-store.d.ts +39 -0
- package/dist/gateway/session-store.js +133 -0
- package/dist/gateway/types.d.ts +265 -0
- package/dist/gateway/types.js +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +854 -0
- package/dist/log.d.ts +7 -0
- package/dist/log.js +44 -0
- package/dist/provision.d.ts +88 -0
- package/dist/provision.js +749 -0
- package/dist/room-context-fetcher.d.ts +18 -0
- package/dist/room-context-fetcher.js +101 -0
- package/dist/room-context.d.ts +53 -0
- package/dist/room-context.js +112 -0
- package/dist/sender-classify.d.ts +30 -0
- package/dist/sender-classify.js +32 -0
- package/dist/snapshot-writer.d.ts +37 -0
- package/dist/snapshot-writer.js +84 -0
- package/dist/status-render.d.ts +28 -0
- package/dist/status-render.js +97 -0
- package/dist/system-context.d.ts +57 -0
- package/dist/system-context.js +91 -0
- package/dist/turn-text.d.ts +36 -0
- package/dist/turn-text.js +57 -0
- package/dist/user-auth.d.ts +75 -0
- package/dist/user-auth.js +245 -0
- package/dist/working-memory.d.ts +46 -0
- package/dist/working-memory.js +274 -0
- package/package.json +39 -0
- package/src/__tests__/activity-tracker.test.ts +130 -0
- package/src/__tests__/agent-discovery.test.ts +191 -0
- package/src/__tests__/agent-workspace.test.ts +147 -0
- package/src/__tests__/control-channel.test.ts +327 -0
- package/src/__tests__/cross-room.test.ts +116 -0
- package/src/__tests__/daemon-config-map.test.ts +416 -0
- package/src/__tests__/daemon.test.ts +300 -0
- package/src/__tests__/device-code.test.ts +152 -0
- package/src/__tests__/doctor.test.ts +218 -0
- package/src/__tests__/protocol-core-reexport.test.ts +24 -0
- package/src/__tests__/provision.test.ts +922 -0
- package/src/__tests__/room-context.test.ts +233 -0
- package/src/__tests__/runtime-discovery.test.ts +173 -0
- package/src/__tests__/snapshot-writer.test.ts +141 -0
- package/src/__tests__/status-render.test.ts +137 -0
- package/src/__tests__/system-context.test.ts +315 -0
- package/src/__tests__/turn-text.test.ts +116 -0
- package/src/__tests__/user-auth.test.ts +125 -0
- package/src/__tests__/working-memory.test.ts +240 -0
- package/src/activity-tracker.ts +140 -0
- package/src/adapters/runtimes.ts +30 -0
- package/src/agent-discovery.ts +262 -0
- package/src/agent-workspace.ts +247 -0
- package/src/config.ts +290 -0
- package/src/control-channel.ts +455 -0
- package/src/cross-room.ts +89 -0
- package/src/daemon-config-map.ts +200 -0
- package/src/daemon.ts +478 -0
- package/src/doctor.ts +282 -0
- package/src/gateway/__tests__/.gitkeep +0 -0
- package/src/gateway/__tests__/botcord-channel.test.ts +480 -0
- package/src/gateway/__tests__/channel-manager.test.ts +475 -0
- package/src/gateway/__tests__/claude-code-adapter.test.ts +318 -0
- package/src/gateway/__tests__/codex-adapter.test.ts +350 -0
- package/src/gateway/__tests__/dispatcher.test.ts +1159 -0
- package/src/gateway/__tests__/gateway-add-channel.test.ts +180 -0
- package/src/gateway/__tests__/gateway-managed-routes.test.ts +181 -0
- package/src/gateway/__tests__/gateway.test.ts +222 -0
- package/src/gateway/__tests__/router.test.ts +247 -0
- package/src/gateway/__tests__/sanitize.test.ts +193 -0
- package/src/gateway/__tests__/session-store.test.ts +235 -0
- package/src/gateway/channel-manager.ts +349 -0
- package/src/gateway/channels/botcord.ts +605 -0
- package/src/gateway/channels/index.ts +6 -0
- package/src/gateway/channels/sanitize.ts +68 -0
- package/src/gateway/dispatcher.ts +554 -0
- package/src/gateway/gateway.ts +211 -0
- package/src/gateway/index.ts +29 -0
- package/src/gateway/log.ts +30 -0
- package/src/gateway/router.ts +60 -0
- package/src/gateway/runtimes/claude-code.ts +180 -0
- package/src/gateway/runtimes/codex.ts +312 -0
- package/src/gateway/runtimes/gemini.ts +43 -0
- package/src/gateway/runtimes/ndjson-stream.ts +225 -0
- package/src/gateway/runtimes/probe.ts +73 -0
- package/src/gateway/runtimes/registry.ts +143 -0
- package/src/gateway/session-store.ts +157 -0
- package/src/gateway/types.ts +325 -0
- package/src/index.ts +961 -0
- package/src/log.ts +47 -0
- package/src/provision.ts +879 -0
- package/src/room-context-fetcher.ts +124 -0
- package/src/room-context.ts +167 -0
- package/src/sender-classify.ts +46 -0
- package/src/snapshot-writer.ts +103 -0
- package/src/status-render.ts +132 -0
- package/src/system-context.ts +162 -0
- package/src/turn-text.ts +93 -0
- package/src/user-auth.ts +295 -0
- package/src/working-memory.ts +352 -0
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
import { afterAll, describe, expect, it } from "vitest";
|
|
2
|
+
import { mkdtempSync, rmSync, writeFileSync, chmodSync } from "node:fs";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { ClaudeCodeAdapter } from "../runtimes/claude-code.js";
|
|
6
|
+
|
|
7
|
+
// The adapter spawns whatever binary we point it at; we point it at a small
|
|
8
|
+
// Node script so we control stdout/stderr/exit precisely without needing the
|
|
9
|
+
// real `claude` CLI.
|
|
10
|
+
const tmpRoot = mkdtempSync(path.join(os.tmpdir(), "gateway-claude-"));
|
|
11
|
+
|
|
12
|
+
function makeScript(name: string, body: string): string {
|
|
13
|
+
const p = path.join(tmpRoot, name);
|
|
14
|
+
writeFileSync(p, `#!/usr/bin/env node\n${body}\n`, { mode: 0o755 });
|
|
15
|
+
chmodSync(p, 0o755);
|
|
16
|
+
return p;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
afterAll(() => {
|
|
20
|
+
rmSync(tmpRoot, { recursive: true, force: true });
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
function runAdapter(script: string, sessionId: string | null = null) {
|
|
24
|
+
const adapter = new ClaudeCodeAdapter({ binary: script });
|
|
25
|
+
const ctrl = new AbortController();
|
|
26
|
+
return adapter.run({
|
|
27
|
+
text: "hi",
|
|
28
|
+
sessionId,
|
|
29
|
+
accountId: "ag_test",
|
|
30
|
+
cwd: tmpRoot,
|
|
31
|
+
signal: ctrl.signal,
|
|
32
|
+
trustLevel: "owner",
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
describe("ClaudeCodeAdapter", () => {
|
|
37
|
+
it("parses session_id from system init + concatenates assistant text", async () => {
|
|
38
|
+
const script = makeScript(
|
|
39
|
+
"happy.js",
|
|
40
|
+
`
|
|
41
|
+
const lines = [
|
|
42
|
+
{type:"system", subtype:"init", session_id:"sid-123"},
|
|
43
|
+
{type:"assistant", message:{content:[{type:"text", text:"hello "},{type:"text", text:"world"}]}},
|
|
44
|
+
{type:"result", subtype:"success", session_id:"sid-123", total_cost_usd:0.0042, result:"hello world"},
|
|
45
|
+
];
|
|
46
|
+
for (const l of lines) process.stdout.write(JSON.stringify(l) + "\\n");
|
|
47
|
+
process.exit(0);
|
|
48
|
+
`,
|
|
49
|
+
);
|
|
50
|
+
const res = await runAdapter(script);
|
|
51
|
+
expect(res.newSessionId).toBe("sid-123");
|
|
52
|
+
expect(res.text).toBe("hello world");
|
|
53
|
+
expect(res.costUsd).toBeCloseTo(0.0042);
|
|
54
|
+
expect(res.error).toBeUndefined();
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("recognizes tool_use block via onBlock callback", async () => {
|
|
58
|
+
const script = makeScript(
|
|
59
|
+
"tooluse.js",
|
|
60
|
+
`
|
|
61
|
+
const lines = [
|
|
62
|
+
{type:"system", subtype:"init", session_id:"sid-2"},
|
|
63
|
+
{type:"assistant", message:{content:[{type:"tool_use", id:"tu1", name:"Bash", input:{}}]}},
|
|
64
|
+
{type:"assistant", message:{content:[{type:"text", text:"done"}]}},
|
|
65
|
+
{type:"result", subtype:"success", session_id:"sid-2", result:"done"},
|
|
66
|
+
];
|
|
67
|
+
for (const l of lines) process.stdout.write(JSON.stringify(l) + "\\n");
|
|
68
|
+
`,
|
|
69
|
+
);
|
|
70
|
+
const adapter = new ClaudeCodeAdapter({ binary: script });
|
|
71
|
+
const ctrl = new AbortController();
|
|
72
|
+
const seen: string[] = [];
|
|
73
|
+
const res = await adapter.run({
|
|
74
|
+
text: "x",
|
|
75
|
+
sessionId: null,
|
|
76
|
+
accountId: "ag_test",
|
|
77
|
+
cwd: tmpRoot,
|
|
78
|
+
signal: ctrl.signal,
|
|
79
|
+
trustLevel: "owner",
|
|
80
|
+
onBlock: (b) => seen.push(b.kind),
|
|
81
|
+
});
|
|
82
|
+
expect(res.text).toBe("done");
|
|
83
|
+
expect(seen).toContain("tool_use");
|
|
84
|
+
expect(seen).toContain("assistant_text");
|
|
85
|
+
expect(seen).toContain("system");
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("skips non-JSON stdout lines and still returns result", async () => {
|
|
89
|
+
const script = makeScript(
|
|
90
|
+
"nonjson.js",
|
|
91
|
+
`
|
|
92
|
+
process.stdout.write("this is not json\\n");
|
|
93
|
+
process.stdout.write(JSON.stringify({type:"system", subtype:"init", session_id:"sid-3"}) + "\\n");
|
|
94
|
+
process.stdout.write(" \\n"); // blank line ignored
|
|
95
|
+
process.stdout.write(JSON.stringify({type:"result", subtype:"success", session_id:"sid-3", result:"ok"}) + "\\n");
|
|
96
|
+
`,
|
|
97
|
+
);
|
|
98
|
+
const res = await runAdapter(script);
|
|
99
|
+
expect(res.newSessionId).toBe("sid-3");
|
|
100
|
+
expect(res.text).toBe("ok");
|
|
101
|
+
expect(res.error).toBeUndefined();
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("surfaces error from non-zero exit + stderr", async () => {
|
|
105
|
+
const script = makeScript(
|
|
106
|
+
"boom.js",
|
|
107
|
+
`
|
|
108
|
+
process.stderr.write("claude: auth failure\\n");
|
|
109
|
+
process.exit(2);
|
|
110
|
+
`,
|
|
111
|
+
);
|
|
112
|
+
const res = await runAdapter(script);
|
|
113
|
+
expect(res.error).toBeDefined();
|
|
114
|
+
expect(res.error).toMatch(/code 2/);
|
|
115
|
+
expect(res.error).toMatch(/auth failure/);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it("wipes newSessionId on non-success result so dispatcher can drop the stale entry", async () => {
|
|
119
|
+
// Mirrors what Claude Code emits when `--resume <missing-uuid>` is used:
|
|
120
|
+
// a fresh `session_id` for the just-spawned empty session, plus a non-success
|
|
121
|
+
// result subtype. Persisting that new id would trap us into re-resuming a
|
|
122
|
+
// useless UUID every turn.
|
|
123
|
+
const script = makeScript(
|
|
124
|
+
"resume-miss.js",
|
|
125
|
+
`
|
|
126
|
+
process.stderr.write("No conversation found\\n");
|
|
127
|
+
process.stdout.write(JSON.stringify({
|
|
128
|
+
type:"result",
|
|
129
|
+
subtype:"error_during_execution",
|
|
130
|
+
session_id:"sid-useless",
|
|
131
|
+
is_error:true,
|
|
132
|
+
errors:["No conversation found with session ID: 00000000-0000-0000-0000-000000000000"]
|
|
133
|
+
}) + "\\n");
|
|
134
|
+
process.exit(1);
|
|
135
|
+
`,
|
|
136
|
+
);
|
|
137
|
+
const res = await runAdapter(script);
|
|
138
|
+
expect(res.newSessionId).toBe("");
|
|
139
|
+
expect(res.error).toBeDefined();
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it("picks up result.result even if assistant text is empty", async () => {
|
|
143
|
+
const script = makeScript(
|
|
144
|
+
"resultonly.js",
|
|
145
|
+
`
|
|
146
|
+
process.stdout.write(JSON.stringify({type:"system", subtype:"init", session_id:"sid-4"}) + "\\n");
|
|
147
|
+
process.stdout.write(JSON.stringify({type:"result", subtype:"success", session_id:"sid-4", total_cost_usd:0.01, result:"final-only"}) + "\\n");
|
|
148
|
+
`,
|
|
149
|
+
);
|
|
150
|
+
const res = await runAdapter(script);
|
|
151
|
+
expect(res.text).toBe("final-only");
|
|
152
|
+
expect(res.costUsd).toBe(0.01);
|
|
153
|
+
expect(res.newSessionId).toBe("sid-4");
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it("returns a deletion signal for session ids that could be parsed as flags", async () => {
|
|
157
|
+
const script = makeScript(
|
|
158
|
+
"should-not-spawn.js",
|
|
159
|
+
`
|
|
160
|
+
process.stdout.write(JSON.stringify({type:"result", subtype:"success", result:"spawned"}) + "\\n");
|
|
161
|
+
`,
|
|
162
|
+
);
|
|
163
|
+
const res = await runAdapter(script, "--bad");
|
|
164
|
+
expect(res.newSessionId).toBe("");
|
|
165
|
+
expect(res.error).toMatch(/invalid sessionId/);
|
|
166
|
+
expect(res.text).toBe("");
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it("passes a valid session id through --resume", async () => {
|
|
170
|
+
const script = makeScript(
|
|
171
|
+
"resume-argv.js",
|
|
172
|
+
`
|
|
173
|
+
const argv = process.argv.slice(2);
|
|
174
|
+
process.stdout.write(JSON.stringify({type:"system", subtype:"init", session_id:"sid-next"}) + "\\n");
|
|
175
|
+
process.stdout.write(JSON.stringify({type:"result", subtype:"success", session_id:"sid-next", result: JSON.stringify(argv)}) + "\\n");
|
|
176
|
+
`,
|
|
177
|
+
);
|
|
178
|
+
const res = await runAdapter(script, "00000000-0000-4000-8000-000000000000");
|
|
179
|
+
const argv = JSON.parse(res.text) as string[];
|
|
180
|
+
const idx = argv.indexOf("--resume");
|
|
181
|
+
expect(idx).toBeGreaterThanOrEqual(0);
|
|
182
|
+
expect(argv[idx + 1]).toBe("00000000-0000-4000-8000-000000000000");
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it("allows non-ASCII historical session titles through --resume", async () => {
|
|
186
|
+
const script = makeScript(
|
|
187
|
+
"resume-title-argv.js",
|
|
188
|
+
`
|
|
189
|
+
const argv = process.argv.slice(2);
|
|
190
|
+
process.stdout.write(JSON.stringify({type:"system", subtype:"init", session_id:"sid-next"}) + "\\n");
|
|
191
|
+
process.stdout.write(JSON.stringify({type:"result", subtype:"success", session_id:"sid-next", result: JSON.stringify(argv)}) + "\\n");
|
|
192
|
+
`,
|
|
193
|
+
);
|
|
194
|
+
const res = await runAdapter(script, "会话标题");
|
|
195
|
+
const argv = JSON.parse(res.text) as string[];
|
|
196
|
+
const idx = argv.indexOf("--resume");
|
|
197
|
+
expect(idx).toBeGreaterThanOrEqual(0);
|
|
198
|
+
expect(argv[idx + 1]).toBe("会话标题");
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
describe("trustLevel → --permission-mode", () => {
|
|
202
|
+
// The adapter's argv is not directly inspectable, so we have the spawned
|
|
203
|
+
// script echo its own argv back through a JSON event and assert on it.
|
|
204
|
+
const echoScript = () =>
|
|
205
|
+
makeScript(
|
|
206
|
+
"echo-argv.js",
|
|
207
|
+
`
|
|
208
|
+
const argv = process.argv.slice(2);
|
|
209
|
+
process.stdout.write(JSON.stringify({type:"system", subtype:"init", session_id:"sid-echo"}) + "\\n");
|
|
210
|
+
process.stdout.write(JSON.stringify({type:"result", subtype:"success", session_id:"sid-echo", result: JSON.stringify(argv)}) + "\\n");
|
|
211
|
+
`,
|
|
212
|
+
);
|
|
213
|
+
|
|
214
|
+
it("owner → --permission-mode acceptEdits", async () => {
|
|
215
|
+
const adapter = new ClaudeCodeAdapter({ binary: echoScript() });
|
|
216
|
+
const ctrl = new AbortController();
|
|
217
|
+
const res = await adapter.run({
|
|
218
|
+
text: "x",
|
|
219
|
+
sessionId: null,
|
|
220
|
+
accountId: "ag_test",
|
|
221
|
+
cwd: tmpRoot,
|
|
222
|
+
signal: ctrl.signal,
|
|
223
|
+
trustLevel: "owner",
|
|
224
|
+
});
|
|
225
|
+
const argv = JSON.parse(res.text) as string[];
|
|
226
|
+
const modeIdx = argv.indexOf("--permission-mode");
|
|
227
|
+
expect(modeIdx).toBeGreaterThanOrEqual(0);
|
|
228
|
+
expect(argv[modeIdx + 1]).toBe("acceptEdits");
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
it("public → --permission-mode default", async () => {
|
|
232
|
+
const adapter = new ClaudeCodeAdapter({ binary: echoScript() });
|
|
233
|
+
const ctrl = new AbortController();
|
|
234
|
+
const res = await adapter.run({
|
|
235
|
+
text: "x",
|
|
236
|
+
sessionId: null,
|
|
237
|
+
accountId: "ag_test",
|
|
238
|
+
cwd: tmpRoot,
|
|
239
|
+
signal: ctrl.signal,
|
|
240
|
+
trustLevel: "public",
|
|
241
|
+
});
|
|
242
|
+
const argv = JSON.parse(res.text) as string[];
|
|
243
|
+
const modeIdx = argv.indexOf("--permission-mode");
|
|
244
|
+
expect(modeIdx).toBeGreaterThanOrEqual(0);
|
|
245
|
+
expect(argv[modeIdx + 1]).toBe("default");
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
it("trusted → --permission-mode default", async () => {
|
|
249
|
+
const adapter = new ClaudeCodeAdapter({ binary: echoScript() });
|
|
250
|
+
const ctrl = new AbortController();
|
|
251
|
+
const res = await adapter.run({
|
|
252
|
+
text: "x",
|
|
253
|
+
sessionId: null,
|
|
254
|
+
accountId: "ag_test",
|
|
255
|
+
cwd: tmpRoot,
|
|
256
|
+
signal: ctrl.signal,
|
|
257
|
+
trustLevel: "trusted",
|
|
258
|
+
});
|
|
259
|
+
const argv = JSON.parse(res.text) as string[];
|
|
260
|
+
const modeIdx = argv.indexOf("--permission-mode");
|
|
261
|
+
expect(modeIdx).toBeGreaterThanOrEqual(0);
|
|
262
|
+
expect(argv[modeIdx + 1]).toBe("default");
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
it("systemContext → --append-system-prompt <text>", async () => {
|
|
266
|
+
const adapter = new ClaudeCodeAdapter({ binary: echoScript() });
|
|
267
|
+
const ctrl = new AbortController();
|
|
268
|
+
const res = await adapter.run({
|
|
269
|
+
text: "x",
|
|
270
|
+
sessionId: null,
|
|
271
|
+
accountId: "ag_test",
|
|
272
|
+
cwd: tmpRoot,
|
|
273
|
+
signal: ctrl.signal,
|
|
274
|
+
trustLevel: "owner",
|
|
275
|
+
systemContext: "MEMORY=remember_this",
|
|
276
|
+
});
|
|
277
|
+
const argv = JSON.parse(res.text) as string[];
|
|
278
|
+
const idx = argv.indexOf("--append-system-prompt");
|
|
279
|
+
expect(idx).toBeGreaterThanOrEqual(0);
|
|
280
|
+
expect(argv[idx + 1]).toBe("MEMORY=remember_this");
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
it("omits --append-system-prompt when systemContext is undefined", async () => {
|
|
284
|
+
const adapter = new ClaudeCodeAdapter({ binary: echoScript() });
|
|
285
|
+
const ctrl = new AbortController();
|
|
286
|
+
const res = await adapter.run({
|
|
287
|
+
text: "x",
|
|
288
|
+
sessionId: null,
|
|
289
|
+
accountId: "ag_test",
|
|
290
|
+
cwd: tmpRoot,
|
|
291
|
+
signal: ctrl.signal,
|
|
292
|
+
trustLevel: "owner",
|
|
293
|
+
});
|
|
294
|
+
const argv = JSON.parse(res.text) as string[];
|
|
295
|
+
expect(argv).not.toContain("--append-system-prompt");
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
it("extraArgs --permission-mode overrides trustLevel", async () => {
|
|
299
|
+
const adapter = new ClaudeCodeAdapter({ binary: echoScript() });
|
|
300
|
+
const ctrl = new AbortController();
|
|
301
|
+
const res = await adapter.run({
|
|
302
|
+
text: "x",
|
|
303
|
+
sessionId: null,
|
|
304
|
+
accountId: "ag_test",
|
|
305
|
+
cwd: tmpRoot,
|
|
306
|
+
signal: ctrl.signal,
|
|
307
|
+
trustLevel: "public",
|
|
308
|
+
extraArgs: ["--permission-mode", "plan"],
|
|
309
|
+
});
|
|
310
|
+
const argv = JSON.parse(res.text) as string[];
|
|
311
|
+
// Only one --permission-mode should appear (the override).
|
|
312
|
+
const modes = argv.filter((a) => a === "--permission-mode");
|
|
313
|
+
expect(modes.length).toBe(1);
|
|
314
|
+
const modeIdx = argv.indexOf("--permission-mode");
|
|
315
|
+
expect(argv[modeIdx + 1]).toBe("plan");
|
|
316
|
+
});
|
|
317
|
+
});
|
|
318
|
+
});
|
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
chmodSync,
|
|
4
|
+
existsSync,
|
|
5
|
+
mkdtempSync,
|
|
6
|
+
readFileSync,
|
|
7
|
+
rmSync,
|
|
8
|
+
writeFileSync,
|
|
9
|
+
} from "node:fs";
|
|
10
|
+
import os from "node:os";
|
|
11
|
+
import path from "node:path";
|
|
12
|
+
import { CodexAdapter } from "../runtimes/codex.js";
|
|
13
|
+
import { agentCodexHomeDir } from "../../agent-workspace.js";
|
|
14
|
+
|
|
15
|
+
// The adapter spawns whatever binary we point it at; we point it at a small
|
|
16
|
+
// Node script so we control stdout/stderr/exit precisely without needing the
|
|
17
|
+
// real `codex` CLI.
|
|
18
|
+
const tmpRoot = mkdtempSync(path.join(os.tmpdir(), "gateway-codex-"));
|
|
19
|
+
|
|
20
|
+
// Isolate per-agent workspace writes — agent-workspace.ts resolves paths via
|
|
21
|
+
// `os.homedir()`, which on POSIX follows `process.env.HOME`. Redirecting it
|
|
22
|
+
// to `tmpRoot` keeps these tests from scribbling on `~/.botcord/`.
|
|
23
|
+
const originalHome = process.env.HOME;
|
|
24
|
+
const agentHomeRoot = mkdtempSync(path.join(os.tmpdir(), "gateway-codex-home-"));
|
|
25
|
+
|
|
26
|
+
beforeAll(() => {
|
|
27
|
+
process.env.HOME = agentHomeRoot;
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
afterAll(() => {
|
|
31
|
+
if (originalHome === undefined) delete process.env.HOME;
|
|
32
|
+
else process.env.HOME = originalHome;
|
|
33
|
+
rmSync(tmpRoot, { recursive: true, force: true });
|
|
34
|
+
rmSync(agentHomeRoot, { recursive: true, force: true });
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
function makeScript(name: string, body: string): string {
|
|
38
|
+
const p = path.join(tmpRoot, name);
|
|
39
|
+
writeFileSync(p, `#!/usr/bin/env node\n${body}\n`, { mode: 0o755 });
|
|
40
|
+
chmodSync(p, 0o755);
|
|
41
|
+
return p;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function runAdapter(script: string, sessionId: string | null = null) {
|
|
45
|
+
const adapter = new CodexAdapter({ binary: script });
|
|
46
|
+
const ctrl = new AbortController();
|
|
47
|
+
return adapter.run({
|
|
48
|
+
text: "hi",
|
|
49
|
+
sessionId,
|
|
50
|
+
accountId: "ag_test",
|
|
51
|
+
cwd: tmpRoot,
|
|
52
|
+
signal: ctrl.signal,
|
|
53
|
+
trustLevel: "owner",
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
describe("CodexAdapter", () => {
|
|
58
|
+
it("parses final agent_message text and persists thread_id as newSessionId", async () => {
|
|
59
|
+
const script = makeScript(
|
|
60
|
+
"happy.js",
|
|
61
|
+
`
|
|
62
|
+
const lines = [
|
|
63
|
+
{type:"thread.started", thread_id:"01234567-89ab-7def-8123-456789abcdef"},
|
|
64
|
+
{type:"turn.started"},
|
|
65
|
+
{type:"item.completed", item:{id:"i0", type:"agent_message", text:"hello from codex"}},
|
|
66
|
+
{type:"turn.completed", usage:{input_tokens:1, output_tokens:2}},
|
|
67
|
+
];
|
|
68
|
+
for (const l of lines) process.stdout.write(JSON.stringify(l) + "\\n");
|
|
69
|
+
process.exit(0);
|
|
70
|
+
`,
|
|
71
|
+
);
|
|
72
|
+
const res = await runAdapter(script);
|
|
73
|
+
// Resume is now safe (systemContext lives in AGENTS.md, not transcript),
|
|
74
|
+
// so thread_id IS persisted for the next turn.
|
|
75
|
+
expect(res.newSessionId).toBe("01234567-89ab-7def-8123-456789abcdef");
|
|
76
|
+
expect(res.text).toBe("hello from codex");
|
|
77
|
+
expect(res.error).toBeUndefined();
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("emits tool_use StreamBlock for command_execution items", async () => {
|
|
81
|
+
const script = makeScript(
|
|
82
|
+
"toolblock.js",
|
|
83
|
+
`
|
|
84
|
+
const lines = [
|
|
85
|
+
{type:"thread.started", thread_id:"01234567-89ab-7def-8123-456789abcde0"},
|
|
86
|
+
{type:"item.started", item:{id:"i0", type:"command_execution", command:"ls"}},
|
|
87
|
+
{type:"item.completed", item:{id:"i1", type:"agent_message", text:"done"}},
|
|
88
|
+
];
|
|
89
|
+
for (const l of lines) process.stdout.write(JSON.stringify(l) + "\\n");
|
|
90
|
+
`,
|
|
91
|
+
);
|
|
92
|
+
const adapter = new CodexAdapter({ binary: script });
|
|
93
|
+
const ctrl = new AbortController();
|
|
94
|
+
const seen: string[] = [];
|
|
95
|
+
const res = await adapter.run({
|
|
96
|
+
text: "x",
|
|
97
|
+
sessionId: null,
|
|
98
|
+
accountId: "ag_test",
|
|
99
|
+
cwd: tmpRoot,
|
|
100
|
+
signal: ctrl.signal,
|
|
101
|
+
trustLevel: "owner",
|
|
102
|
+
onBlock: (b) => seen.push(b.kind),
|
|
103
|
+
});
|
|
104
|
+
expect(res.text).toBe("done");
|
|
105
|
+
expect(seen).toContain("tool_use");
|
|
106
|
+
expect(seen).toContain("assistant_text");
|
|
107
|
+
expect(seen).toContain("system");
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("no sessionId → `exec` subcommand (no resume)", async () => {
|
|
111
|
+
const script = makeScript(
|
|
112
|
+
"fresh-argv.js",
|
|
113
|
+
`
|
|
114
|
+
const argv = process.argv.slice(2);
|
|
115
|
+
process.stdout.write(JSON.stringify({type:"thread.started", thread_id:"01234567-89ab-7def-8123-456789abcde1"}) + "\\n");
|
|
116
|
+
process.stdout.write(JSON.stringify({type:"item.completed", item:{id:"i0", type:"agent_message", text:JSON.stringify(argv)}}) + "\\n");
|
|
117
|
+
`,
|
|
118
|
+
);
|
|
119
|
+
const res = await runAdapter(script, null);
|
|
120
|
+
const argv = JSON.parse(res.text) as string[];
|
|
121
|
+
expect(argv[0]).toBe("exec");
|
|
122
|
+
expect(argv[1]).not.toBe("resume");
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it("with sessionId → `exec resume <uuid>` subcommand", async () => {
|
|
126
|
+
const script = makeScript(
|
|
127
|
+
"resume-argv.js",
|
|
128
|
+
`
|
|
129
|
+
const argv = process.argv.slice(2);
|
|
130
|
+
process.stdout.write(JSON.stringify({type:"thread.started", thread_id:"01234567-89ab-7def-8123-456789abcde2"}) + "\\n");
|
|
131
|
+
process.stdout.write(JSON.stringify({type:"item.completed", item:{id:"i0", type:"agent_message", text:JSON.stringify(argv)}}) + "\\n");
|
|
132
|
+
`,
|
|
133
|
+
);
|
|
134
|
+
const uuid = "01234567-89ab-7def-8123-456789abcdef";
|
|
135
|
+
const res = await runAdapter(script, uuid);
|
|
136
|
+
const argv = JSON.parse(res.text) as string[];
|
|
137
|
+
expect(argv[0]).toBe("exec");
|
|
138
|
+
expect(argv[1]).toBe("resume");
|
|
139
|
+
expect(argv[2]).toBe(uuid);
|
|
140
|
+
// Sandbox policy uses `-c` overrides (resume doesn't accept `-s`).
|
|
141
|
+
expect(argv).not.toContain("-s");
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it("rejects non-UUID sessionId before spawn", async () => {
|
|
145
|
+
const script = makeScript("noop.js", "process.exit(0);");
|
|
146
|
+
const adapter = new CodexAdapter({ binary: script });
|
|
147
|
+
const ctrl = new AbortController();
|
|
148
|
+
await expect(
|
|
149
|
+
adapter.run({
|
|
150
|
+
text: "x",
|
|
151
|
+
sessionId: "--not-a-uuid",
|
|
152
|
+
accountId: "ag_test",
|
|
153
|
+
cwd: tmpRoot,
|
|
154
|
+
signal: ctrl.signal,
|
|
155
|
+
trustLevel: "owner",
|
|
156
|
+
}),
|
|
157
|
+
).rejects.toThrow(/invalid sessionId/);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it("writes systemContext to <CODEX_HOME>/AGENTS.md atomically before spawn", async () => {
|
|
161
|
+
// Capture CODEX_HOME seen by the child so we can verify the adapter's env.
|
|
162
|
+
const script = makeScript(
|
|
163
|
+
"echo-codex-home.js",
|
|
164
|
+
`
|
|
165
|
+
const home = process.env.CODEX_HOME ?? "";
|
|
166
|
+
process.stdout.write(JSON.stringify({type:"thread.started", thread_id:"01234567-89ab-7def-8123-456789abcde3"}) + "\\n");
|
|
167
|
+
process.stdout.write(JSON.stringify({type:"item.completed", item:{id:"i0", type:"agent_message", text: home}}) + "\\n");
|
|
168
|
+
`,
|
|
169
|
+
);
|
|
170
|
+
const adapter = new CodexAdapter({ binary: script });
|
|
171
|
+
const ctrl = new AbortController();
|
|
172
|
+
const res = await adapter.run({
|
|
173
|
+
text: "do the thing",
|
|
174
|
+
sessionId: null,
|
|
175
|
+
accountId: "ag_codex_test",
|
|
176
|
+
cwd: tmpRoot,
|
|
177
|
+
signal: ctrl.signal,
|
|
178
|
+
trustLevel: "owner",
|
|
179
|
+
systemContext: "MEMORY: remember X\nDIGEST: room Y was active",
|
|
180
|
+
});
|
|
181
|
+
const expectedHome = agentCodexHomeDir("ag_codex_test");
|
|
182
|
+
expect(res.text).toBe(expectedHome);
|
|
183
|
+
const agentsMd = path.join(expectedHome, "AGENTS.md");
|
|
184
|
+
expect(existsSync(agentsMd)).toBe(true);
|
|
185
|
+
const body = readFileSync(agentsMd, "utf8");
|
|
186
|
+
expect(body).toContain("MEMORY: remember X");
|
|
187
|
+
expect(body).toContain("DIGEST: room Y was active");
|
|
188
|
+
// No tmp leftovers from the atomic rename.
|
|
189
|
+
const stray = path.join(expectedHome, `.AGENTS.md.${process.pid}.tmp`);
|
|
190
|
+
expect(existsSync(stray)).toBe(false);
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it("does NOT prepend systemContext to the positional prompt", async () => {
|
|
194
|
+
const script = makeScript(
|
|
195
|
+
"echo-prompt.js",
|
|
196
|
+
`
|
|
197
|
+
const argv = process.argv.slice(2);
|
|
198
|
+
const dashIdx = argv.indexOf("--");
|
|
199
|
+
const prompt = dashIdx >= 0 ? argv.slice(dashIdx + 1).join(" ") : "";
|
|
200
|
+
process.stdout.write(JSON.stringify({type:"thread.started", thread_id:"01234567-89ab-7def-8123-456789abcde4"}) + "\\n");
|
|
201
|
+
process.stdout.write(JSON.stringify({type:"item.completed", item:{id:"i0", type:"agent_message", text: prompt}}) + "\\n");
|
|
202
|
+
`,
|
|
203
|
+
);
|
|
204
|
+
const adapter = new CodexAdapter({ binary: script });
|
|
205
|
+
const ctrl = new AbortController();
|
|
206
|
+
const res = await adapter.run({
|
|
207
|
+
text: "do the thing",
|
|
208
|
+
sessionId: null,
|
|
209
|
+
accountId: "ag_test",
|
|
210
|
+
cwd: tmpRoot,
|
|
211
|
+
signal: ctrl.signal,
|
|
212
|
+
trustLevel: "owner",
|
|
213
|
+
systemContext: "MEMORY: remember X",
|
|
214
|
+
});
|
|
215
|
+
expect(res.text).toBe("do the thing");
|
|
216
|
+
expect(res.text).not.toContain("MEMORY:");
|
|
217
|
+
expect(res.text).not.toContain("---");
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
it("returns early when signal is already aborted", async () => {
|
|
221
|
+
const script = makeScript("noop.js", "process.exit(0);");
|
|
222
|
+
const adapter = new CodexAdapter({ binary: script });
|
|
223
|
+
const ctrl = new AbortController();
|
|
224
|
+
ctrl.abort();
|
|
225
|
+
const res = await adapter.run({
|
|
226
|
+
text: "x",
|
|
227
|
+
sessionId: null,
|
|
228
|
+
accountId: "ag_test",
|
|
229
|
+
cwd: tmpRoot,
|
|
230
|
+
signal: ctrl.signal,
|
|
231
|
+
trustLevel: "owner",
|
|
232
|
+
});
|
|
233
|
+
expect(res.text).toBe("");
|
|
234
|
+
expect(res.error).toMatch(/aborted before spawn/);
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
it("surfaces turn.completed failure message as error", async () => {
|
|
238
|
+
const script = makeScript(
|
|
239
|
+
"failed.js",
|
|
240
|
+
`
|
|
241
|
+
const lines = [
|
|
242
|
+
{type:"thread.started", thread_id:"01234567-89ab-7def-8123-456789abcde5"},
|
|
243
|
+
{type:"turn.completed", turn:{status:"failed", error:{message:"rate limited"}}},
|
|
244
|
+
];
|
|
245
|
+
for (const l of lines) process.stdout.write(JSON.stringify(l) + "\\n");
|
|
246
|
+
`,
|
|
247
|
+
);
|
|
248
|
+
const res = await runAdapter(script);
|
|
249
|
+
expect(res.error).toMatch(/rate limited/);
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
it("surfaces non-zero exit + stderr as error", async () => {
|
|
253
|
+
const script = makeScript(
|
|
254
|
+
"boom.js",
|
|
255
|
+
`
|
|
256
|
+
process.stderr.write("codex: auth required\\n");
|
|
257
|
+
process.exit(3);
|
|
258
|
+
`,
|
|
259
|
+
);
|
|
260
|
+
const res = await runAdapter(script);
|
|
261
|
+
expect(res.error).toBeDefined();
|
|
262
|
+
expect(res.error).toMatch(/code 3/);
|
|
263
|
+
expect(res.error).toMatch(/auth required/);
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
describe("trustLevel → sandbox defaults (expressed as `-c` so `resume` accepts them)", () => {
|
|
267
|
+
// Echo argv back via the same `thread.started` / `agent_message` shape
|
|
268
|
+
// so we can assert on the flags the adapter chose.
|
|
269
|
+
const echoScript = () =>
|
|
270
|
+
makeScript(
|
|
271
|
+
"echo-argv.js",
|
|
272
|
+
`
|
|
273
|
+
const argv = process.argv.slice(2);
|
|
274
|
+
process.stdout.write(JSON.stringify({type:"thread.started", thread_id:"01234567-89ab-7def-8123-456789abcdea"}) + "\\n");
|
|
275
|
+
process.stdout.write(JSON.stringify({type:"item.completed", item:{id:"i0", type:"agent_message", text: JSON.stringify(argv)}}) + "\\n");
|
|
276
|
+
`,
|
|
277
|
+
);
|
|
278
|
+
|
|
279
|
+
it("owner → sandbox_mode=\"danger-full-access\" + approval_policy=\"never\"", async () => {
|
|
280
|
+
const adapter = new CodexAdapter({ binary: echoScript() });
|
|
281
|
+
const ctrl = new AbortController();
|
|
282
|
+
const res = await adapter.run({
|
|
283
|
+
text: "x",
|
|
284
|
+
sessionId: null,
|
|
285
|
+
accountId: "ag_test",
|
|
286
|
+
cwd: tmpRoot,
|
|
287
|
+
signal: ctrl.signal,
|
|
288
|
+
trustLevel: "owner",
|
|
289
|
+
});
|
|
290
|
+
const argv = JSON.parse(res.text) as string[];
|
|
291
|
+
expect(argv).toContain('sandbox_mode="danger-full-access"');
|
|
292
|
+
expect(argv).toContain('approval_policy="never"');
|
|
293
|
+
expect(argv).not.toContain("--dangerously-bypass-approvals-and-sandbox");
|
|
294
|
+
expect(argv).not.toContain("-s");
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
it("public → sandbox_mode=\"workspace-write\" + approval_policy=\"on-request\"", async () => {
|
|
298
|
+
const adapter = new CodexAdapter({ binary: echoScript() });
|
|
299
|
+
const ctrl = new AbortController();
|
|
300
|
+
const res = await adapter.run({
|
|
301
|
+
text: "x",
|
|
302
|
+
sessionId: null,
|
|
303
|
+
accountId: "ag_test",
|
|
304
|
+
cwd: tmpRoot,
|
|
305
|
+
signal: ctrl.signal,
|
|
306
|
+
trustLevel: "public",
|
|
307
|
+
});
|
|
308
|
+
const argv = JSON.parse(res.text) as string[];
|
|
309
|
+
expect(argv).toContain('sandbox_mode="workspace-write"');
|
|
310
|
+
expect(argv).toContain('approval_policy="on-request"');
|
|
311
|
+
expect(argv).not.toContain("--dangerously-bypass-approvals-and-sandbox");
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
it("trusted → sandbox_mode=\"workspace-write\" + approval_policy=\"on-request\"", async () => {
|
|
315
|
+
const adapter = new CodexAdapter({ binary: echoScript() });
|
|
316
|
+
const ctrl = new AbortController();
|
|
317
|
+
const res = await adapter.run({
|
|
318
|
+
text: "x",
|
|
319
|
+
sessionId: null,
|
|
320
|
+
accountId: "ag_test",
|
|
321
|
+
cwd: tmpRoot,
|
|
322
|
+
signal: ctrl.signal,
|
|
323
|
+
trustLevel: "trusted",
|
|
324
|
+
});
|
|
325
|
+
const argv = JSON.parse(res.text) as string[];
|
|
326
|
+
expect(argv).toContain('sandbox_mode="workspace-write"');
|
|
327
|
+
expect(argv).toContain('approval_policy="on-request"');
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
it("extraArgs `-s read-only` suppresses the default sandbox `-c`s", async () => {
|
|
331
|
+
const adapter = new CodexAdapter({ binary: echoScript() });
|
|
332
|
+
const ctrl = new AbortController();
|
|
333
|
+
const res = await adapter.run({
|
|
334
|
+
text: "x",
|
|
335
|
+
sessionId: null,
|
|
336
|
+
accountId: "ag_test",
|
|
337
|
+
cwd: tmpRoot,
|
|
338
|
+
signal: ctrl.signal,
|
|
339
|
+
trustLevel: "public",
|
|
340
|
+
extraArgs: ["-s", "read-only"],
|
|
341
|
+
});
|
|
342
|
+
const argv = JSON.parse(res.text) as string[];
|
|
343
|
+
// Only the operator-supplied `-s` appears; our defaults are suppressed.
|
|
344
|
+
expect(argv.filter((a) => a === "-s").length).toBe(1);
|
|
345
|
+
expect(argv[argv.indexOf("-s") + 1]).toBe("read-only");
|
|
346
|
+
expect(argv).not.toContain('sandbox_mode="workspace-write"');
|
|
347
|
+
expect(argv).not.toContain('sandbox_mode="danger-full-access"');
|
|
348
|
+
});
|
|
349
|
+
});
|
|
350
|
+
});
|