@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.
- package/.env.example +37 -22
- package/README.md +2 -0
- package/bin/maestro.mjs +117 -39
- package/bin/maestro.test.mjs +175 -5
- package/docs/guides/front-door-session.md +313 -0
- package/docs/guides/mac-mini.md +100 -28
- package/docs/guides/org-onboarding.md +1 -1
- package/docs/guides/setup-wizard.md +9 -5
- package/docs/runbooks/cohort-cutover.md +11 -1
- package/docs/runbooks/mac-mini-bootstrap.md +38 -63
- package/lib/cadence-bus-requeue.test.mjs +83 -0
- package/lib/cadence-bus.mjs +43 -7
- package/lib/channels/inbox-item.mjs +59 -2
- package/lib/cli/board.mjs +285 -0
- package/lib/cli/board.test.mjs +227 -0
- package/lib/cli/design.mjs +185 -0
- package/lib/cli/design.test.mjs +270 -0
- package/lib/cli/doctor-checks.mjs +441 -0
- package/lib/cli/doctor-checks.test.mjs +336 -0
- package/lib/cli/global-setup-extras.mjs +454 -0
- package/lib/cli/global-setup-extras.test.mjs +462 -0
- package/lib/cli/inbox.mjs +304 -0
- package/lib/cli/inbox.test.mjs +230 -0
- package/lib/cli/session-ack.mjs +63 -0
- package/lib/cli/session-ack.test.mjs +63 -0
- package/lib/cli/session.mjs +760 -0
- package/lib/cli/session.test.mjs +613 -0
- package/lib/collective/global-config.mjs +209 -6
- package/lib/collective/global-config.test.mjs +145 -0
- package/lib/collective/global-skills.mjs +145 -0
- package/lib/collective/global-skills.test.mjs +126 -0
- package/lib/collective/presence.mjs +4 -3
- package/lib/collective/vendor-skills.mjs +305 -0
- package/lib/collective/vendor-skills.test.mjs +306 -0
- package/lib/comms/send-gate.mjs +115 -0
- package/lib/comms/send-gate.test.mjs +113 -0
- package/lib/design/design-md.mjs +793 -0
- package/lib/design/design-md.test.mjs +318 -0
- package/lib/design/fixtures/DESIGN.golden.md +238 -0
- package/lib/design/fixtures/PRODUCT.golden.md +67 -0
- package/lib/design/fixtures/foundation.json +133 -0
- package/lib/design/refresh-gate.mjs +154 -0
- package/lib/design/refresh-gate.test.mjs +144 -0
- package/lib/design/write.mjs +275 -0
- package/lib/design/write.test.mjs +241 -0
- package/lib/feature-init.mjs +2 -2
- package/lib/mcp/server.test.mjs +9 -4
- package/lib/model-router/spawn.test.mjs +21 -0
- package/lib/org/board-mine-cache.mjs +99 -0
- package/lib/org/board-mine-cache.test.mjs +53 -0
- package/lib/org/board.mjs +11 -0
- package/lib/org/board.test.mjs +11 -1
- package/lib/org/client.mjs +36 -0
- package/lib/org/client.test.mjs +46 -0
- package/lib/org/inbound/directedness.mjs +18 -2
- package/lib/org/inbound/directedness.test.mjs +58 -0
- package/lib/org/inbound/index.mjs +8 -1
- package/lib/org/inbound/index.test.mjs +22 -0
- package/lib/org/mesh-directives.test.mjs +110 -0
- package/lib/org/mesh.mjs +61 -1
- package/lib/org/protocol.checksum +1 -1
- package/lib/org/protocol.mjs +52 -0
- package/lib/org/protocol.test.mjs +12 -1
- package/lib/org/registry.mjs +3 -2
- package/lib/org/tool-surface.mjs +120 -0
- package/lib/org/tool-surface.test.mjs +118 -5
- package/lib/prompts/parallelism.mjs +79 -0
- package/lib/prompts/parallelism.test.mjs +177 -0
- package/lib/security/external-content.mjs +1 -1
- package/lib/security/external-content.test.mjs +17 -0
- package/lib/session/config.mjs +137 -0
- package/lib/session/config.test.mjs +92 -0
- package/lib/session/feed-core.mjs +229 -0
- package/lib/session/feed-core.test.mjs +198 -0
- package/lib/session/first-run.mjs +126 -0
- package/lib/session/first-run.test.mjs +121 -0
- package/lib/session/frontdoor.mjs +266 -0
- package/lib/session/frontdoor.test.mjs +205 -0
- package/lib/session/handoffs.mjs +295 -0
- package/lib/session/handoffs.test.mjs +183 -0
- package/lib/session/identity.mjs +220 -0
- package/lib/session/identity.test.mjs +180 -0
- package/lib/session/inbox-claims.mjs +434 -0
- package/lib/session/inbox-claims.test.mjs +286 -0
- package/lib/session/launch-args.mjs +161 -0
- package/lib/session/launch-args.test.mjs +157 -0
- package/lib/session/liveness.mjs +174 -0
- package/lib/session/liveness.test.mjs +100 -0
- package/lib/session/status-summary.mjs +172 -0
- package/lib/session/status-summary.test.mjs +118 -0
- package/lib/session-permissions.mjs +39 -3
- package/lib/session-permissions.test.mjs +20 -0
- package/lib/setup/claude-probe.mjs +161 -24
- package/lib/setup/claude-probe.test.mjs +187 -0
- package/lib/setup/sections/learning.mjs +2 -1
- package/lib/setup/sections/model.mjs +104 -24
- package/lib/setup/sections/model.test.mjs +240 -0
- package/lib/setup/sections/org.mjs +27 -2
- package/lib/setup/sections/org.test.mjs +35 -2
- package/lib/setup/sections/verify.mjs +5 -0
- package/lib/setup/state.mjs +30 -10
- package/lib/setup/state.test.mjs +24 -1
- package/lib/singleton.js +11 -3
- package/lib/singleton.test.mjs +16 -0
- package/lib/subagents/lock.mjs +1 -1
- package/lib/telemetry/collect.mjs +270 -6
- package/lib/telemetry/collect.test.mjs +196 -1
- package/lib/upgrade/global-refresh.mjs +108 -0
- package/lib/upgrade/global-refresh.test.mjs +65 -0
- package/lib/upgrade/launchd-reconcile.mjs +327 -0
- package/lib/upgrade/launchd-reconcile.test.mjs +272 -0
- package/lib/upgrade/post-steps.mjs +151 -0
- package/lib/upgrade/post-steps.test.mjs +200 -0
- package/lib/upgrade/verify.mjs +215 -0
- package/lib/upgrade/verify.test.mjs +164 -0
- package/lib/voice/outbound.mjs +3 -2
- package/lib/voice/post-call-brief.mjs +2 -1
- package/lib/voice/session-rotation.mjs +6 -1
- package/lib/voice/session-rotation.test.mjs +114 -0
- package/package.json +3 -3
- package/plugins/maestro-skills/plugin.json +25 -1
- package/plugins/maestro-skills/skills/board-work.md +63 -0
- package/plugins/maestro-skills/skills/cohort-design.md +153 -0
- package/plugins/maestro-skills/skills/inbound-triage.md +80 -0
- package/plugins/maestro-skills/skills/main-session.md +102 -0
- package/plugins/maestro-skills/skills/peer-sessions.md +65 -0
- package/plugins/maestro-skills/skills/persona-discipline.md +75 -0
- package/plugins/maestro-skills/vendor/emilkowalski/LICENSE +21 -0
- package/plugins/maestro-skills/vendor/emilkowalski/UPSTREAM.json +70 -0
- package/plugins/maestro-skills/vendor/emilkowalski/skills/animate/RECIPES.md +324 -0
- package/plugins/maestro-skills/vendor/emilkowalski/skills/animate/SKILL.md +199 -0
- package/plugins/maestro-skills/vendor/emilkowalski/skills/animation-vocabulary/SKILL.md +173 -0
- package/plugins/maestro-skills/vendor/emilkowalski/skills/apple-design/SKILL.md +282 -0
- package/plugins/maestro-skills/vendor/emilkowalski/skills/emil-design-eng/SKILL.md +674 -0
- package/plugins/maestro-skills/vendor/emilkowalski/skills/find-animation-opportunities/SKILL.md +132 -0
- package/plugins/maestro-skills/vendor/emilkowalski/skills/improve-animations/AUDIT.md +115 -0
- package/plugins/maestro-skills/vendor/emilkowalski/skills/improve-animations/PLAN-TEMPLATE.md +73 -0
- package/plugins/maestro-skills/vendor/emilkowalski/skills/improve-animations/SKILL.md +101 -0
- package/plugins/maestro-skills/vendor/emilkowalski/skills/prototype/PICKER.md +197 -0
- package/plugins/maestro-skills/vendor/emilkowalski/skills/prototype/SKILL.md +90 -0
- package/plugins/maestro-skills/vendor/emilkowalski/skills/review-animations/SKILL.md +112 -0
- package/plugins/maestro-skills/vendor/emilkowalski/skills/review-animations/STANDARDS.md +187 -0
- package/plugins/maestro-skills/vendor/impeccable/LICENSE +191 -0
- package/plugins/maestro-skills/vendor/impeccable/NOTICE.md +11 -0
- package/plugins/maestro-skills/vendor/impeccable/SKILL.md +86 -0
- package/plugins/maestro-skills/vendor/impeccable/UPSTREAM.json +201 -0
- package/plugins/maestro-skills/vendor/impeccable/agents/impeccable-asset-producer.md +42 -0
- package/plugins/maestro-skills/vendor/impeccable/agents/impeccable-documenter.md +29 -0
- package/plugins/maestro-skills/vendor/impeccable/agents/impeccable-finish-reviewer.md +43 -0
- package/plugins/maestro-skills/vendor/impeccable/agents/impeccable-manual-edit-applier.md +97 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/adapt.md +312 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/adapt.native.md +58 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/android.md +46 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/animate.md +89 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/audit.md +136 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/audit.native.md +139 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/bolder.md +33 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/clarify.md +94 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/colorize.md +86 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/craft-floor.md +44 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/craft.md +5 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/critique.md +806 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/degraded/asset-producer.md +37 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/degraded/documenter.md +24 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/degraded/finish-reviewer.md +38 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/degraded/manual-edit-applier.md +92 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/delight.md +70 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/distill.md +111 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/doctor.md +54 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/document.md +416 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/extract.md +69 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/harden.md +336 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/hooks.md +111 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/init.md +131 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/ios.md +51 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/layout.md +84 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/live-setup.md +104 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/live.md +325 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/new-work.md +147 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/onboard.md +234 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/operate.md +61 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/optimize.md +258 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/overdrive.md +127 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/polish.md +105 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/quieter.md +99 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/routing.md +24 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/shape.md +59 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/typeset.md +80 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/visualize.md +46 -0
- package/plugins/maestro-skills/vendor/taste-skill/LICENSE +21 -0
- package/plugins/maestro-skills/vendor/taste-skill/UPSTREAM.json +37 -0
- package/plugins/maestro-skills/vendor/taste-skill/skills/minimalist-skill/SKILL.md +85 -0
- package/plugins/maestro-skills/vendor/taste-skill/skills/redesign-skill/SKILL.md +178 -0
- package/plugins/maestro-skills/vendor/taste-skill/skills/soft-skill/SKILL.md +98 -0
- package/plugins/maestro-skills/vendor/taste-skill/skills/taste-skill/SKILL.md +1206 -0
- package/plugins/maestro-skills/vendor/unlazy/LICENSE +21 -0
- package/plugins/maestro-skills/vendor/unlazy/SECURITY.md +72 -0
- package/plugins/maestro-skills/vendor/unlazy/SKILL.md +104 -0
- package/plugins/maestro-skills/vendor/unlazy/UPSTREAM.json +94 -0
- package/plugins/maestro-skills/vendor/unlazy/references/dispatch.md +82 -0
- package/plugins/maestro-skills/vendor/unlazy/references/gates.md +149 -0
- package/plugins/maestro-skills/vendor/unlazy/references/method.md +49 -0
- package/plugins/maestro-skills/vendor/unlazy/references/orchestration.md +107 -0
- package/plugins/maestro-skills/vendor/unlazy/references/parallel.md +133 -0
- package/plugins/maestro-skills/vendor/unlazy/references/token-economy.md +48 -0
- package/plugins/maestro-skills/vendor/unlazy/scripts/dispatch-check.mjs +139 -0
- package/plugins/maestro-skills/vendor/unlazy/scripts/gate-check.mjs +960 -0
- package/plugins/maestro-skills/vendor/unlazy/scripts/gate-lint.mjs +245 -0
- package/plugins/maestro-skills/vendor/unlazy/scripts/lib/check-supervisor.mjs +46 -0
- package/plugins/maestro-skills/vendor/unlazy/scripts/lib/dispatch.mjs +293 -0
- package/plugins/maestro-skills/vendor/unlazy/scripts/lib/gates.mjs +953 -0
- package/plugins/maestro-skills/vendor/unlazy/scripts/lib/process-tree.mjs +161 -0
- package/plugins/maestro-skills/vendor/unlazy/scripts/lib/regex-worker.mjs +9 -0
- package/plugins/maestro-skills/vendor/unlazy/templates/PLAN.md +116 -0
- package/plugins/maestro-skills/vendor/unlazy/templates/gates-leaf.md +51 -0
- package/plugins/maestro-skills/vendor/unlazy/templates/gates-node.md +51 -0
- package/scaffold/CLAUDE.md +24 -0
- package/scripts/ci/check-durable-write-seam.mjs +147 -0
- package/scripts/ci/check-durable-write-seam.test.mjs +90 -0
- package/scripts/ci/check-skill-packs.mjs +388 -0
- package/scripts/ci/check-skill-packs.test.mjs +495 -0
- package/scripts/ci/check.mjs +6 -0
- package/scripts/collective/hook-runner.mjs +39 -4
- package/scripts/collective/hook-runner.test.mjs +85 -2
- package/scripts/daemon/agent-daemon-board-mine.test.mjs +96 -0
- package/scripts/daemon/agent-daemon-design.test.mjs +238 -0
- package/scripts/daemon/agent-daemon-frontdoor.test.mjs +60 -0
- package/scripts/daemon/agent-daemon.mjs +249 -10
- package/scripts/daemon/agent-daemon.test.mjs +73 -0
- package/scripts/daemon/assurance-e2e.test.mjs +141 -6
- package/scripts/daemon/assurance.mjs +461 -37
- package/scripts/daemon/assurance.test.mjs +408 -43
- package/scripts/daemon/cadence-consumer-frontdoor.test.mjs +393 -0
- package/scripts/daemon/cadence-consumer.mjs +289 -89
- package/scripts/daemon/cadence-handlers.mjs +53 -0
- package/scripts/daemon/classifier.mjs +1 -1
- package/scripts/daemon/dispatcher-resume.test.mjs +166 -0
- package/scripts/daemon/dispatcher.mjs +127 -19
- package/scripts/daemon/health.mjs +12 -1
- package/scripts/daemon/inbox-deferral-session.test.mjs +49 -0
- package/scripts/daemon/inbox-deferral.mjs +6 -0
- package/scripts/daemon/lib/self-echo.mjs +201 -0
- package/scripts/daemon/lib/self-echo.test.mjs +153 -0
- package/scripts/daemon/maestro-daemon.mjs +3 -0
- package/scripts/daemon/prompt-builder.mjs +19 -3
- package/scripts/daemon/responder.mjs +51 -40
- package/scripts/daemon/sdk-version.mjs +51 -0
- package/scripts/daemon/sdk-version.test.mjs +31 -0
- package/scripts/hooks/pre-send-audit.sh +97 -4
- package/scripts/hooks/pre-send-audit.test.mjs +140 -1
- package/scripts/local-triggers/autoupdate.sh +243 -19
- package/scripts/local-triggers/autoupdate.test.mjs +518 -0
- package/scripts/local-triggers/generate-plists.sh +24 -1
- package/scripts/local-triggers/generate-plists.test.mjs +49 -11
- package/scripts/org/send-orgmail.first-contact.test.mjs +102 -0
- package/scripts/org/send-orgmail.mjs +27 -3
- package/scripts/poller/inbox-privilege-injection.test.mjs +167 -0
- package/scripts/poller/slack-poller.mjs +13 -1
- package/scripts/poller/utils.mjs +46 -1
- package/scripts/poller-launchd/install.sh +19 -11
- package/scripts/poller-launchd/install.test.mjs +243 -0
- package/scripts/poller-launchd/launchd-poller-wrapper.sh +92 -0
- package/scripts/poller-launchd/migrate.sh +66 -0
- package/scripts/poller-launchd/poller.plist.template +4 -2
- package/scripts/session/feed.mjs +237 -0
- package/scripts/session/feed.test.mjs +196 -0
- package/scripts/session/supervisor-sh.test.mjs +218 -0
- package/scripts/session/supervisor.mjs +328 -0
- package/scripts/session/supervisor.sh +141 -0
- package/scripts/session/supervisor.test.mjs +482 -0
- package/scripts/setup/configure-macos.sh +250 -55
- package/scripts/setup/configure-macos.test.mjs +306 -0
- package/scripts/setup/init-agent.sh +112 -7
- package/scripts/setup/init-agent.test.mjs +220 -1
- package/scripts/vendor/skill-packs.mjs +354 -0
- package/scripts/vendor/sync-skill-packs.mjs +242 -0
- package/scripts/vendor/sync-skill-packs.test.mjs +103 -0
- package/scripts/watchdog/memory-watchdog.sh +37 -1
- package/scripts/watchdog/memory-watchdog.test.mjs +64 -0
- package/scripts/setup/boot-claude-session.sh +0 -94
|
@@ -1,12 +1,46 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* lib/setup/claude-probe.mjs —
|
|
2
|
+
* lib/setup/claude-probe.mjs — is this agent able to talk to Claude?
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
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
|
-
*
|
|
9
|
-
*
|
|
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
|
-
*
|
|
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 (
|
|
31
|
-
* @
|
|
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
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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
|
|
44
|
-
remedy: "
|
|
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",
|
|
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",
|
|
67
|
-
remedy: "ANTHROPIC_API_KEY is INVALID (HTTP 401) — replace it, or
|
|
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",
|
|
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",
|
|
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
|
-
|
|
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
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
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
|
|
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
|
|
54
|
+
if (!existsSync(p)) { writeFileSync(p, `${key}=${value}\n`, { mode: 0o600 }); return; }
|
|
28
55
|
const body = readFileSync(p, "utf-8");
|
|
29
|
-
if (
|
|
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 (
|
|
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: "
|
|
53
|
-
{ label: "
|
|
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:
|
|
95
|
+
default: DEFAULT_AUTH_MODE,
|
|
56
96
|
});
|
|
97
|
+
let oauthToken = "";
|
|
57
98
|
let apiKey = "";
|
|
58
|
-
if (provider === "
|
|
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
|
-
|
|
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 =
|
|
125
|
+
const a = pull(ctx);
|
|
71
126
|
if (!a) return;
|
|
72
|
-
if (a.provider === "
|
|
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 =
|
|
148
|
+
const probe = (ctx.deps && ctx.deps.probeClaude) || defaultProbeClaude;
|
|
149
|
+
const r = probe(ctx.agentRoot);
|
|
88
150
|
return {
|
|
89
|
-
ok:
|
|
90
|
-
checks: [{ name:
|
|
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
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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
|
}
|