@runfusion/fusion 0.43.0 → 0.44.0

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.
Files changed (137) hide show
  1. package/dist/bin.js +8264 -1823
  2. package/dist/client/.vite/manifest.json +277 -266
  3. package/dist/client/assets/AgentDetailView-CanIeYHg.css +1 -0
  4. package/dist/client/assets/{AgentDetailView-FUKwQxBP.js → AgentDetailView-DxV8pFHX.js} +3 -3
  5. package/dist/client/assets/{AgentPermissionPolicyEditor-DMDUhF1n.js → AgentPermissionPolicyEditor--YS4iMUY.js} +2 -2
  6. package/dist/client/assets/AgentsView-BVILQjdS.css +1 -0
  7. package/dist/client/assets/{AgentsView-BrQLd-yI.js → AgentsView-CTzbcvhK.js} +3 -3
  8. package/dist/client/assets/ChatView-COygcnyk.js +6 -0
  9. package/dist/client/assets/CommandCenter-CTaopaC0.css +1 -0
  10. package/dist/client/assets/CommandCenter-DoKX2HWm.js +56 -0
  11. package/dist/client/assets/DevServerView-Cyevyvqx.js +1 -0
  12. package/dist/client/assets/{DirectoryPicker-DTKtd8L2.css → DirectoryPicker-0rspvcMz.css} +1 -1
  13. package/dist/client/assets/{DirectoryPicker-BFot4CVM.js → DirectoryPicker-DBdyXEhR.js} +1 -1
  14. package/dist/client/assets/DocumentsView-BzSMyrns.js +1 -0
  15. package/dist/client/assets/{EvalsView-DAXqDqn2.js → EvalsView-ikz00o9g.js} +1 -1
  16. package/dist/client/assets/ExperimentalAgentOnboardingModal-CZ_jbgep.css +1 -0
  17. package/dist/client/assets/{ExperimentalAgentOnboardingModal-B4aZ1y0n.js → ExperimentalAgentOnboardingModal-D-rzCCET.js} +10 -10
  18. package/dist/client/assets/GoalsView-Bfu1Oy2-.js +2 -0
  19. package/dist/client/assets/GoalsView-rg9VSIza.css +1 -0
  20. package/dist/client/assets/{InsightsView-D5fzFy-F.js → InsightsView-Cgmf5nES.js} +1 -1
  21. package/dist/client/assets/InsightsView-LMv1M1dU.css +1 -0
  22. package/dist/client/assets/MemoryView-DkEuDKQs.js +2 -0
  23. package/dist/client/assets/{NodesView-BA3pKMZu.js → NodesView-B_KYfS4o.js} +3 -3
  24. package/dist/client/assets/{PiExtensionsManager-C4I11iKm.js → PiExtensionsManager-Dz69kkKs.js} +2 -2
  25. package/dist/client/assets/PluginManager-DSiHizRD.css +1 -0
  26. package/dist/client/assets/{PluginManager-BzTzTMXZ.js → PluginManager-LHCVcduP.js} +1 -1
  27. package/dist/client/assets/PullRequestView-3AdbS2a1.css +1 -0
  28. package/dist/client/assets/{PullRequestView-11xJ565q.js → PullRequestView-DBJutLLl.js} +2 -2
  29. package/dist/client/assets/{ResearchView-BHiXfjzE.js → ResearchView-gpn1XcVy.js} +1 -1
  30. package/dist/client/assets/{SecretsView-CC5OYoCn.js → SecretsView-B7YqIowt.js} +1 -1
  31. package/dist/client/assets/SecretsView-Djo1rahi.css +1 -0
  32. package/dist/client/assets/SessionTerminal-9l4bdKcn.js +2 -0
  33. package/dist/client/assets/SessionTerminal-BNgtmXb3.css +1 -0
  34. package/dist/client/assets/SettingsModal-BGEK9_9A.js +31 -0
  35. package/dist/client/assets/{SettingsModal-DpAxDq6M.css → SettingsModal-CKv0TJfM.css} +1 -1
  36. package/dist/client/assets/SettingsModal-CXogw-Er.css +1 -0
  37. package/dist/client/assets/{SettingsModal-BKEbtdqR.js → SettingsModal-DIIoHGUH.js} +1 -1
  38. package/dist/client/assets/{SetupWizardModal-CBfItlP6.js → SetupWizardModal-BcVeMxeX.js} +1 -1
  39. package/dist/client/assets/{SkillsView-DbcW87-E.js → SkillsView-BxSJ4PHL.js} +1 -1
  40. package/dist/client/assets/{StashRecoveryView-CH9RPtzX.js → StashRecoveryView-BhFQ2FsP.js} +1 -1
  41. package/dist/client/assets/{TodoView-ToviJ0SV.js → TodoView-BZpewjy0.js} +2 -2
  42. package/dist/client/assets/WorkflowNodeEditor-Yn1FRF2v.css +1 -0
  43. package/dist/client/assets/WorkflowNodeEditor-tWsgCdVt.js +8 -0
  44. package/dist/client/assets/{addon-fit-B4jkcKk4.js → addon-fit-SnYDEvGm.js} +1 -1
  45. package/dist/client/assets/{addon-unicode11-Cdne2Sj4.js → addon-unicode11-s5GoZCpi.js} +1 -1
  46. package/dist/client/assets/{addon-web-links-CRNLzj7m.js → addon-web-links-jjt2f_oV.js} +1 -1
  47. package/dist/client/assets/{addon-webgl-CeCSULF1.js → addon-webgl-Icn-RnEx.js} +1 -1
  48. package/dist/client/assets/{app-sEkTyT-w.js → app-BmGA0Hiz.js} +3 -3
  49. package/dist/client/assets/{app-BffGU_HQ.js → app-CEVYlVuJ.js} +3 -3
  50. package/dist/client/assets/{app-C8AHhs5N.js → app-CbBFax2C.js} +3 -3
  51. package/dist/client/assets/{app-B2ZxMfJT.js → app-DORKrGAz.js} +3 -3
  52. package/dist/client/assets/{app-Ri9Te2Dv.js → app-DQibuQ9z.js} +3 -3
  53. package/dist/client/assets/app-k76GrQP0.js +13 -0
  54. package/dist/client/assets/{createLucideIcon-BYvOGRiO.js → createLucideIcon-DmVITl5d.js} +1 -1
  55. package/dist/client/assets/{dashboard-view-C3SsjXo1.js → dashboard-view-B4LXCv5H.js} +4 -4
  56. package/dist/client/assets/{dashboard-view-DlKsVySD.js → dashboard-view-BFoyhTYF.js} +1 -1
  57. package/dist/client/assets/{dashboard-view-DtgLZ_LM.js → dashboard-view-Cxzm_eqc.js} +1 -1
  58. package/dist/client/assets/dashboard-view-_8yfNwju.css +1 -0
  59. package/dist/client/assets/{folder-open-BlPyIWyf.js → folder-open-33WGVnbp.js} +1 -1
  60. package/dist/client/assets/index-Bglw05rM.js +791 -0
  61. package/dist/client/assets/index-FYgnflWD.css +1 -0
  62. package/dist/client/assets/shield-alert-CqeGWBUK.js +6 -0
  63. package/dist/client/assets/{upload-B-qmm1NH.js → upload-C11J2diI.js} +1 -1
  64. package/dist/client/assets/{users-D6C5TXf2.js → users-a-ELbyyZ.js} +1 -1
  65. package/dist/client/assets/{vendor-i18n-CV0mwX_-.js → vendor-i18n-DYlSUfot.js} +1 -1
  66. package/dist/client/assets/vendor-react-BTgM5bbZ.js +49 -0
  67. package/dist/client/assets/{vendor-reactflow-CRZcPCXF.js → vendor-reactflow-ZasuEfF9.js} +3 -3
  68. package/dist/client/assets/{vendor-xterm-tcedd2Jt.js → vendor-xterm-ea7-K05z.js} +1 -1
  69. package/dist/client/assets/{x-CqNX_9E2.js → x-PItWc-wQ.js} +1 -1
  70. package/dist/client/assets/{zoom-out-CSTHbJgD.js → zoom-out-CzlHv0Kj.js} +1 -1
  71. package/dist/client/icons/icon-192.png +0 -0
  72. package/dist/client/icons/icon-512.png +0 -0
  73. package/dist/client/index.html +6 -6
  74. package/dist/client/sw.js +1 -1
  75. package/dist/client/theme-data.css +288 -285
  76. package/dist/client/version.json +1 -1
  77. package/dist/droid-cli/package.json +1 -1
  78. package/dist/droid-cli/src/__tests__/execsync-guard.test.ts +17 -0
  79. package/dist/droid-cli/src/__tests__/process-manager.test.ts +1 -45
  80. package/dist/extension.js +4946 -820
  81. package/dist/pi-claude-cli/index.ts +47 -8
  82. package/dist/pi-claude-cli/package.json +4 -1
  83. package/dist/pi-claude-cli/src/__tests__/acp-dispatch.test.ts +80 -0
  84. package/dist/pi-claude-cli/src/__tests__/acp-driver.test.ts +423 -0
  85. package/dist/pi-claude-cli/src/__tests__/execsync-guard.test.ts +14 -0
  86. package/dist/pi-claude-cli/src/__tests__/process-manager.test.ts +1 -45
  87. package/dist/pi-claude-cli/src/__tests__/provider.test.ts +0 -1
  88. package/dist/pi-claude-cli/src/acp-driver.ts +585 -0
  89. package/dist/pi-claude-cli/src/event-bridge.ts +7 -0
  90. package/dist/pi-claude-cli/src/mcp-config.ts +29 -0
  91. package/dist/pi-claude-cli/src/process-manager.ts +6 -38
  92. package/dist/plugin-sdk/index.d.ts +5181 -5144
  93. package/dist/plugins/fusion-plugin-acp-runtime/bundled.js +263 -19
  94. package/dist/plugins/fusion-plugin-acp-runtime/package.json +1 -1
  95. package/dist/plugins/fusion-plugin-cli-printing-press/package.json +1 -1
  96. package/dist/plugins/fusion-plugin-compound-engineering/bundled.js +37 -7
  97. package/dist/plugins/fusion-plugin-compound-engineering/manifest.json +4 -4
  98. package/dist/plugins/fusion-plugin-compound-engineering/package.json +1 -1
  99. package/dist/plugins/fusion-plugin-cursor-runtime/package.json +1 -1
  100. package/dist/plugins/fusion-plugin-dependency-graph/package.json +1 -1
  101. package/dist/plugins/fusion-plugin-droid-runtime/bundled.js +1 -1
  102. package/dist/plugins/fusion-plugin-droid-runtime/package.json +1 -1
  103. package/dist/plugins/fusion-plugin-hermes-runtime/package.json +1 -1
  104. package/dist/plugins/fusion-plugin-openclaw-runtime/package.json +1 -1
  105. package/dist/plugins/fusion-plugin-paperclip-runtime/package.json +1 -1
  106. package/dist/plugins/fusion-plugin-reports/package.json +1 -1
  107. package/dist/plugins/fusion-plugin-roadmap/package.json +1 -1
  108. package/dist/plugins/fusion-plugin-whatsapp-chat/package.json +1 -1
  109. package/dist/plugins/fusion-plugin-whatsapp-chat/src/__tests__/index.test.ts +5 -1
  110. package/package.json +1 -1
  111. package/skill/fusion/references/engine-tools.md +6 -4
  112. package/dist/client/assets/AgentDetailView-DiWELtb1.css +0 -1
  113. package/dist/client/assets/AgentsView-DfznJDek.css +0 -1
  114. package/dist/client/assets/ChatView-Wz_0goif.js +0 -6
  115. package/dist/client/assets/DevServerView-DRqQpHsc.js +0 -1
  116. package/dist/client/assets/DocumentsView-hamhUcvL.js +0 -1
  117. package/dist/client/assets/ExperimentalAgentOnboardingModal-BNpveCEk.css +0 -1
  118. package/dist/client/assets/GoalsView-2QOp9DA0.js +0 -2
  119. package/dist/client/assets/GoalsView-DI66UsjT.css +0 -1
  120. package/dist/client/assets/InsightsView-C9-mqVvf.css +0 -1
  121. package/dist/client/assets/MemoryView-B082KoR-.js +0 -2
  122. package/dist/client/assets/PluginManager-UHVLvWWl.css +0 -1
  123. package/dist/client/assets/PullRequestView-WQIS0zUC.css +0 -1
  124. package/dist/client/assets/ReliabilityView-CpELpGqw.js +0 -1
  125. package/dist/client/assets/ReliabilityView-eoKBhBc4.css +0 -1
  126. package/dist/client/assets/SecretsView-D5bHZ-wM.css +0 -1
  127. package/dist/client/assets/SessionTerminal-BiigPP2l.css +0 -1
  128. package/dist/client/assets/SessionTerminal-G70Jwkzs.js +0 -2
  129. package/dist/client/assets/SettingsModal-C0M1CqOu.js +0 -31
  130. package/dist/client/assets/SettingsModal-nF_GaeeK.css +0 -1
  131. package/dist/client/assets/WorkflowNodeEditor-BTu0D9c5.js +0 -8
  132. package/dist/client/assets/WorkflowNodeEditor-BokhW3de.css +0 -1
  133. package/dist/client/assets/app-CUu1sm3x.js +0 -13
  134. package/dist/client/assets/dashboard-view-Du53JbmQ.css +0 -1
  135. package/dist/client/assets/index-DqhCiU35.css +0 -1
  136. package/dist/client/assets/index-bZCpHtGq.js +0 -793
  137. package/dist/client/assets/vendor-react-D37HfzIf.js +0 -49
@@ -8,6 +8,7 @@
8
8
  import { getModels } from "@earendil-works/pi-ai";
9
9
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
10
10
  import { streamViaCli } from "./src/provider.js";
11
+ import { streamViaAcp } from "./src/acp-driver.js";
11
12
  import {
12
13
  validateCliPresenceAsync,
13
14
  validateCliAuthAsync,
@@ -18,9 +19,32 @@ import {
18
19
  getCustomToolDefs,
19
20
  toolsFromContext,
20
21
  writeMcpConfig,
22
+ buildAcpMcpServers,
21
23
  type McpToolDef,
22
24
  } from "./src/mcp-config.js";
23
25
 
26
+ /**
27
+ * Route A kill-switch (U11). When `FUSION_CLAUDE_ACP=1` AND a bridge binary path
28
+ * is available (`FUSION_CLAUDE_ACP_BRIDGE`, injected by the engine seam per
29
+ * KTD10), the provider drives Claude through the ACP bridge instead of
30
+ * `claude -p`. OFF by default: the live `-p` path is untouched until soak.
31
+ */
32
+ function resolveAcpBridgePath(): string | undefined {
33
+ if (process.env.FUSION_CLAUDE_ACP !== "1") return undefined;
34
+ const p = process.env.FUSION_CLAUDE_ACP_BRIDGE;
35
+ return typeof p === "string" && p.length > 0 ? p : undefined;
36
+ }
37
+
38
+ /** Resolve custom tool defs the same way ensureMcpConfig does (context → registry). */
39
+ function resolveToolDefs(
40
+ pi: ExtensionAPI,
41
+ contextTools?: ReadonlyArray<{ name: string; description: string; parameters: Record<string, unknown> }>,
42
+ ): McpToolDef[] {
43
+ let toolDefs = toolsFromContext(contextTools);
44
+ if (toolDefs.length === 0 && Array.isArray(pi.getAllTools())) toolDefs = getCustomToolDefs(pi);
45
+ return toolDefs;
46
+ }
47
+
24
48
  // Kill all active Claude subprocesses on process exit to prevent orphans
25
49
  process.on("exit", killAllProcesses);
26
50
 
@@ -220,14 +244,29 @@ export default function (pi: ExtensionAPI) {
220
244
  api: "pi-claude-cli",
221
245
  models,
222
246
  streamSimple: (model, context, options) => {
223
- const configPath = ensureMcpConfig(
224
- pi,
225
- (context as { tools?: ReadonlyArray<{
226
- name: string;
227
- description: string;
228
- parameters: Record<string, unknown>;
229
- }> }).tools,
230
- );
247
+ const contextTools = (context as { tools?: ReadonlyArray<{
248
+ name: string;
249
+ description: string;
250
+ parameters: Record<string, unknown>;
251
+ }> }).tools;
252
+
253
+ // Route A (U11): drive Claude through the ACP bridge when the kill-switch
254
+ // is on AND a bridge path is injected. OFF by default → `-p` path below.
255
+ const bridgePath = resolveAcpBridgePath();
256
+ if (bridgePath) {
257
+ const toolDefs = resolveToolDefs(pi, contextTools);
258
+ const hash = createHash("sha1").update(JSON.stringify(toolDefs)).digest("hex").slice(0, 12);
259
+ return streamViaAcp(model, context, {
260
+ ...options,
261
+ bridgePath,
262
+ mcpServers: buildAcpMcpServers(toolDefs, hash),
263
+ // Forward only HOME/PATH so the bridged `claude` authenticates from the
264
+ // login/keychain session (R17); never inherited process.env or API keys.
265
+ bridgeEnv: { HOME: process.env.HOME, PATH: process.env.PATH },
266
+ });
267
+ }
268
+
269
+ const configPath = ensureMcpConfig(pi, contextTools);
231
270
  return streamViaCli(model, context, {
232
271
  ...options,
233
272
  mcpConfigPath: configPath,
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fusion/pi-claude-cli",
3
- "version": "0.43.0",
3
+ "version": "0.44.0",
4
4
  "description": "Fusion vendored fork: pi coding-agent extension that routes LLM calls through the Claude Code CLI. Forked from rchern/pi-claude-cli (MIT). See UPSTREAM.md.",
5
5
  "license": "MIT",
6
6
  "private": true,
@@ -19,6 +19,9 @@
19
19
  "url": "https://github.com/Runfusion/Fusion",
20
20
  "directory": "packages/pi-claude-cli"
21
21
  },
22
+ "dependencies": {
23
+ "@agentclientprotocol/sdk": "0.24.0"
24
+ },
22
25
  "peerDependencies": {
23
26
  "@earendil-works/pi-ai": "*",
24
27
  "@earendil-works/pi-coding-agent": "*"
@@ -0,0 +1,80 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
2
+
3
+ // Spy on both transports so we can assert which one streamSimple dispatches to.
4
+ const { streamViaCli, streamViaAcp } = vi.hoisted(() => ({
5
+ streamViaCli: vi.fn(() => ({ kind: "cli" })),
6
+ streamViaAcp: vi.fn(() => ({ kind: "acp" })),
7
+ }));
8
+ vi.mock("../provider.js", () => ({ streamViaCli }));
9
+ vi.mock("../acp-driver.js", () => ({ streamViaAcp }));
10
+ // Registering the provider kicks off async CLI presence/auth probes; stub them
11
+ // so the test doesn't trip the "real CLI launch blocked" guard.
12
+ vi.mock("../process-manager.js", () => ({
13
+ validateCliPresenceAsync: vi.fn(async () => ({ ok: true })),
14
+ validateCliAuthAsync: vi.fn(async () => ({ ok: true })),
15
+ killAllProcesses: vi.fn(),
16
+ }));
17
+ // Belt-and-suspenders: no real CLI spawn even if a probe slips through.
18
+ vi.mock("node:child_process", () => ({ spawn: vi.fn(() => ({ on: vi.fn(), stdout: { on: vi.fn() }, stderr: { on: vi.fn() }, stdin: { write: vi.fn(), end: vi.fn() }, kill: vi.fn() })), execSync: vi.fn(() => Buffer.from("")) }));
19
+
20
+ vi.mock("@earendil-works/pi-ai", () => ({
21
+ getModels: vi.fn(() => []),
22
+ AssistantMessageEventStream: vi.fn(),
23
+ calculateCost: vi.fn(),
24
+ }));
25
+
26
+ import register from "../../index.js";
27
+
28
+ function registerAndGetStreamSimple() {
29
+ const calls: Array<[string, { streamSimple: (...a: unknown[]) => unknown }]> = [];
30
+ const pi = {
31
+ registerProvider: (name: string, cfg: { streamSimple: (...a: unknown[]) => unknown }) => calls.push([name, cfg]),
32
+ on: vi.fn(),
33
+ getAllTools: () => [],
34
+ setActiveTools: vi.fn(),
35
+ } as never;
36
+ register(pi);
37
+ return calls[0][1].streamSimple;
38
+ }
39
+
40
+ const MODEL = { id: "claude-sonnet-4-5" } as never;
41
+ const CTX = { messages: [{ role: "user", content: "hi" }], tools: [] } as never;
42
+
43
+ describe("streamSimple kill-switch dispatch (U11/R9/R14)", () => {
44
+ const saved = { flag: process.env.FUSION_CLAUDE_ACP, bridge: process.env.FUSION_CLAUDE_ACP_BRIDGE };
45
+ beforeEach(() => { streamViaCli.mockClear(); streamViaAcp.mockClear(); });
46
+ afterEach(() => {
47
+ process.env.FUSION_CLAUDE_ACP = saved.flag;
48
+ process.env.FUSION_CLAUDE_ACP_BRIDGE = saved.bridge;
49
+ });
50
+
51
+ it("defaults to the -p path (streamViaCli) when the kill-switch is OFF", () => {
52
+ delete process.env.FUSION_CLAUDE_ACP;
53
+ const streamSimple = registerAndGetStreamSimple();
54
+ streamSimple(MODEL, CTX, {});
55
+ expect(streamViaCli).toHaveBeenCalledTimes(1);
56
+ expect(streamViaAcp).not.toHaveBeenCalled();
57
+ });
58
+
59
+ it("stays on -p when the flag is set but NO bridge path is provided", () => {
60
+ process.env.FUSION_CLAUDE_ACP = "1";
61
+ delete process.env.FUSION_CLAUDE_ACP_BRIDGE;
62
+ const streamSimple = registerAndGetStreamSimple();
63
+ streamSimple(MODEL, CTX, {});
64
+ expect(streamViaCli).toHaveBeenCalledTimes(1);
65
+ expect(streamViaAcp).not.toHaveBeenCalled();
66
+ });
67
+
68
+ it("dispatches to the ACP bridge when the flag AND a bridge path are set", () => {
69
+ process.env.FUSION_CLAUDE_ACP = "1";
70
+ process.env.FUSION_CLAUDE_ACP_BRIDGE = "/abs/claude-code-cli-acp";
71
+ const streamSimple = registerAndGetStreamSimple();
72
+ streamSimple(MODEL, CTX, {});
73
+ expect(streamViaAcp).toHaveBeenCalledTimes(1);
74
+ expect(streamViaCli).not.toHaveBeenCalled();
75
+ // bridgePath forwarded; env restricted to the allow-list at spawn (driver).
76
+ const opts = (streamViaAcp.mock.calls[0] as unknown[])[2] as { bridgePath?: string; bridgeEnv?: Record<string, unknown> };
77
+ expect(opts.bridgePath).toBe("/abs/claude-code-cli-acp");
78
+ expect(opts.bridgeEnv).toBeDefined();
79
+ });
80
+ });
@@ -0,0 +1,423 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
2
+ import { EventEmitter } from "node:events";
3
+ import { PassThrough } from "node:stream";
4
+
5
+ // Synthetic ACP session/update sequence the mocked prompt() will replay.
6
+ let scriptedUpdates: Array<Record<string, unknown>> = [];
7
+ let scriptedUsage: Record<string, number> | undefined;
8
+ // When set, prompt() never resolves — simulates a turn waiting on the bridge so
9
+ // only an out-of-band event (child death / abort) can end it.
10
+ let scriptedHang = false;
11
+
12
+ // Driver validates the bridge path with existsSync — make the fake path "exist".
13
+ // writeFileSync/unlinkSync back the R17 auth-failure signal (spied).
14
+ const fsSpies = vi.hoisted(() => ({ writeFileSync: vi.fn(), unlinkSync: vi.fn() }));
15
+ vi.mock("node:fs", () => ({ existsSync: () => true, writeFileSync: fsSpies.writeFileSync, unlinkSync: fsSpies.unlinkSync }));
16
+
17
+ vi.mock("node:child_process", () => ({
18
+ spawn: vi.fn(() => {
19
+ const proc = new EventEmitter() as EventEmitter & Record<string, unknown>;
20
+ proc.stdin = new PassThrough();
21
+ proc.stdout = new PassThrough();
22
+ proc.stderr = new PassThrough();
23
+ proc.kill = vi.fn();
24
+ proc.pid = 4242;
25
+ return proc;
26
+ }),
27
+ }));
28
+
29
+ // Mock the ACP SDK: ClientSideConnection.prompt() replays scriptedUpdates onto
30
+ // the client handler, then resolves — so we exercise the real translation logic.
31
+ vi.mock("@agentclientprotocol/sdk", () => ({
32
+ PROTOCOL_VERSION: 1,
33
+ ndJsonStream: vi.fn(() => ({})),
34
+ ClientSideConnection: vi.fn(function (this: Record<string, unknown>, factory: () => { sessionUpdate: (p: unknown) => Promise<void> }) {
35
+ const handler = factory();
36
+ this.initialize = vi.fn(async () => ({ protocolVersion: 1 }));
37
+ this.newSession = vi.fn(async () => ({ sessionId: "s1" }));
38
+ this.prompt = vi.fn(async () => {
39
+ if (scriptedHang) return new Promise(() => {}); // never resolves
40
+ for (const u of scriptedUpdates) await handler.sessionUpdate({ update: u });
41
+ return { stopReason: "end_turn", usage: scriptedUsage };
42
+ });
43
+ }),
44
+ }));
45
+
46
+ const { MockStream } = vi.hoisted(() => {
47
+ const MockStream: unknown = vi.fn(function (this: Record<string, unknown>) {
48
+ const events: Array<Record<string, unknown>> = [];
49
+ this.push = vi.fn((e: Record<string, unknown>) => events.push(e));
50
+ this.end = vi.fn();
51
+ this._events = events;
52
+ });
53
+ return { MockStream };
54
+ });
55
+
56
+ vi.mock("@earendil-works/pi-ai", () => ({
57
+ AssistantMessageEventStream: MockStream,
58
+ calculateCost: vi.fn(),
59
+ }));
60
+
61
+ import { spawn } from "node:child_process";
62
+ import { ClientSideConnection } from "@agentclientprotocol/sdk";
63
+ import { streamViaAcp, buildBridgeEnv } from "../acp-driver.js";
64
+
65
+ const MODEL = { id: "claude-sonnet-4-5", name: "Claude Sonnet 4.5" } as never;
66
+ const CTX = { messages: [{ role: "user", content: "hi" }] } as never;
67
+ const OPTS = { bridgePath: "/fake/claude-code-cli-acp", cwd: "/tmp", mcpServers: [], bridgeEnv: { HOME: "/h", PATH: "/b" } };
68
+
69
+ function eventsOf(stream: { _events: Array<Record<string, unknown>> }) {
70
+ return stream._events;
71
+ }
72
+ const flush = () => new Promise((r) => setTimeout(r, 30));
73
+
74
+ describe("streamViaAcp — ACP→pi translation (U11)", () => {
75
+ beforeEach(() => { scriptedUpdates = []; scriptedUsage = undefined; scriptedHang = false; });
76
+
77
+ it("feeds ACP token usage (incl. cache tokens) into the done message (item 2)", async () => {
78
+ scriptedUsage = { inputTokens: 11, outputTokens: 22, cachedReadTokens: 5, cachedWriteTokens: 3 };
79
+ scriptedUpdates = [{ sessionUpdate: "agent_message_chunk", content: { type: "text", text: "hi" } }];
80
+ const stream = streamViaAcp(MODEL, CTX, OPTS) as unknown as { _events: Array<Record<string, unknown>> };
81
+ await flush();
82
+ const done = stream._events.find((e) => e.type === "done") as { message?: { usage?: { input?: number; output?: number; cacheRead?: number; cacheWrite?: number; totalTokens?: number } } };
83
+ expect(done?.message?.usage?.input).toBe(11);
84
+ expect(done?.message?.usage?.output).toBe(22);
85
+ expect(done?.message?.usage?.cacheRead).toBe(5);
86
+ expect(done?.message?.usage?.cacheWrite).toBe(3);
87
+ expect(done?.message?.usage?.totalTokens).toBe(41);
88
+ });
89
+
90
+ it("ignores a malformed/untrusted usage payload (string/NaN/negative)", async () => {
91
+ scriptedUsage = { inputTokens: "99" as unknown as number, outputTokens: NaN, cachedReadTokens: -5 };
92
+ scriptedUpdates = [{ sessionUpdate: "agent_message_chunk", content: { type: "text", text: "hi" } }];
93
+ const stream = streamViaAcp(MODEL, CTX, OPTS) as unknown as { _events: Array<Record<string, unknown>> };
94
+ await flush();
95
+ const done = stream._events.find((e) => e.type === "done") as { message?: { usage?: { input?: number; output?: number } } };
96
+ // Coerced to undefined → bridge leaves usage at 0; never a string/NaN.
97
+ expect(done?.message?.usage?.input).toBe(0);
98
+ expect(Number.isNaN(done?.message?.usage?.output)).toBe(false);
99
+ });
100
+
101
+ it("does not emit usage on a tool-use (break-early) turn", async () => {
102
+ scriptedUsage = { inputTokens: 11, outputTokens: 22 };
103
+ scriptedUpdates = [
104
+ { sessionUpdate: "tool_call", toolCallId: "t1", _meta: { claudeCode: { toolName: "mcp__custom-tools__fn_task_list" } }, rawInput: {} },
105
+ ];
106
+ const stream = streamViaAcp(MODEL, CTX, OPTS) as unknown as { _events: Array<Record<string, unknown>> };
107
+ await flush();
108
+ const done = stream._events.find((e) => e.type === "done") as { message?: { usage?: { input?: number } } };
109
+ expect(done?.message?.usage?.input ?? 0).toBe(0); // tool-use turn reports zero usage
110
+ });
111
+
112
+ it("translates agent_message_chunk text into pi text events + done(stop)", async () => {
113
+ scriptedUpdates = [
114
+ { sessionUpdate: "agent_message_chunk", content: { type: "text", text: "Hello " } },
115
+ { sessionUpdate: "agent_message_chunk", content: { type: "text", text: "world" } },
116
+ ];
117
+ const stream = streamViaAcp(MODEL, CTX, OPTS) as unknown as { _events: Array<Record<string, unknown>> };
118
+ await flush();
119
+ const types = eventsOf(stream).map((e) => e.type);
120
+ expect(types).toContain("start");
121
+ expect(types).toContain("text_start");
122
+ expect(types.filter((t) => t === "text_delta").length).toBe(2);
123
+ const done = eventsOf(stream).find((e) => e.type === "done");
124
+ expect(done).toBeDefined();
125
+ expect(done!.reason).toBe("stop");
126
+ });
127
+
128
+ it("breaks early on a tool_call: emits toolcall_start + done(toolUse), no execution", async () => {
129
+ scriptedUpdates = [
130
+ { sessionUpdate: "agent_message_chunk", content: { type: "text", text: "let me check" } },
131
+ { sessionUpdate: "tool_call", toolCallId: "t1", _meta: { claudeCode: { toolName: "mcp__custom-tools__fn_task_list" } }, rawInput: {} },
132
+ // anything after the tool call must be ignored (break-early)
133
+ { sessionUpdate: "agent_message_chunk", content: { type: "text", text: "SHOULD NOT APPEAR" } },
134
+ ];
135
+ const stream = streamViaAcp(MODEL, CTX, OPTS) as unknown as { _events: Array<Record<string, unknown>> };
136
+ await flush();
137
+ const types = eventsOf(stream).map((e) => e.type);
138
+ expect(types).toContain("toolcall_start");
139
+ const done = eventsOf(stream).find((e) => e.type === "done");
140
+ expect(done!.reason).toBe("toolUse");
141
+ // break-early: the post-tool text delta must not have been translated
142
+ const deltas = eventsOf(stream).filter((e) => e.type === "text_delta").map((e) => e.delta);
143
+ expect(deltas.join("")).not.toContain("SHOULD NOT APPEAR");
144
+ });
145
+
146
+ it("does NOT break early on an internal ToolSearch; breaks on the real fn_* tool (U9 sequence)", async () => {
147
+ // Claude emits ToolSearch (not pi-known) to load the deferred MCP tool FIRST,
148
+ // then the real mcp__custom-tools__fn_task_list. The old code aborted on
149
+ // ToolSearch; the gated code must wait for the real tool (P0 fix).
150
+ scriptedUpdates = [
151
+ { sessionUpdate: "tool_call", toolCallId: "ts1", _meta: { claudeCode: { toolName: "ToolSearch" } }, rawInput: { query: "x" } },
152
+ { sessionUpdate: "agent_message_chunk", content: { type: "text", text: "found it, calling" } },
153
+ { sessionUpdate: "tool_call", toolCallId: "real", _meta: { claudeCode: { toolName: "mcp__custom-tools__fn_task_list" } }, rawInput: {} },
154
+ ];
155
+ const stream = streamViaAcp(MODEL, CTX, OPTS) as unknown as { _events: Array<Record<string, unknown>> };
156
+ await flush();
157
+ // The text AFTER ToolSearch must have been processed (we didn't abort on ToolSearch)
158
+ const deltas = eventsOf(stream).filter((e) => e.type === "text_delta").map((e) => e.delta);
159
+ expect(deltas.join("")).toContain("found it");
160
+ // And we broke on the real tool
161
+ expect(eventsOf(stream).some((e) => e.type === "toolcall_start")).toBe(true);
162
+ const done = eventsOf(stream).find((e) => e.type === "done");
163
+ expect(done!.reason).toBe("toolUse");
164
+ });
165
+
166
+ it("R17 auth-signal: sets on a 'Not logged in' turn, clears on a real response, ignores long answers", async () => {
167
+ const run = async (text: string) => {
168
+ scriptedUpdates = [{ sessionUpdate: "agent_message_chunk", content: { type: "text", text } }];
169
+ streamViaAcp(MODEL, CTX, OPTS);
170
+ await flush();
171
+ };
172
+ const wroteAuthFailed = () =>
173
+ fsSpies.writeFileSync.mock.calls.some((c) => String(c[1]).includes('"authFailed":true'));
174
+
175
+ // Baseline: a real response leaves the signal cleared (lastAuthFailed=false).
176
+ await run("Here is a normal answer.");
177
+ fsSpies.writeFileSync.mockClear();
178
+ fsSpies.unlinkSync.mockClear();
179
+
180
+ // 1. A turn that is ONLY the bridge's "Not logged in" message → signal written.
181
+ await run("Not logged in · Please run /login");
182
+ expect(wroteAuthFailed()).toBe(true);
183
+
184
+ // 2. A real response → signal cleared (unlink).
185
+ fsSpies.unlinkSync.mockClear();
186
+ await run("Sure — here's the result you asked for.");
187
+ expect(fsSpies.unlinkSync).toHaveBeenCalled();
188
+
189
+ // 3. A LONG legit answer that merely mentions the phrase → NOT flagged.
190
+ fsSpies.writeFileSync.mockClear();
191
+ await run(`If you are not logged in, the CLI prompts you to authenticate. ${"detail ".repeat(20)}`);
192
+ expect(wroteAuthFailed()).toBe(false);
193
+ });
194
+
195
+ it("ends with done even when the turn produces no content", async () => {
196
+ scriptedUpdates = [];
197
+ const stream = streamViaAcp(MODEL, CTX, OPTS) as unknown as { _events: Array<Record<string, unknown>> };
198
+ await flush();
199
+ expect(eventsOf(stream).some((e) => e.type === "done")).toBe(true);
200
+ });
201
+ });
202
+
203
+ describe("connection reuse (item 1) — gated by FUSION_CLAUDE_ACP_REUSE", () => {
204
+ const savedReuse = process.env.FUSION_CLAUDE_ACP_REUSE;
205
+ beforeEach(() => {
206
+ scriptedUpdates = [];
207
+ scriptedUsage = undefined;
208
+ scriptedHang = false;
209
+ vi.mocked(spawn).mockClear();
210
+ vi.mocked(ClientSideConnection).mockClear();
211
+ });
212
+ afterEach(() => {
213
+ if (savedReuse === undefined) delete process.env.FUSION_CLAUDE_ACP_REUSE;
214
+ else process.env.FUSION_CLAUDE_ACP_REUSE = savedReuse;
215
+ });
216
+
217
+ it("reuses one warm bridge connection across turns; turn 2 skips spawn + session/new", async () => {
218
+ process.env.FUSION_CLAUDE_ACP_REUSE = "1";
219
+ const reuseOpts = { ...OPTS, sessionId: "conv-reuse-1" };
220
+
221
+ // Turn 1 (cold): needs >1 message so reuseKey activates and the connection caches.
222
+ const ctx1 = { messages: [{ role: "user", content: "hi" }, { role: "assistant", content: "hello" }] } as never;
223
+ scriptedUpdates = [{ sessionUpdate: "agent_message_chunk", content: { type: "text", text: "turn one" } }];
224
+ streamViaAcp(MODEL, ctx1, reuseOpts);
225
+ await flush();
226
+ expect(vi.mocked(spawn)).toHaveBeenCalledTimes(1);
227
+ expect(vi.mocked(ClientSideConnection)).toHaveBeenCalledTimes(1);
228
+
229
+ // Turn 2 (warm): same sessionId → no new spawn, no new connection.
230
+ const ctx2 = { messages: [...(ctx1 as unknown as { messages: unknown[] }).messages, { role: "user", content: "again" }] } as never;
231
+ scriptedUpdates = [{ sessionUpdate: "agent_message_chunk", content: { type: "text", text: "turn two" } }];
232
+ const s2 = streamViaAcp(MODEL, ctx2, reuseOpts) as unknown as { _events: Array<Record<string, unknown>> };
233
+ await flush();
234
+ expect(vi.mocked(spawn)).toHaveBeenCalledTimes(1); // no second spawn
235
+ expect(vi.mocked(ClientSideConnection)).toHaveBeenCalledTimes(1); // no second connection
236
+
237
+ // The single warm connection's prompt() ran once per turn.
238
+ const conn = vi.mocked(ClientSideConnection).mock.instances[0] as unknown as { prompt: ReturnType<typeof vi.fn>; newSession: ReturnType<typeof vi.fn> };
239
+ expect(conn.prompt).toHaveBeenCalledTimes(2);
240
+ expect(conn.newSession).toHaveBeenCalledTimes(1); // session/new only on the cold turn
241
+ const done = s2._events.find((e) => e.type === "done");
242
+ expect(done!.reason).toBe("stop");
243
+
244
+ // Cleanup: evict the warm connection + clear its (unref'd) idle timer.
245
+ (vi.mocked(spawn).mock.results[0].value as EventEmitter).emit("close", 0);
246
+ });
247
+
248
+ it("fails a reuse turn FAST when the warm child dies mid-prompt (P0: no 30min hang)", async () => {
249
+ process.env.FUSION_CLAUDE_ACP_REUSE = "1";
250
+ const reuseOpts = { ...OPTS, sessionId: "conv-death" };
251
+
252
+ // Turn 1 (cold) caches the warm connection.
253
+ const ctx1 = { messages: [{ role: "user", content: "hi" }, { role: "assistant", content: "hello" }] } as never;
254
+ scriptedUpdates = [{ sessionUpdate: "agent_message_chunk", content: { type: "text", text: "one" } }];
255
+ streamViaAcp(MODEL, ctx1, reuseOpts);
256
+ await flush();
257
+ const child = vi.mocked(spawn).mock.results[0].value as EventEmitter;
258
+
259
+ // Turn 2 (warm) hangs on prompt() — only the child-death path can end it.
260
+ scriptedHang = true;
261
+ const ctx2 = { messages: [...(ctx1 as unknown as { messages: unknown[] }).messages, { role: "user", content: "again" }] } as never;
262
+ const s2 = streamViaAcp(MODEL, ctx2, reuseOpts) as unknown as { _events: Array<Record<string, unknown>> };
263
+ await flush();
264
+ expect(s2._events.some((e) => e.type === "done")).toBe(false); // still waiting
265
+
266
+ // The warm child dies. The cold turn's close handler routes failure to the
267
+ // CURRENT (reuse) turn via router.fail, so it ends immediately.
268
+ child.emit("close", 1);
269
+ await flush();
270
+ const done = s2._events.find((e) => e.type === "done") as { reason?: string; message?: { content?: Array<{ text?: string }> } };
271
+ expect(done).toBeDefined();
272
+ expect(done!.reason).toBe("stop");
273
+ expect(JSON.stringify(done!.message?.content)).toContain("Error");
274
+
275
+ // Cache was evicted: a subsequent turn cold-spawns a fresh bridge.
276
+ scriptedHang = false;
277
+ scriptedUpdates = [{ sessionUpdate: "agent_message_chunk", content: { type: "text", text: "fresh" } }];
278
+ const ctx3 = { messages: [...(ctx2 as unknown as { messages: unknown[] }).messages, { role: "assistant", content: "" }, { role: "user", content: "q3" }] } as never;
279
+ streamViaAcp(MODEL, ctx3, reuseOpts);
280
+ await flush();
281
+ expect(vi.mocked(spawn)).toHaveBeenCalledTimes(2); // turn 1 + the post-death cold restart
282
+ });
283
+
284
+ it("does NOT keep the connection warm after a tool-use (break-early) turn — prompt() still pending", async () => {
285
+ process.env.FUSION_CLAUDE_ACP_REUSE = "1";
286
+ const reuseOpts = { ...OPTS, sessionId: "conv-tooluse" };
287
+
288
+ // Turn 1 (cold) breaks early on a pi-known tool — prompt() never resolves
289
+ // (the break happens mid-stream), so the connection must be torn down, not
290
+ // released warm, or turn 2 would launch a concurrent prompt on it.
291
+ const ctx1 = { messages: [{ role: "user", content: "hi" }, { role: "assistant", content: "hello" }] } as never;
292
+ scriptedUpdates = [
293
+ { sessionUpdate: "tool_call", toolCallId: "t1", _meta: { claudeCode: { toolName: "mcp__custom-tools__fn_task_list" } }, rawInput: {} },
294
+ ];
295
+ const s1 = streamViaAcp(MODEL, ctx1, reuseOpts) as unknown as { _events: Array<Record<string, unknown>> };
296
+ await flush();
297
+ expect(s1._events.find((e) => e.type === "done")!.reason).toBe("toolUse");
298
+ expect(vi.mocked(spawn)).toHaveBeenCalledTimes(1);
299
+
300
+ // Turn 2 must cold-spawn a fresh bridge (no warm reuse after a tool turn).
301
+ scriptedUpdates = [{ sessionUpdate: "agent_message_chunk", content: { type: "text", text: "after tool" } }];
302
+ const ctx2 = { messages: [...(ctx1 as unknown as { messages: unknown[] }).messages, { role: "user", content: "again" }] } as never;
303
+ streamViaAcp(MODEL, ctx2, reuseOpts);
304
+ await flush();
305
+ expect(vi.mocked(spawn)).toHaveBeenCalledTimes(2); // fresh spawn → no concurrent prompt on a warm conn
306
+ });
307
+
308
+ it("cold-starts (no warm reuse) when the resume delta is empty (P1: no empty-prompt hang)", async () => {
309
+ process.env.FUSION_CLAUDE_ACP_REUSE = "1";
310
+ const reuseOpts = { ...OPTS, sessionId: "conv-empty" };
311
+
312
+ // Turn 1 (cold) caches.
313
+ const ctx1 = { messages: [{ role: "user", content: "hi" }, { role: "assistant", content: "hello" }] } as never;
314
+ scriptedUpdates = [{ sessionUpdate: "agent_message_chunk", content: { type: "text", text: "one" } }];
315
+ streamViaAcp(MODEL, ctx1, reuseOpts);
316
+ await flush();
317
+ expect(vi.mocked(spawn)).toHaveBeenCalledTimes(1);
318
+
319
+ // Turn 2 whose context ends in an assistant message → buildResumePrompt is
320
+ // empty → must NOT take the warm path (would hang); cold-starts instead.
321
+ const ctx2 = { messages: [...(ctx1 as unknown as { messages: unknown[] }).messages, { role: "user", content: "x" }, { role: "assistant", content: "y" }] } as never;
322
+ scriptedUpdates = [{ sessionUpdate: "agent_message_chunk", content: { type: "text", text: "two" } }];
323
+ const s2 = streamViaAcp(MODEL, ctx2, reuseOpts) as unknown as { _events: Array<Record<string, unknown>> };
324
+ await flush();
325
+ expect(vi.mocked(spawn)).toHaveBeenCalledTimes(2); // empty resume → fresh spawn
326
+ const done = s2._events.find((e) => e.type === "done");
327
+ expect(done!.reason).toBe("stop"); // produced a normal turn, did not hang
328
+ });
329
+
330
+ it("does NOT reuse when the flag is off (default): each turn spawns a fresh bridge", async () => {
331
+ delete process.env.FUSION_CLAUDE_ACP_REUSE;
332
+ const reuseOpts = { ...OPTS, sessionId: "conv-off" };
333
+ const ctx = { messages: [{ role: "user", content: "hi" }, { role: "assistant", content: "x" }] } as never;
334
+ scriptedUpdates = [{ sessionUpdate: "agent_message_chunk", content: { type: "text", text: "a" } }];
335
+ streamViaAcp(MODEL, ctx, reuseOpts);
336
+ await flush();
337
+ streamViaAcp(MODEL, ctx, reuseOpts);
338
+ await flush();
339
+ expect(vi.mocked(spawn)).toHaveBeenCalledTimes(2);
340
+ expect(vi.mocked(ClientSideConnection)).toHaveBeenCalledTimes(2);
341
+ });
342
+ });
343
+
344
+ describe("buildBridgeEnv — R17 auth opt-in (item 3)", () => {
345
+ const saved = {
346
+ flag: process.env.FUSION_CLAUDE_ACP_FORWARD_AUTH,
347
+ oauth: process.env.CLAUDE_CODE_OAUTH_TOKEN,
348
+ authTok: process.env.ANTHROPIC_AUTH_TOKEN,
349
+ key: process.env.ANTHROPIC_API_KEY,
350
+ };
351
+ // Start each case from a clean slate so an ambient auth var in the runner's
352
+ // env can't shadow the token a test means to exercise (precedence is global).
353
+ beforeEach(() => {
354
+ delete process.env.FUSION_CLAUDE_ACP_FORWARD_AUTH;
355
+ delete process.env.CLAUDE_CODE_OAUTH_TOKEN;
356
+ delete process.env.ANTHROPIC_AUTH_TOKEN;
357
+ delete process.env.ANTHROPIC_API_KEY;
358
+ });
359
+ afterEach(() => {
360
+ for (const [k, v] of [
361
+ ["FUSION_CLAUDE_ACP_FORWARD_AUTH", saved.flag],
362
+ ["CLAUDE_CODE_OAUTH_TOKEN", saved.oauth],
363
+ ["ANTHROPIC_AUTH_TOKEN", saved.authTok],
364
+ ["ANTHROPIC_API_KEY", saved.key],
365
+ ] as const) {
366
+ if (v === undefined) delete process.env[k]; else process.env[k] = v;
367
+ }
368
+ });
369
+
370
+ it("does NOT forward auth vars by default (secure default)", () => {
371
+ delete process.env.FUSION_CLAUDE_ACP_FORWARD_AUTH;
372
+ process.env.ANTHROPIC_API_KEY = "sk-secret";
373
+ const env = buildBridgeEnv({ HOME: "/h", PATH: "/b" });
374
+ expect(env.ANTHROPIC_API_KEY).toBeUndefined();
375
+ expect(env.HOME).toBe("/h");
376
+ });
377
+
378
+ it("forwards a single auth token when opted in", () => {
379
+ process.env.FUSION_CLAUDE_ACP_FORWARD_AUTH = "1";
380
+ delete process.env.CLAUDE_CODE_OAUTH_TOKEN;
381
+ process.env.ANTHROPIC_API_KEY = "sk-secret";
382
+ const env = buildBridgeEnv({ HOME: "/h", PATH: "/b" });
383
+ expect(env.ANTHROPIC_API_KEY).toBe("sk-secret");
384
+ });
385
+
386
+ it("prefers CLAUDE_CODE_OAUTH_TOKEN and forwards only one token", () => {
387
+ process.env.FUSION_CLAUDE_ACP_FORWARD_AUTH = "1";
388
+ process.env.CLAUDE_CODE_OAUTH_TOKEN = "oauth-tok";
389
+ process.env.ANTHROPIC_API_KEY = "sk-secret";
390
+ const env = buildBridgeEnv({ HOME: "/h", PATH: "/b" });
391
+ expect(env.CLAUDE_CODE_OAUTH_TOKEN).toBe("oauth-tok");
392
+ expect(env.ANTHROPIC_API_KEY).toBeUndefined();
393
+ });
394
+
395
+ it("forwards ANTHROPIC_AUTH_TOKEN (middle precedence) when no OAuth token", () => {
396
+ process.env.FUSION_CLAUDE_ACP_FORWARD_AUTH = "1";
397
+ delete process.env.CLAUDE_CODE_OAUTH_TOKEN;
398
+ process.env.ANTHROPIC_AUTH_TOKEN = "auth-tok";
399
+ process.env.ANTHROPIC_API_KEY = "sk-secret";
400
+ const env = buildBridgeEnv({ HOME: "/h", PATH: "/b" });
401
+ expect(env.ANTHROPIC_AUTH_TOKEN).toBe("auth-tok");
402
+ expect(env.ANTHROPIC_API_KEY).toBeUndefined();
403
+ });
404
+
405
+ it("treats a whitespace-only higher-preference token as absent (no shadowing, no blank forward)", () => {
406
+ process.env.FUSION_CLAUDE_ACP_FORWARD_AUTH = "1";
407
+ process.env.CLAUDE_CODE_OAUTH_TOKEN = " "; // blank → must be skipped
408
+ process.env.ANTHROPIC_API_KEY = "sk-real";
409
+ const env = buildBridgeEnv({ HOME: "/h", PATH: "/b" });
410
+ expect(env.CLAUDE_CODE_OAUTH_TOKEN).toBeUndefined();
411
+ expect(env.ANTHROPIC_API_KEY).toBe("sk-real"); // real lower-preference token wins
412
+ });
413
+
414
+ it("reads the auth token from process.env, never a caller-supplied value (no token substitution)", () => {
415
+ process.env.FUSION_CLAUDE_ACP_FORWARD_AUTH = "1";
416
+ delete process.env.CLAUDE_CODE_OAUTH_TOKEN;
417
+ delete process.env.ANTHROPIC_AUTH_TOKEN;
418
+ process.env.ANTHROPIC_API_KEY = "real-from-env";
419
+ // A caller trying to inject a different token via the supplied env must be ignored.
420
+ const env = buildBridgeEnv({ HOME: "/h", PATH: "/b", ANTHROPIC_API_KEY: "attacker" } as NodeJS.ProcessEnv);
421
+ expect(env.ANTHROPIC_API_KEY).toBe("real-from-env");
422
+ });
423
+ });
@@ -0,0 +1,14 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import path from "node:path";
3
+ import { describe, expect, it } from "vitest";
4
+
5
+ describe("process-manager execSync guard", () => {
6
+ it("keeps Claude CLI probing non-blocking", () => {
7
+ const sourcePath = path.resolve(import.meta.dirname, "../process-manager.ts");
8
+
9
+ expect(existsSync(sourcePath)).toBe(true);
10
+ const src = readFileSync(sourcePath, "utf-8");
11
+
12
+ expect(/\bexecSync\b/.test(src)).toBe(false);
13
+ });
14
+ });