@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
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* install.test.mjs — scripts/poller-launchd/{install.sh, poller.plist.template,
|
|
3
|
+
* launchd-poller-wrapper.sh}
|
|
4
|
+
*
|
|
5
|
+
* Audit F6: install.sh used to grep SLACK_USER_TOKEN out of .env and `sed` it
|
|
6
|
+
* into the rendered plist — an unescaped substitution that left the secret in
|
|
7
|
+
* a 0644 file under ~/Library/LaunchAgents. Now the plist runs
|
|
8
|
+
* launchd-poller-wrapper.sh, which sources .env (0600) at start and execs
|
|
9
|
+
* node; the plist carries no secret and is chmod 600 regardless.
|
|
10
|
+
*
|
|
11
|
+
* install.sh calls launchctl; the tests stub it. The wrapper resolves node
|
|
12
|
+
* via MAESTRO_NODE_BIN first so a stub node can prove .env reached the child.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { test } from "node:test";
|
|
16
|
+
import assert from "node:assert/strict";
|
|
17
|
+
import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, existsSync, chmodSync, statSync, copyFileSync, rmSync, realpathSync } from "node:fs";
|
|
18
|
+
import { spawnSync } from "node:child_process";
|
|
19
|
+
import { join, dirname } from "node:path";
|
|
20
|
+
import { tmpdir } from "node:os";
|
|
21
|
+
import { fileURLToPath } from "node:url";
|
|
22
|
+
|
|
23
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
24
|
+
const installSrc = readFileSync(join(__dirname, "install.sh"), "utf8");
|
|
25
|
+
const templateSrc = readFileSync(join(__dirname, "poller.plist.template"), "utf8");
|
|
26
|
+
|
|
27
|
+
const SECRET = "xoxp-not-a-real-token-0123456789";
|
|
28
|
+
|
|
29
|
+
function fixture() {
|
|
30
|
+
const root = mkdtempSync(join(tmpdir(), "poller-launchd-test-"));
|
|
31
|
+
const agent = join(root, "agent");
|
|
32
|
+
const home = join(root, "home");
|
|
33
|
+
const bin = join(root, "bin");
|
|
34
|
+
const log = join(root, "calls.log");
|
|
35
|
+
mkdirSync(join(agent, "scripts", "poller-launchd"), { recursive: true });
|
|
36
|
+
mkdirSync(join(agent, "scripts", "poller"), { recursive: true });
|
|
37
|
+
mkdirSync(join(agent, "config"), { recursive: true });
|
|
38
|
+
mkdirSync(join(agent, "logs", "polling"), { recursive: true });
|
|
39
|
+
mkdirSync(join(home, "Library", "LaunchAgents"), { recursive: true });
|
|
40
|
+
mkdirSync(bin, { recursive: true });
|
|
41
|
+
for (const f of ["install.sh", "poller.plist.template", "launchd-poller-wrapper.sh"]) {
|
|
42
|
+
if (existsSync(join(__dirname, f))) copyFileSync(join(__dirname, f), join(agent, "scripts", "poller-launchd", f));
|
|
43
|
+
}
|
|
44
|
+
for (const f of ["install.sh", "launchd-poller-wrapper.sh"]) {
|
|
45
|
+
const p = join(agent, "scripts", "poller-launchd", f);
|
|
46
|
+
if (existsSync(p)) chmodSync(p, 0o755);
|
|
47
|
+
}
|
|
48
|
+
writeFileSync(join(agent, "config", "agent.json"), JSON.stringify({ firstName: "Ivy" }));
|
|
49
|
+
// dotenv-shaped, deliberately awkward: an unquoted value with spaces (legal
|
|
50
|
+
// for Node's dotenv, fatal under `source`), an inline comment, a quoted
|
|
51
|
+
// value containing `#`, an `export` prefix, and a `$` that must NOT expand.
|
|
52
|
+
writeFileSync(join(agent, ".env"), [
|
|
53
|
+
"# agent env",
|
|
54
|
+
`SLACK_USER_TOKEN=${SECRET}`,
|
|
55
|
+
"OTHER_VALUE=with spaces # trailing comment",
|
|
56
|
+
'QUOTED="a # not a comment"',
|
|
57
|
+
"export EXPORTED=yes",
|
|
58
|
+
"LITERAL='$HOME stays literal'",
|
|
59
|
+
"",
|
|
60
|
+
].join("\n"));
|
|
61
|
+
chmodSync(join(agent, ".env"), 0o600);
|
|
62
|
+
writeFileSync(join(bin, "launchctl"), `#!/bin/bash\necho "launchctl $*" >> "${log}"\nexit 0\n`);
|
|
63
|
+
chmodSync(join(bin, "launchctl"), 0o755);
|
|
64
|
+
return { root, agent, home, bin, log };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const env = (fx, extra = {}) => ({ HOME: fx.home, PATH: `${fx.bin}:/usr/bin:/bin`, ...extra });
|
|
68
|
+
const plistPath = (fx) => join(fx.home, "Library", "LaunchAgents", "ai.maestro.ivy-poller.plist");
|
|
69
|
+
|
|
70
|
+
// --- static ------------------------------------------------------------------
|
|
71
|
+
|
|
72
|
+
test("install.sh no longer greps SLACK_USER_TOKEN nor seds XML", () => {
|
|
73
|
+
assert.ok(!/SLACK_USER_TOKEN/.test(installSrc), "install.sh must not read the Slack token");
|
|
74
|
+
assert.ok(!/sed -i/.test(installSrc), "install.sh must not edit the rendered plist in place");
|
|
75
|
+
assert.match(installSrc, /chmod 600/, "the plist must be chmod 600");
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test("the template runs the wrapper, not node directly, and carries no env secrets", () => {
|
|
79
|
+
assert.match(templateSrc, /launchd-poller-wrapper\.sh/);
|
|
80
|
+
assert.ok(!/SLACK_USER_TOKEN/.test(templateSrc));
|
|
81
|
+
assert.ok(!/\{\{NODE_BIN\}\}/.test(templateSrc), "node is resolved by the wrapper, not templated into the plist");
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
// --- install.sh --------------------------------------------------------------
|
|
85
|
+
|
|
86
|
+
test("install.sh renders a 0600 plist running the wrapper with no secret in it", () => {
|
|
87
|
+
const fx = fixture();
|
|
88
|
+
try {
|
|
89
|
+
const r = spawnSync("/bin/bash", [join(fx.agent, "scripts", "poller-launchd", "install.sh")], { encoding: "utf8", env: env(fx) });
|
|
90
|
+
assert.equal(r.status, 0, r.stderr + r.stdout);
|
|
91
|
+
const p = plistPath(fx);
|
|
92
|
+
assert.ok(existsSync(p), "plist must be installed under ~/Library/LaunchAgents");
|
|
93
|
+
const body = readFileSync(p, "utf8");
|
|
94
|
+
assert.ok(!body.includes(SECRET), "the Slack token must not be in the plist");
|
|
95
|
+
assert.ok(!/SLACK_USER_TOKEN/.test(body));
|
|
96
|
+
assert.match(body, /<string>ai\.maestro\.ivy-poller<\/string>/);
|
|
97
|
+
assert.match(body, new RegExp(`<string>${fx.agent.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}/scripts/poller-launchd/launchd-poller-wrapper\\.sh</string>`));
|
|
98
|
+
assert.ok(!/\{\{[A-Z_]+\}\}/.test(body), `unresolved template tokens:\n${body}`);
|
|
99
|
+
assert.equal(statSync(p).mode & 0o777, 0o600, "plist must be 0600");
|
|
100
|
+
const calls = readFileSync(fx.log, "utf8");
|
|
101
|
+
assert.match(calls, /launchctl load .*ai\.maestro\.ivy-poller\.plist/);
|
|
102
|
+
assert.ok(!/SLACK_USER_TOKEN|Injected/.test(r.stdout), "install output must not mention the token");
|
|
103
|
+
} finally { rmSync(fx.root, { recursive: true, force: true }); }
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test("install.sh is idempotent (re-run replaces the plist, still 0600)", () => {
|
|
107
|
+
const fx = fixture();
|
|
108
|
+
try {
|
|
109
|
+
const script = join(fx.agent, "scripts", "poller-launchd", "install.sh");
|
|
110
|
+
assert.equal(spawnSync("/bin/bash", [script], { encoding: "utf8", env: env(fx) }).status, 0);
|
|
111
|
+
// Simulate a previously-installed world-readable plist.
|
|
112
|
+
chmodSync(plistPath(fx), 0o644);
|
|
113
|
+
assert.equal(spawnSync("/bin/bash", [script], { encoding: "utf8", env: env(fx) }).status, 0);
|
|
114
|
+
assert.equal(statSync(plistPath(fx)).mode & 0o777, 0o600);
|
|
115
|
+
assert.match(readFileSync(fx.log, "utf8"), /launchctl unload/);
|
|
116
|
+
} finally { rmSync(fx.root, { recursive: true, force: true }); }
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
// --- wrapper -----------------------------------------------------------------
|
|
120
|
+
|
|
121
|
+
test("launchd-poller-wrapper.sh sources .env and execs node on scripts/poller/index.mjs", () => {
|
|
122
|
+
const fx = fixture();
|
|
123
|
+
try {
|
|
124
|
+
const wrapper = join(fx.agent, "scripts", "poller-launchd", "launchd-poller-wrapper.sh");
|
|
125
|
+
assert.ok(existsSync(wrapper), "wrapper must exist");
|
|
126
|
+
// A stub node that reports what reached it.
|
|
127
|
+
const stubNode = join(fx.bin, "node");
|
|
128
|
+
writeFileSync(stubNode, `#!/bin/bash\necho "ARGV=$*"\necho "TOKEN=$SLACK_USER_TOKEN"\necho "OTHER=[$OTHER_VALUE]"\necho "QUOTED=[$QUOTED]"\necho "EXPORTED=[$EXPORTED]"\necho "LITERAL=[$LITERAL]"\necho "AGENT_ROOT=$AGENT_ROOT"\n`);
|
|
129
|
+
chmodSync(stubNode, 0o755);
|
|
130
|
+
const r = spawnSync("/bin/bash", [wrapper], { encoding: "utf8", env: env(fx, { MAESTRO_NODE_BIN: stubNode }) });
|
|
131
|
+
assert.equal(r.status, 0, r.stderr + r.stdout);
|
|
132
|
+
assert.match(r.stdout, new RegExp(`ARGV=.*scripts/poller/index\\.mjs`), `stdout:\n${r.stdout}`);
|
|
133
|
+
assert.match(r.stdout, new RegExp(`TOKEN=${SECRET}`), ".env must be sourced so the token reaches the poller");
|
|
134
|
+
assert.match(r.stdout, /OTHER=\[with spaces\]/, "unquoted value with spaces survives; inline comment stripped");
|
|
135
|
+
assert.match(r.stdout, /QUOTED=\[a # not a comment\]/, "a quoted value keeps its #");
|
|
136
|
+
assert.match(r.stdout, /EXPORTED=\[yes\]/, "`export KEY=` lines are accepted");
|
|
137
|
+
assert.match(r.stdout, /LITERAL=\[\$HOME stays literal\]/, "no expansion of $ inside values");
|
|
138
|
+
// The wrapper resolves AGENT_ROOT with `pwd -P` (macOS: /var → /private/var).
|
|
139
|
+
assert.match(r.stdout, new RegExp(`AGENT_ROOT=${realpathSync(fx.agent).replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}`));
|
|
140
|
+
// The secret must never be echoed by the wrapper itself.
|
|
141
|
+
assert.ok(!r.stderr.includes(SECRET));
|
|
142
|
+
} finally { rmSync(fx.root, { recursive: true, force: true }); }
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
test("launchd-poller-wrapper.sh runs without a .env (fail-open)", () => {
|
|
146
|
+
const fx = fixture();
|
|
147
|
+
try {
|
|
148
|
+
rmSync(join(fx.agent, ".env"));
|
|
149
|
+
const stubNode = join(fx.bin, "node");
|
|
150
|
+
writeFileSync(stubNode, "#!/bin/bash\necho ok\n");
|
|
151
|
+
chmodSync(stubNode, 0o755);
|
|
152
|
+
const wrapper = join(fx.agent, "scripts", "poller-launchd", "launchd-poller-wrapper.sh");
|
|
153
|
+
const r = spawnSync("/bin/bash", [wrapper], { encoding: "utf8", env: env(fx, { MAESTRO_NODE_BIN: stubNode }) });
|
|
154
|
+
assert.equal(r.status, 0, r.stderr + r.stdout);
|
|
155
|
+
} finally { rmSync(fx.root, { recursive: true, force: true }); }
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
// --- review fixes (2026-09-08) ---------------------------------------------
|
|
159
|
+
|
|
160
|
+
test("launchd-poller-wrapper.sh strips a trailing CR (CRLF .env) and never overrides a variable already in the environment (dotenv semantics)", () => {
|
|
161
|
+
const fx = fixture();
|
|
162
|
+
try {
|
|
163
|
+
writeFileSync(join(fx.agent, ".env"), `SLACK_USER_TOKEN=${SECRET}\r\nPRESET=from-dotenv\r\nOTHER_VALUE=plain\r\n`);
|
|
164
|
+
const stubNode = join(fx.bin, "node");
|
|
165
|
+
writeFileSync(stubNode, `#!/bin/bash\nprintf 'TOKEN=[%s]\\n' "$SLACK_USER_TOKEN"\nprintf 'PRESET=[%s]\\n' "$PRESET"\nprintf 'OTHER=[%s]\\n' "$OTHER_VALUE"\n`);
|
|
166
|
+
chmodSync(stubNode, 0o755);
|
|
167
|
+
const wrapper = join(fx.agent, "scripts", "poller-launchd", "launchd-poller-wrapper.sh");
|
|
168
|
+
const r = spawnSync("/bin/bash", [wrapper], { encoding: "utf8", env: env(fx, { MAESTRO_NODE_BIN: stubNode, PRESET: "from-env" }) });
|
|
169
|
+
assert.equal(r.status, 0, r.stderr + r.stdout);
|
|
170
|
+
assert.match(r.stdout, new RegExp(`TOKEN=\\[${SECRET}\\]`), `CR must be stripped:\n${JSON.stringify(r.stdout)}`);
|
|
171
|
+
assert.match(r.stdout, /PRESET=\[from-env\]/, "a variable already set in the environment wins (dotenv does not override)");
|
|
172
|
+
assert.match(r.stdout, /OTHER=\[plain\]/);
|
|
173
|
+
} finally { rmSync(fx.root, { recursive: true, force: true }); }
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
// migrate.sh — audit F6 follow-through for plists installed by the OLD install.sh
|
|
177
|
+
// (SLACK_USER_TOKEN inline, mode 644). Re-renders the agent's own poller plist
|
|
178
|
+
// through install.sh; a foreign agent's plist is only tightened to 0600.
|
|
179
|
+
|
|
180
|
+
const migratePath = join(__dirname, "migrate.sh");
|
|
181
|
+
const oldPlist = (label, secret) => `<?xml version="1.0" encoding="UTF-8"?>
|
|
182
|
+
<plist version="1.0"><dict>
|
|
183
|
+
<key>Label</key><string>${label}</string>
|
|
184
|
+
<key>EnvironmentVariables</key><dict><key>SLACK_USER_TOKEN</key><string>${secret}</string></dict>
|
|
185
|
+
</dict></plist>
|
|
186
|
+
`;
|
|
187
|
+
|
|
188
|
+
function migrateFixture() {
|
|
189
|
+
const fx = fixture();
|
|
190
|
+
copyFileSync(migratePath, join(fx.agent, "scripts", "poller-launchd", "migrate.sh"));
|
|
191
|
+
chmodSync(join(fx.agent, "scripts", "poller-launchd", "migrate.sh"), 0o755);
|
|
192
|
+
return fx;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
test("migrate.sh re-renders an installed poller plist that carries SLACK_USER_TOKEN at 0644 — secret gone, 0600, job reloaded", () => {
|
|
196
|
+
const fx = migrateFixture();
|
|
197
|
+
try {
|
|
198
|
+
writeFileSync(plistPath(fx), oldPlist("ai.maestro.ivy-poller", SECRET));
|
|
199
|
+
chmodSync(plistPath(fx), 0o644);
|
|
200
|
+
const r = spawnSync("/bin/bash", [join(fx.agent, "scripts", "poller-launchd", "migrate.sh")], { encoding: "utf8", env: env(fx) });
|
|
201
|
+
assert.equal(r.status, 0, r.stderr + r.stdout);
|
|
202
|
+
const body = readFileSync(plistPath(fx), "utf8");
|
|
203
|
+
assert.ok(!body.includes(SECRET) && !/SLACK_USER_TOKEN/.test(body), "the secret must be gone from the plist");
|
|
204
|
+
assert.match(body, /launchd-poller-wrapper\.sh/, "must be the wrapper-based render");
|
|
205
|
+
assert.equal(statSync(plistPath(fx)).mode & 0o777, 0o600);
|
|
206
|
+
assert.match(readFileSync(fx.log, "utf8"), /launchctl load .*ai\.maestro\.ivy-poller\.plist/, "the job is reloaded on the new render");
|
|
207
|
+
assert.ok(!(r.stdout + r.stderr).includes(SECRET), "the migration must never echo the secret");
|
|
208
|
+
assert.match(r.stdout, /migrated|re-rendered/i);
|
|
209
|
+
} finally { rmSync(fx.root, { recursive: true, force: true }); }
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
test("migrate.sh is a no-op on a clean seat (no poller plist, or a wrapper-based 0600 one) and idempotent", () => {
|
|
213
|
+
const fx = migrateFixture();
|
|
214
|
+
try {
|
|
215
|
+
const script = join(fx.agent, "scripts", "poller-launchd", "migrate.sh");
|
|
216
|
+
// No poller plist at all.
|
|
217
|
+
let r = spawnSync("/bin/bash", [script], { encoding: "utf8", env: env(fx) });
|
|
218
|
+
assert.equal(r.status, 0, r.stderr + r.stdout);
|
|
219
|
+
assert.ok(!existsSync(fx.log), `nothing to do → no launchctl calls; got:\n${existsSync(fx.log) ? readFileSync(fx.log, "utf8") : ""}`);
|
|
220
|
+
// A current render.
|
|
221
|
+
assert.equal(spawnSync("/bin/bash", [join(fx.agent, "scripts", "poller-launchd", "install.sh")], { encoding: "utf8", env: env(fx) }).status, 0);
|
|
222
|
+
rmSync(fx.log, { force: true });
|
|
223
|
+
r = spawnSync("/bin/bash", [script], { encoding: "utf8", env: env(fx) });
|
|
224
|
+
assert.equal(r.status, 0, r.stderr + r.stdout);
|
|
225
|
+
assert.ok(!existsSync(fx.log), "a clean plist is left alone (no unload/load churn)");
|
|
226
|
+
} finally { rmSync(fx.root, { recursive: true, force: true }); }
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
test("migrate.sh tightens a foreign agent's leaking poller plist to 0600 without re-rendering it", () => {
|
|
230
|
+
const fx = migrateFixture();
|
|
231
|
+
try {
|
|
232
|
+
const foreign = join(fx.home, "Library", "LaunchAgents", "ai.maestro.zed-poller.plist");
|
|
233
|
+
writeFileSync(foreign, oldPlist("ai.maestro.zed-poller", SECRET));
|
|
234
|
+
chmodSync(foreign, 0o644);
|
|
235
|
+
const r = spawnSync("/bin/bash", [join(fx.agent, "scripts", "poller-launchd", "migrate.sh")], { encoding: "utf8", env: env(fx) });
|
|
236
|
+
assert.equal(r.status, 0, r.stderr + r.stdout);
|
|
237
|
+
assert.equal(statSync(foreign).mode & 0o777, 0o600, "at least no longer world-readable");
|
|
238
|
+
assert.ok(readFileSync(foreign, "utf8").includes(SECRET), "not ours to re-render (its agent dir is unknown)");
|
|
239
|
+
assert.match(r.stdout, /zed-poller/);
|
|
240
|
+
assert.match(r.stdout, /install\.sh/, "must say how to finish the migration from that agent's dir");
|
|
241
|
+
assert.ok(!(r.stdout + r.stderr).includes(SECRET));
|
|
242
|
+
} finally { rmSync(fx.root, { recursive: true, force: true }); }
|
|
243
|
+
});
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# launchd-poller-wrapper.sh — Bootstraps env for the legacy poller under launchd.
|
|
3
|
+
#
|
|
4
|
+
# Exec'd by ai.maestro.{firstname}-poller.plist (rendered from
|
|
5
|
+
# poller.plist.template by install.sh). launchd's environment is bare, so we
|
|
6
|
+
# set HOME/PATH/USER/AGENT_ROOT here — the same idiom as
|
|
7
|
+
# scripts/daemon/launchd-wrapper.sh — and, unlike that wrapper, SOURCE the
|
|
8
|
+
# agent's .env (0600) so SLACK_USER_TOKEN & co. reach the poller.
|
|
9
|
+
#
|
|
10
|
+
# Audit F6: install.sh used to copy SLACK_USER_TOKEN out of .env into the plist
|
|
11
|
+
# via an unescaped `sed`, leaving the secret in a 0644 file. The secret now
|
|
12
|
+
# lives only in .env; the plist names this wrapper and nothing else.
|
|
13
|
+
#
|
|
14
|
+
# MAESTRO_NODE_BIN overrides node resolution (tests / non-standard installs).
|
|
15
|
+
|
|
16
|
+
set -e
|
|
17
|
+
|
|
18
|
+
AGENT_ROOT="$(cd "$(dirname "$0")/../.." && pwd -P)"
|
|
19
|
+
export AGENT_ROOT
|
|
20
|
+
export HOME="${HOME:-/Users/$(whoami)}"
|
|
21
|
+
export USER="${USER:-$(whoami)}"
|
|
22
|
+
export PATH="/opt/homebrew/bin:/opt/homebrew/sbin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin:$PATH"
|
|
23
|
+
export NODE_ENV="${NODE_ENV:-production}"
|
|
24
|
+
|
|
25
|
+
cd "$AGENT_ROOT"
|
|
26
|
+
|
|
27
|
+
# ── .env ────────────────────────────────────────────────────────────────────
|
|
28
|
+
# Read with dotenv semantics rather than `source`: the file is written for
|
|
29
|
+
# Node's dotenv, where `KEY=Ivy Tester` is legal, but under `source` + set -e
|
|
30
|
+
# that line runs `Tester` as a command and kills the job. Rules: skip blanks
|
|
31
|
+
# and comments, accept `export KEY=…`, strip matching surrounding quotes, strip
|
|
32
|
+
# an inline ` # comment` from an UNQUOTED value, never expand `$` or backticks,
|
|
33
|
+
# strip a trailing CR (a .env pasted from a CRLF editor would otherwise hand
|
|
34
|
+
# the poller a token ending in `\r` → invalid_auth with no hint), and never
|
|
35
|
+
# override a variable that is already in the environment (dotenv semantics:
|
|
36
|
+
# the environment wins over the file).
|
|
37
|
+
# Fail-open: no .env → the poller starts with launchd's env and reports its own
|
|
38
|
+
# missing-token error. Nothing from the file is ever echoed.
|
|
39
|
+
load_dotenv() {
|
|
40
|
+
local line key val
|
|
41
|
+
while IFS= read -r line || [ -n "$line" ]; do
|
|
42
|
+
line="${line%$'\r'}"
|
|
43
|
+
line="${line#"${line%%[![:space:]]*}"}"
|
|
44
|
+
case "$line" in ''|'#'*) continue ;; esac
|
|
45
|
+
line="${line#export }"
|
|
46
|
+
case "$line" in *=*) ;; *) continue ;; esac
|
|
47
|
+
key="${line%%=*}"
|
|
48
|
+
val="${line#*=}"
|
|
49
|
+
key="${key%"${key##*[![:space:]]}"}"
|
|
50
|
+
case "$key" in ''|*[!A-Za-z0-9_]*|[0-9]*) continue ;; esac
|
|
51
|
+
if [ -n "${!key+x}" ]; then continue; fi
|
|
52
|
+
case "$val" in
|
|
53
|
+
\"*\") val="${val#\"}"; val="${val%\"}" ;;
|
|
54
|
+
\'*\') val="${val#\'}"; val="${val%\'}" ;;
|
|
55
|
+
*) val="${val%%[[:space:]]#*}"; val="${val%"${val##*[![:space:]]}"}" ;;
|
|
56
|
+
esac
|
|
57
|
+
case "$val" in
|
|
58
|
+
*$'\r') val="${val%$'\r'}" ;;
|
|
59
|
+
esac
|
|
60
|
+
export "$key=$val"
|
|
61
|
+
done < "$1"
|
|
62
|
+
}
|
|
63
|
+
if [ -f "$AGENT_ROOT/.env" ]; then
|
|
64
|
+
load_dotenv "$AGENT_ROOT/.env"
|
|
65
|
+
fi
|
|
66
|
+
|
|
67
|
+
# ── node ────────────────────────────────────────────────────────────────────
|
|
68
|
+
NODE_BIN="${MAESTRO_NODE_BIN:-}"
|
|
69
|
+
if [ -z "$NODE_BIN" ] || [ ! -x "$NODE_BIN" ]; then
|
|
70
|
+
NODE_BIN=""
|
|
71
|
+
for candidate in \
|
|
72
|
+
"$HOME/.nvm/versions/node/v24/bin/node" \
|
|
73
|
+
"$HOME/.nvm/versions/node/v22/bin/node" \
|
|
74
|
+
"$HOME/.nvm/versions/node/v20/bin/node" \
|
|
75
|
+
/opt/homebrew/bin/node \
|
|
76
|
+
/usr/local/bin/node \
|
|
77
|
+
/usr/bin/node; do
|
|
78
|
+
if [ -x "$candidate" ]; then NODE_BIN="$candidate"; break; fi
|
|
79
|
+
done
|
|
80
|
+
fi
|
|
81
|
+
if [ -z "$NODE_BIN" ] && [ -d "$HOME/.nvm/versions/node" ]; then
|
|
82
|
+
NODE_BIN=$(ls -1d "$HOME/.nvm/versions/node"/v*/bin/node 2>/dev/null | sort -V | tail -1)
|
|
83
|
+
fi
|
|
84
|
+
if [ -z "$NODE_BIN" ]; then
|
|
85
|
+
NODE_BIN="$(command -v node 2>/dev/null || true)"
|
|
86
|
+
fi
|
|
87
|
+
if [ -z "$NODE_BIN" ] || [ ! -x "$NODE_BIN" ]; then
|
|
88
|
+
echo "[poller-wrapper] FATAL: could not find node binary" >&2
|
|
89
|
+
exit 127
|
|
90
|
+
fi
|
|
91
|
+
|
|
92
|
+
exec "$NODE_BIN" "$AGENT_ROOT/scripts/poller/index.mjs" "$@"
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# migrate.sh — audit F6 follow-through for poller plists installed BEFORE the
|
|
3
|
+
# wrapper-based render.
|
|
4
|
+
#
|
|
5
|
+
# The old install.sh copied SLACK_USER_TOKEN out of .env into
|
|
6
|
+
# ~/Library/LaunchAgents/ai.maestro.<first>-poller.plist at mode 644. The new
|
|
7
|
+
# install.sh never does, but nothing else on a seat rewrites an already
|
|
8
|
+
# installed plist — autoupdate, `maestro upgrade`, init-agent Step 5 and
|
|
9
|
+
# doctor all leave it alone — so the secret stays world-readable until this
|
|
10
|
+
# runs. Called from init-agent Step 5 and from autoupdate.sh after a healthy
|
|
11
|
+
# upgrade; safe to run any time.
|
|
12
|
+
#
|
|
13
|
+
# own label (ai.maestro.<this agent's first>-poller): re-rendered through
|
|
14
|
+
# install.sh (secret gone, 0600, job reloaded)
|
|
15
|
+
# other label: chmod 600 only — its agent dir is unknown here; say how to
|
|
16
|
+
# finish from there
|
|
17
|
+
# clean plist (no token, 0600): untouched — no unload/load churn
|
|
18
|
+
#
|
|
19
|
+
# Never prints a plist's contents. Exit 0 unless the re-render itself fails.
|
|
20
|
+
|
|
21
|
+
set -u
|
|
22
|
+
|
|
23
|
+
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
24
|
+
AGENT_DIR="$(cd "$SCRIPT_DIR/../.." && pwd)"
|
|
25
|
+
AGENT_JSON="$AGENT_DIR/config/agent.json"
|
|
26
|
+
LAUNCH_AGENTS="$HOME/Library/LaunchAgents"
|
|
27
|
+
|
|
28
|
+
# Same first-name chain as install.sh, so "own label" means the same thing.
|
|
29
|
+
AGENT_FIRST=""
|
|
30
|
+
if [ -f "$AGENT_JSON" ]; then
|
|
31
|
+
if command -v jq >/dev/null 2>&1; then
|
|
32
|
+
AGENT_FIRST=$(jq -r '.firstName // empty' "$AGENT_JSON" | tr '[:upper:]' '[:lower:]')
|
|
33
|
+
else
|
|
34
|
+
AGENT_FIRST=$(awk -F'"' '/"firstName"[[:space:]]*:/ { print tolower($4); exit }' "$AGENT_JSON")
|
|
35
|
+
fi
|
|
36
|
+
fi
|
|
37
|
+
[ -z "$AGENT_FIRST" ] && AGENT_FIRST=$(basename "$AGENT_DIR" | sed 's/-ai$//')
|
|
38
|
+
[ -z "$AGENT_FIRST" ] && AGENT_FIRST="agent"
|
|
39
|
+
OWN_LABEL="ai.maestro.${AGENT_FIRST}-poller"
|
|
40
|
+
|
|
41
|
+
file_mode() { stat -f '%Lp' "$1" 2>/dev/null || stat -c '%a' "$1" 2>/dev/null || echo ""; }
|
|
42
|
+
|
|
43
|
+
rc=0
|
|
44
|
+
for plist in "$LAUNCH_AGENTS"/ai.maestro.*-poller.plist; do
|
|
45
|
+
[ -f "$plist" ] || continue
|
|
46
|
+
label="$(basename "$plist" .plist)"
|
|
47
|
+
reason=""
|
|
48
|
+
if grep -q "SLACK_USER_TOKEN" "$plist" 2>/dev/null; then reason="carries SLACK_USER_TOKEN"; fi
|
|
49
|
+
mode="$(file_mode "$plist")"
|
|
50
|
+
if [ -n "$mode" ] && [ "$mode" != "600" ]; then reason="${reason:+$reason, }mode $mode"; fi
|
|
51
|
+
[ -z "$reason" ] && continue
|
|
52
|
+
|
|
53
|
+
if [ "$label" = "$OWN_LABEL" ]; then
|
|
54
|
+
echo "[poller-migrate] $label $reason — re-rendering via install.sh (secret stays in .env, plist 0600)"
|
|
55
|
+
if bash "$SCRIPT_DIR/install.sh" >/dev/null; then
|
|
56
|
+
echo "[poller-migrate] migrated $label"
|
|
57
|
+
else
|
|
58
|
+
echo "[poller-migrate] FAILED to re-render $label — run: bash $SCRIPT_DIR/install.sh" >&2
|
|
59
|
+
rc=1
|
|
60
|
+
fi
|
|
61
|
+
else
|
|
62
|
+
chmod 600 "$plist" 2>/dev/null || true
|
|
63
|
+
echo "[poller-migrate] $label $reason — tightened to 0600; finish from its own agent dir: bash scripts/poller-launchd/install.sh"
|
|
64
|
+
fi
|
|
65
|
+
done
|
|
66
|
+
exit "$rc"
|
|
@@ -5,10 +5,12 @@
|
|
|
5
5
|
<key>Label</key>
|
|
6
6
|
<string>ai.maestro.{{FIRSTNAME}}-poller</string>
|
|
7
7
|
|
|
8
|
+
<!-- The wrapper sources the agent's .env (0600) and execs node on
|
|
9
|
+
scripts/poller/index.mjs. No secret is ever rendered into this file
|
|
10
|
+
(audit F6); install.sh chmods the installed copy to 600 regardless. -->
|
|
8
11
|
<key>ProgramArguments</key>
|
|
9
12
|
<array>
|
|
10
|
-
<string>{{
|
|
11
|
-
<string>{{AGENT_ROOT}}/scripts/poller/index.mjs</string>
|
|
13
|
+
<string>{{AGENT_ROOT}}/scripts/poller-launchd/launchd-poller-wrapper.sh</string>
|
|
12
14
|
</array>
|
|
13
15
|
|
|
14
16
|
<key>StartInterval</key>
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* scripts/session/feed.mjs — the main session's event intake (runner).
|
|
4
|
+
*
|
|
5
|
+
* Runs INSIDE the main Claude session under `Monitor({persistent:true})`:
|
|
6
|
+
* every line it prints to stdout is one JSON object the session treats as a
|
|
7
|
+
* unit of work. NOTHING ELSE goes to stdout — diagnostics go to stderr.
|
|
8
|
+
*
|
|
9
|
+
* Responsibilities, all fail-open (a broken probe degrades one line, never the
|
|
10
|
+
* loop):
|
|
11
|
+
*
|
|
12
|
+
* heartbeat every 15 s → state/session/heartbeat.json (tmp + rename), which
|
|
13
|
+
* is how the daemon measures that the front door is open.
|
|
14
|
+
* inbox state/inbox/<service>/*.yaml for every configured service
|
|
15
|
+
* (default ["cohort"]) — polled every second, plus fs.watch where
|
|
16
|
+
* the platform provides it, so a new item is emitted promptly.
|
|
17
|
+
* handoffs state/session/handoffs/*.json written by the cadence consumer.
|
|
18
|
+
* signals restart-requested marker, upgrade-notice.json, stale daemon.
|
|
19
|
+
* seen-set state/session/feed-seen.json so a Monitor restart replays nothing.
|
|
20
|
+
*
|
|
21
|
+
* The decision logic is lib/session/feed-core.mjs (pure); this file is the
|
|
22
|
+
* I/O shell. `createFeed` is exported so the runner is testable with an
|
|
23
|
+
* injected clock and emitter; `--once` runs a single tick + beat and exits.
|
|
24
|
+
*
|
|
25
|
+
* Usage: node scripts/session/feed.mjs [--once]
|
|
26
|
+
* Env: AGENT_ROOT (or AGENT_DIR) — the agent directory.
|
|
27
|
+
*
|
|
28
|
+
* @module scripts/session/feed
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
import { readdirSync, readFileSync, statSync, mkdirSync, watch as fsWatch } from "node:fs";
|
|
32
|
+
import { join } from "node:path";
|
|
33
|
+
import { pathToFileURL } from "node:url";
|
|
34
|
+
|
|
35
|
+
import { resolveAgentRoot } from "../../lib/agent-root.mjs";
|
|
36
|
+
import { writeJsonAtomic } from "../../lib/fs-atomic.mjs";
|
|
37
|
+
import { loadSessionConfig, sessionPaths } from "../../lib/session/config.mjs";
|
|
38
|
+
import { loadMainSession, agentFirstName, mainSessionName } from "../../lib/session/identity.mjs";
|
|
39
|
+
import { buildHeartbeat, writeHeartbeat, HEARTBEAT_INTERVAL_MS } from "../../lib/session/liveness.mjs";
|
|
40
|
+
import { computeFeedEvents, parseSeen, emptySeen, isLiveInboxFile, isHandoffFile } from "../../lib/session/feed-core.mjs";
|
|
41
|
+
|
|
42
|
+
/** Inbox / handoff poll cadence. */
|
|
43
|
+
export const POLL_INTERVAL_MS = 1_000;
|
|
44
|
+
|
|
45
|
+
/** @param {string[]} argv */
|
|
46
|
+
export function parseFeedArgs(argv) {
|
|
47
|
+
return { once: argv.includes("--once") };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function pidAliveDefault(pid) {
|
|
51
|
+
try { process.kill(pid, 0); return true; } catch { return false; }
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Build a feed bound to one agent root.
|
|
56
|
+
*
|
|
57
|
+
* @param {{agentRoot:string, config:{services:string[]}, now?:Function, emit?:(obj:object)=>void, log?:(line:string)=>void, pid?:number, ppid?:number, pidAlive?:(pid:number)=>boolean, fs?:object}} a
|
|
58
|
+
*/
|
|
59
|
+
export function createFeed(a) {
|
|
60
|
+
const agentRoot = a.agentRoot;
|
|
61
|
+
const paths = sessionPaths(agentRoot);
|
|
62
|
+
const now = a.now || Date.now;
|
|
63
|
+
const emit = a.emit || ((obj) => process.stdout.write(JSON.stringify(obj) + "\n"));
|
|
64
|
+
const log = a.log || ((line) => process.stderr.write(`[feed] ${line}\n`));
|
|
65
|
+
const pidAlive = a.pidAlive || pidAliveDefault;
|
|
66
|
+
const fs = { readdirSync, readFileSync, statSync, mkdirSync, writeJsonAtomic, ...(a.fs || {}) };
|
|
67
|
+
const services = Array.isArray(a.config && a.config.services) && a.config.services.length ? a.config.services : ["cohort"];
|
|
68
|
+
|
|
69
|
+
const first = agentFirstName(agentRoot, fs);
|
|
70
|
+
const name = mainSessionName(first);
|
|
71
|
+
|
|
72
|
+
let seen = null;
|
|
73
|
+
function loadSeen() {
|
|
74
|
+
if (seen) return seen;
|
|
75
|
+
try { seen = parseSeen(fs.readFileSync(paths.feedSeenFile, "utf8")); } catch { seen = emptySeen(); /* first run */ }
|
|
76
|
+
return seen;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* The files in `dir` that `keep` accepts. A file the seen-set already holds
|
|
81
|
+
* is listed WITHOUT its body (feed-core carries a seen key forward without
|
|
82
|
+
* looking at the body), so a tick costs one readdir plus one read per NEW
|
|
83
|
+
* file rather than one read per backlog item per second.
|
|
84
|
+
*/
|
|
85
|
+
function listDir(dir, keep, isSeen) {
|
|
86
|
+
let names;
|
|
87
|
+
try { names = fs.readdirSync(dir); } catch { return []; /* dir absent until the first item lands */ }
|
|
88
|
+
const out = [];
|
|
89
|
+
for (const file of names) {
|
|
90
|
+
if (!keep(file)) continue;
|
|
91
|
+
const path = join(dir, file);
|
|
92
|
+
if (isSeen && isSeen(file)) { out.push({ file, path, body: null }); continue; }
|
|
93
|
+
let body = null;
|
|
94
|
+
try { body = fs.readFileSync(path, "utf8"); } catch { continue; /* raced a rename; next tick */ }
|
|
95
|
+
out.push({ file, path, body });
|
|
96
|
+
}
|
|
97
|
+
return out;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function mtimeOf(path) {
|
|
101
|
+
try { return fs.statSync(path).mtimeMs; } catch { return null; }
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function daemonPidAlive() {
|
|
105
|
+
try {
|
|
106
|
+
const pid = parseInt(fs.readFileSync(paths.daemonPidFile, "utf8"), 10);
|
|
107
|
+
return Number.isFinite(pid) && pid > 0 ? pidAlive(pid) : null;
|
|
108
|
+
} catch {
|
|
109
|
+
return null; // no pidfile → let the health-file age decide
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function snapshot() {
|
|
114
|
+
const prev = loadSeen();
|
|
115
|
+
const inbox = {};
|
|
116
|
+
for (const service of services) {
|
|
117
|
+
const seenInbox = prev.inbox || {};
|
|
118
|
+
inbox[service] = listDir(paths.inboxDir(service), isLiveInboxFile, (file) => seenInbox[`${service}/${file}`] != null);
|
|
119
|
+
}
|
|
120
|
+
const seenHandoffs = prev.handoffs || {};
|
|
121
|
+
const upgradeMtime = mtimeOf(paths.upgradeNoticeFile);
|
|
122
|
+
let upgradeNotice = null;
|
|
123
|
+
if (upgradeMtime != null) {
|
|
124
|
+
let body = "";
|
|
125
|
+
try { body = fs.readFileSync(paths.upgradeNoticeFile, "utf8"); } catch { body = ""; }
|
|
126
|
+
upgradeNotice = { mtimeMs: upgradeMtime, body };
|
|
127
|
+
}
|
|
128
|
+
return {
|
|
129
|
+
inbox,
|
|
130
|
+
handoffs: listDir(paths.handoffsDir, isHandoffFile, (file) => seenHandoffs[file] != null),
|
|
131
|
+
signals: {
|
|
132
|
+
restartRequestedMtimeMs: mtimeOf(paths.restartRequestedFile),
|
|
133
|
+
upgradeNotice,
|
|
134
|
+
daemonHealthMtimeMs: mtimeOf(paths.daemonHealthFile),
|
|
135
|
+
daemonPidAlive: daemonPidAlive(),
|
|
136
|
+
},
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** One scan: emit new lines, persist the seen-set when it changed. */
|
|
141
|
+
function tick() {
|
|
142
|
+
const prev = loadSeen();
|
|
143
|
+
const { events, seen: next } = computeFeedEvents(snapshot(), prev, { now });
|
|
144
|
+
for (const ev of events) emit(ev);
|
|
145
|
+
if (JSON.stringify(next) !== JSON.stringify(prev)) {
|
|
146
|
+
seen = next;
|
|
147
|
+
try { fs.writeJsonAtomic(paths.feedSeenFile, next); } catch (err) {
|
|
148
|
+
// An unwritable seen-set costs a replay after a restart, not a dropped event.
|
|
149
|
+
log(`seen-set not persisted: ${err && err.message ? err.message : err}`);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
return events;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* `tick`, but a throw (an emitter whose stdout is gone, a defect in a
|
|
157
|
+
* future parser) costs this tick and one stderr line — never the loop.
|
|
158
|
+
*/
|
|
159
|
+
function tickSafely() {
|
|
160
|
+
try { return tick(); } catch (err) {
|
|
161
|
+
log(`tick failed: ${err && err.message ? err.message : err}`);
|
|
162
|
+
return [];
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** One heartbeat. */
|
|
167
|
+
function beat() {
|
|
168
|
+
const rec = loadMainSession(agentRoot, fs);
|
|
169
|
+
const hb = buildHeartbeat({
|
|
170
|
+
pid: a.pid ?? process.pid,
|
|
171
|
+
ppid: a.ppid ?? process.ppid,
|
|
172
|
+
sessionId: rec ? rec.sessionId : null,
|
|
173
|
+
name,
|
|
174
|
+
now,
|
|
175
|
+
});
|
|
176
|
+
const res = writeHeartbeat(agentRoot, hb, fs);
|
|
177
|
+
if (!res.ok) log(`heartbeat not written: ${res.error}`);
|
|
178
|
+
return res;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** Directories worth an fs.watch (only those that exist right now). */
|
|
182
|
+
function watchDirs() {
|
|
183
|
+
return [...services.map((s) => paths.inboxDir(s)), paths.handoffsDir, paths.stateDir];
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
return { tick, tickSafely, beat, snapshot, watchDirs, name, services, paths };
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Run forever: beat now and every 15 s; tick now, every second, and on any
|
|
191
|
+
* fs.watch event (debounced to the next macrotask). Resolves only on `--once`.
|
|
192
|
+
* @param {{agentRoot:string, once?:boolean}} a
|
|
193
|
+
*/
|
|
194
|
+
export async function runFeed(a) {
|
|
195
|
+
const agentRoot = a.agentRoot;
|
|
196
|
+
const config = await loadSessionConfig(agentRoot);
|
|
197
|
+
const feed = createFeed({ agentRoot, config });
|
|
198
|
+
try { mkdirSync(feed.paths.stateDir, { recursive: true }); } catch { /* beat() reports it */ }
|
|
199
|
+
|
|
200
|
+
feed.beat();
|
|
201
|
+
feed.tickSafely(); // the first snapshot is guarded like every later one
|
|
202
|
+
if (a.once) return 0;
|
|
203
|
+
|
|
204
|
+
const beatTimer = setInterval(() => feed.beat(), HEARTBEAT_INTERVAL_MS);
|
|
205
|
+
let pending = false;
|
|
206
|
+
const scheduleTick = () => {
|
|
207
|
+
if (pending) return;
|
|
208
|
+
pending = true;
|
|
209
|
+
setImmediate(() => { pending = false; feed.tickSafely(); });
|
|
210
|
+
};
|
|
211
|
+
const pollTimer = setInterval(scheduleTick, POLL_INTERVAL_MS);
|
|
212
|
+
|
|
213
|
+
// fs.watch is a fast path, never a dependency: the 1 s poll is the guarantee.
|
|
214
|
+
const watchers = [];
|
|
215
|
+
for (const dir of feed.watchDirs()) {
|
|
216
|
+
try { watchers.push(fsWatch(dir, { persistent: false }, scheduleTick)); } catch { /* dir absent or watch unsupported — the poll covers it */ }
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
await new Promise((resolve) => {
|
|
220
|
+
const stop = () => { clearInterval(beatTimer); clearInterval(pollTimer); for (const w of watchers) { try { w.close(); } catch { /* already closed */ } } resolve(); };
|
|
221
|
+
process.once("SIGTERM", stop);
|
|
222
|
+
process.once("SIGINT", stop);
|
|
223
|
+
process.stdout.once("close", stop); // the Monitor went away
|
|
224
|
+
});
|
|
225
|
+
return 0;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const isMain = (() => {
|
|
229
|
+
try { return process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href; } catch { return false; }
|
|
230
|
+
})();
|
|
231
|
+
|
|
232
|
+
if (isMain) {
|
|
233
|
+
const { once } = parseFeedArgs(process.argv.slice(2));
|
|
234
|
+
runFeed({ agentRoot: resolveAgentRoot(), once })
|
|
235
|
+
.then((code) => process.exit(code))
|
|
236
|
+
.catch((err) => { process.stderr.write(`[feed] fatal: ${err && err.stack ? err.stack : err}\n`); process.exit(1); });
|
|
237
|
+
}
|