@cohortapp/agent-sdk 2.11.15 → 2.13.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 (280) hide show
  1. package/.env.example +37 -22
  2. package/README.md +2 -0
  3. package/bin/maestro.mjs +117 -39
  4. package/bin/maestro.test.mjs +175 -5
  5. package/docs/guides/front-door-session.md +313 -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/design.mjs +185 -0
  17. package/lib/cli/design.test.mjs +270 -0
  18. package/lib/cli/doctor-checks.mjs +441 -0
  19. package/lib/cli/doctor-checks.test.mjs +336 -0
  20. package/lib/cli/global-setup-extras.mjs +454 -0
  21. package/lib/cli/global-setup-extras.test.mjs +462 -0
  22. package/lib/cli/inbox.mjs +304 -0
  23. package/lib/cli/inbox.test.mjs +230 -0
  24. package/lib/cli/session-ack.mjs +63 -0
  25. package/lib/cli/session-ack.test.mjs +63 -0
  26. package/lib/cli/session.mjs +760 -0
  27. package/lib/cli/session.test.mjs +613 -0
  28. package/lib/collective/global-config.mjs +209 -6
  29. package/lib/collective/global-config.test.mjs +145 -0
  30. package/lib/collective/global-skills.mjs +145 -0
  31. package/lib/collective/global-skills.test.mjs +126 -0
  32. package/lib/collective/presence.mjs +4 -3
  33. package/lib/collective/vendor-skills.mjs +305 -0
  34. package/lib/collective/vendor-skills.test.mjs +306 -0
  35. package/lib/comms/send-gate.mjs +115 -0
  36. package/lib/comms/send-gate.test.mjs +113 -0
  37. package/lib/design/design-md.mjs +793 -0
  38. package/lib/design/design-md.test.mjs +318 -0
  39. package/lib/design/fixtures/DESIGN.golden.md +238 -0
  40. package/lib/design/fixtures/PRODUCT.golden.md +67 -0
  41. package/lib/design/fixtures/foundation.json +133 -0
  42. package/lib/design/refresh-gate.mjs +154 -0
  43. package/lib/design/refresh-gate.test.mjs +144 -0
  44. package/lib/design/write.mjs +275 -0
  45. package/lib/design/write.test.mjs +241 -0
  46. package/lib/feature-init.mjs +2 -2
  47. package/lib/mcp/server.test.mjs +9 -4
  48. package/lib/model-router/spawn.test.mjs +21 -0
  49. package/lib/org/board-mine-cache.mjs +99 -0
  50. package/lib/org/board-mine-cache.test.mjs +53 -0
  51. package/lib/org/board.mjs +11 -0
  52. package/lib/org/board.test.mjs +11 -1
  53. package/lib/org/client.mjs +36 -0
  54. package/lib/org/client.test.mjs +46 -0
  55. package/lib/org/inbound/directedness.mjs +18 -2
  56. package/lib/org/inbound/directedness.test.mjs +58 -0
  57. package/lib/org/inbound/index.mjs +8 -1
  58. package/lib/org/inbound/index.test.mjs +22 -0
  59. package/lib/org/mesh-directives.test.mjs +110 -0
  60. package/lib/org/mesh.mjs +61 -1
  61. package/lib/org/protocol.checksum +1 -1
  62. package/lib/org/protocol.mjs +52 -0
  63. package/lib/org/protocol.test.mjs +12 -1
  64. package/lib/org/registry.mjs +3 -2
  65. package/lib/org/tool-surface.mjs +120 -0
  66. package/lib/org/tool-surface.test.mjs +118 -5
  67. package/lib/prompts/parallelism.mjs +79 -0
  68. package/lib/prompts/parallelism.test.mjs +177 -0
  69. package/lib/security/external-content.mjs +1 -1
  70. package/lib/security/external-content.test.mjs +17 -0
  71. package/lib/session/config.mjs +137 -0
  72. package/lib/session/config.test.mjs +92 -0
  73. package/lib/session/feed-core.mjs +229 -0
  74. package/lib/session/feed-core.test.mjs +198 -0
  75. package/lib/session/first-run.mjs +126 -0
  76. package/lib/session/first-run.test.mjs +121 -0
  77. package/lib/session/frontdoor.mjs +266 -0
  78. package/lib/session/frontdoor.test.mjs +205 -0
  79. package/lib/session/handoffs.mjs +295 -0
  80. package/lib/session/handoffs.test.mjs +183 -0
  81. package/lib/session/identity.mjs +220 -0
  82. package/lib/session/identity.test.mjs +180 -0
  83. package/lib/session/inbox-claims.mjs +434 -0
  84. package/lib/session/inbox-claims.test.mjs +286 -0
  85. package/lib/session/launch-args.mjs +161 -0
  86. package/lib/session/launch-args.test.mjs +157 -0
  87. package/lib/session/liveness.mjs +174 -0
  88. package/lib/session/liveness.test.mjs +100 -0
  89. package/lib/session/status-summary.mjs +172 -0
  90. package/lib/session/status-summary.test.mjs +118 -0
  91. package/lib/session-permissions.mjs +39 -3
  92. package/lib/session-permissions.test.mjs +20 -0
  93. package/lib/setup/claude-probe.mjs +161 -24
  94. package/lib/setup/claude-probe.test.mjs +187 -0
  95. package/lib/setup/sections/learning.mjs +2 -1
  96. package/lib/setup/sections/model.mjs +104 -24
  97. package/lib/setup/sections/model.test.mjs +240 -0
  98. package/lib/setup/sections/org.mjs +27 -2
  99. package/lib/setup/sections/org.test.mjs +35 -2
  100. package/lib/setup/sections/verify.mjs +5 -0
  101. package/lib/setup/state.mjs +30 -10
  102. package/lib/setup/state.test.mjs +24 -1
  103. package/lib/singleton.js +11 -3
  104. package/lib/singleton.test.mjs +16 -0
  105. package/lib/subagents/lock.mjs +1 -1
  106. package/lib/telemetry/collect.mjs +270 -6
  107. package/lib/telemetry/collect.test.mjs +196 -1
  108. package/lib/upgrade/global-refresh.mjs +108 -0
  109. package/lib/upgrade/global-refresh.test.mjs +65 -0
  110. package/lib/upgrade/launchd-reconcile.mjs +327 -0
  111. package/lib/upgrade/launchd-reconcile.test.mjs +272 -0
  112. package/lib/upgrade/post-steps.mjs +151 -0
  113. package/lib/upgrade/post-steps.test.mjs +200 -0
  114. package/lib/upgrade/verify.mjs +215 -0
  115. package/lib/upgrade/verify.test.mjs +164 -0
  116. package/lib/voice/outbound.mjs +3 -2
  117. package/lib/voice/post-call-brief.mjs +2 -1
  118. package/lib/voice/session-rotation.mjs +6 -1
  119. package/lib/voice/session-rotation.test.mjs +114 -0
  120. package/package.json +3 -3
  121. package/plugins/maestro-skills/plugin.json +25 -1
  122. package/plugins/maestro-skills/skills/board-work.md +63 -0
  123. package/plugins/maestro-skills/skills/cohort-design.md +153 -0
  124. package/plugins/maestro-skills/skills/inbound-triage.md +80 -0
  125. package/plugins/maestro-skills/skills/main-session.md +102 -0
  126. package/plugins/maestro-skills/skills/peer-sessions.md +65 -0
  127. package/plugins/maestro-skills/skills/persona-discipline.md +75 -0
  128. package/plugins/maestro-skills/vendor/emilkowalski/LICENSE +21 -0
  129. package/plugins/maestro-skills/vendor/emilkowalski/UPSTREAM.json +70 -0
  130. package/plugins/maestro-skills/vendor/emilkowalski/skills/animate/RECIPES.md +324 -0
  131. package/plugins/maestro-skills/vendor/emilkowalski/skills/animate/SKILL.md +199 -0
  132. package/plugins/maestro-skills/vendor/emilkowalski/skills/animation-vocabulary/SKILL.md +173 -0
  133. package/plugins/maestro-skills/vendor/emilkowalski/skills/apple-design/SKILL.md +282 -0
  134. package/plugins/maestro-skills/vendor/emilkowalski/skills/emil-design-eng/SKILL.md +674 -0
  135. package/plugins/maestro-skills/vendor/emilkowalski/skills/find-animation-opportunities/SKILL.md +132 -0
  136. package/plugins/maestro-skills/vendor/emilkowalski/skills/improve-animations/AUDIT.md +115 -0
  137. package/plugins/maestro-skills/vendor/emilkowalski/skills/improve-animations/PLAN-TEMPLATE.md +73 -0
  138. package/plugins/maestro-skills/vendor/emilkowalski/skills/improve-animations/SKILL.md +101 -0
  139. package/plugins/maestro-skills/vendor/emilkowalski/skills/prototype/PICKER.md +197 -0
  140. package/plugins/maestro-skills/vendor/emilkowalski/skills/prototype/SKILL.md +90 -0
  141. package/plugins/maestro-skills/vendor/emilkowalski/skills/review-animations/SKILL.md +112 -0
  142. package/plugins/maestro-skills/vendor/emilkowalski/skills/review-animations/STANDARDS.md +187 -0
  143. package/plugins/maestro-skills/vendor/impeccable/LICENSE +191 -0
  144. package/plugins/maestro-skills/vendor/impeccable/NOTICE.md +11 -0
  145. package/plugins/maestro-skills/vendor/impeccable/SKILL.md +86 -0
  146. package/plugins/maestro-skills/vendor/impeccable/UPSTREAM.json +201 -0
  147. package/plugins/maestro-skills/vendor/impeccable/agents/impeccable-asset-producer.md +42 -0
  148. package/plugins/maestro-skills/vendor/impeccable/agents/impeccable-documenter.md +29 -0
  149. package/plugins/maestro-skills/vendor/impeccable/agents/impeccable-finish-reviewer.md +43 -0
  150. package/plugins/maestro-skills/vendor/impeccable/agents/impeccable-manual-edit-applier.md +97 -0
  151. package/plugins/maestro-skills/vendor/impeccable/reference/adapt.md +312 -0
  152. package/plugins/maestro-skills/vendor/impeccable/reference/adapt.native.md +58 -0
  153. package/plugins/maestro-skills/vendor/impeccable/reference/android.md +46 -0
  154. package/plugins/maestro-skills/vendor/impeccable/reference/animate.md +89 -0
  155. package/plugins/maestro-skills/vendor/impeccable/reference/audit.md +136 -0
  156. package/plugins/maestro-skills/vendor/impeccable/reference/audit.native.md +139 -0
  157. package/plugins/maestro-skills/vendor/impeccable/reference/bolder.md +33 -0
  158. package/plugins/maestro-skills/vendor/impeccable/reference/clarify.md +94 -0
  159. package/plugins/maestro-skills/vendor/impeccable/reference/colorize.md +86 -0
  160. package/plugins/maestro-skills/vendor/impeccable/reference/craft-floor.md +44 -0
  161. package/plugins/maestro-skills/vendor/impeccable/reference/craft.md +5 -0
  162. package/plugins/maestro-skills/vendor/impeccable/reference/critique.md +806 -0
  163. package/plugins/maestro-skills/vendor/impeccable/reference/degraded/asset-producer.md +37 -0
  164. package/plugins/maestro-skills/vendor/impeccable/reference/degraded/documenter.md +24 -0
  165. package/plugins/maestro-skills/vendor/impeccable/reference/degraded/finish-reviewer.md +38 -0
  166. package/plugins/maestro-skills/vendor/impeccable/reference/degraded/manual-edit-applier.md +92 -0
  167. package/plugins/maestro-skills/vendor/impeccable/reference/delight.md +70 -0
  168. package/plugins/maestro-skills/vendor/impeccable/reference/distill.md +111 -0
  169. package/plugins/maestro-skills/vendor/impeccable/reference/doctor.md +54 -0
  170. package/plugins/maestro-skills/vendor/impeccable/reference/document.md +416 -0
  171. package/plugins/maestro-skills/vendor/impeccable/reference/extract.md +69 -0
  172. package/plugins/maestro-skills/vendor/impeccable/reference/harden.md +336 -0
  173. package/plugins/maestro-skills/vendor/impeccable/reference/hooks.md +111 -0
  174. package/plugins/maestro-skills/vendor/impeccable/reference/init.md +131 -0
  175. package/plugins/maestro-skills/vendor/impeccable/reference/ios.md +51 -0
  176. package/plugins/maestro-skills/vendor/impeccable/reference/layout.md +84 -0
  177. package/plugins/maestro-skills/vendor/impeccable/reference/live-setup.md +104 -0
  178. package/plugins/maestro-skills/vendor/impeccable/reference/live.md +325 -0
  179. package/plugins/maestro-skills/vendor/impeccable/reference/new-work.md +147 -0
  180. package/plugins/maestro-skills/vendor/impeccable/reference/onboard.md +234 -0
  181. package/plugins/maestro-skills/vendor/impeccable/reference/operate.md +61 -0
  182. package/plugins/maestro-skills/vendor/impeccable/reference/optimize.md +258 -0
  183. package/plugins/maestro-skills/vendor/impeccable/reference/overdrive.md +127 -0
  184. package/plugins/maestro-skills/vendor/impeccable/reference/polish.md +105 -0
  185. package/plugins/maestro-skills/vendor/impeccable/reference/quieter.md +99 -0
  186. package/plugins/maestro-skills/vendor/impeccable/reference/routing.md +24 -0
  187. package/plugins/maestro-skills/vendor/impeccable/reference/shape.md +59 -0
  188. package/plugins/maestro-skills/vendor/impeccable/reference/typeset.md +80 -0
  189. package/plugins/maestro-skills/vendor/impeccable/reference/visualize.md +46 -0
  190. package/plugins/maestro-skills/vendor/taste-skill/LICENSE +21 -0
  191. package/plugins/maestro-skills/vendor/taste-skill/UPSTREAM.json +37 -0
  192. package/plugins/maestro-skills/vendor/taste-skill/skills/minimalist-skill/SKILL.md +85 -0
  193. package/plugins/maestro-skills/vendor/taste-skill/skills/redesign-skill/SKILL.md +178 -0
  194. package/plugins/maestro-skills/vendor/taste-skill/skills/soft-skill/SKILL.md +98 -0
  195. package/plugins/maestro-skills/vendor/taste-skill/skills/taste-skill/SKILL.md +1206 -0
  196. package/plugins/maestro-skills/vendor/unlazy/LICENSE +21 -0
  197. package/plugins/maestro-skills/vendor/unlazy/SECURITY.md +72 -0
  198. package/plugins/maestro-skills/vendor/unlazy/SKILL.md +104 -0
  199. package/plugins/maestro-skills/vendor/unlazy/UPSTREAM.json +94 -0
  200. package/plugins/maestro-skills/vendor/unlazy/references/dispatch.md +82 -0
  201. package/plugins/maestro-skills/vendor/unlazy/references/gates.md +149 -0
  202. package/plugins/maestro-skills/vendor/unlazy/references/method.md +49 -0
  203. package/plugins/maestro-skills/vendor/unlazy/references/orchestration.md +107 -0
  204. package/plugins/maestro-skills/vendor/unlazy/references/parallel.md +133 -0
  205. package/plugins/maestro-skills/vendor/unlazy/references/token-economy.md +48 -0
  206. package/plugins/maestro-skills/vendor/unlazy/scripts/dispatch-check.mjs +139 -0
  207. package/plugins/maestro-skills/vendor/unlazy/scripts/gate-check.mjs +960 -0
  208. package/plugins/maestro-skills/vendor/unlazy/scripts/gate-lint.mjs +245 -0
  209. package/plugins/maestro-skills/vendor/unlazy/scripts/lib/check-supervisor.mjs +46 -0
  210. package/plugins/maestro-skills/vendor/unlazy/scripts/lib/dispatch.mjs +293 -0
  211. package/plugins/maestro-skills/vendor/unlazy/scripts/lib/gates.mjs +953 -0
  212. package/plugins/maestro-skills/vendor/unlazy/scripts/lib/process-tree.mjs +161 -0
  213. package/plugins/maestro-skills/vendor/unlazy/scripts/lib/regex-worker.mjs +9 -0
  214. package/plugins/maestro-skills/vendor/unlazy/templates/PLAN.md +116 -0
  215. package/plugins/maestro-skills/vendor/unlazy/templates/gates-leaf.md +51 -0
  216. package/plugins/maestro-skills/vendor/unlazy/templates/gates-node.md +51 -0
  217. package/scaffold/CLAUDE.md +24 -0
  218. package/scripts/ci/check-durable-write-seam.mjs +147 -0
  219. package/scripts/ci/check-durable-write-seam.test.mjs +90 -0
  220. package/scripts/ci/check-skill-packs.mjs +388 -0
  221. package/scripts/ci/check-skill-packs.test.mjs +495 -0
  222. package/scripts/ci/check.mjs +6 -0
  223. package/scripts/collective/hook-runner.mjs +39 -4
  224. package/scripts/collective/hook-runner.test.mjs +85 -2
  225. package/scripts/daemon/agent-daemon-board-mine.test.mjs +96 -0
  226. package/scripts/daemon/agent-daemon-design.test.mjs +238 -0
  227. package/scripts/daemon/agent-daemon-frontdoor.test.mjs +60 -0
  228. package/scripts/daemon/agent-daemon.mjs +249 -10
  229. package/scripts/daemon/agent-daemon.test.mjs +73 -0
  230. package/scripts/daemon/assurance-e2e.test.mjs +141 -6
  231. package/scripts/daemon/assurance.mjs +461 -37
  232. package/scripts/daemon/assurance.test.mjs +408 -43
  233. package/scripts/daemon/cadence-consumer-frontdoor.test.mjs +393 -0
  234. package/scripts/daemon/cadence-consumer.mjs +289 -89
  235. package/scripts/daemon/cadence-handlers.mjs +53 -0
  236. package/scripts/daemon/classifier.mjs +1 -1
  237. package/scripts/daemon/dispatcher-resume.test.mjs +166 -0
  238. package/scripts/daemon/dispatcher.mjs +127 -19
  239. package/scripts/daemon/health.mjs +12 -1
  240. package/scripts/daemon/inbox-deferral-session.test.mjs +49 -0
  241. package/scripts/daemon/inbox-deferral.mjs +6 -0
  242. package/scripts/daemon/lib/self-echo.mjs +201 -0
  243. package/scripts/daemon/lib/self-echo.test.mjs +153 -0
  244. package/scripts/daemon/maestro-daemon.mjs +3 -0
  245. package/scripts/daemon/prompt-builder.mjs +19 -3
  246. package/scripts/daemon/responder.mjs +51 -40
  247. package/scripts/daemon/sdk-version.mjs +51 -0
  248. package/scripts/daemon/sdk-version.test.mjs +31 -0
  249. package/scripts/hooks/pre-send-audit.sh +97 -4
  250. package/scripts/hooks/pre-send-audit.test.mjs +140 -1
  251. package/scripts/local-triggers/autoupdate.sh +243 -19
  252. package/scripts/local-triggers/autoupdate.test.mjs +518 -0
  253. package/scripts/local-triggers/generate-plists.sh +24 -1
  254. package/scripts/local-triggers/generate-plists.test.mjs +49 -11
  255. package/scripts/org/send-orgmail.first-contact.test.mjs +102 -0
  256. package/scripts/org/send-orgmail.mjs +27 -3
  257. package/scripts/poller/inbox-privilege-injection.test.mjs +167 -0
  258. package/scripts/poller/slack-poller.mjs +13 -1
  259. package/scripts/poller/utils.mjs +46 -1
  260. package/scripts/poller-launchd/install.sh +19 -11
  261. package/scripts/poller-launchd/install.test.mjs +243 -0
  262. package/scripts/poller-launchd/launchd-poller-wrapper.sh +92 -0
  263. package/scripts/poller-launchd/migrate.sh +66 -0
  264. package/scripts/poller-launchd/poller.plist.template +4 -2
  265. package/scripts/session/feed.mjs +237 -0
  266. package/scripts/session/feed.test.mjs +196 -0
  267. package/scripts/session/supervisor-sh.test.mjs +218 -0
  268. package/scripts/session/supervisor.mjs +328 -0
  269. package/scripts/session/supervisor.sh +141 -0
  270. package/scripts/session/supervisor.test.mjs +482 -0
  271. package/scripts/setup/configure-macos.sh +250 -55
  272. package/scripts/setup/configure-macos.test.mjs +306 -0
  273. package/scripts/setup/init-agent.sh +112 -7
  274. package/scripts/setup/init-agent.test.mjs +220 -1
  275. package/scripts/vendor/skill-packs.mjs +354 -0
  276. package/scripts/vendor/sync-skill-packs.mjs +242 -0
  277. package/scripts/vendor/sync-skill-packs.test.mjs +103 -0
  278. package/scripts/watchdog/memory-watchdog.sh +37 -1
  279. package/scripts/watchdog/memory-watchdog.test.mjs +64 -0
  280. package/scripts/setup/boot-claude-session.sh +0 -94
@@ -1,12 +1,46 @@
1
1
  /**
2
- * lib/setup/claude-probe.mjs — reachability probe for the Claude API.
2
+ * lib/setup/claude-probe.mjs — is this agent able to talk to Claude?
3
3
  *
4
- * Mirrors the curl check in bin/maestro.mjs (doctor, ~:1566): POST a 5-token
5
- * ping to api.anthropic.com with the .env key. Returns a structured result with
6
- * a remedy line so the wizard's capability table and doctor can share the logic.
4
+ * One probe, shared by the setup wizard's capability table, `maestro doctor`
5
+ * and the fleet beat (`lib/telemetry/collect.mjs#detectClaudeAuth`), so the
6
+ * three never disagree about the same machine.
7
7
  *
8
- * Honors MAESTRO_PREFER_SUBSCRIPTION_AUTH (subscription/keychain OAuth wins → the
9
- * probe reports `ok` without an API key).
8
+ * Auth modes, resolved from the agent's `.env`:
9
+ *
10
+ * subscription CLAUDE_CODE_OAUTH_TOKEN is set (the `claude setup-token`
11
+ * output — the fleet default) and/or
12
+ * MAESTRO_PREFER_SUBSCRIPTION_AUTH=1 (keychain login).
13
+ * The probe runs `claude auth status --json` NON-INTERACTIVELY
14
+ * with the token in the child env and maps the answer to
15
+ * `ok | relogin_required | unknown`. A missing/slow CLI is
16
+ * `unknown`, which is fail-OPEN (`ok:true` + a note): an
17
+ * unverifiable state must not brick setup on a machine that
18
+ * is otherwise fine, and the beat reports it honestly.
19
+ * api-key ANTHROPIC_API_KEY only → a 5-token POST to api.anthropic.com
20
+ * (the historic curl ping). 401 is the one hard failure.
21
+ * none nothing configured → not ok, remedy names the token path.
22
+ *
23
+ * WHAT THE PROBE CAN AND CANNOT PROVE (`verified` on the result):
24
+ * `claude auth status` reports the PRESENCE of a credential, not its
25
+ * validity — with CLAUDE_CODE_OAUTH_TOKEN=anything in the env it answers
26
+ * `{loggedIn:true, authMethod:"oauth_token"}` and exits 0 (checked
27
+ * 2026-09-08 with a bogus token). So a subscription `ok` is REPORTED
28
+ * (`verified:false`, detail says so) and only a real spawn exercises the
29
+ * token: a rejection lands in logs/sessions, which the beat's log scan and
30
+ * `detectClaudeAuth` read as `relogin_required`. `relogin_required` from
31
+ * the CLI (no credential at all) is a hard answer. The api-key ping makes a
32
+ * real call, so its 200 is `verified:true`. Consumers that need proof —
33
+ * the fleet beat — treat an unverified ok as `unknown` and let the log
34
+ * evidence decide, exactly as before the probe existed.
35
+ *
36
+ * Mode resolution reads the agent's `.env` FIRST and the ambient env second
37
+ * (`opts.env`, default process.env) — the same precedence doctor's `authMode`
38
+ * uses, and what the headless runbooks prescribe (the token arrives via
39
+ * launchd's environment, no .env). The two rows doctor prints can therefore
40
+ * not contradict each other.
41
+ *
42
+ * Injectable (`opts.exec`, `opts.curl`, `opts.claudeBin`, `opts.env`) so every
43
+ * outcome is unit-testable offline; never throws.
10
44
  *
11
45
  * @module lib/setup/claude-probe
12
46
  */
@@ -15,35 +49,138 @@
15
49
 
16
50
  import { existsSync, readFileSync } from "node:fs";
17
51
  import { join } from "node:path";
18
- import { spawnSync } from "node:child_process";
52
+ import { spawnSync, execFileSync } from "node:child_process";
53
+ import { resolveClaudeBin } from "../claude-bin.mjs";
54
+
55
+ /** Hard ceiling on the `claude auth status` fork — the beat must never wedge on it. */
56
+ export const AUTH_STATUS_TIMEOUT_MS = 8000;
19
57
 
20
58
  /** Parse a single KEY from a .env file body. */
21
59
  function envValue(body, key) {
22
- const m = body.match(new RegExp(`^${key}=(.+)$`, "m"));
60
+ const m = String(body || "").match(new RegExp(`^${key}=(.+)$`, "m"));
23
61
  return m ? m[1].trim().replace(/^["']|["']$/g, "") : "";
24
62
  }
25
63
 
26
64
  /**
27
- * Probe Claude API reachability for an agent repo.
65
+ * Map `claude auth status --json` output to the three-state vocabulary.
66
+ * Pure. Tolerates the text form (`--text`, older CLIs) by fingerprint.
67
+ * @param {string|null|undefined} stdout
68
+ * @returns {"ok"|"relogin_required"|"unknown"}
69
+ */
70
+ export function parseAuthStatus(stdout) {
71
+ const s = typeof stdout === "string" ? stdout.trim() : "";
72
+ if (!s) return "unknown";
73
+ try {
74
+ const j = JSON.parse(s);
75
+ if (j && typeof j === "object" && "loggedIn" in j) return j.loggedIn ? "ok" : "relogin_required";
76
+ } catch { /* not JSON — fall through to the text fingerprints */ }
77
+ const lower = s.toLowerCase();
78
+ if (/not logged in|please run \/login|claude login|login required|expired/.test(lower)) return "relogin_required";
79
+ if (/logged in\b|loggedin\s*:\s*true/.test(lower)) return "ok";
80
+ return "unknown";
81
+ }
82
+
83
+ /**
84
+ * The env for the auth probe: `baseEnv` plus the .env token when present. Only
85
+ * the token crosses over — the rest of `.env` is the agent's secret set and has
86
+ * no business in a diagnostic fork.
87
+ * @param {string} envBody the .env file body
88
+ * @param {object} [baseEnv] usually process.env
89
+ * @returns {object}
90
+ */
91
+ export function authStatusEnv(envBody, baseEnv = process.env) {
92
+ const out = { ...(baseEnv || {}) };
93
+ const token = envValue(envBody, "CLAUDE_CODE_OAUTH_TOKEN");
94
+ if (token) out.CLAUDE_CODE_OAUTH_TOKEN = token;
95
+ return out;
96
+ }
97
+
98
+ /** `.env` first, then the ambient env — one value per key, empty when neither has it. */
99
+ function resolveValue(body, baseEnv, key) {
100
+ const fromFile = envValue(body, key);
101
+ if (fromFile) return fromFile;
102
+ const v = baseEnv && baseEnv[key];
103
+ return v == null ? "" : String(v).trim();
104
+ }
105
+
106
+ /**
107
+ * Probe Claude auth/reachability for an agent repo.
28
108
  * @param {string} agentRoot
29
109
  * @param {object} [opts]
30
- * @param {(args:string[])=>{stdout?:string}} [opts.curl] injectable curl (for tests)
31
- * @returns {{ok:boolean, mode:'subscription'|'api-key'|'none', code?:string, name:string, remedy?:string}}
110
+ * @param {(args:string[])=>{stdout?:string}} [opts.curl] injectable curl (api-key mode; tests)
111
+ * @param {(cmd:string,args:string[],o:object)=>string} [opts.exec] injectable execFileSync (subscription mode; tests)
112
+ * @param {string} [opts.claudeBin] path of the claude CLI (default: resolveClaudeBin())
113
+ * @param {object} [opts.env] base env for the CLI fork (default: process.env)
114
+ * @returns {{ok:boolean, mode:'subscription'|'api-key'|'none', auth?:'ok'|'relogin_required'|'unknown', verified:boolean, code?:string, name:string, remedy?:string, detail?:string}}
115
+ * `verified` is true only when a real call proved the credential (api-key 200);
116
+ * a subscription `ok` is the CLI reporting a login it has not exercised.
32
117
  */
33
118
  export function probeClaude(agentRoot, opts = {}) {
34
119
  const envPath = join(agentRoot, ".env");
35
120
  const body = existsSync(envPath) ? readFileSync(envPath, "utf-8") : "";
36
- const preferSubs = /^(1|true|yes)$/i.test(envValue(body, "MAESTRO_PREFER_SUBSCRIPTION_AUTH"));
37
- if (preferSubs) {
38
- return { ok: true, mode: "subscription", name: "Claude API (subscription auth)" };
39
- }
40
- const key = envValue(body, "ANTHROPIC_API_KEY");
121
+ const baseEnv = opts.env || process.env;
122
+ const token = resolveValue(body, baseEnv, "CLAUDE_CODE_OAUTH_TOKEN");
123
+ const preferSubs = /^(1|true|yes)$/i.test(resolveValue(body, baseEnv, "MAESTRO_PREFER_SUBSCRIPTION_AUTH"));
124
+
125
+ if (token || preferSubs) return probeSubscription(body, opts, token ? "oauth-token" : "keychain");
126
+
127
+ const key = resolveValue(body, baseEnv, "ANTHROPIC_API_KEY");
41
128
  if (!key) {
42
129
  return {
43
- ok: false, mode: "none", name: "Claude API reachable",
44
- remedy: "set ANTHROPIC_API_KEY in .env, or MAESTRO_PREFER_SUBSCRIPTION_AUTH=1 to use Claude Code subscription",
130
+ ok: false, mode: "none", auth: "unknown", verified: false, name: "Claude auth",
131
+ remedy: "run `claude setup-token` on any logged-in machine and put the result in .env as CLAUDE_CODE_OAUTH_TOKEN (plus MAESTRO_PREFER_SUBSCRIPTION_AUTH=1) — or set ANTHROPIC_API_KEY",
45
132
  };
46
133
  }
134
+ return probeApiKey(key, opts);
135
+ }
136
+
137
+ function probeSubscription(body, opts, source) {
138
+ const name = source === "oauth-token" ? "Claude auth (subscription — OAuth token)" : "Claude auth (subscription — keychain login)";
139
+ const bin = opts.claudeBin || resolveClaudeBin();
140
+ const run = opts.exec || ((cmd, args, o) => execFileSync(cmd, args, o));
141
+ let stdout = "";
142
+ let failed = "";
143
+ try {
144
+ stdout = String(run(bin, ["auth", "status", "--json"], {
145
+ env: authStatusEnv(body, opts.env || process.env),
146
+ timeout: AUTH_STATUS_TIMEOUT_MS,
147
+ encoding: "utf-8",
148
+ stdio: ["ignore", "pipe", "ignore"],
149
+ maxBuffer: 256 * 1024,
150
+ }) || "");
151
+ } catch (err) {
152
+ // A non-zero exit still carries stdout on execFileSync errors (the CLI
153
+ // prints `{"loggedIn":false}` and exits 1); read it before giving up.
154
+ stdout = err && typeof err.stdout === "string" ? err.stdout : (err && err.stdout ? String(err.stdout) : "");
155
+ failed = err && (err.code || err.message) ? String(err.code || err.message) : "exec failed";
156
+ }
157
+ const auth = parseAuthStatus(stdout);
158
+ if (auth === "ok") {
159
+ // REPORTED, not verified — see the module docblock. Say so in the row.
160
+ return {
161
+ ok: true, mode: "subscription", auth, verified: false, name,
162
+ detail: source === "oauth-token"
163
+ ? "CLI reports a login for the token; the token itself is only proven by a real spawn (a rejection shows in logs/sessions and on the beat as relogin_required)"
164
+ : "CLI reports a keychain login; it is only proven by a real spawn (a rejection shows in logs/sessions and on the beat as relogin_required)",
165
+ };
166
+ }
167
+ if (auth === "relogin_required") {
168
+ return {
169
+ ok: false, mode: "subscription", auth, verified: false, name,
170
+ remedy: source === "oauth-token"
171
+ ? "the OAuth token was rejected — mint a fresh one with `claude setup-token` and replace CLAUDE_CODE_OAUTH_TOKEN in .env"
172
+ : "not logged in — run `claude setup-token` on any logged-in machine and put the result in .env as CLAUDE_CODE_OAUTH_TOKEN (the keychain login expires headlessly)",
173
+ };
174
+ }
175
+ // unknown: fail-OPEN. Say why, never silently.
176
+ return {
177
+ ok: true, mode: "subscription", auth: "unknown", verified: false, name,
178
+ detail: `could not verify (claude auth status ${failed ? `failed: ${failed}` : "gave no usable answer"}) — run \`claude auth status\` by hand`,
179
+ };
180
+ }
181
+
182
+ function probeApiKey(key, opts) {
183
+ const name = "Claude API reachable (api-key)";
47
184
  const run = opts.curl || ((args) => spawnSync("curl", args, { encoding: "utf-8" }));
48
185
  let code = "";
49
186
  try {
@@ -58,17 +195,17 @@ export function probeClaude(agentRoot, opts = {}) {
58
195
  "-d", JSON.stringify({ model: "claude-haiku-4-5", max_tokens: 5, messages: [{ role: "user", content: "ping" }] }),
59
196
  ]);
60
197
  code = (res && res.stdout ? res.stdout : "").trim();
61
- } catch { code = ""; }
198
+ } catch { code = ""; /* curl missing/throwing is reported as "no network" below */ }
62
199
 
63
- if (code === "200") return { ok: true, mode: "api-key", code, name: "Claude API reachable" };
200
+ if (code === "200") return { ok: true, mode: "api-key", auth: "ok", verified: true, code, name };
64
201
  if (code === "401") {
65
202
  return {
66
- ok: false, mode: "api-key", code, name: "Claude API reachable",
67
- remedy: "ANTHROPIC_API_KEY is INVALID (HTTP 401) — replace it, or set MAESTRO_PREFER_SUBSCRIPTION_AUTH=1",
203
+ ok: false, mode: "api-key", auth: "relogin_required", verified: false, code, name,
204
+ remedy: "ANTHROPIC_API_KEY is INVALID (HTTP 401) — replace it, or switch to the subscription token (`claude setup-token` → CLAUDE_CODE_OAUTH_TOKEN)",
68
205
  };
69
206
  }
70
207
  if (!code) {
71
- return { ok: false, mode: "api-key", code, name: "Claude API reachable", remedy: "no network / curl missing — could not verify the key" };
208
+ return { ok: false, mode: "api-key", auth: "unknown", verified: false, code, name, remedy: "no network / curl missing — could not verify the key" };
72
209
  }
73
- return { ok: false, mode: "api-key", code, name: "Claude API reachable", remedy: `unexpected HTTP ${code} (expected 200)` };
210
+ return { ok: false, mode: "api-key", auth: "unknown", verified: false, code, name, remedy: `unexpected HTTP ${code} (expected 200)` };
74
211
  }
@@ -0,0 +1,187 @@
1
+ /**
2
+ * claude-probe.test.mjs — the Claude auth/reachability probe (lib/setup/claude-probe.mjs).
3
+ *
4
+ * Hermetic: the `claude auth status` exec and the curl ping are injected, so no
5
+ * network and no real CLI fork. Covers the three auth modes (oauth-token /
6
+ * keychain subscription / api-key) and the ok | relogin_required | unknown
7
+ * mapping the fleet beat reads.
8
+ *
9
+ * Run: node --test lib/setup/claude-probe.test.mjs
10
+ */
11
+ "use strict";
12
+
13
+ import { test } from "node:test";
14
+ import assert from "node:assert/strict";
15
+ import { mkdtempSync, writeFileSync, rmSync } from "node:fs";
16
+ import { tmpdir } from "node:os";
17
+ import { join } from "node:path";
18
+
19
+ import { probeClaude, parseAuthStatus, authStatusEnv, AUTH_STATUS_TIMEOUT_MS } from "./claude-probe.mjs";
20
+
21
+ function rootWithEnv(body) {
22
+ const root = mkdtempSync(join(tmpdir(), "claude-probe-"));
23
+ writeFileSync(join(root, ".env"), body);
24
+ return root;
25
+ }
26
+
27
+ /** An execFileSync-shaped fake: records the call, returns canned stdout / throws. */
28
+ function fakeExec(reply) {
29
+ const calls = [];
30
+ const fn = (cmd, args, opts) => {
31
+ calls.push({ cmd, args, opts });
32
+ if (reply instanceof Error) throw reply;
33
+ if (typeof reply === "function") return reply(cmd, args, opts);
34
+ return reply;
35
+ };
36
+ fn.calls = calls;
37
+ return fn;
38
+ }
39
+
40
+ test("parseAuthStatus maps the CLI's JSON to the three-state vocabulary", () => {
41
+ assert.equal(parseAuthStatus('{"loggedIn":true,"authMethod":"claude.ai","subscriptionType":"max"}'), "ok");
42
+ assert.equal(parseAuthStatus('{"loggedIn":false}'), "relogin_required");
43
+ assert.equal(parseAuthStatus("Not logged in. Run `claude login`."), "relogin_required");
44
+ assert.equal(parseAuthStatus(""), "unknown");
45
+ assert.equal(parseAuthStatus("garbage that is not json"), "unknown");
46
+ assert.equal(parseAuthStatus(null), "unknown");
47
+ });
48
+
49
+ test("authStatusEnv overlays the .env token onto the base env without leaking other keys", () => {
50
+ const env = authStatusEnv("CLAUDE_CODE_OAUTH_TOKEN=\"tok-123\"\nOTHER=1\n", { PATH: "/usr/bin" });
51
+ assert.equal(env.CLAUDE_CODE_OAUTH_TOKEN, "tok-123");
52
+ assert.equal(env.PATH, "/usr/bin");
53
+ assert.equal(env.OTHER, undefined);
54
+ // No token in .env → the base env is returned untouched (keychain login may still work).
55
+ const bare = authStatusEnv("MAESTRO_PREFER_SUBSCRIPTION_AUTH=1\n", { PATH: "/usr/bin" });
56
+ assert.equal(bare.CLAUDE_CODE_OAUTH_TOKEN, undefined);
57
+ });
58
+
59
+ test("oauth-token mode: runs `claude auth status` with the .env token in env → ok", () => {
60
+ const root = rootWithEnv("CLAUDE_CODE_OAUTH_TOKEN=tok-abc\nMAESTRO_PREFER_SUBSCRIPTION_AUTH=1\n");
61
+ try {
62
+ const exec = fakeExec('{"loggedIn":true,"authMethod":"claude.ai"}');
63
+ const r = probeClaude(root, { exec, claudeBin: "/fake/claude", env: { PATH: "/usr/bin" } });
64
+ assert.equal(r.ok, true);
65
+ assert.equal(r.mode, "subscription");
66
+ assert.equal(r.auth, "ok");
67
+ assert.equal(exec.calls.length, 1);
68
+ assert.equal(exec.calls[0].cmd, "/fake/claude");
69
+ assert.deepEqual(exec.calls[0].args, ["auth", "status", "--json"]);
70
+ assert.equal(exec.calls[0].opts.env.CLAUDE_CODE_OAUTH_TOKEN, "tok-abc");
71
+ assert.equal(exec.calls[0].opts.timeout, AUTH_STATUS_TIMEOUT_MS);
72
+ assert.equal(AUTH_STATUS_TIMEOUT_MS, 8000);
73
+ } finally { rmSync(root, { recursive: true, force: true }); }
74
+ });
75
+
76
+ test("subscription mode: loggedIn:false → relogin_required, not ok, remedy names setup-token", () => {
77
+ const root = rootWithEnv("MAESTRO_PREFER_SUBSCRIPTION_AUTH=1\n");
78
+ try {
79
+ const r = probeClaude(root, { exec: fakeExec('{"loggedIn":false}'), claudeBin: "/fake/claude" });
80
+ assert.equal(r.ok, false);
81
+ assert.equal(r.auth, "relogin_required");
82
+ assert.match(r.remedy, /claude setup-token/);
83
+ } finally { rmSync(root, { recursive: true, force: true }); }
84
+ });
85
+
86
+ test("subscription mode: a missing/broken CLI is unknown and FAIL-OPEN (ok:true with a note)", () => {
87
+ const root = rootWithEnv("MAESTRO_PREFER_SUBSCRIPTION_AUTH=1\n");
88
+ try {
89
+ const r = probeClaude(root, { exec: fakeExec(Object.assign(new Error("ENOENT"), { code: "ENOENT" })), claudeBin: "/fake/claude" });
90
+ assert.equal(r.ok, true, "an unverifiable auth state must not hard-fail setup/doctor");
91
+ assert.equal(r.auth, "unknown");
92
+ assert.match(r.detail, /could not verify/i);
93
+ } finally { rmSync(root, { recursive: true, force: true }); }
94
+ });
95
+
96
+ test("a token WITHOUT the prefer flag still selects subscription mode (the token is the auth)", () => {
97
+ const root = rootWithEnv("CLAUDE_CODE_OAUTH_TOKEN=tok-abc\n");
98
+ try {
99
+ const r = probeClaude(root, { exec: fakeExec('{"loggedIn":true}'), claudeBin: "/fake/claude" });
100
+ assert.equal(r.mode, "subscription");
101
+ assert.equal(r.auth, "ok");
102
+ } finally { rmSync(root, { recursive: true, force: true }); }
103
+ });
104
+
105
+ test("api-key mode still pings api.anthropic.com via the injected curl", () => {
106
+ const root = rootWithEnv("ANTHROPIC_API_KEY=sk-ant-test\n");
107
+ try {
108
+ const exec = fakeExec("{}");
109
+ const r = probeClaude(root, { curl: () => ({ stdout: "200" }), exec });
110
+ assert.equal(r.ok, true);
111
+ assert.equal(r.mode, "api-key");
112
+ assert.equal(exec.calls.length, 0, "api-key mode never forks the CLI");
113
+ const bad = probeClaude(root, { curl: () => ({ stdout: "401" }), exec });
114
+ assert.equal(bad.ok, false);
115
+ assert.match(bad.remedy, /INVALID/);
116
+ } finally { rmSync(root, { recursive: true, force: true }); }
117
+ });
118
+
119
+ test("no auth at all → not ok, remedy names the oauth-token path first", () => {
120
+ const root = rootWithEnv("");
121
+ try {
122
+ const r = probeClaude(root, { exec: fakeExec("{}"), curl: () => ({ stdout: "" }) });
123
+ assert.equal(r.ok, false);
124
+ assert.equal(r.mode, "none");
125
+ assert.match(r.remedy, /CLAUDE_CODE_OAUTH_TOKEN/);
126
+ assert.match(r.remedy, /claude setup-token/);
127
+ } finally { rmSync(root, { recursive: true, force: true }); }
128
+ });
129
+
130
+ // ---------------------------------------------------------------------------
131
+ // Review 2026-09-08: what the probe can and cannot prove
132
+ // ---------------------------------------------------------------------------
133
+
134
+ test("an env-token 'ok' is REPORTED, never VERIFIED: `claude auth status` says loggedIn for any token value", () => {
135
+ // Verified on 2026-09-08: CLAUDE_CODE_OAUTH_TOKEN=sk-ant-oat01-bogus claude auth status --json
136
+ // → {loggedIn:true, authMethod:"oauth_token"} exit 0. The CLI reports the
137
+ // presence of a credential; only a real spawn exercises it.
138
+ const root = rootWithEnv("CLAUDE_CODE_OAUTH_TOKEN=sk-ant-oat01-bogus\nMAESTRO_PREFER_SUBSCRIPTION_AUTH=1\n");
139
+ try {
140
+ const r = probeClaude(root, { exec: fakeExec('{"loggedIn":true,"authMethod":"oauth_token"}'), claudeBin: "/fake/claude", env: {} });
141
+ assert.equal(r.ok, true);
142
+ assert.equal(r.auth, "ok");
143
+ assert.equal(r.verified, false, "the probe cannot validate an OAuth token");
144
+ assert.match(r.detail, /CLI reports a login/i);
145
+ assert.match(r.detail, /spawn/i, "says where the token IS proven");
146
+ assert.doesNotMatch(r.detail, /verified/i);
147
+ } finally { rmSync(root, { recursive: true, force: true }); }
148
+ });
149
+
150
+ test("an api-key 200 IS verified (a real call was made); 401 is a hard relogin", () => {
151
+ const root = rootWithEnv("ANTHROPIC_API_KEY=sk-ant-test\n");
152
+ try {
153
+ const good = probeClaude(root, { curl: () => ({ stdout: "200" }), exec: fakeExec("{}") });
154
+ assert.equal(good.verified, true);
155
+ const bad = probeClaude(root, { curl: () => ({ stdout: "401" }), exec: fakeExec("{}") });
156
+ assert.equal(bad.verified, false);
157
+ assert.equal(bad.auth, "relogin_required");
158
+ } finally { rmSync(root, { recursive: true, force: true }); }
159
+ });
160
+
161
+ test("a token that lives ONLY in the ambient env (no .env) still selects subscription mode — the headless docs prescribe exactly that", () => {
162
+ const root = rootWithEnv("");
163
+ try {
164
+ const exec = fakeExec('{"loggedIn":true,"authMethod":"oauth_token"}');
165
+ const r = probeClaude(root, { exec, claudeBin: "/fake/claude", env: { PATH: "/usr/bin", CLAUDE_CODE_OAUTH_TOKEN: "tok-from-launchd" } });
166
+ assert.equal(r.mode, "subscription");
167
+ assert.equal(r.auth, "ok");
168
+ assert.match(r.name, /OAuth token/);
169
+ assert.equal(exec.calls[0].opts.env.CLAUDE_CODE_OAUTH_TOKEN, "tok-from-launchd");
170
+ // .env wins over the ambient env for the value (same precedence doctor's authMode uses).
171
+ const root2 = rootWithEnv("CLAUDE_CODE_OAUTH_TOKEN=tok-from-dotenv\n");
172
+ try {
173
+ const exec2 = fakeExec('{"loggedIn":true}');
174
+ probeClaude(root2, { exec: exec2, claudeBin: "/fake/claude", env: { CLAUDE_CODE_OAUTH_TOKEN: "tok-from-launchd" } });
175
+ assert.equal(exec2.calls[0].opts.env.CLAUDE_CODE_OAUTH_TOKEN, "tok-from-dotenv");
176
+ } finally { rmSync(root2, { recursive: true, force: true }); }
177
+ // Ambient MAESTRO_PREFER_SUBSCRIPTION_AUTH / ANTHROPIC_API_KEY count too.
178
+ const keychain = probeClaude(root, { exec: fakeExec('{"loggedIn":true}'), claudeBin: "/fake/claude", env: { MAESTRO_PREFER_SUBSCRIPTION_AUTH: "1" } });
179
+ assert.equal(keychain.mode, "subscription");
180
+ assert.match(keychain.name, /keychain/);
181
+ const key = probeClaude(root, { exec: fakeExec("{}"), curl: () => ({ stdout: "200" }), env: { ANTHROPIC_API_KEY: "sk-env" } });
182
+ assert.equal(key.mode, "api-key");
183
+ // Nothing anywhere → none (the ambient env is the injected one, not the test runner's).
184
+ const none = probeClaude(root, { exec: fakeExec("{}"), curl: () => ({ stdout: "" }), env: {} });
185
+ assert.equal(none.mode, "none");
186
+ } finally { rmSync(root, { recursive: true, force: true }); }
187
+ });
@@ -19,6 +19,7 @@
19
19
  import { join } from "node:path";
20
20
  import { existsSync, readFileSync, writeFileSync, mkdirSync, copyFileSync } from "node:fs";
21
21
  import { homedir } from "node:os";
22
+ import { writeJsonAtomic } from "../../fs-atomic.mjs";
22
23
  import * as io from "../io.mjs";
23
24
  import { countersPath, emptyCounters } from "../../learning/counters.mjs";
24
25
 
@@ -89,7 +90,7 @@ function seedCounters(agentRoot) {
89
90
  if (existsSync(p)) return;
90
91
  try {
91
92
  mkdirSync(join(agentRoot, "state", "learning"), { recursive: true });
92
- writeFileSync(p, JSON.stringify(emptyCounters(), null, 2) + "\n");
93
+ writeJsonAtomic(p, emptyCounters());
93
94
  } catch { /* fail-open */ }
94
95
  }
95
96
 
@@ -1,10 +1,22 @@
1
1
  /**
2
2
  * sections/model.mjs — model / provider selection. (order 30)
3
3
  *
4
- * Records the provider choice: either Anthropic API key (recorded in .env) or
5
- * the Claude Code subscription (MAESTRO_PREFER_SUBSCRIPTION_AUTH=1). Optionally
6
- * drops a config/model-routing.yaml from the shipped example for cost routing.
7
- * Verify probes Claude API reachability (shared with doctor).
4
+ * Records how the agent authenticates to Claude. Three modes, in the order the
5
+ * wizard offers them:
6
+ *
7
+ * oauth-token DEFAULT. The operator runs `claude setup-token` on any
8
+ * machine where they are logged in and pastes the result.
9
+ * .env gets CLAUDE_CODE_OAUTH_TOKEN + MAESTRO_PREFER_SUBSCRIPTION_AUTH=1.
10
+ * Headless reads CLAUDE_CODE_OAUTH_TOKEN from the environment.
11
+ * This is the fleet posture: the interactive keychain login
12
+ * expires headlessly; the long-lived token does not.
13
+ * subscription Keychain login on this machine (MAESTRO_PREFER_SUBSCRIPTION_AUTH=1 only).
14
+ * api-key ANTHROPIC_API_KEY (pay-per-token).
15
+ *
16
+ * Every write goes through appendEnv (never clobbers an existing value) and
17
+ * .env is chmod 600 afterwards — it now carries a credential either way.
18
+ * Optionally drops a config/model-routing.yaml from the shipped example.
19
+ * Verify runs the shared Claude probe (lib/setup/claude-probe.mjs).
8
20
  *
9
21
  * @module lib/setup/sections/model
10
22
  */
@@ -12,9 +24,13 @@
12
24
  "use strict";
13
25
 
14
26
  import { join } from "node:path";
15
- import { existsSync, readFileSync, appendFileSync, writeFileSync, copyFileSync } from "node:fs";
16
- import * as io from "../io.mjs";
17
- import { probeClaude } from "../claude-probe.mjs";
27
+ import { existsSync, readFileSync, appendFileSync, writeFileSync, copyFileSync, chmodSync } from "node:fs";
28
+ import * as defaultIo from "../io.mjs";
29
+ import { probeClaude as defaultProbeClaude } from "../claude-probe.mjs";
30
+
31
+ /** The three auth modes, in wizard order. The first is the default. */
32
+ export const AUTH_MODES = Object.freeze(["oauth-token", "subscription", "api-key"]);
33
+ export const DEFAULT_AUTH_MODE = AUTH_MODES[0];
18
34
 
19
35
  function readEnv(agentRoot) {
20
36
  const p = join(agentRoot, ".env");
@@ -22,14 +38,34 @@ function readEnv(agentRoot) {
22
38
  }
23
39
  function hasKey(body, key) { return new RegExp(`^${key}=.+`, "m").test(body); }
24
40
 
41
+ /**
42
+ * Write KEY=value into .env without clobbering a real value. Three cases:
43
+ * - the key has a non-empty value → leave it alone (never clobber)
44
+ * - the key is present but EMPTY → fill that line in place. A `.env` copied
45
+ * from `.env.example` (doctor's own remedy) ships `CLAUDE_CODE_OAUTH_TOKEN=`
46
+ * and `MAESTRO_PREFER_SUBSCRIPTION_AUTH=` as empty placeholders; treating
47
+ * those as "already set" left a freshly set-up seat with no auth at all.
48
+ * An empty line is "unset" for every consumer we have (dotenv, the probe,
49
+ * doctor's parseDotEnv, hasKey() just above) — this writer now agrees.
50
+ * - the key is absent → append
51
+ */
25
52
  function appendEnv(agentRoot, key, value) {
26
53
  const p = join(agentRoot, ".env");
27
- if (!existsSync(p)) { writeFileSync(p, `${key}=${value}\n`); return; }
54
+ if (!existsSync(p)) { writeFileSync(p, `${key}=${value}\n`, { mode: 0o600 }); return; }
28
55
  const body = readFileSync(p, "utf-8");
29
- if (new RegExp(`^${key}=`, "m").test(body)) return; // never clobber an existing value
56
+ if (hasKey(body, key)) return; // never clobber an existing value
57
+ const empty = new RegExp(`^${key}=[ \\t]*$`, "m");
58
+ if (empty.test(body)) { writeFileSync(p, body.replace(empty, `${key}=${value}`)); return; }
30
59
  appendFileSync(p, `${body.endsWith("\n") || body === "" ? "" : "\n"}${key}=${value}\n`);
31
60
  }
32
61
 
62
+ /** .env carries a credential in every mode — owner-only, always. POSIX mode bits only. */
63
+ function lockDownEnv(agentRoot) {
64
+ const p = join(agentRoot, ".env");
65
+ if (!existsSync(p) || typeof process.getuid !== "function") return;
66
+ try { chmodSync(p, 0o600); } catch { /* a foreign-owned .env is reported by doctor, not fixed here */ }
67
+ }
68
+
33
69
  export default {
34
70
  id: "model",
35
71
  label: "Model & provider",
@@ -37,25 +73,36 @@ export default {
37
73
 
38
74
  async detect(ctx) {
39
75
  const env = readEnv(ctx.agentRoot);
76
+ const token = hasKey(env, "CLAUDE_CODE_OAUTH_TOKEN");
40
77
  const subs = /^MAESTRO_PREFER_SUBSCRIPTION_AUTH=(1|true|yes)/im.test(env);
41
78
  const apiKey = hasKey(env, "ANTHROPIC_API_KEY");
42
- if (subs) return { status: "complete", summary: "Claude Code subscription auth" };
79
+ if (token) return { status: "complete", summary: "Claude subscription — OAuth token (.env)" };
80
+ if (subs) return { status: "complete", summary: "Claude subscription — keychain login" };
43
81
  if (apiKey) return { status: "complete", summary: "Anthropic API key (.env)" };
44
82
  return { status: "unconfigured", missing: ["provider auth"] };
45
83
  },
46
84
 
47
85
  async prompt(ctx) {
86
+ const io = ctx.io || defaultIo;
48
87
  const provider = await io.select(ctx, {
49
88
  message: "How should the agent authenticate to Claude?",
50
89
  key: "provider",
51
90
  choices: [
52
- { label: "Claude Code subscription (keychain OAuth — no API key needed)", value: "subscription" },
53
- { label: "Anthropic API key (paste an sk-ant-… key)", value: "api-key" },
91
+ { label: "Subscription token — run `claude setup-token` on any logged-in machine and paste the result (recommended)", value: "oauth-token" },
92
+ { label: "Subscription via this machine's keychain login (expires headlessly — not for a seat machine)", value: "subscription" },
93
+ { label: "Anthropic API key (paste an sk-ant-… key, pay-per-token)", value: "api-key" },
54
94
  ],
55
- default: "subscription",
95
+ default: DEFAULT_AUTH_MODE,
56
96
  });
97
+ let oauthToken = "";
57
98
  let apiKey = "";
58
- if (provider === "api-key") {
99
+ if (provider === "oauth-token") {
100
+ oauthToken = await io.secret(ctx, {
101
+ message: "Run `claude setup-token` on any machine where you are logged in, then paste the token here (headless: CLAUDE_CODE_OAUTH_TOKEN)",
102
+ key: "claudeOauthToken",
103
+ env: "CLAUDE_CODE_OAUTH_TOKEN",
104
+ });
105
+ } else if (provider === "api-key") {
59
106
  apiKey = await io.secret(ctx, { message: "Paste ANTHROPIC_API_KEY (sk-ant-…)", key: "anthropicApiKey" });
60
107
  }
61
108
  const routing = await io.confirm(ctx, {
@@ -63,40 +110,73 @@ export default {
63
110
  key: "modelRouting",
64
111
  default: false,
65
112
  });
66
- ctx.answers.model = { provider, apiKey, routing };
113
+ // The credential VALUES stay out of `answers.model`: the runner persists the
114
+ // whole answers bag to state/setup/progress.json (0644) after prompt(), and
115
+ // a nested value is as leaked as a top-level one. They live under their own
116
+ // top-level keys — `claudeOauthToken` / `anthropicApiKey` — which the
117
+ // checkpoint's SECRET_ANSWER_KEY_RE strips on every save; apply() reads
118
+ // them from there. `hasToken` / `hasApiKey` record the fact without the value.
119
+ ctx.answers.claudeOauthToken = String(oauthToken || "").trim();
120
+ ctx.answers.anthropicApiKey = String(apiKey || "");
121
+ ctx.answers.model = { provider, hasToken: !!ctx.answers.claudeOauthToken, hasApiKey: !!ctx.answers.anthropicApiKey, routing };
67
122
  },
68
123
 
69
124
  async apply(ctx) {
70
- const a = (ctx.answers && ctx.answers.model) || pull(ctx);
125
+ const a = pull(ctx);
71
126
  if (!a) return;
72
- if (a.provider === "subscription") {
127
+ if (a.provider === "oauth-token") {
128
+ if (a.oauthToken) appendEnv(ctx.agentRoot, "CLAUDE_CODE_OAUTH_TOKEN", a.oauthToken);
129
+ // Without a token this degrades to the keychain posture: spawns still
130
+ // strip API keys and ride whatever login exists; verify reports the truth.
131
+ appendEnv(ctx.agentRoot, "MAESTRO_PREFER_SUBSCRIPTION_AUTH", "1");
132
+ } else if (a.provider === "subscription") {
73
133
  appendEnv(ctx.agentRoot, "MAESTRO_PREFER_SUBSCRIPTION_AUTH", "1");
74
134
  } else if (a.provider === "api-key" && a.apiKey) {
75
135
  appendEnv(ctx.agentRoot, "ANTHROPIC_API_KEY", a.apiKey);
76
136
  }
137
+ lockDownEnv(ctx.agentRoot);
77
138
  if (a.routing) {
78
139
  const dst = join(ctx.agentRoot, "config", "model-routing.yaml");
79
140
  const example = join(ctx.maestroRoot, "scaffold", "config", "model-routing.yaml.example");
80
141
  if (!existsSync(dst) && existsSync(example)) {
81
- try { copyFileSync(example, dst); } catch { /* fail-open */ }
142
+ try { copyFileSync(example, dst); } catch { /* fail-open: routing is an optimisation, never a gate */ }
82
143
  }
83
144
  }
84
145
  },
85
146
 
86
147
  async verify(ctx) {
87
- const probe = probeClaude(ctx.agentRoot);
148
+ const probe = (ctx.deps && ctx.deps.probeClaude) || defaultProbeClaude;
149
+ const r = probe(ctx.agentRoot);
88
150
  return {
89
- ok: probe.ok,
90
- checks: [{ name: probe.name, ok: probe.ok, remedy: probe.remedy }],
151
+ ok: r.ok,
152
+ checks: [{ name: r.name, ok: r.ok, remedy: r.remedy, detail: r.detail, auth: r.auth, mode: r.mode }],
91
153
  };
92
154
  },
93
155
  };
94
156
 
157
+ /**
158
+ * Resolve the section's answers. `answers.model` (written by prompt(), or by
159
+ * an older checkpoint) carries the provider and the routing choice; the
160
+ * credential values are read from the top-level `claudeOauthToken` /
161
+ * `anthropicApiKey` answers (or, headless, from CLAUDE_CODE_OAUTH_TOKEN in the
162
+ * environment) — they are never stored under `answers.model`. An older
163
+ * checkpoint that still nests `oauthToken` / `apiKey` under `model` is honoured
164
+ * so a mid-flight resume across the upgrade does not lose its paste. A bare
165
+ * token implies the oauth-token mode; a bare API key implies api-key; nothing
166
+ * → null.
167
+ */
95
168
  function pull(ctx) {
96
169
  const ans = ctx.answers || {};
97
- if (ans.model) return ans.model;
98
- if ("provider" in ans || "anthropicApiKey" in ans) {
99
- return { provider: ans.provider || (ans.anthropicApiKey ? "api-key" : "subscription"), apiKey: ans.anthropicApiKey || "", routing: !!ans.modelRouting };
170
+ const m = ans.model && typeof ans.model === "object" ? ans.model : null;
171
+ const envToken = String(ans.claudeOauthToken || (m && m.oauthToken) || process.env.CLAUDE_CODE_OAUTH_TOKEN || "").trim();
172
+ const apiKey = String(ans.anthropicApiKey || (m && m.apiKey) || "");
173
+ if (m) {
174
+ const provider = m.provider || (apiKey ? "api-key" : DEFAULT_AUTH_MODE);
175
+ return { provider, oauthToken: provider === "oauth-token" ? envToken : "", apiKey, routing: !!m.routing };
176
+ }
177
+ if ("provider" in ans || "anthropicApiKey" in ans || envToken) {
178
+ const provider = ans.provider || (apiKey ? "api-key" : DEFAULT_AUTH_MODE);
179
+ return { provider, oauthToken: provider === "oauth-token" ? envToken : "", apiKey, routing: !!ans.modelRouting };
100
180
  }
101
181
  return null;
102
182
  }