agent-dealer 1.2.4 → 1.2.6

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 (72) hide show
  1. package/bundle/server/dist/adapters/agent-deck-bind.js +33 -3
  2. package/bundle/server/dist/adapters/agent-deck-bind.test.js +60 -2
  3. package/bundle/server/dist/adapters/agent-health.js +28 -6
  4. package/bundle/server/dist/adapters/agent-health.test.js +66 -11
  5. package/bundle/server/dist/adapters/git-worktree.js +187 -18
  6. package/bundle/server/dist/adapters/git-worktree.test.js +293 -0
  7. package/bundle/server/dist/adapters/github.js +110 -12
  8. package/bundle/server/dist/adapters/github.test.js +274 -3
  9. package/bundle/server/dist/adapters/muse-capability.js +356 -0
  10. package/bundle/server/dist/adapters/muse-capability.test.js +464 -0
  11. package/bundle/server/dist/capacity/claude-local-cache.js +266 -81
  12. package/bundle/server/dist/capacity/claude-local-cache.test.js +335 -36
  13. package/bundle/server/dist/capacity/muse-host.js +92 -32
  14. package/bundle/server/dist/capacity/muse-host.test.js +130 -4
  15. package/bundle/server/dist/coordinator/admission.test.js +85 -0
  16. package/bundle/server/dist/coordinator/args.js +4 -3
  17. package/bundle/server/dist/coordinator/checkpoint.js +5 -3
  18. package/bundle/server/dist/coordinator/checkpoint.test.js +23 -0
  19. package/bundle/server/dist/coordinator/commands.js +100 -12
  20. package/bundle/server/dist/coordinator/developer-effect.js +128 -16
  21. package/bundle/server/dist/coordinator/developer-effect.test.js +291 -12
  22. package/bundle/server/dist/coordinator/human-resolution.js +33 -1
  23. package/bundle/server/dist/coordinator/muse-developer.integration.test.js +291 -55
  24. package/bundle/server/dist/coordinator/muse-spawn.js +72 -164
  25. package/bundle/server/dist/coordinator/projection.js +1 -0
  26. package/bundle/server/dist/coordinator/prompts.js +25 -6
  27. package/bundle/server/dist/coordinator/prompts.test.js +36 -8
  28. package/bundle/server/dist/coordinator/routing.js +1 -0
  29. package/bundle/server/dist/coordinator/routing.test.js +20 -0
  30. package/bundle/server/dist/coordinator/spawn.js +9 -1
  31. package/bundle/server/dist/coordinator/worktree-cwd-guard.js +48 -0
  32. package/bundle/server/dist/coordinator/worktree-cwd-guard.test.js +83 -0
  33. package/bundle/server/dist/coordinator/worktree-owner-liveness.js +6 -1
  34. package/bundle/server/dist/coordinator/worktree-owner-liveness.test.js +2 -1
  35. package/bundle/server/dist/repository/human-actions.js +13 -0
  36. package/bundle/server/dist/routes/human-actions.js +10 -1
  37. package/bundle/server/dist/routes/human-actions.test.js +117 -1
  38. package/bundle/server/dist/routes/index.js +12 -5
  39. package/bundle/server/dist/routes/runtime-capacity.test.js +3 -2
  40. package/bundle/server/dist/routes/version.js +30 -0
  41. package/bundle/server/dist/routes/version.test.js +21 -0
  42. package/bundle/server/dist/runners/muse-config-core.js +63 -16
  43. package/bundle/server/dist/runners/muse-config.js +1 -1
  44. package/bundle/server/dist/runners/muse-config.test.js +135 -29
  45. package/bundle/server/dist/runners/spawn-cli.js +17 -3
  46. package/bundle/server/dist/runners/spawn-cli.test.js +59 -0
  47. package/bundle/server/package.json +2 -2
  48. package/bundle/server/static-ui/assets/{index-CYqRh_-S.css → index-BII-LgB8.css} +1 -1
  49. package/bundle/server/static-ui/assets/index-CoDzZcsz.js +60 -0
  50. package/bundle/server/static-ui/index.html +2 -2
  51. package/bundle/shared/dist/agents.d.ts +15 -15
  52. package/bundle/shared/dist/agents.js +6 -0
  53. package/bundle/shared/dist/index.d.ts +7 -7
  54. package/bundle/shared/package.json +1 -1
  55. package/dist/doctor.d.ts +7 -3
  56. package/dist/doctor.js +21 -13
  57. package/dist/doctor.test.js +9 -8
  58. package/dist/managed/index.d.ts +1 -1
  59. package/dist/managed/index.js +1 -1
  60. package/dist/managed/updater.d.ts +16 -6
  61. package/dist/managed/updater.js +28 -11
  62. package/dist/managed-update-restart.test.d.ts +1 -0
  63. package/dist/managed-update-restart.test.js +227 -0
  64. package/dist/ports.d.ts +6 -0
  65. package/dist/ports.js +10 -0
  66. package/dist/runtime-state.d.ts +10 -0
  67. package/dist/runtime-state.js +26 -0
  68. package/dist/start.js +67 -2
  69. package/dist/status.js +30 -2
  70. package/dist/update-check.js +3 -6
  71. package/package.json +1 -1
  72. package/bundle/server/static-ui/assets/index-UO4lHZw4.js +0 -60
@@ -1,17 +1,24 @@
1
1
  // packages/server/src/coordinator/muse-developer.integration.test.ts
2
2
  //
3
- // NOT-181 acceptance: a `muse_code` developer profile goes through the real coordinator
4
- // (startWorkflow → tick → routing) and the real developer effect — real git worktree, real push,
5
- // real spawn of a *fake Muse process* (fixtures/fake-muse.mjs), fake GitHub. No paid service is
6
- // ever called. Scenarios are picked with FAKE_MUSE_SCENARIO; the invocation is recorded to a file.
7
- import { test, before, beforeEach, after } from "node:test";
3
+ // NOT-278 acceptance: a `muse_code` developer profile with a deck goes through the real
4
+ // coordinator (startWorkflow → tick → routing) and the real developer effect — deck preflight,
5
+ // real git worktree, real push, real spawn of a *fake Muse process* (fixtures/fake-muse.mjs)
6
+ // on the isolated exec lane, fake GitHub. No paid service is ever called. Scenarios are picked
7
+ // with FAKE_MUSE_SCENARIO; the invocation is recorded to a file.
8
+ //
9
+ // The per-attempt Muse home lives under the Dealer-owned worker MCP config root (outside the
10
+ // worktree and outside OS temp — the temp roots are refused, so every fixture root here is a
11
+ // home scratch dir). The serve lane is never used for a deck-enabled Muse turn.
12
+ import { test, before, beforeEach, after, afterEach } from "node:test";
8
13
  import assert from "node:assert/strict";
9
14
  import { execFileSync } from "node:child_process";
10
15
  import fs from "node:fs";
11
16
  import os from "node:os";
12
17
  import path from "node:path";
13
18
  import { fileURLToPath } from "node:url";
14
- process.env.AGENT_DEALER_HOME = fs.mkdtempSync(path.join(os.tmpdir(), "dealer-muse-home-"));
19
+ const HOME_SCRATCH = fs.mkdtempSync(path.join(os.homedir(), ".dealer-muse-it-"));
20
+ process.env.AGENT_DEALER_HOME = path.join(HOME_SCRATCH, "dealer-home");
21
+ fs.mkdirSync(process.env.AGENT_DEALER_HOME, { recursive: true });
15
22
  process.env.AGENT_DEALER_SKIP_AGENT_HEALTH = "1";
16
23
  process.env.MAX_COORDINATOR_CONCURRENCY = "1";
17
24
  process.env.COORDINATOR_HEARTBEAT_MS = "20";
@@ -42,32 +49,68 @@ const { runCoordinatorTick, drainCoordinator } = await import("./worker-loop.js"
42
49
  const { runDeveloperEffect } = await import("./developer-effect.js");
43
50
  const { realDeveloperSpawn } = await import("./spawn.js");
44
51
  const { realGithubAdapter } = await import("../adapters/github.js");
45
- const { runMuseDeveloperSession, MUSE_ATTEMPT_ROOT } = await import("./muse-spawn.js");
52
+ const { runMuseDeveloperSession } = await import("./muse-spawn.js");
53
+ const { getAgentDeckMcpUrl } = await import("../adapters/agent-deck.js");
54
+ const { getWorkerMcpConfigDir } = await import("../paths.js");
55
+ const { assertMuseSettings } = await import("../runners/muse-config.js");
46
56
  before(() => migrate());
47
57
  beforeEach(() => {
48
58
  getDb().exec("DELETE FROM work_items; DELETE FROM runtime_availability");
49
59
  fs.rmSync(RECORD, { force: true });
50
60
  delete process.env.FAKE_MUSE_SCENARIO;
61
+ saveEnv();
62
+ // Deterministic credentials: no saved login under this config home, so the attempt uses the
63
+ // fake API key on stdin — unless a test creates muse/auth.json below it (the symlink path).
64
+ // The sentinel vars prove the child receives exactly the approved environment.
65
+ process.env.XDG_CONFIG_HOME = fs.mkdtempSync(path.join(HOME_SCRATCH, "cfg-"));
66
+ process.env.META_API_KEY = FAKE_API_KEY;
67
+ process.env.MUSE_SENTINEL = "must-not-reach-child";
68
+ process.env.CODEX_HOME = path.join(HOME_SCRATCH, "codex-home-sentinel");
69
+ process.env.DEALER_MUSE_SENTINEL = "must-not-reach-child";
51
70
  });
71
+ afterEach(() => restoreEnv());
52
72
  after(() => {
53
73
  resetEffectHandlers();
54
74
  delete process.env.DEVELOPER_TIMEOUT_MS;
75
+ restoreEnv();
76
+ fs.rmSync(HOME_SCRATCH, { recursive: true, force: true });
55
77
  });
78
+ /** Fake API key for the stdin-delivery path — assertions require it to appear nowhere. */
79
+ const FAKE_API_KEY = "mk-test-fake-key-0123456789abcdef";
80
+ const MANAGED_ENV = ["XDG_CONFIG_HOME", "META_API_KEY", "MUSE_SENTINEL", "CODEX_HOME", "DEALER_MUSE_SENTINEL"];
81
+ let savedEnv = {};
82
+ function saveEnv() {
83
+ savedEnv = Object.fromEntries(MANAGED_ENV.map((k) => [k, process.env[k]]));
84
+ }
85
+ function restoreEnv() {
86
+ for (const k of MANAGED_ENV) {
87
+ if (savedEnv[k] === undefined)
88
+ delete process.env[k];
89
+ else
90
+ process.env[k] = savedEnv[k];
91
+ }
92
+ }
56
93
  const TEST_DECK_ID = "00000000-0000-4000-a000-000000000099";
94
+ /** The MCP endpoint the spawn binds: the configured endpoint, always ending in `/mcp`. */
95
+ function expectedMcpUrl() {
96
+ return `${getAgentDeckMcpUrl().replace(/\/mcp\/?$/, "")}/mcp`;
97
+ }
57
98
  let repo;
58
99
  let remote;
59
100
  function git(cwd, ...args) {
60
101
  return execFileSync("git", args, { cwd, encoding: "utf8" }).trim();
61
102
  }
62
103
  before(() => {
63
- repo = fs.mkdtempSync(path.join(os.tmpdir(), "dealer-muse-repo-"));
104
+ // Home scratch, never OS temp: the per-attempt guard refuses temp-dir worktrees, and the
105
+ // coordinator worktree lives beside the repo it was cloned from.
106
+ repo = fs.mkdtempSync(path.join(HOME_SCRATCH, "dealer-muse-repo-"));
64
107
  git(repo, "init", "-q", "-b", "main");
65
108
  git(repo, "config", "user.email", "test@example.com");
66
109
  git(repo, "config", "user.name", "Test");
67
110
  fs.writeFileSync(path.join(repo, "README.md"), "hello\n");
68
111
  git(repo, "add", ".");
69
112
  git(repo, "commit", "-q", "-m", "init");
70
- remote = fs.mkdtempSync(path.join(os.tmpdir(), "dealer-muse-remote-"));
113
+ remote = fs.mkdtempSync(path.join(HOME_SCRATCH, "dealer-muse-remote-"));
71
114
  execFileSync("git", ["init", "-q", "--bare", "-b", "main", remote]);
72
115
  git(repo, "remote", "add", "origin", remote);
73
116
  git(repo, "push", "-q", "origin", "main");
@@ -77,8 +120,17 @@ after(() => {
77
120
  fs.rmSync(remote, { recursive: true, force: true });
78
121
  });
79
122
  function makeMuseIssue(opts = {}) {
80
- // The Agents form still asks for a deck; Muse must ignore it (no MCP, no deck).
81
- const dev = createAgent({ name: `muse-${Math.random()}`, runtime: "muse_code", deckId: TEST_DECK_ID });
123
+ // The Agents form requires a deck; the Muse developer session receives it on the exec lane.
124
+ // A null deckId simulates a legacy deck-less profile: the form can no longer produce one,
125
+ // so create with a deck and clear the column directly.
126
+ const dev = createAgent({
127
+ name: `muse-${Math.random()}`,
128
+ runtime: "muse_code",
129
+ deckId: opts.deckId ?? TEST_DECK_ID,
130
+ });
131
+ if (opts.deckId === null) {
132
+ getDb().prepare("UPDATE agents SET deck_id = NULL WHERE id = ?").run(dev.id);
133
+ }
82
134
  const rev = createAgent({ name: `rev-${Math.random()}`, runtime: "codex_local", deckId: TEST_DECK_ID });
83
135
  return createIssue({
84
136
  title: "Add widget",
@@ -121,12 +173,25 @@ function fakeGithub() {
121
173
  },
122
174
  };
123
175
  }
124
- /** A deck call would mean a Muse worker got Agent Deck — it must never happen. */
125
- const forbiddenDeckCallTool = async () => {
126
- throw new Error("Agent Deck must not be called for a Muse Code worker");
127
- };
128
- function registerMuseDeveloper() {
129
- registerEffectHandler("developer", (ctx) => runDeveloperEffect(ctx, { deckCallTool: forbiddenDeckCallTool, spawn: realDeveloperSpawn, github: fakeGithub() }));
176
+ /** Live deck preflight stub: the test deck answers as bound (same shape as the real MCP tool). */
177
+ function museDeckCallTool(deckId = TEST_DECK_ID) {
178
+ return async (name, _args) => {
179
+ if (name !== "get_bound_deck")
180
+ throw new Error(`unexpected deck call ${name}`);
181
+ return {
182
+ content: [{ type: "text", text: JSON.stringify({ id: deckId, name: "test-deck" }) }],
183
+ };
184
+ };
185
+ }
186
+ function registerMuseDeveloper(deckCallTool = museDeckCallTool()) {
187
+ registerEffectHandler("developer", (ctx) => runDeveloperEffect(ctx, { deckCallTool, spawn: realDeveloperSpawn, github: fakeGithub() }));
188
+ }
189
+ /** Spawn wrapper that counts invocations — proves preflight failures never reach a child process. */
190
+ function countingSpawn(counter) {
191
+ return async (input) => {
192
+ counter.count += 1;
193
+ return realDeveloperSpawn(input);
194
+ };
130
195
  }
131
196
  async function pump(max = 1) {
132
197
  for (let i = 0; i < max; i++) {
@@ -137,6 +202,26 @@ async function pump(max = 1) {
137
202
  function recorded() {
138
203
  return JSON.parse(fs.readFileSync(RECORD, "utf8"));
139
204
  }
205
+ /** The captured settings file: exactly one required agent-deck server, accepted by the guard. */
206
+ function expectDeckSettings(rec, deckId = TEST_DECK_ID) {
207
+ const settings = JSON.parse(rec.settings);
208
+ assert.deepEqual(Object.keys(settings).sort(), ["mcpServers", "run", "runtime_capabilities", "schema_version"]);
209
+ assert.deepEqual(Object.keys(settings.mcpServers), ["agent-deck"]);
210
+ const server = settings.mcpServers["agent-deck"];
211
+ assert.equal(server.type, "streamable-http");
212
+ assert.equal(server.url, expectedMcpUrl());
213
+ assert.equal(server.mode, "required");
214
+ // cwdReal was resolved by the fixture at spawn time: the coordinator removes the
215
+ // worktree after the turn, so resolving rec.cwd here would race cleanup.
216
+ assert.deepEqual(server.headers, {
217
+ "x-agent-deck-deck-id": deckId,
218
+ "x-agent-deck-workspace": rec.cwdReal,
219
+ });
220
+ assert.equal("enabled_tools" in server, false, "developers are unfiltered: no allowlist claim");
221
+ assert.equal("disabled_tools" in server, false, "developers are unfiltered: no denylist claim");
222
+ assertMuseSettings(settings, { url: expectedMcpUrl(), deckId, workspace: rec.cwdReal }, "developer");
223
+ return settings;
224
+ }
140
225
  test("fake-Muse developer session goes admission → verified branch → draft PR, with a frozen muse_code snapshot", async () => {
141
226
  const issueId = makeMuseIssue();
142
227
  registerMuseDeveloper();
@@ -149,8 +234,6 @@ test("fake-Muse developer session goes admission → verified branch → draft P
149
234
  assert.ok(issue.prNumber);
150
235
  assert.ok(git(repo, "ls-remote", "origin", `refs/heads/${issue.branch}`).length > 0);
151
236
  assert.equal(git(remote, "show", `${issue.branch}:feature.txt`), "implemented");
152
- // The per-attempt config dir was excluded from `git add .` and never reached the pushed tree.
153
- assert.equal(git(remote, "ls-tree", "-r", "--name-only", issue.branch).includes(MUSE_ATTEMPT_ROOT), false);
154
237
  const dev = listWorkerSessionsForIssue(issueId).find((s) => s.role === "developer");
155
238
  assert.equal(dev.status, "done");
156
239
  assert.equal(dev.runtime, "muse_code");
@@ -160,15 +243,17 @@ test("fake-Muse developer session goes admission → verified branch → draft P
160
243
  const conclusion = listArtifactsForIssue(issueId).find((a) => a.kind === "implementation_conclusion");
161
244
  assert.match(String(JSON.parse(conclusion.contentJson).text), /added the widget/);
162
245
  });
163
- test("the spawn is the pinned developer posture with per-attempt config under the worktree and no MCP or deck", async () => {
246
+ test("the turn runs the isolated exec lane (never serve) with the required agent-deck server and the exact environment", async () => {
164
247
  const issueId = makeMuseIssue();
165
248
  registerMuseDeveloper();
166
249
  process.env.FAKE_MUSE_SCENARIO = "success";
167
250
  startWorkflow(issueId);
168
251
  await pump();
169
252
  const rec = recorded();
253
+ // Exec lane, not the shared serve host: the serve protocol has no per-session MCP identity and
254
+ // could never produce a per-attempt settings file — the binary itself ran here.
170
255
  assert.equal(rec.argv[0], "exec");
171
- for (const expected of ["--json", "--no-foreign-personal-context", "--disable-web-tools"]) {
256
+ for (const expected of ["--json", "--no-foreign-personal-context", "--disable-web-tools", "--api-key-stdin"]) {
172
257
  assert.ok(rec.argv.includes(expected), expected);
173
258
  }
174
259
  assert.equal(rec.argv[rec.argv.indexOf("--model") + 1], MUSE_CODE_CONTRIBUTOR_MODEL);
@@ -179,15 +264,52 @@ test("the spawn is the pinned developer posture with per-attempt config under th
179
264
  assert.ok(!rec.argv.includes(forbidden), forbidden);
180
265
  }
181
266
  assert.equal(rec.noAutoUpdate, "1");
182
- // Config and data live under the worktree the session runs in.
183
- assert.ok(rec.xdgConfigHome.startsWith(path.join(rec.cwd, MUSE_ATTEMPT_ROOT)), rec.xdgConfigHome);
184
- assert.ok(rec.xdgDataHome.startsWith(path.join(rec.cwd, MUSE_ATTEMPT_ROOT)), rec.xdgDataHome);
185
- const settings = JSON.parse(rec.settings);
186
- assert.equal("mcpServers" in settings, false, "no MCP servers");
187
- assert.deepEqual(settings.run, { workflow_trigger_mode: "off", subagent_delegation_mode: "off" });
188
- // The config dir is invisible to git, so the worktree was porcelain-clean at spawn time.
267
+ // Per-attempt config lives under the Dealer-owned worker MCP config root — outside the
268
+ // worktree and outside OS temp — and is removed after the session.
269
+ const mcpRoot = fs.realpathSync(getWorkerMcpConfigDir());
270
+ assert.ok(rec.xdgConfigHome.startsWith(`${mcpRoot}${path.sep}`), rec.xdgConfigHome);
271
+ assert.ok(rec.xdgDataHome.startsWith(`${mcpRoot}${path.sep}`), rec.xdgDataHome);
272
+ assert.ok(!rec.xdgConfigHome.startsWith(rec.cwd), "config is not under the worktree");
273
+ assert.equal(fs.existsSync(rec.xdgConfigHome), false, "per-attempt config removed after success");
274
+ assert.equal(fs.existsSync(rec.xdgDataHome), false, "per-attempt data removed after success");
275
+ expectDeckSettings(rec);
276
+ // Exact environment: ambient secrets and sentinels never reached the child.
277
+ assert.deepEqual(rec.envLeak, {
278
+ META_API_KEY: false,
279
+ MUSE_SENTINEL: false,
280
+ CODEX_HOME: false,
281
+ DEALER_MUSE_SENTINEL: false,
282
+ });
283
+ // API-key auth travels on stdin only: the key is in neither argv, env, settings, nor the record.
284
+ assert.equal(rec.apiKeyStdin, true);
285
+ assert.equal(rec.stdinBytes, FAKE_API_KEY.length + 1);
286
+ assert.equal(rec.authLinked, false, "api-key auth links no auth file");
287
+ const recordRaw = fs.readFileSync(RECORD, "utf8");
288
+ assert.ok(!recordRaw.includes(FAKE_API_KEY), "record holds a stdin length, never the key");
289
+ assert.ok(!rec.settings.includes(FAKE_API_KEY), "settings hold no key");
290
+ // The config dir is outside the worktree, so the worktree was porcelain-clean at spawn time.
189
291
  assert.equal(rec.porcelain, "");
190
292
  });
293
+ test("saved-login auth is symlinked, never copied, and carries no stdin key", async () => {
294
+ const operatorAuthDir = path.join(process.env.XDG_CONFIG_HOME, "muse");
295
+ fs.mkdirSync(operatorAuthDir, { recursive: true });
296
+ const operatorAuth = path.join(operatorAuthDir, "auth.json");
297
+ fs.writeFileSync(operatorAuth, '{"token":"operator-owned"}');
298
+ const issueId = makeMuseIssue();
299
+ registerMuseDeveloper();
300
+ process.env.FAKE_MUSE_SCENARIO = "success";
301
+ startWorkflow(issueId);
302
+ await pump();
303
+ assert.equal(getIssue(issueId).status, "reviewing");
304
+ const rec = recorded();
305
+ assert.equal(rec.authLinked, true);
306
+ assert.equal(rec.authSymlink, true, "the login is linked, never copied");
307
+ assert.equal(rec.apiKeyStdin, false);
308
+ assert.equal(rec.stdinBytes, 0, "no key on stdin for saved-login auth");
309
+ assert.equal(fs.existsSync(rec.xdgConfigHome), false, "per-attempt config (and link) removed");
310
+ assert.equal(fs.readFileSync(operatorAuth, "utf8"), '{"token":"operator-owned"}', "operator credentials untouched");
311
+ expectDeckSettings(rec);
312
+ });
191
313
  test("usage events record muse_code, the confirmed model, duration and tokens; cost stays null", async () => {
192
314
  const issueId = makeMuseIssue();
193
315
  registerMuseDeveloper();
@@ -232,6 +354,8 @@ test("auth failure takes the infra-retry path without spending a review round",
232
354
  const retry = listWorkItemsForIssue(issueId).find((w) => w.status === "pending");
233
355
  assert.ok(retry, "a fresh developer attempt is queued");
234
356
  assert.equal(listUsageEventsForIssue(issueId).length, 1, "the failed spawn still records usage");
357
+ const rec = recorded();
358
+ assert.equal(fs.existsSync(rec.xdgConfigHome), false, "per-attempt config removed after auth failure");
235
359
  });
236
360
  test("a usage cap defers the work item and records Muse availability, spending no attempt", async () => {
237
361
  const issueId = makeMuseIssue();
@@ -240,8 +364,8 @@ test("a usage cap defers the work item and records Muse availability, spending n
240
364
  startWorkflow(issueId);
241
365
  await pump();
242
366
  const issue = getIssue(issueId);
243
- assert.equal(issue.infraAttempts, 0);
244
367
  assert.equal(issue.currentRound, 1);
368
+ assert.equal(issue.infraAttempts, 0, "a usage cap defers without spending an attempt");
245
369
  const item = listWorkItemsForIssue(issueId)[0];
246
370
  assert.equal(item.status, "pending");
247
371
  assert.ok(Date.parse(item.availableAt) > Date.now());
@@ -267,6 +391,8 @@ test("a timeout follows the existing timeout path (infra retry, no review round)
267
391
  assert.equal(issue.infraAttempts, 1);
268
392
  const dev = listWorkerSessionsForIssue(issueId).find((s) => s.role === "developer");
269
393
  assert.equal(dev.status, "timed_out");
394
+ const rec = recorded();
395
+ assert.equal(fs.existsSync(rec.xdgConfigHome), false, "per-attempt config removed after timeout");
270
396
  });
271
397
  test("a run whose server-confirmed model differs is a failed session, not a handoff", async () => {
272
398
  const issueId = makeMuseIssue();
@@ -304,14 +430,68 @@ for (const [scenario, tool] of [
304
430
  assert.equal(action?.actionType, "policy_escalation");
305
431
  assert.match(action?.reason ?? JSON.stringify(action), /muse_cron_used/);
306
432
  assert.ok(listWorkflowEventsForIssue(issueId).some((e) => e.type === "worker.failed"));
307
- // The worktree is kept for inspection, without the per-attempt Muse config.
308
- assert.equal(fs.existsSync(path.join(dev.worktreePath, MUSE_ATTEMPT_ROOT)), false);
433
+ // The worktree is kept for inspection; the per-attempt config outside it is removed.
434
+ const rec = recorded();
435
+ assert.equal(fs.existsSync(rec.xdgConfigHome), false);
436
+ assert.equal(fs.existsSync(rec.xdgDataHome), false);
309
437
  assert.equal(listUsageEventsForIssue(issueId).length, 1, "the spend is still recorded");
310
438
  });
311
439
  }
440
+ // ── deck admission: Muse is fail-closed without a deck, like every runtime ───────────────────
441
+ test("a Muse developer profile without deckId fails as deck_failure before any Muse process starts", async () => {
442
+ const issueId = makeMuseIssue({ deckId: null });
443
+ const spawnCounter = { count: 0 };
444
+ registerEffectHandler("developer", (ctx) => runDeveloperEffect(ctx, {
445
+ deckCallTool: museDeckCallTool(),
446
+ spawn: countingSpawn(spawnCounter),
447
+ github: fakeGithub(),
448
+ }));
449
+ assert.equal(startWorkflow(issueId).ok, true);
450
+ await pump();
451
+ assert.equal(spawnCounter.count, 0, "no spawn without a deck");
452
+ assert.equal(fs.existsSync(RECORD), false, "no Muse process ever started");
453
+ const dev = listWorkerSessionsForIssue(issueId).find((s) => s.role === "developer");
454
+ assert.equal(dev.status, "failed");
455
+ assert.match(dev.errorJson ?? "", /deck/i);
456
+ assert.notEqual(getIssue(issueId).status, "reviewing");
457
+ });
458
+ test("an unreachable deck waits as deck_unavailable without spawning", async () => {
459
+ const issueId = makeMuseIssue();
460
+ const spawnCounter = { count: 0 };
461
+ const unreachable = async () => {
462
+ throw Object.assign(new Error("connect ECONNREFUSED 127.0.0.1:1110"), { code: "ECONNREFUSED" });
463
+ };
464
+ registerEffectHandler("developer", (ctx) => runDeveloperEffect(ctx, {
465
+ deckCallTool: unreachable,
466
+ spawn: countingSpawn(spawnCounter),
467
+ github: fakeGithub(),
468
+ }));
469
+ assert.equal(startWorkflow(issueId).ok, true);
470
+ await pump();
471
+ assert.equal(spawnCounter.count, 0, "no spawn while the deck is unreachable");
472
+ assert.equal(fs.existsSync(RECORD), false, "no Muse process ever started");
473
+ const dev = listWorkerSessionsForIssue(issueId).find((s) => s.role === "developer");
474
+ assert.equal(dev.status, "cancelled", "a deck wait is a cancellation, never a crash");
475
+ });
476
+ test("a responding but wrong deck fails as deck_failure without spawning", async () => {
477
+ const issueId = makeMuseIssue();
478
+ const spawnCounter = { count: 0 };
479
+ registerEffectHandler("developer", (ctx) => runDeveloperEffect(ctx, {
480
+ deckCallTool: museDeckCallTool("some-other-deck-id"),
481
+ spawn: countingSpawn(spawnCounter),
482
+ github: fakeGithub(),
483
+ }));
484
+ assert.equal(startWorkflow(issueId).ok, true);
485
+ await pump();
486
+ assert.equal(spawnCounter.count, 0, "no spawn on a deck mismatch");
487
+ assert.equal(fs.existsSync(RECORD), false, "no Muse process ever started");
488
+ const dev = listWorkerSessionsForIssue(issueId).find((s) => s.role === "developer");
489
+ assert.equal(dev.status, "failed");
490
+ assert.match(dev.errorJson ?? "", /deck/i);
491
+ });
312
492
  // ── per-attempt config cleanup, on the spawn itself ────────────────────────────────────────────
313
493
  function scratchWorktree() {
314
- const wt = fs.mkdtempSync(path.join(os.tmpdir(), "dealer-muse-wt-"));
494
+ const wt = fs.mkdtempSync(path.join(HOME_SCRATCH, "dealer-muse-wt-"));
315
495
  git(wt, "init", "-q", "-b", "main");
316
496
  git(wt, "config", "user.email", "t@example.com");
317
497
  git(wt, "config", "user.name", "T");
@@ -320,26 +500,40 @@ function scratchWorktree() {
320
500
  git(wt, "commit", "-q", "-m", "init");
321
501
  return wt;
322
502
  }
503
+ /** Direct spawn input on a scratch worktree: the deck comes from the frozen profile value. */
504
+ function directInput(wt, sessionId, extra = {}) {
505
+ return {
506
+ sessionId,
507
+ runtime: "muse_code",
508
+ policy: { worktreeWrite: true },
509
+ model: MUSE_CODE_CONTRIBUTOR_MODEL,
510
+ deckId: TEST_DECK_ID,
511
+ agentDeckUrl: "http://127.0.0.1:1110/mcp",
512
+ prompt: "Implement it",
513
+ cwd: wt,
514
+ timeoutMs: 20_000,
515
+ logPath: path.join(HOME_SCRATCH, `muse-log-${sessionId}.ndjson`),
516
+ ...extra,
517
+ };
518
+ }
323
519
  for (const scenario of ["success", "auth", "usage-cap", "cron"]) {
324
520
  test(`per-attempt Muse config is removed after a ${scenario} session`, async () => {
325
521
  const wt = scratchWorktree();
326
522
  try {
327
523
  process.env.FAKE_MUSE_SCENARIO = scenario;
328
- const result = await runMuseDeveloperSession({
329
- sessionId: "00000000-0000-4000-8000-000000000001",
330
- runtime: "muse_code",
331
- policy: { worktreeWrite: true },
332
- model: MUSE_CODE_CONTRIBUTOR_MODEL,
333
- prompt: "Implement it",
334
- cwd: wt,
335
- timeoutMs: 20_000,
336
- logPath: path.join(wt, "..", `muse-log-${scenario}-${Date.now()}.ndjson`),
337
- });
524
+ const result = await runMuseDeveloperSession(directInput(wt, "00000000-0000-4000-8000-000000000001"));
338
525
  const rec = recorded();
339
- assert.ok(rec.xdgConfigHome.startsWith(path.join(wt, MUSE_ATTEMPT_ROOT)), "config was written under the worktree");
340
- assert.equal(fs.existsSync(path.join(wt, MUSE_ATTEMPT_ROOT)), false);
526
+ const mcpRoot = fs.realpathSync(getWorkerMcpConfigDir());
527
+ assert.ok(rec.xdgConfigHome.startsWith(`${mcpRoot}${path.sep}`), "config was written outside the worktree and outside temp");
341
528
  assert.equal(fs.existsSync(rec.xdgConfigHome), false);
342
529
  assert.equal(fs.existsSync(rec.xdgDataHome), false);
530
+ assert.deepEqual(rec.envLeak, {
531
+ META_API_KEY: false,
532
+ MUSE_SENTINEL: false,
533
+ CODEX_HOME: false,
534
+ DEALER_MUSE_SENTINEL: false,
535
+ });
536
+ assert.ok(!fs.readFileSync(RECORD, "utf8").includes(FAKE_API_KEY), "no key in the record");
343
537
  assert.ok(result.muse);
344
538
  fs.rmSync(result.logPath, { force: true });
345
539
  if (result.muse.rawLogPath)
@@ -354,21 +548,63 @@ test("per-attempt Muse config is removed when the process cannot be started", as
354
548
  const wt = scratchWorktree();
355
549
  const prev = process.env.MUSE_CLI;
356
550
  process.env.MUSE_CLI = path.join(wt, "no-such-muse");
551
+ const before = new Set(fs.readdirSync(getWorkerMcpConfigDir()));
357
552
  try {
358
- await assert.rejects(runMuseDeveloperSession({
359
- sessionId: "00000000-0000-4000-8000-000000000002",
360
- runtime: "muse_code",
361
- policy: { worktreeWrite: true },
362
- model: MUSE_CODE_CONTRIBUTOR_MODEL,
363
- prompt: "Implement it",
364
- cwd: wt,
365
- timeoutMs: 5_000,
366
- logPath: path.join(wt, "..", `muse-log-enoent-${Date.now()}.ndjson`),
367
- }));
368
- assert.equal(fs.existsSync(path.join(wt, MUSE_ATTEMPT_ROOT)), false);
553
+ await assert.rejects(runMuseDeveloperSession(directInput(wt, "00000000-0000-4000-8000-000000000002")));
554
+ assert.equal(fs.existsSync(RECORD), false, "no Muse process ever started");
369
555
  }
370
556
  finally {
371
557
  process.env.MUSE_CLI = prev;
372
558
  fs.rmSync(wt, { recursive: true, force: true });
373
559
  }
560
+ const after = new Set(fs.readdirSync(getWorkerMcpConfigDir()));
561
+ assert.deepEqual([...after].filter((e) => !before.has(e)), [], "no stranded attempt dir");
562
+ });
563
+ test("an aborted session still removes the per-attempt config", async () => {
564
+ const wt = scratchWorktree();
565
+ const controller = new AbortController();
566
+ try {
567
+ process.env.FAKE_MUSE_SCENARIO = "hang";
568
+ const pending = runMuseDeveloperSession(directInput(wt, "00000000-0000-4000-8000-000000000003", {
569
+ timeoutMs: 30_000,
570
+ signal: controller.signal,
571
+ }));
572
+ setTimeout(() => controller.abort(), 500);
573
+ await pending;
574
+ const rec = recorded();
575
+ assert.equal(fs.existsSync(rec.xdgConfigHome), false, "per-attempt config removed after abort");
576
+ }
577
+ finally {
578
+ delete process.env.FAKE_MUSE_SCENARIO;
579
+ fs.rmSync(wt, { recursive: true, force: true });
580
+ }
581
+ });
582
+ test("missing credentials fail before spawn: nothing is written and no process starts", async () => {
583
+ const wt = scratchWorktree();
584
+ const savedKey = process.env.META_API_KEY;
585
+ const savedCfg = process.env.XDG_CONFIG_HOME;
586
+ process.env.XDG_CONFIG_HOME = fs.mkdtempSync(path.join(HOME_SCRATCH, "cfg-noauth-"));
587
+ delete process.env.META_API_KEY;
588
+ try {
589
+ await assert.rejects(runMuseDeveloperSession(directInput(wt, "00000000-0000-4000-8000-000000000004")), /Muse Code auth required/);
590
+ assert.equal(fs.existsSync(RECORD), false, "no Muse process ever started");
591
+ }
592
+ finally {
593
+ if (savedKey === undefined)
594
+ delete process.env.META_API_KEY;
595
+ else
596
+ process.env.META_API_KEY = savedKey;
597
+ process.env.XDG_CONFIG_HOME = savedCfg;
598
+ fs.rmSync(wt, { recursive: true, force: true });
599
+ }
600
+ });
601
+ test("a missing deckId fails before spawn: nothing is written and no process starts", async () => {
602
+ const wt = scratchWorktree();
603
+ try {
604
+ await assert.rejects(runMuseDeveloperSession(directInput(wt, "00000000-0000-4000-8000-000000000005", { deckId: null })), /deckId/);
605
+ assert.equal(fs.existsSync(RECORD), false, "no Muse process ever started");
606
+ }
607
+ finally {
608
+ fs.rmSync(wt, { recursive: true, force: true });
609
+ }
374
610
  });