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
@@ -70,6 +70,10 @@ function sessionLooksLive(session) {
70
70
  *
71
71
  * Returns alive if **any** candidate session for this path is live — basename predecessor
72
72
  * and every `worktree_path` match (see {@link listWorkerSessionsOwningWorktree}).
73
+ *
74
+ * NOT-280: a dead verdict names the most recently created candidate — the predecessor
75
+ * whose dirt a resume salvage is attributed to. No candidate at all stays a bare dead
76
+ * verdict (unknown owner), which keeps dirt on the fail-closed conflict path.
73
77
  */
74
78
  export function checkDeveloperWorktreeOwnerLiveness(worktreePath) {
75
79
  const candidates = listWorkerSessionsOwningWorktree(worktreePath);
@@ -78,5 +82,6 @@ export function checkDeveloperWorktreeOwnerLiveness(worktreePath) {
78
82
  return { state: "alive", sessionId: session.id };
79
83
  }
80
84
  }
81
- return { state: "dead" };
85
+ const latest = [...candidates].sort((a, b) => b.createdAt.localeCompare(a.createdAt))[0];
86
+ return latest ? { state: "dead", sessionId: latest.id } : { state: "dead" };
82
87
  }
@@ -137,7 +137,8 @@ test("checkDeveloperWorktreeOwnerLiveness: terminal session with a dead pid is d
137
137
  patchRunningSession(session.id, { worktreePath: wt });
138
138
  recordSessionProcess(session.id, pid, COORDINATOR_PROCESS_OWNER, startTime);
139
139
  completeSession(session.id, { status: "failed", errorJson: JSON.stringify({ reason: "gone" }) });
140
- assert.deepEqual(checkDeveloperWorktreeOwnerLiveness(wt), { state: "dead" });
140
+ // NOT-280: a proven-dead owner is named so a resume salvage can be tied to it.
141
+ assert.deepEqual(checkDeveloperWorktreeOwnerLiveness(wt), { state: "dead", sessionId: session.id });
141
142
  });
142
143
  test("checkDeveloperWorktreeOwnerLiveness: unknown path with no session is dead", () => {
143
144
  assert.deepEqual(checkDeveloperWorktreeOwnerLiveness("/repo/.agent-dealer-worktrees/no-such-session-developer"), { state: "dead" });
@@ -144,6 +144,19 @@ export function updateOpenHumanAction(id, patch) {
144
144
  .run(patch.reason ?? null, patch.question ?? null, patch.evidence !== undefined ? JSON.stringify(patch.evidence) : null, id);
145
145
  return getHumanAction(id);
146
146
  }
147
+ /**
148
+ * NOT-280: put a resolved action back to open, unchanged — used when a Resume ran into the
149
+ * exact same blocker again, so the operator keeps one action instead of an identical copy.
150
+ * Only touches resolved rows; returns null otherwise.
151
+ */
152
+ export function reopenHumanAction(id) {
153
+ const info = getDb()
154
+ .prepare(`UPDATE human_actions SET
155
+ status = 'open', resolution_json = NULL, resolved_by = NULL, resolved_at = NULL
156
+ WHERE id = ? AND status = 'resolved'`)
157
+ .run(id);
158
+ return info.changes === 0 ? null : getHumanAction(id);
159
+ }
147
160
  /** Run-scoped equivalent of `findOpenHumanAction` (NOT-95 — outbound-draft delivery
148
161
  * parking has no Issue). */
149
162
  export function findOpenHumanActionForRun(runId, actionType) {
@@ -1,5 +1,6 @@
1
1
  import { getHumanAction, listOpenHumanActions } from "../repository/human-actions.js";
2
2
  import { resolveHumanActionAndAdvanceAsync } from "../coordinator/commands.js";
3
+ import { normalizeResolutionNote } from "../coordinator/human-resolution.js";
3
4
  import { triggerIssueReflect, resolveReflectionInteractionAction } from "../coordinator/reflect-trigger.js";
4
5
  import { resolveOutboundDeliveryAction } from "../queue/approve-deliver.js";
5
6
  export async function registerHumanActionRoutes(app) {
@@ -14,6 +15,12 @@ export async function registerHumanActionRoutes(app) {
14
15
  if (!resolvedBy || !choice) {
15
16
  return reply.status(400).send({ error: "resolvedBy and choice are required" });
16
17
  }
18
+ // NOT-272: optional human note for a product_scope_decision resolution — validated
19
+ // the same way as resolvedBy/choice above (present-but-not-a-string 400s, never 500s).
20
+ const normalizedNote = normalizeResolutionNote(body?.note);
21
+ if (!normalizedNote.ok) {
22
+ return reply.status(400).send({ error: normalizedNote.error });
23
+ }
17
24
  // Read before resolving — the issue id this action belongs to, needed for the reflect
18
25
  // trigger below and stable regardless of how resolution turns out.
19
26
  const action = getHumanAction(id);
@@ -45,7 +52,9 @@ export async function registerHumanActionRoutes(app) {
45
52
  }
46
53
  // Awaits undraft+merge when final_review:complete (NOT-102) — sync resolve alone would
47
54
  // only park at AUTO_MERGE_INTENT and leave the PR draft.
48
- const result = await resolveHumanActionAndAdvanceAsync(id, resolvedBy, choice);
55
+ const result = await resolveHumanActionAndAdvanceAsync(id, resolvedBy, choice, {
56
+ note: normalizedNote.note,
57
+ });
49
58
  if (!result.ok)
50
59
  return reply.status(result.code).send({ error: result.error });
51
60
  // Reflect is a best-effort network call to Agent Deck (health check + a sequential
@@ -12,7 +12,7 @@ const { createHumanAction, getHumanAction, listHumanActionsForIssue } = await im
12
12
  const { registerHumanActionRoutes } = await import("./human-actions.js");
13
13
  const { startWorkflow, applyCompletion } = await import("../coordinator/commands.js");
14
14
  const { ReviewerResult } = await import("../coordinator/reviewer-result.js");
15
- const { claimWorkItem } = await import("../repository/work-items.js");
15
+ const { claimWorkItem, getWorkItem } = await import("../repository/work-items.js");
16
16
  const { listArtifactsForIssue } = await import("../repository/artifacts-for-issue.js");
17
17
  const { createRun, getRun, transitionRun, addArtifact, updateRunFields } = await import("../repository/runs.js");
18
18
  const { pendingSendCount, getPendingOutboundDraft } = await import("../repository/outbound-drafts.js");
@@ -284,3 +284,119 @@ test("POST resolve 400s on a choice not valid for reflection_interaction_require
284
284
  assert.equal(res.statusCode, 400);
285
285
  await app.close();
286
286
  });
287
+ // NOT-272: seeds.
288
+ function seedPreStartScopeDecision() {
289
+ const issue = createIssue({ title: "Scope gated", repo: "acme/app", baseBranch: "main", developerAgentId: BUILTIN_AGENT_CLAUDE_ID, reviewerAgentId: BUILTIN_AGENT_CURSOR_ID, acceptanceCriteria: "Works", maxReviewRounds: 3, maxInfraAttempts: 3, source: "manual" });
290
+ const action = createHumanAction({
291
+ issueId: issue.id,
292
+ actionType: "product_scope_decision",
293
+ reason: "Scope unclear before start",
294
+ question: "Is this in scope?",
295
+ responseOptions: [{ choice: "resume", label: "Resume development" }],
296
+ });
297
+ return { issue, action };
298
+ }
299
+ /** Drives a real issue to a mid-workflow product_scope_decision via a reviewer
300
+ * escalate verdict carrying a productScopeQuestion (NOT-150 routing). */
301
+ async function seedMidWorkflowScopeDecision() {
302
+ const issue = createIssue({ title: "Scope escalated", repo: "acme/app", baseBranch: "main", developerAgentId: BUILTIN_AGENT_CLAUDE_ID, reviewerAgentId: BUILTIN_AGENT_CURSOR_ID, acceptanceCriteria: "Works", maxReviewRounds: 3, maxInfraAttempts: 3, source: "manual" });
303
+ const start = startWorkflow(issue.id);
304
+ assert.equal(start.ok, true);
305
+ const devItem = claimWorkItem(`scope-note-test-${issue.id}-dev`, { leaseMs: 60_000 });
306
+ await applyCompletion(devItem.id, devItem.leaseToken, {
307
+ kind: "clean_handoff",
308
+ branch: `issue-${issue.id}`,
309
+ headSha: "a".repeat(40),
310
+ baseSha: "b".repeat(40),
311
+ prNumber: 1,
312
+ prUrl: "https://gh/pr/1"
313
+ });
314
+ const reviewItem = claimWorkItem(`scope-note-test-${issue.id}-rev`, { leaseMs: 60_000 });
315
+ await applyCompletion(reviewItem.id, reviewItem.leaseToken, {
316
+ kind: "verdict",
317
+ result: ReviewerResult.parse({
318
+ verdict: "escalated",
319
+ baseSha: "b".repeat(40),
320
+ headSha: "a".repeat(40),
321
+ acceptanceCriteriaAssessment: "unclear scope",
322
+ evidenceAssessment: "fine",
323
+ findings: [],
324
+ risks: [],
325
+ productScopeQuestion: "Is the runner migration in scope?"
326
+ })
327
+ });
328
+ const humanAction = listHumanActionsForIssue(issue.id).find((a) => a.actionType === "product_scope_decision");
329
+ assert.ok(humanAction, "expected a product_scope_decision human action");
330
+ return { issueId: issue.id, actionId: humanAction.id };
331
+ }
332
+ test("NOT-272: POST resolve 400s (not 500) on a non-string note", async () => {
333
+ const app = await buildApp();
334
+ const { action } = seedPreStartScopeDecision();
335
+ const res = await app.inject({ method: "POST", url: `/api/human-actions/${action.id}/resolve`, payload: { resolvedBy: "yusuke", choice: "resume", note: 123 } });
336
+ assert.equal(res.statusCode, 400);
337
+ assert.match(res.json().error, /note must be a string/);
338
+ assert.equal(getHumanAction(action.id).status, "open", "a rejected resolve must leave the action open");
339
+ await app.close();
340
+ });
341
+ test("NOT-272: POST resolve 400s on an over-long note", async () => {
342
+ const app = await buildApp();
343
+ const { action } = seedPreStartScopeDecision();
344
+ const res = await app.inject({ method: "POST", url: `/api/human-actions/${action.id}/resolve`, payload: { resolvedBy: "yusuke", choice: "resume", note: "x".repeat(4001) } });
345
+ assert.equal(res.statusCode, 400);
346
+ assert.match(res.json().error, /at most 4000/);
347
+ assert.equal(getHumanAction(action.id).status, "open");
348
+ await app.close();
349
+ });
350
+ test("NOT-272: pre-start product_scope_decision resolve with a note persists it and carries it on the round-1 payload", async () => {
351
+ const app = await buildApp();
352
+ const { action } = seedPreStartScopeDecision();
353
+ const note = "The runner migration IS in scope — proceed with it.";
354
+ const res = await app.inject({ method: "POST", url: `/api/human-actions/${action.id}/resolve`, payload: { resolvedBy: "yusuke", choice: "resume", note: ` ${note} ` } });
355
+ assert.equal(res.statusCode, 200);
356
+ const body = res.json();
357
+ assert.equal(body.restarted, true);
358
+ assert.ok(body.nextWorkItemId);
359
+ const stored = getHumanAction(action.id);
360
+ assert.equal(stored.status, "resolved");
361
+ assert.deepEqual(JSON.parse(stored.resolutionJson), { choice: "resume", note }, "note round-trips exactly (trimmed)");
362
+ const payload = JSON.parse(getWorkItem(body.nextWorkItemId).payloadJson);
363
+ assert.equal(payload.scopeDecisionNote, note, "the very next round's payload carries the note");
364
+ await app.close();
365
+ });
366
+ test("NOT-272: mid-workflow product_scope_decision resolve with a note persists it and carries it on the next developer payload", async () => {
367
+ const app = await buildApp();
368
+ const { issueId, actionId } = await seedMidWorkflowScopeDecision();
369
+ const note = "Out of scope for this ticket — finish the runner work already here, nothing more.";
370
+ const res = await app.inject({ method: "POST", url: `/api/human-actions/${actionId}/resolve`, payload: { resolvedBy: "yusuke", choice: "resume", note } });
371
+ assert.equal(res.statusCode, 200);
372
+ const body = res.json();
373
+ assert.equal(body.issueStatus, "developing");
374
+ assert.ok(body.nextWorkItemId);
375
+ const stored = getHumanAction(actionId);
376
+ assert.deepEqual(JSON.parse(stored.resolutionJson), { choice: "resume", note });
377
+ const payload = JSON.parse(getWorkItem(body.nextWorkItemId).payloadJson);
378
+ assert.equal(payload.scopeDecisionNote, note);
379
+ assert.equal(getIssue(issueId).status, "developing");
380
+ await app.close();
381
+ });
382
+ test("NOT-272: resolving a product_scope_decision without a note stores exactly today's record and queues a noteless payload", async () => {
383
+ const app = await buildApp();
384
+ const { action } = seedPreStartScopeDecision();
385
+ const res = await app.inject({ method: "POST", url: `/api/human-actions/${action.id}/resolve`, payload: { resolvedBy: "yusuke", choice: "resume" } });
386
+ assert.equal(res.statusCode, 200);
387
+ const body = res.json();
388
+ const stored = getHumanAction(action.id);
389
+ assert.equal(stored.resolutionJson, JSON.stringify({ choice: "resume" }));
390
+ const payload = JSON.parse(getWorkItem(body.nextWorkItemId).payloadJson);
391
+ assert.ok(!("scopeDecisionNote" in payload), "no new payload key without a note");
392
+ await app.close();
393
+ });
394
+ test("NOT-272: a note sent with any other action type is dropped, never stored", async () => {
395
+ const app = await buildApp();
396
+ const { actionId } = await seedRealIssueAwaitingFinalReview();
397
+ const res = await app.inject({ method: "POST", url: `/api/human-actions/${actionId}/resolve`, payload: { resolvedBy: "yusuke", choice: "close", note: "should not persist" } });
398
+ assert.equal(res.statusCode, 200);
399
+ const stored = getHumanAction(actionId);
400
+ assert.equal(stored.resolutionJson, JSON.stringify({ choice: "close" }));
401
+ await app.close();
402
+ });
@@ -12,6 +12,7 @@ import { refreshMuseCapacityIfStale } from "../capacity/muse-probe.js";
12
12
  import { refreshCodexCapacityIfStale } from "../capacity/codex-app-server.js";
13
13
  import { getCursorTeamBillingSnapshot, refreshCursorTeamBillingIfStale, } from "../capacity/cursor-team.js";
14
14
  import { getCursorIndividualBillingSnapshot, refreshCursorIndividualBillingIfStale, refreshCursorIndividualCapacityIfStale, } from "../capacity/cursor-individual.js";
15
+ import { registerVersionRoute } from "./version.js";
15
16
  async function resolveDeckName(deckId) {
16
17
  if (!deckId)
17
18
  return null;
@@ -22,6 +23,7 @@ async function resolveDeckName(deckId) {
22
23
  }
23
24
  export async function registerRoutes(app) {
24
25
  app.get("/health", async () => ({ ok: true }));
26
+ registerVersionRoute(app);
25
27
  // NOT-159: process-lifetime Linear GraphQL counters (API-key bucket diagnostics).
26
28
  app.get("/api/debug/linear-usage", async () => getLinearUsageSnapshot());
27
29
  app.get("/api/runtimes/:runtime/models", async (req, reply) => {
@@ -76,11 +78,16 @@ export async function registerRoutes(app) {
76
78
  // subprocess.
77
79
  // NOT-268: Claude local-first ladder — one background refresh ingests
78
80
  // Claude Code's own free cache (plain file read, never a spawn) and then
79
- // considers the paid probe without blocking the read: a bounded Haiku
80
- // probe fires at most once per hour and only under the explicit
81
- // valid 5H/1W observation is older than 60 minutes. This is the default;
82
- // `AGENT_DEALER_CLAUDE_CAPACITY_REFRESH=off` is a strict no-op (no spawn,
83
- // no spend). Failures never break the read below.
81
+ // considers the free `/usage` refresh without blocking the read: a
82
+ // bounded `claude -p "/usage"` call fires at most once per 14 minutes,
83
+ // only when either valid 5H/1W observation is missing or at least 14
84
+ // minutes old (NOT-281 — checked per window so a fresh sibling never
85
+ // suppresses its stale twin), and only when enabled (default on;
86
+ // `AGENT_DEALER_CLAUDE_CAPACITY_REFRESH=off` disables it). `/usage` is a
87
+ // local slash-command — no model call, $0 cost, ~300ms, verified live
88
+ // 2026-09-27 (the original design spawned a real model turn and was
89
+ // fixed after a live proof showed it never worked; see
90
+ // docs/RUNTIME_CAPACITY.md). Failures never break the read below.
84
91
  app.get("/api/runtime-capacity", async () => {
85
92
  try {
86
93
  void refreshClaudeCapacityIfStale().catch(() => {
@@ -15,8 +15,9 @@ process.env.AGENT_DEALER_SKIP_AGENT_HEALTH = "1";
15
15
  // Never spawn a real provider from route tests: the on-demand Codex refresh is
16
16
  // covered against the fake App Server in codex-app-server.test.ts.
17
17
  process.env.AGENT_DEALER_CODEX_CAPACITY_REFRESH = "off";
18
- // Claude's paid-after-1h fallback is default-on in production. Route tests
19
- // exercise stored/read behavior only and must never launch a real paid probe.
18
+ // Claude's `/usage` refresh is free and default-on in production (fixed
19
+ // 2026-09-27 — see docs/RUNTIME_CAPACITY.md); still disabled here so route
20
+ // tests never spawn a real `claude` subprocess.
20
21
  process.env.AGENT_DEALER_CLAUDE_CAPACITY_REFRESH = "off";
21
22
  // Muse's one-hour fallback is also default-on; route tests cover the free
22
23
  // host trigger only and must never run a real model turn.
@@ -0,0 +1,30 @@
1
+ // packages/server/src/routes/version.ts
2
+ //
3
+ // NOT-279: the running backend reports its own version. The CLI's installed version (and
4
+ // the managed `current` symlink) can move ahead of a server that is still serving the old
5
+ // build, so `agent-dealer status` must ask the live process instead of inferring from disk.
6
+ import fs from "node:fs";
7
+ import path from "node:path";
8
+ import { fileURLToPath } from "node:url";
9
+ /** Read once at module load: the package this process was started from, not whatever is on
10
+ * disk later (a managed update may replace sibling install dirs while we keep running). */
11
+ function readServerPackageVersion() {
12
+ try {
13
+ // src/routes/version.ts and dist/routes/version.js both sit two levels below package.json.
14
+ const pkgPath = path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "..", "package.json");
15
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf8"));
16
+ return typeof pkg.version === "string" ? pkg.version : null;
17
+ }
18
+ catch {
19
+ return null;
20
+ }
21
+ }
22
+ const RUNNING_VERSION = readServerPackageVersion();
23
+ const STARTED_AT = new Date().toISOString();
24
+ export function registerVersionRoute(app) {
25
+ app.get("/api/version", async () => ({
26
+ version: RUNNING_VERSION,
27
+ pid: process.pid,
28
+ startedAt: STARTED_AT,
29
+ }));
30
+ }
@@ -0,0 +1,21 @@
1
+ // packages/server/src/routes/version.test.ts
2
+ import { test } from "node:test";
3
+ import assert from "node:assert/strict";
4
+ import fs from "node:fs";
5
+ import path from "node:path";
6
+ import { fileURLToPath } from "node:url";
7
+ import Fastify from "fastify";
8
+ import { registerVersionRoute } from "./version.js";
9
+ test("NOT-279: /api/version reports the running server package version and pid", async () => {
10
+ const pkgPath = path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "..", "package.json");
11
+ const expected = JSON.parse(fs.readFileSync(pkgPath, "utf8")).version;
12
+ const app = Fastify();
13
+ registerVersionRoute(app);
14
+ const res = await app.inject({ method: "GET", url: "/api/version" });
15
+ assert.equal(res.statusCode, 200);
16
+ const body = res.json();
17
+ assert.equal(body.version, expected);
18
+ assert.equal(body.pid, process.pid);
19
+ assert.ok(!Number.isNaN(Date.parse(body.startedAt)));
20
+ await app.close();
21
+ });
@@ -13,7 +13,11 @@ export const NOT_177_EVIDENCE = Object.freeze({
13
13
  });
14
14
  export const MUSE_MODEL = "muse-spark-1.3-contributor";
15
15
  export const AGENT_DECK_SERVER = "agent-deck";
16
- /** Deck reads a worker needs. `call_service_tool` (outbound mutation) is never in this list. */
16
+ /**
17
+ * NOT-278: the read-only allowlist is a reviewer requirement, not a developer requirement.
18
+ * Developers receive the selected deck's full surface, including `call_service_tool`. These
19
+ * lists only ever materialize for a reviewer build that proves allowlist enforcement.
20
+ */
17
21
  export const AGENT_DECK_READ_TOOLS = [
18
22
  "get_bound_deck",
19
23
  "get_playbook",
@@ -46,6 +50,10 @@ const FORBIDDEN_FLAGS = [
46
50
  ];
47
51
  /** Which ambient env vars reach Muse. Everything else (META_API_KEY, MUSE_*, CODEX_HOME, ...) is dropped. */
48
52
  const ENV_ALLOWLIST = ["PATH", "HOME", "USER", "LOGNAME", "SHELL", "LANG", "TERM", "TMPDIR"];
53
+ /** Test-only harness vars for the fake `muse` fixture (fixtures/fake-muse.mjs), which can only be
54
+ * selected via the operator/test `MUSE_CLI` override — and passed through only when that
55
+ * override is set, so production runs of the default binary never carry them. */
56
+ const FAKE_HARNESS_PREFIX = "FAKE_MUSE_";
49
57
  const ATTEMPT_PREFIX = "muse-attempt-";
50
58
  const SENTINEL = ".dealer-muse-attempt";
51
59
  /** Messages name fields and paths, never values that could be credentials. */
@@ -59,10 +67,17 @@ export class MuseIsolationError extends Error {
59
67
  this.name = "MuseIsolationError";
60
68
  }
61
69
  }
62
- /** Capabilities a role needs that the evidence does not prove. Both roles need both today. */
63
- export function unenforceableRestrictions(_role, evidence = NOT_177_EVIDENCE) {
70
+ /**
71
+ * NOT-278: capabilities a role needs that the evidence does not prove. Developers receive the
72
+ * selected deck's full surface (including `call_service_tool`), so no allowlist enforcement is
73
+ * required; `cron_*` stays prohibited by the developer prompt and is detected post-run
74
+ * (`muse_cron_used`), not mechanically disabled. Reviewers still need both controls proven.
75
+ */
76
+ export function unenforceableRestrictions(role, evidence = NOT_177_EVIDENCE) {
77
+ if (role === "developer")
78
+ return [];
64
79
  const missing = [];
65
- // Outbound mutation (`call_service_tool`) must stay denied for every role, and Muse only
80
+ // Outbound mutation (`call_service_tool`) must stay denied for reviewers, and Muse only
66
81
  // stores enabled_tools/disabled_tools unless this is proven.
67
82
  if (!evidence.mcp_tool_allowlist_enforcement)
68
83
  missing.push("mcp_tool_allowlist_enforcement");
@@ -71,15 +86,37 @@ export function unenforceableRestrictions(_role, evidence = NOT_177_EVIDENCE) {
71
86
  missing.push("cron_tool_disable");
72
87
  return missing;
73
88
  }
74
- export function buildMuseSettings(agentDeck, evidence = NOT_177_EVIDENCE) {
89
+ /**
90
+ * NOT-278: the deckless worker posture shared by every Muse settings.json — workflows and
91
+ * subagents off, reminder child runs off, plus `cron_*` only when proven mechanically
92
+ * disableable. The capacity serve host (which is shared across decks and never runs a
93
+ * session) uses this directly; per-attempt sessions add the required `agent-deck` server
94
+ * below. Exported only for that host — it is not a session attempt and `assertMuseSettings`
95
+ * rejects it (exactly one required `agent-deck` server is mandatory for a launch).
96
+ */
97
+ export function buildMuseBaseSettings(role = "developer", evidence = NOT_177_EVIDENCE) {
75
98
  const runtime_capabilities = {};
76
99
  for (const name of REMINDERS) {
77
100
  runtime_capabilities[`plugin:tbh-reminders:reminder:${name}`] = { enabled: false };
78
101
  }
79
- if (evidence.cron_tool_disable) {
102
+ // NOT-278: `cron_*` can only be claimed disabled for reviewers on a build that proves it.
103
+ // Developers are prohibited by prompt and detected post-run; their settings never claim it.
104
+ if (role === "reviewer" && evidence.cron_tool_disable) {
80
105
  for (const tool of CRON_TOOLS)
81
106
  runtime_capabilities[`tool:${tool}`] = { enabled: false };
82
107
  }
108
+ return {
109
+ schema_version: 1,
110
+ run: { workflow_trigger_mode: "off", subagent_delegation_mode: "off" },
111
+ runtime_capabilities,
112
+ };
113
+ }
114
+ /**
115
+ * NOT-278: developers always receive the selected deck's full surface unfiltered — their
116
+ * settings never carry `enabled_tools`/`disabled_tools` claims. The read-only allowlist only
117
+ * materializes for reviewers on a build that proves enforcement.
118
+ */
119
+ export function buildMuseSettings(agentDeck, role = "developer", evidence = NOT_177_EVIDENCE) {
83
120
  const server = {
84
121
  type: "streamable-http",
85
122
  url: agentDeck.url,
@@ -90,20 +127,18 @@ export function buildMuseSettings(agentDeck, evidence = NOT_177_EVIDENCE) {
90
127
  // `mode`, not `required`: writing both drops the whole MCP block. Never "optional".
91
128
  mode: "required",
92
129
  };
93
- if (evidence.mcp_tool_allowlist_enforcement) {
130
+ if (role === "reviewer" && evidence.mcp_tool_allowlist_enforcement) {
94
131
  server.enabled_tools = [...AGENT_DECK_READ_TOOLS];
95
132
  server.disabled_tools = [...AGENT_DECK_DENIED_TOOLS];
96
133
  }
97
134
  return {
98
- schema_version: 1,
99
- run: { workflow_trigger_mode: "off", subagent_delegation_mode: "off" },
100
- runtime_capabilities,
135
+ ...buildMuseBaseSettings(role, evidence),
101
136
  mcpServers: { [AGENT_DECK_SERVER]: server },
102
137
  };
103
138
  }
104
139
  /** Whitelist check on a settings object (generated or re-read from disk). */
105
- export function assertMuseSettings(settings, agentDeck, evidence = NOT_177_EVIDENCE) {
106
- const expected = buildMuseSettings(agentDeck, evidence);
140
+ export function assertMuseSettings(settings, agentDeck, role = "developer", evidence = NOT_177_EVIDENCE) {
141
+ const expected = buildMuseSettings(agentDeck, role, evidence);
107
142
  if (JSON.stringify(sortKeys(settings)) !== JSON.stringify(sortKeys(expected))) {
108
143
  throw new MuseIsolationError("invalid_settings", "Muse settings differ from the approved role settings (extra, missing, or altered keys)");
109
144
  }
@@ -194,6 +229,17 @@ export function buildMuseEnv(root, ambient = process.env) {
194
229
  if (key.startsWith("LC_") && value !== undefined)
195
230
  env[key] = value;
196
231
  }
232
+ // Test-only seam: the fake fixture is driven by FAKE_MUSE_* (scenario, record path, version
233
+ // pins) and the exec lane is exact, so without this the fixture cannot be driven at all.
234
+ // These vars join attempt.env only when the operator/test MUSE_CLI override selects the
235
+ // binary — i.e. only the fixture can ever receive them, never the default real binary —
236
+ // and the pre-spawn verify() snapshot covers them exactly like every other approved var.
237
+ if (ambient.MUSE_CLI?.trim()) {
238
+ for (const [key, value] of Object.entries(ambient)) {
239
+ if (key.startsWith(FAKE_HARNESS_PREFIX) && value !== undefined)
240
+ env[key] = value;
241
+ }
242
+ }
197
243
  env.MUSE_NO_AUTO_UPDATE = "1";
198
244
  env.XDG_CONFIG_HOME = path.join(root, "config");
199
245
  env.XDG_DATA_HOME = path.join(root, "data");
@@ -337,7 +383,8 @@ function sameJson(a, b) {
337
383
  * Validates, refuses unenforceable restrictions, then writes the per-attempt dir (0700) with
338
384
  * settings.json (0600) and an auth link. Throws before spawn; leaves nothing behind on failure.
339
385
  *
340
- * Always uses the pinned `NOT_177_EVIDENCE`, under which both roles are currently refused. There is
386
+ * Always uses the pinned `NOT_177_EVIDENCE`, under which developers are admitted unfiltered
387
+ * (NOT-278: the full deck surface, no allowlist claims) and reviewers are still refused. There is
341
388
  * deliberately no runtime seam that accepts other evidence: the enforced-path tests load a
342
389
  * source-rewritten copy of this file instead (see muse-config.test.ts).
343
390
  */
@@ -355,8 +402,8 @@ function prepareWithEvidence(rawInput, evidence) {
355
402
  const { worktree, baseDir } = validateInput(input);
356
403
  const agentDeck = input.agentDeck;
357
404
  const credentialKind = input.credential.kind;
358
- const settings = deepFreeze(buildMuseSettings(agentDeck, evidence));
359
- assertMuseSettings(settings, agentDeck, evidence);
405
+ const settings = deepFreeze(buildMuseSettings(agentDeck, input.role, evidence));
406
+ assertMuseSettings(settings, agentDeck, input.role, evidence);
360
407
  const apiKey = input.credential.kind === "api-key" ? input.credential.apiKey : undefined;
361
408
  const argv = buildMuseArgv({
362
409
  role: input.role,
@@ -445,7 +492,7 @@ function prepareWithEvidence(rawInput, evidence) {
445
492
  throw err;
446
493
  throw new MuseIsolationError("invalid_settings", "Muse settings.json is missing or unreadable");
447
494
  }
448
- assertMuseSettings(onDisk, agentDeck, evidence);
495
+ assertMuseSettings(onDisk, agentDeck, input.role, evidence);
449
496
  const entries = fs.readdirSync(path.dirname(settingsPath)).sort();
450
497
  const allowed = credentialKind === "auth-file" ? ["auth.json", "settings.json"] : ["settings.json"];
451
498
  if (entries.join() !== allowed.join()) {
@@ -2,4 +2,4 @@
2
2
  //
3
3
  // The implementation lives in muse-config-core.ts, which pins `prepareMuseAttempt` to
4
4
  // `NOT_177_EVIDENCE`. No module in the build exports a preparer that accepts caller-supplied evidence.
5
- export { AGENT_DECK_DENIED_TOOLS, AGENT_DECK_READ_TOOLS, AGENT_DECK_SERVER, MUSE_MODEL, MuseIsolationError, NOT_177_EVIDENCE, assertMuseArgv, assertMuseSettings, buildMuseArgv, buildMuseEnv, buildMuseSettings, createRedactor, defaultOperatorAuthPath, prepareMuseAttempt, unenforceableRestrictions, } from "./muse-config-core.js";
5
+ export { AGENT_DECK_DENIED_TOOLS, AGENT_DECK_READ_TOOLS, AGENT_DECK_SERVER, MUSE_MODEL, MuseIsolationError, NOT_177_EVIDENCE, assertMuseArgv, assertMuseSettings, buildMuseArgv, buildMuseBaseSettings, buildMuseEnv, buildMuseSettings, createRedactor, defaultOperatorAuthPath, prepareMuseAttempt, unenforceableRestrictions, } from "./muse-config-core.js";