@cohortapp/agent-sdk 2.11.14 → 2.12.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 (172) hide show
  1. package/.env.example +37 -22
  2. package/README.md +2 -0
  3. package/bin/maestro.mjs +113 -39
  4. package/bin/maestro.test.mjs +175 -5
  5. package/docs/guides/front-door-session.md +264 -0
  6. package/docs/guides/mac-mini.md +100 -28
  7. package/docs/guides/org-onboarding.md +1 -1
  8. package/docs/guides/setup-wizard.md +9 -5
  9. package/docs/runbooks/cohort-cutover.md +11 -1
  10. package/docs/runbooks/mac-mini-bootstrap.md +38 -63
  11. package/lib/cadence-bus-requeue.test.mjs +83 -0
  12. package/lib/cadence-bus.mjs +43 -7
  13. package/lib/channels/inbox-item.mjs +59 -2
  14. package/lib/cli/board.mjs +285 -0
  15. package/lib/cli/board.test.mjs +227 -0
  16. package/lib/cli/doctor-checks.mjs +441 -0
  17. package/lib/cli/doctor-checks.test.mjs +336 -0
  18. package/lib/cli/global-setup-extras.mjs +410 -0
  19. package/lib/cli/global-setup-extras.test.mjs +367 -0
  20. package/lib/cli/inbox.mjs +304 -0
  21. package/lib/cli/inbox.test.mjs +230 -0
  22. package/lib/cli/session-ack.mjs +63 -0
  23. package/lib/cli/session-ack.test.mjs +63 -0
  24. package/lib/cli/session.mjs +750 -0
  25. package/lib/cli/session.test.mjs +602 -0
  26. package/lib/collective/global-config.mjs +204 -6
  27. package/lib/collective/global-config.test.mjs +140 -0
  28. package/lib/collective/global-skills.mjs +145 -0
  29. package/lib/collective/global-skills.test.mjs +126 -0
  30. package/lib/collective/presence.mjs +4 -3
  31. package/lib/comms/send-gate.mjs +115 -0
  32. package/lib/comms/send-gate.test.mjs +113 -0
  33. package/lib/feature-init.mjs +2 -2
  34. package/lib/identity/persona.mjs +29 -0
  35. package/lib/identity/persona.test.mjs +26 -1
  36. package/lib/mcp/server.test.mjs +9 -4
  37. package/lib/model-router/spawn.test.mjs +21 -0
  38. package/lib/org/board-mine-cache.mjs +99 -0
  39. package/lib/org/board-mine-cache.test.mjs +53 -0
  40. package/lib/org/board.mjs +11 -0
  41. package/lib/org/board.test.mjs +11 -1
  42. package/lib/org/client.mjs +36 -0
  43. package/lib/org/client.test.mjs +46 -0
  44. package/lib/org/inbound/directedness.mjs +18 -2
  45. package/lib/org/inbound/directedness.test.mjs +58 -0
  46. package/lib/org/inbound/index.mjs +8 -1
  47. package/lib/org/inbound/index.test.mjs +22 -0
  48. package/lib/org/mesh-directives.test.mjs +110 -0
  49. package/lib/org/mesh.mjs +61 -1
  50. package/lib/org/protocol.checksum +1 -1
  51. package/lib/org/protocol.mjs +52 -0
  52. package/lib/org/protocol.test.mjs +12 -1
  53. package/lib/org/registry.mjs +3 -2
  54. package/lib/org/tool-surface.mjs +120 -0
  55. package/lib/org/tool-surface.test.mjs +118 -5
  56. package/lib/security/external-content.mjs +1 -1
  57. package/lib/security/external-content.test.mjs +17 -0
  58. package/lib/session/config.mjs +137 -0
  59. package/lib/session/config.test.mjs +92 -0
  60. package/lib/session/feed-core.mjs +229 -0
  61. package/lib/session/feed-core.test.mjs +198 -0
  62. package/lib/session/first-run.mjs +126 -0
  63. package/lib/session/first-run.test.mjs +121 -0
  64. package/lib/session/frontdoor.mjs +266 -0
  65. package/lib/session/frontdoor.test.mjs +205 -0
  66. package/lib/session/handoffs.mjs +295 -0
  67. package/lib/session/handoffs.test.mjs +183 -0
  68. package/lib/session/identity.mjs +220 -0
  69. package/lib/session/identity.test.mjs +180 -0
  70. package/lib/session/inbox-claims.mjs +434 -0
  71. package/lib/session/inbox-claims.test.mjs +286 -0
  72. package/lib/session/launch-args.mjs +161 -0
  73. package/lib/session/launch-args.test.mjs +157 -0
  74. package/lib/session/liveness.mjs +174 -0
  75. package/lib/session/liveness.test.mjs +100 -0
  76. package/lib/session/status-summary.mjs +172 -0
  77. package/lib/session/status-summary.test.mjs +118 -0
  78. package/lib/session-permissions.mjs +39 -3
  79. package/lib/session-permissions.test.mjs +20 -0
  80. package/lib/setup/claude-probe.mjs +161 -24
  81. package/lib/setup/claude-probe.test.mjs +187 -0
  82. package/lib/setup/sections/learning.mjs +2 -1
  83. package/lib/setup/sections/model.mjs +104 -24
  84. package/lib/setup/sections/model.test.mjs +240 -0
  85. package/lib/setup/sections/org.mjs +27 -2
  86. package/lib/setup/sections/org.test.mjs +35 -2
  87. package/lib/setup/sections/verify.mjs +5 -0
  88. package/lib/setup/state.mjs +30 -10
  89. package/lib/setup/state.test.mjs +24 -1
  90. package/lib/singleton.js +11 -3
  91. package/lib/singleton.test.mjs +16 -0
  92. package/lib/subagents/lock.mjs +1 -1
  93. package/lib/telemetry/collect.mjs +270 -6
  94. package/lib/telemetry/collect.test.mjs +196 -1
  95. package/lib/upgrade/global-refresh.mjs +108 -0
  96. package/lib/upgrade/global-refresh.test.mjs +65 -0
  97. package/lib/upgrade/launchd-reconcile.mjs +327 -0
  98. package/lib/upgrade/launchd-reconcile.test.mjs +272 -0
  99. package/lib/upgrade/post-steps.mjs +151 -0
  100. package/lib/upgrade/post-steps.test.mjs +200 -0
  101. package/lib/upgrade/verify.mjs +215 -0
  102. package/lib/upgrade/verify.test.mjs +164 -0
  103. package/lib/voice/outbound.mjs +3 -2
  104. package/lib/voice/post-call-brief.mjs +2 -1
  105. package/lib/voice/session-rotation.mjs +6 -1
  106. package/lib/voice/session-rotation.test.mjs +114 -0
  107. package/package.json +3 -3
  108. package/plugins/maestro-skills/plugin.json +21 -1
  109. package/plugins/maestro-skills/skills/board-work.md +63 -0
  110. package/plugins/maestro-skills/skills/inbound-triage.md +80 -0
  111. package/plugins/maestro-skills/skills/main-session.md +102 -0
  112. package/plugins/maestro-skills/skills/peer-sessions.md +65 -0
  113. package/plugins/maestro-skills/skills/persona-discipline.md +75 -0
  114. package/scaffold/CLAUDE.md +34 -0
  115. package/scripts/ci/check-durable-write-seam.mjs +147 -0
  116. package/scripts/ci/check-durable-write-seam.test.mjs +90 -0
  117. package/scripts/ci/check.mjs +3 -0
  118. package/scripts/collective/hook-runner.mjs +39 -4
  119. package/scripts/collective/hook-runner.test.mjs +85 -2
  120. package/scripts/daemon/agent-daemon-board-mine.test.mjs +96 -0
  121. package/scripts/daemon/agent-daemon-frontdoor.test.mjs +60 -0
  122. package/scripts/daemon/agent-daemon.mjs +141 -10
  123. package/scripts/daemon/agent-daemon.test.mjs +73 -0
  124. package/scripts/daemon/assurance-e2e.test.mjs +141 -6
  125. package/scripts/daemon/assurance.mjs +461 -37
  126. package/scripts/daemon/assurance.test.mjs +408 -43
  127. package/scripts/daemon/cadence-consumer-frontdoor.test.mjs +334 -0
  128. package/scripts/daemon/cadence-consumer.mjs +254 -78
  129. package/scripts/daemon/cadence-handlers.mjs +53 -0
  130. package/scripts/daemon/classifier.mjs +1 -1
  131. package/scripts/daemon/dispatcher-resume.test.mjs +166 -0
  132. package/scripts/daemon/dispatcher.mjs +127 -19
  133. package/scripts/daemon/health.mjs +12 -1
  134. package/scripts/daemon/inbox-deferral-session.test.mjs +49 -0
  135. package/scripts/daemon/inbox-deferral.mjs +6 -0
  136. package/scripts/daemon/lib/self-echo.mjs +201 -0
  137. package/scripts/daemon/lib/self-echo.test.mjs +153 -0
  138. package/scripts/daemon/maestro-daemon.mjs +3 -0
  139. package/scripts/daemon/prompt-builder.mjs +9 -1
  140. package/scripts/daemon/prompt-builder.test.mjs +22 -0
  141. package/scripts/daemon/responder.mjs +61 -41
  142. package/scripts/daemon/sdk-version.mjs +51 -0
  143. package/scripts/daemon/sdk-version.test.mjs +31 -0
  144. package/scripts/hooks/pre-send-audit.sh +97 -4
  145. package/scripts/hooks/pre-send-audit.test.mjs +140 -1
  146. package/scripts/local-triggers/autoupdate.sh +243 -19
  147. package/scripts/local-triggers/autoupdate.test.mjs +488 -0
  148. package/scripts/local-triggers/generate-plists.sh +24 -1
  149. package/scripts/local-triggers/generate-plists.test.mjs +49 -11
  150. package/scripts/org/send-orgmail.first-contact.test.mjs +102 -0
  151. package/scripts/org/send-orgmail.mjs +27 -3
  152. package/scripts/poller/inbox-privilege-injection.test.mjs +167 -0
  153. package/scripts/poller/slack-poller.mjs +13 -1
  154. package/scripts/poller/utils.mjs +46 -1
  155. package/scripts/poller-launchd/install.sh +19 -11
  156. package/scripts/poller-launchd/install.test.mjs +243 -0
  157. package/scripts/poller-launchd/launchd-poller-wrapper.sh +92 -0
  158. package/scripts/poller-launchd/migrate.sh +66 -0
  159. package/scripts/poller-launchd/poller.plist.template +4 -2
  160. package/scripts/session/feed.mjs +237 -0
  161. package/scripts/session/feed.test.mjs +196 -0
  162. package/scripts/session/supervisor-sh.test.mjs +218 -0
  163. package/scripts/session/supervisor.mjs +328 -0
  164. package/scripts/session/supervisor.sh +141 -0
  165. package/scripts/session/supervisor.test.mjs +482 -0
  166. package/scripts/setup/configure-macos.sh +250 -55
  167. package/scripts/setup/configure-macos.test.mjs +306 -0
  168. package/scripts/setup/init-agent.sh +112 -7
  169. package/scripts/setup/init-agent.test.mjs +220 -1
  170. package/scripts/watchdog/memory-watchdog.sh +37 -1
  171. package/scripts/watchdog/memory-watchdog.test.mjs +64 -0
  172. package/scripts/setup/boot-claude-session.sh +0 -94
@@ -0,0 +1,240 @@
1
+ /**
2
+ * model.test.mjs — model/provider setup section (order 30) tests.
3
+ *
4
+ * The oauth-token mode is the fleet DEFAULT (§3.9): the operator pastes the
5
+ * output of `claude setup-token`, headless reads CLAUDE_CODE_OAUTH_TOKEN from
6
+ * the environment, and .env ends up 0600 with the token + the prefer flag.
7
+ *
8
+ * Run: node --test lib/setup/sections/model.test.mjs
9
+ */
10
+ "use strict";
11
+
12
+ import { test } from "node:test";
13
+ import assert from "node:assert/strict";
14
+ import { mkdtempSync, readFileSync, writeFileSync, statSync, existsSync, rmSync } from "node:fs";
15
+ import { tmpdir } from "node:os";
16
+ import { join, dirname } from "node:path";
17
+ import { fileURLToPath } from "node:url";
18
+
19
+ import model, { AUTH_MODES, DEFAULT_AUTH_MODE } from "./model.mjs";
20
+ import * as io from "../io.mjs";
21
+
22
+ const MAESTRO_ROOT = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "..");
23
+
24
+ function tmpRoot(envBody) {
25
+ const root = mkdtempSync(join(tmpdir(), "maestro-modelsec-"));
26
+ if (envBody !== undefined) writeFileSync(join(root, ".env"), envBody);
27
+ return root;
28
+ }
29
+ function ctxFor(root, answers = {}, extra = {}) {
30
+ return { agentRoot: root, maestroRoot: MAESTRO_ROOT, flags: { headless: true }, headless: true, answers, io, ...extra };
31
+ }
32
+ function readEnv(root) { return readFileSync(join(root, ".env"), "utf-8"); }
33
+ function withEnv(patch, fn) {
34
+ const saved = {};
35
+ for (const k of Object.keys(patch)) { saved[k] = process.env[k]; if (patch[k] === undefined) delete process.env[k]; else process.env[k] = patch[k]; }
36
+ return Promise.resolve().then(fn).finally(() => {
37
+ for (const k of Object.keys(patch)) { if (saved[k] === undefined) delete process.env[k]; else process.env[k] = saved[k]; }
38
+ });
39
+ }
40
+
41
+ test("section shape: id/order=30/hooks; oauth-token is the default of three modes", () => {
42
+ assert.equal(model.id, "model");
43
+ assert.equal(model.order, 30);
44
+ for (const h of ["detect", "prompt", "apply", "verify"]) assert.equal(typeof model[h], "function");
45
+ assert.deepEqual([...AUTH_MODES], ["oauth-token", "subscription", "api-key"]);
46
+ assert.equal(DEFAULT_AUTH_MODE, "oauth-token");
47
+ });
48
+
49
+ test("headless with no answers: provider defaults to oauth-token and the token is read from CLAUDE_CODE_OAUTH_TOKEN", async () => {
50
+ const root = tmpRoot("");
51
+ try {
52
+ await withEnv({ CLAUDE_CODE_OAUTH_TOKEN: "sk-ant-oat01-fromenv" }, async () => {
53
+ const ctx = ctxFor(root);
54
+ await model.prompt(ctx);
55
+ assert.equal(ctx.answers.model.provider, "oauth-token");
56
+ assert.equal(ctx.answers.model.hasToken, true);
57
+ assert.equal(ctx.answers.claudeOauthToken, "sk-ant-oat01-fromenv", "the value lives under the key the checkpoint redacts, never under answers.model");
58
+ await model.apply(ctx);
59
+ });
60
+ const env = readEnv(root);
61
+ assert.match(env, /^CLAUDE_CODE_OAUTH_TOKEN=sk-ant-oat01-fromenv$/m);
62
+ assert.match(env, /^MAESTRO_PREFER_SUBSCRIPTION_AUTH=1$/m);
63
+ assert.doesNotMatch(env, /^ANTHROPIC_API_KEY=/m);
64
+ assert.equal(statSync(join(root, ".env")).mode & 0o777, 0o600, ".env is chmod 600 after apply");
65
+ const d = await model.detect(ctxFor(root));
66
+ assert.equal(d.status, "complete");
67
+ assert.match(d.summary, /OAuth token/i);
68
+ } finally { rmSync(root, { recursive: true, force: true }); }
69
+ });
70
+
71
+ test("the prompt copy tells the operator to run `claude setup-token` on any machine and paste the result", async () => {
72
+ const root = tmpRoot("");
73
+ try {
74
+ const seen = [];
75
+ const spyIo = {
76
+ ...io,
77
+ select: async (_ctx, opts) => { seen.push(opts); return opts.default; },
78
+ secret: async (_ctx, opts) => { seen.push(opts); return "pasted-token"; },
79
+ confirm: async (_ctx, opts) => { seen.push(opts); return false; },
80
+ };
81
+ const ctx = ctxFor(root, {}, { io: spyIo });
82
+ await model.prompt(ctx);
83
+ const select = seen.find((o) => o.key === "provider");
84
+ assert.ok(select, "the provider select ran");
85
+ assert.equal(select.default, "oauth-token");
86
+ const tokenChoice = select.choices.find((c) => c.value === "oauth-token");
87
+ assert.match(tokenChoice.label, /claude setup-token/);
88
+ const secret = seen.find((o) => o.key === "claudeOauthToken");
89
+ assert.ok(secret, "the token secret prompt ran");
90
+ assert.match(secret.message, /claude setup-token/);
91
+ assert.match(secret.message, /any machine/i);
92
+ assert.match(secret.message, /paste/i);
93
+ assert.equal(secret.env, "CLAUDE_CODE_OAUTH_TOKEN");
94
+ assert.equal(ctx.answers.claudeOauthToken, "pasted-token");
95
+ assert.equal(ctx.answers.model.hasToken, true);
96
+ } finally { rmSync(root, { recursive: true, force: true }); }
97
+ });
98
+
99
+ test("apply is idempotent and never clobbers an existing token", async () => {
100
+ const root = tmpRoot("CLAUDE_CODE_OAUTH_TOKEN=old-token\n");
101
+ try {
102
+ const ctx = ctxFor(root, { provider: "oauth-token", claudeOauthToken: "new-token" });
103
+ await model.prompt(ctx);
104
+ await model.apply(ctx);
105
+ await model.apply(ctx);
106
+ const env = readEnv(root);
107
+ assert.equal((env.match(/^CLAUDE_CODE_OAUTH_TOKEN=/gm) || []).length, 1);
108
+ assert.match(env, /^CLAUDE_CODE_OAUTH_TOKEN=old-token$/m);
109
+ assert.equal((env.match(/^MAESTRO_PREFER_SUBSCRIPTION_AUTH=1$/gm) || []).length, 1);
110
+ } finally { rmSync(root, { recursive: true, force: true }); }
111
+ });
112
+
113
+ test("oauth-token chosen but no token available: still writes the prefer flag, detect reports keychain mode", async () => {
114
+ const root = tmpRoot("");
115
+ try {
116
+ await withEnv({ CLAUDE_CODE_OAUTH_TOKEN: undefined }, async () => {
117
+ const ctx = ctxFor(root);
118
+ await model.prompt(ctx);
119
+ await model.apply(ctx);
120
+ });
121
+ const env = readEnv(root);
122
+ assert.doesNotMatch(env, /^CLAUDE_CODE_OAUTH_TOKEN=/m);
123
+ assert.match(env, /^MAESTRO_PREFER_SUBSCRIPTION_AUTH=1$/m);
124
+ const d = await model.detect(ctxFor(root));
125
+ assert.equal(d.status, "complete");
126
+ assert.match(d.summary, /keychain/i);
127
+ } finally { rmSync(root, { recursive: true, force: true }); }
128
+ });
129
+
130
+ test("subscription (keychain) and api-key modes keep working", async () => {
131
+ const a = tmpRoot("");
132
+ const b = tmpRoot(undefined); // no .env at all → created 0600
133
+ try {
134
+ const ca = ctxFor(a, { provider: "subscription" });
135
+ await model.prompt(ca); await model.apply(ca);
136
+ assert.match(readEnv(a), /^MAESTRO_PREFER_SUBSCRIPTION_AUTH=1$/m);
137
+ assert.doesNotMatch(readEnv(a), /^CLAUDE_CODE_OAUTH_TOKEN=/m);
138
+
139
+ const cb = ctxFor(b, { provider: "api-key", anthropicApiKey: "sk-ant-api03-x" });
140
+ await model.prompt(cb); await model.apply(cb);
141
+ assert.ok(existsSync(join(b, ".env")));
142
+ assert.match(readEnv(b), /^ANTHROPIC_API_KEY=sk-ant-api03-x$/m);
143
+ assert.doesNotMatch(readEnv(b), /^MAESTRO_PREFER_SUBSCRIPTION_AUTH=/m);
144
+ assert.equal(statSync(join(b, ".env")).mode & 0o777, 0o600);
145
+ const d = await model.detect(ctxFor(b));
146
+ assert.match(d.summary, /API key/);
147
+ } finally { rmSync(a, { recursive: true, force: true }); rmSync(b, { recursive: true, force: true }); }
148
+ });
149
+
150
+ test("legacy flat answers (no answers.model) still resolve; a bare token answer implies oauth-token", async () => {
151
+ const root = tmpRoot("");
152
+ try {
153
+ const ctx = ctxFor(root, { claudeOauthToken: "flat-token" });
154
+ await model.apply(ctx); // no prompt() — the headless resume path
155
+ const env = readEnv(root);
156
+ assert.match(env, /^CLAUDE_CODE_OAUTH_TOKEN=flat-token$/m);
157
+ assert.match(env, /^MAESTRO_PREFER_SUBSCRIPTION_AUTH=1$/m);
158
+ } finally { rmSync(root, { recursive: true, force: true }); }
159
+ });
160
+
161
+ test("verify uses the injectable probe (ctx.deps.probeClaude) and surfaces auth + detail", async () => {
162
+ const root = tmpRoot("CLAUDE_CODE_OAUTH_TOKEN=t\nMAESTRO_PREFER_SUBSCRIPTION_AUTH=1\n");
163
+ try {
164
+ const seen = [];
165
+ const probeClaude = (agentRoot) => { seen.push(agentRoot); return { ok: true, mode: "subscription", auth: "unknown", name: "Claude auth", detail: "could not verify" }; };
166
+ const r = await model.verify(ctxFor(root, {}, { deps: { probeClaude } }));
167
+ assert.deepEqual(seen, [root]);
168
+ assert.equal(r.ok, true);
169
+ assert.equal(r.checks[0].auth, "unknown");
170
+ assert.equal(r.checks[0].detail, "could not verify");
171
+ } finally { rmSync(root, { recursive: true, force: true }); }
172
+ });
173
+
174
+ // ---------------------------------------------------------------------------
175
+ // Review 2026-09-08 findings
176
+ // ---------------------------------------------------------------------------
177
+
178
+ test("a .env seeded from .env.example (empty `KEY=` lines) is FILLED IN, not treated as already set", async () => {
179
+ // .env.example ships `CLAUDE_CODE_OAUTH_TOKEN=` and `MAESTRO_PREFER_SUBSCRIPTION_AUTH=`
180
+ // as empty placeholders, and doctor's remedy is "copy from .env.example" —
181
+ // so this is the common path, and it used to leave the seat with no auth.
182
+ const root = tmpRoot(readFileSync(join(MAESTRO_ROOT, ".env.example"), "utf-8"));
183
+ try {
184
+ await withEnv({ CLAUDE_CODE_OAUTH_TOKEN: undefined }, async () => {
185
+ const ctx = ctxFor(root, { provider: "oauth-token", claudeOauthToken: "sk-ant-oat01-REAL" });
186
+ await model.prompt(ctx);
187
+ await model.apply(ctx);
188
+ });
189
+ const env = readEnv(root);
190
+ assert.match(env, /^CLAUDE_CODE_OAUTH_TOKEN=sk-ant-oat01-REAL$/m);
191
+ assert.equal((env.match(/^CLAUDE_CODE_OAUTH_TOKEN=/gm) || []).length, 1, "the placeholder line is replaced in place, not duplicated");
192
+ assert.match(env, /^MAESTRO_PREFER_SUBSCRIPTION_AUTH=1$/m);
193
+ assert.equal((env.match(/^MAESTRO_PREFER_SUBSCRIPTION_AUTH=/gm) || []).length, 1);
194
+ const d = await model.detect(ctxFor(root));
195
+ assert.equal(d.status, "complete");
196
+ assert.match(d.summary, /OAuth token/i);
197
+ // The api-key lane had the same latent bug.
198
+ const b = tmpRoot("ANTHROPIC_API_KEY=\n");
199
+ try {
200
+ const cb = ctxFor(b, { provider: "api-key", anthropicApiKey: "sk-ant-api03-x" });
201
+ await model.prompt(cb); await model.apply(cb);
202
+ assert.match(readEnv(b), /^ANTHROPIC_API_KEY=sk-ant-api03-x$/m);
203
+ assert.equal((readEnv(b).match(/^ANTHROPIC_API_KEY=/gm) || []).length, 1);
204
+ } finally { rmSync(b, { recursive: true, force: true }); }
205
+ } finally { rmSync(root, { recursive: true, force: true }); }
206
+ });
207
+
208
+ test("the pasted token and API key never sit in ctx.answers.model — nothing for progress.json to leak", async () => {
209
+ const root = tmpRoot("");
210
+ try {
211
+ const spyIo = {
212
+ ...io,
213
+ select: async (_ctx, opts) => opts.default,
214
+ secret: async (_ctx, _opts) => "sk-ant-oat01-PASTED",
215
+ confirm: async () => false,
216
+ };
217
+ const ctx = ctxFor(root, {}, { io: spyIo });
218
+ await model.prompt(ctx);
219
+ const flat = JSON.stringify(ctx.answers.model);
220
+ assert.doesNotMatch(flat, /PASTED/, "answers.model carries no credential value");
221
+ assert.equal(ctx.answers.model.provider, "oauth-token");
222
+ assert.equal(ctx.answers.model.hasToken, true);
223
+ // The secret lives under its own top-level key, which the checkpoint's
224
+ // SECRET_ANSWER_KEY_RE already strips on every save.
225
+ assert.equal(ctx.answers.claudeOauthToken, "sk-ant-oat01-PASTED");
226
+ await model.apply(ctx);
227
+ assert.match(readEnv(root), /^CLAUDE_CODE_OAUTH_TOKEN=sk-ant-oat01-PASTED$/m);
228
+
229
+ const b = tmpRoot("");
230
+ try {
231
+ const spyB = { ...spyIo, select: async () => "api-key", secret: async () => "sk-ant-api03-PASTED" };
232
+ const cb = ctxFor(b, {}, { io: spyB });
233
+ await model.prompt(cb);
234
+ assert.doesNotMatch(JSON.stringify(cb.answers.model), /PASTED/);
235
+ assert.equal(cb.answers.anthropicApiKey, "sk-ant-api03-PASTED");
236
+ await model.apply(cb);
237
+ assert.match(readEnv(b), /^ANTHROPIC_API_KEY=sk-ant-api03-PASTED$/m);
238
+ } finally { rmSync(b, { recursive: true, force: true }); }
239
+ } finally { rmSync(root, { recursive: true, force: true }); }
240
+ });
@@ -35,6 +35,7 @@ import { mergeAgentJson, readAgentJson } from "../sot.mjs";
35
35
  import { cohortPullEnv, pullAndPlan } from "../enroll-from-cohort.mjs";
36
36
  import { bootstrapAndPersist } from "../../org/bootstrap-context.mjs";
37
37
  import { runGenerator } from "../run-generator.mjs";
38
+ import { looksLikeOrgId } from "../../cli/doctor-checks.mjs";
38
39
 
39
40
  const CONFIG_REL = join("config", "org.yaml");
40
41
 
@@ -332,7 +333,9 @@ export default {
332
333
  });
333
334
  ctx.answers.orgBase = base;
334
335
  if (!base) return;
335
- ctx.answers.orgId = await io.text(ctx, { message: "Org id / slug (optional)", key: "orgId", env: "COHORT_ORG_ID", default: "" });
336
+ ctx.answers.orgId = await io.text(ctx, { message: "Org ID (optional — the ID, org_default_<slug>, NOT the slug)", key: "orgId", env: "COHORT_ORG_ID", default: "" });
337
+ const idWarning = orgIdWarning(ctx.answers.orgId);
338
+ if (idWarning) io.note(` ⚠ ${idWarning}`, { dim: false });
336
339
 
337
340
  // Two honest credential lanes (hq now ships pairing.request/approve):
338
341
  // apikey — an admin mints an org API key (Bearer) which you paste here.
@@ -442,12 +445,20 @@ export default {
442
445
  checks.push({ name: "config/org.yaml present", ok: cfgPresent, remedy: cfgPresent ? undefined : "run `maestro setup --only org`" });
443
446
 
444
447
  const cfg = await readOrgConfig(ctx.agentRoot);
448
+ // F2: the org-ID pin is compared by hq with strict equality against the
449
+ // org ID; the slug 401s every call. Judged here (the wizard's own gate)
450
+ // as well as by doctor, so a slug never survives setup unremarked.
451
+ const pin = String((cfg && cfg.org && cfg.org.cohort && cfg.org.cohort.orgId) || "").trim();
452
+ if (pin) {
453
+ const w = orgIdWarning(pin);
454
+ checks.push({ name: "COHORT_ORG_ID is the org ID (not the slug)", ok: !w, remedy: w || undefined });
455
+ }
445
456
  const enabled = isEnabled(cfg);
446
457
  if (!enabled) {
447
458
  // Org enrollment is OPTIONAL — a not-yet-enrolled agent WARNs (remedy shown)
448
459
  // but does not hard-fail setup. ok=true so the agent can run standalone.
449
460
  checks.push({ name: "org knowledge plane (not enrolled — optional)", ok: true, remedy: "set org.cohort.base + token in config/org.yaml, or pair with the org admin" });
450
- return { ok: true, checks };
461
+ return { ok: checks.every((c) => c.ok), checks };
451
462
  }
452
463
 
453
464
  // Best-effort, NON-BLOCKING reachability probe. A GET /v1/snapshot round-trips
@@ -473,3 +484,17 @@ export default {
473
484
  return { ok: checks.every((c) => c.ok), checks };
474
485
  },
475
486
  };
487
+
488
+ /**
489
+ * F2: is this COHORT_ORG_ID value the org SLUG rather than the org ID? Pure.
490
+ * hq compares the `x-org-id` pin with strict equality against the org ID
491
+ * (`org_default_<slug>`), so a slug 401s every call the agent makes — and the
492
+ * wizard used to invite exactly that with "Org id / slug (optional)".
493
+ * @param {string} value
494
+ * @returns {string|null} a warning naming the likely ID, or null when fine/blank
495
+ */
496
+ export function orgIdWarning(value) {
497
+ const v = String(value || "").trim();
498
+ if (!v || looksLikeOrgId(v)) return null;
499
+ return `COHORT_ORG_ID=${v} looks like the org SLUG — hq compares the pin strictly against the org ID (org_default_<slug>), so every call would 401. Use the ID, probably org_default_${v.toLowerCase()} (Cohort → Settings shows it); \`maestro doctor\` checks it against the server.`;
500
+ }
@@ -6,13 +6,13 @@
6
6
 
7
7
  import { test } from "node:test";
8
8
  import assert from "node:assert/strict";
9
- import { mkdtempSync, existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
9
+ import { mkdtempSync, existsSync, readFileSync, writeFileSync, mkdirSync, rmSync } from "node:fs";
10
10
  import { tmpdir } from "node:os";
11
11
  import { join, dirname } from "node:path";
12
12
  import { fileURLToPath } from "node:url";
13
13
  import yaml from "js-yaml";
14
14
 
15
- import org, { buildOrgConfig, readOrgConfig, pullProfileIntoAgentConfig, orgContextShapesBacklog, requestPairing, deriveAgentIdentity } from "./org.mjs";
15
+ import org, { buildOrgConfig, readOrgConfig, pullProfileIntoAgentConfig, orgContextShapesBacklog, requestPairing, deriveAgentIdentity, orgIdWarning } from "./org.mjs";
16
16
  import { isEnabled } from "../../org/client.mjs";
17
17
  import { readAgentJson, writeAgentJson } from "../sot.mjs";
18
18
  import * as io from "../io.mjs";
@@ -311,3 +311,36 @@ test("verify does NOT block / hard-fail on an unreachable enabled server (fail-o
311
311
  assert.ok(reach, "a reachability check is recorded");
312
312
  assert.equal(reach.ok, true, "reachability must never hard-fail setup");
313
313
  });
314
+
315
+ // ---------------------------------------------------------------------------
316
+ // F2 (audit 2026-09-08): the wizard must not silently accept the org SLUG as
317
+ // COHORT_ORG_ID — hq compares the pin with strict equality against the org ID
318
+ // (`org_default_<slug>`), so a slug 401s every call.
319
+ // ---------------------------------------------------------------------------
320
+
321
+ test("orgIdWarning: a slug-shaped value gets a warning naming the ID form; an ID or blank gets none", () => {
322
+ assert.equal(orgIdWarning(""), null);
323
+ assert.equal(orgIdWarning("org_default_acme"), null);
324
+ const w = orgIdWarning("acme");
325
+ assert.match(w, /slug/i);
326
+ assert.match(w, /org_default_acme/);
327
+ assert.match(w, /401/);
328
+ });
329
+
330
+ test("verify: a slug-shaped orgId in config/org.yaml is a failing check with the remedy; the ID form passes", async () => {
331
+ const root = tmpRoot();
332
+ try {
333
+ await org.apply(ctxFor(root, { orgBase: "https://cohort.example", orgId: "acme", orgToken: "tok-123" }));
334
+ const bad = await org.verify(ctxFor(root));
335
+ const shape = bad.checks.find((c) => /COHORT_ORG_ID/.test(c.name));
336
+ assert.ok(shape, "the shape check ran");
337
+ assert.equal(shape.ok, false);
338
+ assert.match(shape.remedy, /org_default_acme/);
339
+ assert.equal(bad.ok, false);
340
+
341
+ writeFileSync(join(root, "config", "org.yaml"), "org:\n cohort:\n enabled: true\n base: https://cohort.example\n orgId: org_default_acme\n token: tok-123\n");
342
+ const good = await org.verify(ctxFor(root));
343
+ const shape2 = good.checks.find((c) => /COHORT_ORG_ID/.test(c.name));
344
+ assert.ok(shape2 && shape2.ok === true, "the ID form is accepted");
345
+ } finally { rmSync(root, { recursive: true, force: true }); }
346
+ });
@@ -209,10 +209,15 @@ export default {
209
209
  const where = r.section ? ` [${r.section}]` : "";
210
210
  io.note(`${mark} ${r.name}${where}`);
211
211
  if (!r.ok && r.remedy) io.note(` ↳ ${r.remedy}`, { dim: true });
212
+ else if (r.ok && r.detail) io.note(` ↳ ${r.detail}`, { dim: true });
212
213
  }
213
214
  if (result.probe && !result.probe.ok && result.probe.remedy) {
214
215
  io.note(` ✗ ${result.probe.name}`);
215
216
  io.note(` ↳ ${result.probe.remedy}`, { dim: true });
217
+ } else if (result.probe && result.probe.ok && result.probe.detail) {
218
+ // Fail-open outcomes (auth "unknown") pass the gate but are never silent.
219
+ io.note(` ✓ ${result.probe.name}`);
220
+ io.note(` ↳ ${result.probe.detail}`, { dim: true });
216
221
  }
217
222
  },
218
223
  };
@@ -63,29 +63,49 @@ export const SECRET_ANSWER_KEY_RE =
63
63
  * actually lives.
64
64
  *
65
65
  * The dropped key names are recorded on `_redacted` so the file still SAYS what
66
- * was removed. Names are not secrets; values are.
66
+ * was removed. Names are not secrets; values are. The scan RECURSES: section
67
+ * bags (`answers.model`, …) are objects, and a credential nested one level
68
+ * down is exactly as leaked as a top-level one.
67
69
  *
68
70
  * @param {object|null|undefined} answers
69
71
  * @returns {{answers:object, redacted:string[]}}
70
72
  */
71
73
  export function redactSecretAnswers(answers) {
72
74
  const src = answers && typeof answers === "object" ? answers : {};
73
- const out = {};
74
75
  const redacted = [];
75
- for (const [k, v] of Object.entries(src)) {
76
- if (k === "_redacted") continue;
77
- if (SECRET_ANSWER_KEY_RE.test(k) && typeof v === "string" && v) {
78
- redacted.push(k);
79
- continue;
80
- }
81
- out[k] = v;
82
- }
76
+ const out = redactInto(src, "", redacted);
77
+ delete out._redacted;
83
78
  const prior = Array.isArray(src._redacted) ? src._redacted : [];
84
79
  const all = [...new Set([...prior, ...redacted])].sort();
85
80
  if (all.length) out._redacted = all;
86
81
  return { answers: out, redacted };
87
82
  }
88
83
 
84
+ /**
85
+ * The recursive walk behind redactSecretAnswers. Sections keep their own bag
86
+ * under `answers.<section>` (e.g. `answers.model = {provider, …}`), so a
87
+ * credential one level down used to sail past a top-level-only scan and land
88
+ * in the 0644 checkpoint — the .env was chmod 600 for nothing. Every object at
89
+ * every depth is scanned; arrays are walked element-wise (a dropped key inside
90
+ * an element leaves the element in place so indices stay stable). Dotted
91
+ * paths (`model.oauthToken`) go on `_redacted` so the file still says what
92
+ * was removed.
93
+ */
94
+ function redactInto(value, path, redacted) {
95
+ if (Array.isArray(value)) return value.map((v, i) => redactInto(v, `${path}[${i}]`, redacted));
96
+ if (value === null || typeof value !== "object") return value;
97
+ const out = {};
98
+ for (const [k, v] of Object.entries(value)) {
99
+ const full = path ? `${path}.${k}` : k;
100
+ if (SECRET_ANSWER_KEY_RE.test(k) && typeof v === "string" && v) {
101
+ redacted.push(full);
102
+ continue;
103
+ }
104
+ out[k] = redactInto(v, full, redacted);
105
+ }
106
+ return out;
107
+ }
108
+
89
109
  /** Absolute path to the progress file for an agent root. */
90
110
  export function progressPath(agentRoot) {
91
111
  return join(agentRoot, PROGRESS_REL);
@@ -8,7 +8,7 @@
8
8
 
9
9
  import { test } from "node:test";
10
10
  import assert from "node:assert/strict";
11
- import { mkdtempSync, writeFileSync, mkdirSync, existsSync, readFileSync } from "node:fs";
11
+ import { mkdtempSync, writeFileSync, mkdirSync, existsSync, readFileSync, rmSync } from "node:fs";
12
12
  import { tmpdir } from "node:os";
13
13
  import { join } from "node:path";
14
14
  import {
@@ -90,3 +90,26 @@ test("mergeAnswers updates without disturbing sections", () => {
90
90
  assert.equal(p.answers.tone, "warm");
91
91
  assert.equal(p.sections.identity.status, "complete");
92
92
  });
93
+
94
+ test("redactSecretAnswers recurses: a credential nested under a section bag never reaches progress.json", () => {
95
+ const root = mkdtempSync(join(tmpdir(), "setup-state-nested-"));
96
+ try {
97
+ mergeAnswers(root, {
98
+ claudeOauthToken: "sk-ant-oat01-FLAT",
99
+ model: { provider: "oauth-token", oauthToken: "sk-ant-oat01-NESTED", apiKey: "sk-ant-api03-NESTED", routing: false },
100
+ deep: { a: { b: { apiKey: "sk-deep" } }, keep: "yes" },
101
+ list: [{ token: "in-a-list" }, "plain"],
102
+ });
103
+ const raw = readFileSync(progressPath(root), "utf-8");
104
+ assert.doesNotMatch(raw, /FLAT|NESTED|sk-deep|in-a-list/, "no credential value on disk at any depth");
105
+ const p = loadProgress(root);
106
+ assert.equal(p.answers.model.provider, "oauth-token", "non-secret siblings survive");
107
+ assert.equal(p.answers.model.routing, false);
108
+ assert.equal(p.answers.deep.keep, "yes");
109
+ assert.deepEqual(p.answers.list, [{}, "plain"]);
110
+ assert.ok(p.answers._redacted.includes("claudeOauthToken"));
111
+ assert.ok(p.answers._redacted.includes("model.oauthToken"));
112
+ assert.ok(p.answers._redacted.includes("model.apiKey"));
113
+ assert.ok(p.answers._redacted.includes("deep.a.b.apiKey"));
114
+ } finally { rmSync(root, { recursive: true, force: true }); }
115
+ });
package/lib/singleton.js CHANGED
@@ -46,10 +46,16 @@ function locksDir() {
46
46
  *
47
47
  * Handles stale PID files (process died without cleanup).
48
48
  *
49
+ * By default SIGINT/SIGTERM handlers are installed that release the lock and
50
+ * `process.exit(0)`. A caller that must run its own shutdown on those signals
51
+ * (the session supervisor forwards SIGTERM to its multiplexer before exiting)
52
+ * passes `{ signals: false }`; the `exit` cleanup is registered either way.
53
+ *
49
54
  * @param {string} name - Service name (e.g., "slack-events", "daemon", "huddle-server")
55
+ * @param {{signals?: boolean}} [opts]
50
56
  * @returns {boolean}
51
57
  */
52
- export function acquireLock(name) {
58
+ export function acquireLock(name, opts = {}) {
53
59
  const dir = locksDir();
54
60
  mkdirSync(dir, { recursive: true });
55
61
  const pidFile = join(dir, `${name}.pid`);
@@ -96,8 +102,10 @@ export function acquireLock(name) {
96
102
  try { unlinkSync(pidFile); } catch { /* ignore */ }
97
103
  };
98
104
  process.on("exit", cleanup);
99
- process.on("SIGINT", () => { cleanup(); process.exit(0); });
100
- process.on("SIGTERM", () => { cleanup(); process.exit(0); });
105
+ if (opts.signals !== false) {
106
+ process.on("SIGINT", () => { cleanup(); process.exit(0); });
107
+ process.on("SIGTERM", () => { cleanup(); process.exit(0); });
108
+ }
101
109
 
102
110
  return true;
103
111
  }
@@ -133,3 +133,19 @@ test("a legacy pid-only lock for a live PID is honoured", () => {
133
133
  });
134
134
 
135
135
  test.after(() => rmSync(root, { recursive: true, force: true }));
136
+
137
+ test("acquireLock(name, {signals:false}) installs no SIGTERM/SIGINT handlers — the caller owns shutdown; exit cleanup stays", () => {
138
+ const term = process.listenerCount("SIGTERM");
139
+ const int = process.listenerCount("SIGINT");
140
+ const exit = process.listenerCount("exit");
141
+ assert.equal(acquireLock("owned", { signals: false }), true);
142
+ assert.equal(process.listenerCount("SIGTERM"), term, "no SIGTERM handler");
143
+ assert.equal(process.listenerCount("SIGINT"), int, "no SIGINT handler");
144
+ assert.equal(process.listenerCount("exit"), exit + 1, "the exit cleanup is still registered");
145
+ releaseLock("owned");
146
+ // Default behaviour unchanged: signal handlers are installed.
147
+ assert.equal(acquireLock("default"), true);
148
+ assert.equal(process.listenerCount("SIGTERM"), term + 1);
149
+ assert.equal(process.listenerCount("SIGINT"), int + 1);
150
+ releaseLock("default");
151
+ });
@@ -245,7 +245,7 @@ export function queueOutbox(agentRoot, op, payload) {
245
245
  const seq = String(++outboxSeq).padStart(4, "0");
246
246
  const salt = randomBytes(3).toString("hex");
247
247
  const p = join(outboxDir(agentRoot), `${ts}-${seq}-${salt}-${String(op).replace(/[^\w.-]/g, "_")}.json`);
248
- writeFileSync(p, JSON.stringify({ op: String(op), payload: payload || {}, queuedAt: new Date().toISOString() }, null, 2));
248
+ writeJsonAtomic(p, { op: String(op), payload: payload || {}, queuedAt: new Date().toISOString() });
249
249
  return p;
250
250
  } catch {
251
251
  return null;