@mmerterden/multi-agent-pipeline 20.29.0 → 20.33.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/CHANGELOG.md +86 -0
- package/README.md +50 -5
- package/README.tr.md +64 -5
- package/docs/architecture.md +3 -3
- package/docs/ecosystem.md +5 -5
- package/docs/facts.json +9 -9
- package/install/_unattended-profile.mjs +61 -0
- package/install/catalog-history.json +1 -1
- package/install/claude.mjs +19 -3
- package/install/codex.mjs +2 -0
- package/install/copilot.mjs +13 -0
- package/install/index.mjs +10 -1
- package/install/templates/claude-hooks.json +12 -1
- package/manifest.json +230 -88
- package/package.json +3 -1
- package/pipeline/commands/multi-agent/analysis/SKILL.md +4 -4
- package/pipeline/commands/multi-agent/autopilot-on/SKILL.md +13 -15
- package/pipeline/commands/multi-agent/office-off/SKILL.md +27 -0
- package/pipeline/commands/multi-agent/office-on/SKILL.md +51 -0
- package/pipeline/commands/multi-agent/office-status/SKILL.md +29 -0
- package/pipeline/commands/multi-agent/prune-logs/SKILL.md +4 -1
- package/pipeline/commands/multi-agent/review/SKILL.md +4 -3
- package/pipeline/commands/multi-agent/scenario-audit/SKILL.md +4 -2
- package/pipeline/commands/multi-agent/serve/SKILL.md +4 -2
- package/pipeline/commands/multi-agent/stack/SKILL.md +15 -8
- package/pipeline/commands/multi-agent/steer/SKILL.md +10 -9
- package/pipeline/commands/multi-agent/sync/SKILL.md +15 -3
- package/pipeline/commands/multi-agent/test-gen/SKILL.md +29 -3
- package/pipeline/contract/CHANGELOG.md +108 -0
- package/pipeline/contract/README.md +72 -12
- package/pipeline/contract/fixtures/error-invalid-request.json +1 -1
- package/pipeline/contract/fixtures/error-unauthorized.json +1 -1
- package/pipeline/contract/fixtures/error-unsigned.json +1 -1
- package/pipeline/contract/fixtures/launch-plan.json +1 -1
- package/pipeline/contract/fixtures/runs-awaiting-question.json +6 -1
- package/pipeline/contract/fixtures/runs-empty.json +1 -1
- package/pipeline/contract/fixtures/runs-failed.json +6 -1
- package/pipeline/contract/fixtures/runs-old-schema.json +6 -1
- package/pipeline/contract/fixtures/runs-pr-opened-redacted.json +5 -1
- package/pipeline/contract/fixtures/runs-pr-opened.json +6 -1
- package/pipeline/contract/fixtures/runs-running.json +6 -1
- package/pipeline/contract/manifest.json +506 -27
- package/pipeline/contract/types/index.d.ts +829 -4
- package/pipeline/lib/bg-launch.mjs +106 -0
- package/pipeline/lib/code-digest.mjs +49 -0
- package/pipeline/lib/credential-resolve.mjs +42 -2
- package/pipeline/multi-agent-refs/cross-cli-contract.md +5 -4
- package/pipeline/multi-agent-refs/features/office-threat-model.md +109 -0
- package/pipeline/multi-agent-refs/features/unattended-security.md +1 -1
- package/pipeline/multi-agent-refs/help/en.md +3 -0
- package/pipeline/multi-agent-refs/help/tr.md +3 -0
- package/pipeline/multi-agent-refs/phases/modes.md +7 -7
- package/pipeline/multi-agent-refs/phases/phase-5-report.md +16 -10
- package/pipeline/multi-agent-refs/review-standalone-tracker.md +37 -0
- package/pipeline/multi-agent-refs/tracker-contract.md +8 -3
- package/pipeline/office/NOTICE.md +45 -0
- package/pipeline/office/assets/ARCHIVE_BOXES.png +0 -0
- package/pipeline/office/assets/BIN.png +0 -0
- package/pipeline/office/assets/BOOKSHELF.png +0 -0
- package/pipeline/office/assets/CACTUS.png +0 -0
- package/pipeline/office/assets/CLOCK.png +0 -0
- package/pipeline/office/assets/COFFEE.png +0 -0
- package/pipeline/office/assets/COFFEE_MACHINE.png +0 -0
- package/pipeline/office/assets/COFFEE_TABLE.png +0 -0
- package/pipeline/office/assets/COUNTER_SINK.png +0 -0
- package/pipeline/office/assets/CUSHIONED_BENCH.png +0 -0
- package/pipeline/office/assets/CUSHIONED_CHAIR_BACK.png +0 -0
- package/pipeline/office/assets/CUSHIONED_CHAIR_FRONT.png +0 -0
- package/pipeline/office/assets/CUSHIONED_CHAIR_SIDE.png +0 -0
- package/pipeline/office/assets/DESK_FRONT.png +0 -0
- package/pipeline/office/assets/DESK_SIDE.png +0 -0
- package/pipeline/office/assets/DEVICE_STAND.png +0 -0
- package/pipeline/office/assets/DOUBLE_BOOKSHELF.png +0 -0
- package/pipeline/office/assets/FILING_CABINET.png +0 -0
- package/pipeline/office/assets/FRIDGE.png +0 -0
- package/pipeline/office/assets/HANGING_PLANT.png +0 -0
- package/pipeline/office/assets/LARGE_PAINTING.png +0 -0
- package/pipeline/office/assets/LARGE_PLANT.png +0 -0
- package/pipeline/office/assets/MAILBOX.png +0 -0
- package/pipeline/office/assets/MOP_BUCKET.png +0 -0
- package/pipeline/office/assets/PC_BACK.png +0 -0
- package/pipeline/office/assets/PC_FRONT_OFF.png +0 -0
- package/pipeline/office/assets/PC_FRONT_ON_1.png +0 -0
- package/pipeline/office/assets/PC_FRONT_ON_2.png +0 -0
- package/pipeline/office/assets/PC_FRONT_ON_3.png +0 -0
- package/pipeline/office/assets/PC_SIDE.png +0 -0
- package/pipeline/office/assets/PLANT.png +0 -0
- package/pipeline/office/assets/PLANT_2.png +0 -0
- package/pipeline/office/assets/POT.png +0 -0
- package/pipeline/office/assets/PRINTER.png +0 -0
- package/pipeline/office/assets/RECEPTION_DESK.png +0 -0
- package/pipeline/office/assets/SERVER_RACK.png +0 -0
- package/pipeline/office/assets/SERVER_RACK_2.png +0 -0
- package/pipeline/office/assets/SMALL_PAINTING.png +0 -0
- package/pipeline/office/assets/SMALL_PAINTING_2.png +0 -0
- package/pipeline/office/assets/SMALL_TABLE_FRONT.png +0 -0
- package/pipeline/office/assets/SMALL_TABLE_SIDE.png +0 -0
- package/pipeline/office/assets/SOFA_BACK.png +0 -0
- package/pipeline/office/assets/SOFA_FRONT.png +0 -0
- package/pipeline/office/assets/SOFA_SIDE.png +0 -0
- package/pipeline/office/assets/TABLE_FRONT.png +0 -0
- package/pipeline/office/assets/TOILET_STALL.png +0 -0
- package/pipeline/office/assets/VENDING.png +0 -0
- package/pipeline/office/assets/WASHBASIN.png +0 -0
- package/pipeline/office/assets/WATER_COOLER.png +0 -0
- package/pipeline/office/assets/WHITEBOARD.png +0 -0
- package/pipeline/office/assets/WOODEN_BENCH.png +0 -0
- package/pipeline/office/assets/WOODEN_CHAIR_BACK.png +0 -0
- package/pipeline/office/assets/WOODEN_CHAIR_FRONT.png +0 -0
- package/pipeline/office/assets/WOODEN_CHAIR_SIDE.png +0 -0
- package/pipeline/office/assets/char_0.png +0 -0
- package/pipeline/office/assets/char_1.png +0 -0
- package/pipeline/office/assets/char_2.png +0 -0
- package/pipeline/office/assets/char_3.png +0 -0
- package/pipeline/office/assets/char_4.png +0 -0
- package/pipeline/office/assets/char_5.png +0 -0
- package/pipeline/office/assets/floor_0.png +0 -0
- package/pipeline/office/assets/floor_1.png +0 -0
- package/pipeline/office/assets/floor_2.png +0 -0
- package/pipeline/office/assets/floor_3.png +0 -0
- package/pipeline/office/assets/floor_4.png +0 -0
- package/pipeline/office/assets/floor_5.png +0 -0
- package/pipeline/office/assets/floor_6.png +0 -0
- package/pipeline/office/assets/floor_7.png +0 -0
- package/pipeline/office/assets/floor_8.png +0 -0
- package/pipeline/office/assets/wall_0.png +0 -0
- package/pipeline/office/index.html +8431 -0
- package/pipeline/office/toolkit.json +3 -0
- package/pipeline/schemas/activity-chat.schema.json +78 -0
- package/pipeline/schemas/activity-roles.json +48 -0
- package/pipeline/schemas/activity-roles.schema.json +49 -0
- package/pipeline/schemas/activity.schema.json +613 -0
- package/pipeline/schemas/agent-state.schema.json +493 -115
- package/pipeline/schemas/autopilot-on-request.schema.json +33 -0
- package/pipeline/schemas/autopilot-on.schema.json +111 -0
- package/pipeline/schemas/contract-error.schema.json +45 -4
- package/pipeline/schemas/issue-maturity.schema.json +68 -0
- package/pipeline/schemas/job-change.schema.json +72 -0
- package/pipeline/schemas/job-request.schema.json +38 -0
- package/pipeline/schemas/launch.json +4 -4
- package/pipeline/schemas/ledger.schema.json +202 -0
- package/pipeline/schemas/office-credentials.schema.json +71 -0
- package/pipeline/schemas/office-jobs.json +253 -0
- package/pipeline/schemas/office-jobs.schema.json +62 -0
- package/pipeline/schemas/office-memory.schema.json +105 -0
- package/pipeline/schemas/office-settings-request.schema.json +30 -0
- package/pipeline/schemas/office-settings.schema.json +67 -0
- package/pipeline/schemas/permissions.schema.json +47 -0
- package/pipeline/schemas/phases.json +10 -2
- package/pipeline/schemas/phases.schema.json +86 -19
- package/pipeline/schemas/prefs.schema.json +19 -0
- package/pipeline/schemas/prs.schema.json +78 -0
- package/pipeline/schemas/prune-logs-request.schema.json +26 -0
- package/pipeline/schemas/prune-logs.schema.json +52 -0
- package/pipeline/schemas/repo-change.schema.json +54 -0
- package/pipeline/schemas/repo-request.schema.json +29 -0
- package/pipeline/schemas/repos.schema.json +49 -1
- package/pipeline/schemas/review-verdict-request.schema.json +20 -0
- package/pipeline/schemas/review-verdict.schema.json +62 -0
- package/pipeline/schemas/routine-delete.schema.json +59 -0
- package/pipeline/schemas/routines.schema.json +38 -0
- package/pipeline/schemas/run-evidence.schema.json +29 -0
- package/pipeline/schemas/run-results.schema.json +218 -0
- package/pipeline/schemas/runs-index.schema.json +257 -46
- package/pipeline/schemas/service-read.schema.json +58 -0
- package/pipeline/schemas/stack-change.schema.json +82 -0
- package/pipeline/schemas/stack-request.schema.json +33 -0
- package/pipeline/schemas/stack.schema.json +59 -0
- package/pipeline/schemas/steer-request.schema.json +25 -0
- package/pipeline/schemas/steer.schema.json +59 -0
- package/pipeline/schemas/system-status.schema.json +111 -0
- package/pipeline/schemas/tool-call-request.schema.json +19 -0
- package/pipeline/schemas/tool-call.schema.json +47 -0
- package/pipeline/schemas/tools.schema.json +65 -0
- package/pipeline/schemas/unattended-profile.json +7 -2
- package/pipeline/scripts/_active-run.mjs +2 -1
- package/pipeline/scripts/_cost.mjs +66 -1
- package/pipeline/scripts/_office-smoke-lib.sh +115 -0
- package/pipeline/scripts/_spawn-async.mjs +77 -0
- package/pipeline/scripts/_transcript-tail.mjs +274 -0
- package/pipeline/scripts/activity-snapshot.mjs +1223 -0
- package/pipeline/scripts/autopilot-control.mjs +214 -9
- package/pipeline/scripts/autopilot-runner.mjs +7 -5
- package/pipeline/scripts/contract-server.mjs +1853 -197
- package/pipeline/scripts/cost-analyze.mjs +2 -53
- package/pipeline/scripts/cost-table.json +1 -0
- package/pipeline/scripts/gen-mode-dispatch.mjs +3 -1
- package/pipeline/scripts/job-launch.mjs +477 -0
- package/pipeline/scripts/launch-request.mjs +94 -9
- package/pipeline/scripts/office-build.mjs +226 -0
- package/pipeline/scripts/office-control.mjs +410 -0
- package/pipeline/scripts/office-credentials.mjs +106 -0
- package/pipeline/scripts/office-ledger.mjs +409 -0
- package/pipeline/scripts/office-memory.mjs +200 -0
- package/pipeline/scripts/office-prune.mjs +125 -0
- package/pipeline/scripts/office-settings.mjs +145 -0
- package/pipeline/scripts/phase-tracker.sh +12 -5
- package/pipeline/scripts/prune-logs.sh +53 -2
- package/pipeline/scripts/routine-registry.mjs +42 -6
- package/pipeline/scripts/run-results.mjs +373 -0
- package/pipeline/scripts/runs-index.mjs +62 -2
- package/pipeline/scripts/service-read.mjs +244 -0
- package/pipeline/scripts/stack-toggle.mjs +285 -0
- package/pipeline/scripts/steer-run.mjs +97 -0
- package/pipeline/scripts/system-status.mjs +196 -0
- package/pipeline/scripts/task-mirror.mjs +150 -0
- package/pipeline/scripts/task-mirror.sh +20 -0
- package/pipeline/scripts/toolkit-tools.mjs +625 -0
- package/pipeline/scripts/transcript-index.mjs +4 -1
- package/pipeline/scripts/uninstall.mjs +3 -0
- package/pipeline/scripts/work-items.mjs +473 -0
- package/pipeline/skills/.skill-manifest.json +23 -11
- package/pipeline/skills/.skills-index.json +34 -1
- package/pipeline/skills/shared/README.md +6 -3
- package/pipeline/skills/shared/core/multi-agent-autopilot-on/SKILL.md +13 -15
- package/pipeline/skills/shared/core/multi-agent-office-off/SKILL.md +16 -0
- package/pipeline/skills/shared/core/multi-agent-office-on/SKILL.md +21 -0
- package/pipeline/skills/shared/core/multi-agent-office-status/SKILL.md +17 -0
- package/pipeline/skills/shared/core/multi-agent-prune-logs/SKILL.md +4 -1
- package/pipeline/skills/shared/core/multi-agent-review/SKILL.md +19 -0
- package/pipeline/skills/shared/core/multi-agent-scenario-audit/SKILL.md +4 -2
- package/pipeline/skills/shared/core/multi-agent-serve/SKILL.md +4 -2
- package/pipeline/skills/shared/core/multi-agent-stack/SKILL.md +14 -6
- package/pipeline/skills/shared/core/multi-agent-steer/SKILL.md +10 -9
- package/pipeline/skills/shared/core/multi-agent-sync/SKILL.md +3 -3
- package/pipeline/skills/shared/core/multi-agent-test-gen/SKILL.md +26 -3
- package/pipeline/skills/skills-index.md +4 -1
|
@@ -20,15 +20,61 @@
|
|
|
20
20
|
* GET /v1/issues issues.mjs --json [--source S] [--project K]... [--max N]
|
|
21
21
|
* GET /v1/worktrees worktrees.mjs --json [--measure] [--redact]
|
|
22
22
|
* GET /v1/launch-spec launch-request.mjs spec
|
|
23
|
-
* GET /v1/repos launch-request.mjs repos [--redact]
|
|
23
|
+
* GET /v1/repos launch-request.mjs repos [--redact]; ?all=1 adds
|
|
24
|
+
* the suggested and the missing registered ones
|
|
25
|
+
* POST /v1/repos launch-request.mjs register-repo (two steps)
|
|
26
|
+
* DELETE /v1/repos/{id} remove a registered repository (two steps)
|
|
24
27
|
* POST /v1/launch?repo=P write a launch request, return launch-request.mjs plan
|
|
25
28
|
* POST /v1/runs/{id}/answer answer-question.mjs --question Q --answer IDS
|
|
26
29
|
* GET /v1/runs/{id}/log run-log.mjs {id} --tail N
|
|
27
30
|
* POST /v1/runs/{id}/resume launch-request.mjs resume: the plan that resumes it
|
|
28
31
|
* POST /v1/runs/{id}/kill run-kill.mjs preview, then apply (two steps)
|
|
29
32
|
* POST /v1/gc gc-plan.mjs preview, then apply (two steps)
|
|
33
|
+
* POST /v1/prune-logs office-prune.mjs preview, then apply the listed folders (two steps)
|
|
34
|
+
* GET /v1/settings office-settings.mjs --json
|
|
35
|
+
* POST /v1/settings office-settings.mjs set S V, after a preview (two steps)
|
|
30
36
|
* GET /v1/autopilot autopilot-control.mjs status
|
|
31
37
|
* POST /v1/autopilot/off autopilot-control.mjs off [--now]
|
|
38
|
+
* POST /v1/autopilot/on autopilot-control.mjs on-preview, then on (two steps)
|
|
39
|
+
* POST /v1/runs/{id}/steer steer-run.mjs: queue one instruction for the next phase
|
|
40
|
+
* GET /v1/activity activity-snapshot.mjs --json
|
|
41
|
+
* GET /v1/activity/stream the same document as server-sent events, on change
|
|
42
|
+
* GET /v1/agents/{sessionId}/{agentId}/chat activity-snapshot.mjs chat
|
|
43
|
+
* GET /v1/ledger office-ledger.mjs --json
|
|
44
|
+
* GET /v1/system system-status.mjs --json
|
|
45
|
+
* GET /v1/credentials office-credentials.mjs --json [--probe]
|
|
46
|
+
* GET /v1/memory office-memory.mjs --json
|
|
47
|
+
* GET /v1/runs/{id}/results run-results.mjs: diff, findings, tests, evidence, analysis
|
|
48
|
+
* GET /v1/runs/{id}/evidence/{name} run-results.mjs --evidence: one screenshot
|
|
49
|
+
* POST /v1/runs/{id}/review-verdict work-items.mjs verdict, then --post (two steps)
|
|
50
|
+
* GET /v1/issues/{key}/maturity work-items.mjs maturity (lib/issue-fetcher.sh)
|
|
51
|
+
* GET /v1/prs work-items.mjs prs
|
|
52
|
+
* GET /v1/tools toolkit-tools.mjs list (the registered toolkit MCP server)
|
|
53
|
+
* POST /v1/tools/{name}/call toolkit-tools.mjs call
|
|
54
|
+
* GET /v1/services/{confluence,crashlytics,figma} service-read.mjs
|
|
55
|
+
* GET /v1/permissions pending host permission prompts (none can be read yet)
|
|
56
|
+
* GET /v1/routines routine-registry.mjs list --json
|
|
57
|
+
* DELETE /v1/routines/{name} routine-registry.mjs remove (two steps)
|
|
58
|
+
* GET /v1/stack?repo=R stack-toggle.mjs status
|
|
59
|
+
* POST /v1/stack stack-toggle.mjs set (two steps)
|
|
60
|
+
* GET /v1/jobs job-launch.mjs list: the jobs the office can start
|
|
61
|
+
* POST /v1/jobs start one in the background (two steps)
|
|
62
|
+
*
|
|
63
|
+
* Every route carries two tags for a remote carrier to enforce later, and
|
|
64
|
+
* manifest.json repeats them: `remote` is the scope it would need (read,
|
|
65
|
+
* answer, launch, or local-only for anything that widens what runs on this
|
|
66
|
+
* machine: autopilot on, stack, routine removal, kill, gc), and `risk` is the
|
|
67
|
+
* confirmation tier it would need (read, low, high). Locally the bearer token
|
|
68
|
+
* is the only credential either way.
|
|
69
|
+
*
|
|
70
|
+
* GET /office serves the browser panel from MA_OFFICE_PAGE, else
|
|
71
|
+
* prefs.global.office.page, else the installed copy (office/index.html next
|
|
72
|
+
* to this scripts directory), and is 404 when none exists. The page carries
|
|
73
|
+
* no token: it trades a one-time code from its URL fragment for one. It and
|
|
74
|
+
* its sprites under /office/assets/ are the only routes that need none (with
|
|
75
|
+
* /favicon.ico, an empty 204, because a browser asks for it on its own), and
|
|
76
|
+
* its response carries a per-response CSP nonce for its inline script and
|
|
77
|
+
* style.
|
|
32
78
|
*
|
|
33
79
|
* Phone routes, the same handlers behind a device signature instead of the
|
|
34
80
|
* bearer token, always redacted (features/phone-api.md):
|
|
@@ -38,14 +84,19 @@
|
|
|
38
84
|
* POST /v1/phone/runs/{id}/answer scope answer
|
|
39
85
|
* POST /v1/phone/launch?repo=P scope launch, and only after `phone-devices.mjs launch on`
|
|
40
86
|
*
|
|
41
|
-
* There is deliberately no route that merges, marks ready, runs a
|
|
42
|
-
*
|
|
43
|
-
* and environment launch.json declares
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
*
|
|
87
|
+
* There is deliberately no route that merges, marks ready, runs a shell
|
|
88
|
+
* command or takes a prompt. POST /v1/launch and POST /v1/runs/{id}/resume
|
|
89
|
+
* return the argv and environment launch.json declares for a client to start.
|
|
90
|
+
* POST /v1/jobs is the one route that starts a session itself, for the office:
|
|
91
|
+
* a job from schemas/office-jobs.json with typed arguments, two requests, the
|
|
92
|
+
* unattended profile, workspace trust and the autopilot runner's sandbox check
|
|
93
|
+
* first, then `claude --bg` without a shell (lib/bg-launch.mjs), never from a
|
|
94
|
+
* phone. Besides that the only `claude` this process runs is `claude agents
|
|
95
|
+
* --json` (read, for processAlive) and `claude stop <session>`, for a kill or
|
|
96
|
+
* `autopilot/off` with now.
|
|
47
97
|
*
|
|
48
|
-
* Kill
|
|
98
|
+
* Kill, gc, autopilot on, stack and routine removal change what this machine
|
|
99
|
+
* keeps or runs, so each takes two requests. The first returns a
|
|
49
100
|
* preview and a confirm token: 32 random bytes, kept in this process's memory
|
|
50
101
|
* only, bound to the route and the run id, valid for CONFIRM_TTL_MS and for one
|
|
51
102
|
* use. The second sends the token back and acts on exactly what the preview
|
|
@@ -63,8 +114,11 @@
|
|
|
63
114
|
*
|
|
64
115
|
* 1. Host must be 127.0.0.1:<port> or localhost:<port>, which is what stops
|
|
65
116
|
* a DNS-rebound page from reaching the socket under another name (403);
|
|
66
|
-
* 2. an Origin header must be
|
|
67
|
-
*
|
|
117
|
+
* 2. an Origin header must be this server's own origin (the panel it
|
|
118
|
+
* serves) or one of --allow-origin; any other is refused, and no CORS
|
|
119
|
+
* header is sent to it (403). A write (POST, DELETE) and the chat route
|
|
120
|
+
* accept no Origin but the server's own: a page somewhere else can never
|
|
121
|
+
* drive one, even an allowed one;
|
|
68
122
|
* 3. an allowed preflight is answered, before authentication, since a
|
|
69
123
|
* browser sends no credentials on one (204);
|
|
70
124
|
* 4. a path under /v1/phone/ leaves here for the device-signature checks in
|
|
@@ -79,21 +133,37 @@
|
|
|
79
133
|
*
|
|
80
134
|
* `?redact=1` is `--redact`. A request that carries an Origin is a browser
|
|
81
135
|
* page, which can relay what it reads anywhere, so it is always served
|
|
82
|
-
* redacted whatever the query says.
|
|
136
|
+
* redacted whatever the query says. The bearer token is read from the
|
|
137
|
+
* Authorization header only: never a cookie, never a query parameter.
|
|
138
|
+
*
|
|
139
|
+
* Every write route appends one line (who, what, when, outcome; never a
|
|
140
|
+
* body or a token) to ~/.claude/logs/multi-agent/office-actions.jsonl.
|
|
83
141
|
*
|
|
84
142
|
* Lifetime. The server exits after --idle-minutes without a request (15 by
|
|
85
|
-
* default)
|
|
86
|
-
*
|
|
143
|
+
* default, 0 never), after --max-hours however busy it is (8 by default, 0 no
|
|
144
|
+
* limit; an open event stream does not extend it), and on SIGTERM, SIGINT or
|
|
145
|
+
* SIGHUP, removing its token file either way. office-on starts it with both at
|
|
146
|
+
* 0, so the panel runs until office-off.
|
|
147
|
+
*
|
|
148
|
+
* Panel bootstrap. `POST /office/code` (bearer, no Origin) mints a code that
|
|
149
|
+
* is valid for 10 seconds and one use; office-control.mjs opens the browser
|
|
150
|
+
* with it in the URL fragment. The page trades it for the token with
|
|
151
|
+
* `POST /office/exchange` (no bearer, own Origin only); a second use, an
|
|
152
|
+
* expired code or an unknown one is 401. The token itself never appears in a
|
|
153
|
+
* URL. `GET /office/version` gives the installed page's build id and whether
|
|
154
|
+
* this server's code changed on disk since it started; `POST /office/reload`
|
|
155
|
+
* mints a fresh code for the page itself, so it can reload onto a newly
|
|
156
|
+
* installed page. It is started on demand by `/multi-agent:serve` or by a client, and is
|
|
87
157
|
* never installed as a service.
|
|
88
158
|
*
|
|
89
159
|
* Zero runtime dependencies (ADR-0004): node:http, node:crypto and the
|
|
90
160
|
* producers' own modules.
|
|
91
161
|
*
|
|
92
162
|
* Usage:
|
|
93
|
-
* node contract-server.mjs [--port N] [--idle-minutes N] [--allow-origin <origin>]... [--print-token]
|
|
163
|
+
* node contract-server.mjs [--port N] [--idle-minutes N] [--max-hours N] [--allow-origin <origin>]... [--print-token]
|
|
94
164
|
*
|
|
95
165
|
* Stdout: one JSON line once listening - {contractVersion, url, port, pid,
|
|
96
|
-
* tokenFile, idleMinutes[, token]}.
|
|
166
|
+
* tokenFile, idleMinutes, maxHours[, token]}.
|
|
97
167
|
* Exit: 0 stopped (idle or signal), 1 could not listen, 2 usage error.
|
|
98
168
|
*
|
|
99
169
|
* @module pipeline/scripts/contract-server
|
|
@@ -102,6 +172,7 @@
|
|
|
102
172
|
import { Buffer } from "node:buffer";
|
|
103
173
|
import { createHash, randomBytes, randomUUID, timingSafeEqual } from "node:crypto";
|
|
104
174
|
import {
|
|
175
|
+
appendFileSync,
|
|
105
176
|
chmodSync,
|
|
106
177
|
existsSync,
|
|
107
178
|
mkdirSync,
|
|
@@ -113,15 +184,63 @@ import {
|
|
|
113
184
|
} from "node:fs";
|
|
114
185
|
import { createServer } from "node:http";
|
|
115
186
|
import { homedir } from "node:os";
|
|
116
|
-
import { isAbsolute, join, sep } from "node:path";
|
|
117
|
-
import {
|
|
187
|
+
import { basename, dirname, isAbsolute, join, sep } from "node:path";
|
|
188
|
+
import { fileURLToPath } from "node:url";
|
|
189
|
+
import { clearInterval, clearTimeout, setInterval, setTimeout } from "node:timers";
|
|
118
190
|
import { answerStateFile } from "./answer-question.mjs";
|
|
191
|
+
import { CONTRACT_VERSION as AUTOPILOT_VERSION, ControlError } from "./autopilot-control.mjs";
|
|
192
|
+
import { spawnAsync } from "./_spawn-async.mjs";
|
|
193
|
+
import {
|
|
194
|
+
CONTRACT_VERSION as ACTIVITY_VERSION,
|
|
195
|
+
activitySnapshot,
|
|
196
|
+
agentChat,
|
|
197
|
+
createActivityCache,
|
|
198
|
+
} from "./activity-snapshot.mjs";
|
|
199
|
+
import { RoutineError, listRoutines, removeRoutine } from "./routine-registry.mjs";
|
|
200
|
+
import {
|
|
201
|
+
CONTRACT_VERSION as STACK_VERSION,
|
|
202
|
+
StackError,
|
|
203
|
+
stackSet,
|
|
204
|
+
stackStatus,
|
|
205
|
+
} from "./stack-toggle.mjs";
|
|
206
|
+
import { CONTRACT_VERSION as STEER_VERSION, SteerError, queueSteer } from "./steer-run.mjs";
|
|
207
|
+
import { officeLedger } from "./office-ledger.mjs";
|
|
208
|
+
import { systemStatus } from "./system-status.mjs";
|
|
209
|
+
import { officeCredentials } from "./office-credentials.mjs";
|
|
210
|
+
import { officeMemory } from "./office-memory.mjs";
|
|
211
|
+
import { AGES as PRUNE_AGES, pruneApply, prunePreview } from "./office-prune.mjs";
|
|
212
|
+
import {
|
|
213
|
+
CHOICES,
|
|
214
|
+
SettingsError,
|
|
215
|
+
applySetting,
|
|
216
|
+
freeChainOf,
|
|
217
|
+
readSettings,
|
|
218
|
+
} from "./office-settings.mjs";
|
|
219
|
+
import { EVIDENCE_NAME, ResultsError, runEvidence, runResults } from "./run-results.mjs";
|
|
220
|
+
import {
|
|
221
|
+
WorkError,
|
|
222
|
+
issueMaturity,
|
|
223
|
+
postReviewVerdict,
|
|
224
|
+
pullRequests,
|
|
225
|
+
reviewVerdictPreview,
|
|
226
|
+
} from "./work-items.mjs";
|
|
119
227
|
import {
|
|
120
|
-
CONTRACT_VERSION as
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
228
|
+
CONTRACT_VERSION as TOOLS_VERSION,
|
|
229
|
+
activeToolkitVersion,
|
|
230
|
+
TOOL_NAME,
|
|
231
|
+
ToolError,
|
|
232
|
+
createToolkitClient,
|
|
233
|
+
} from "./toolkit-tools.mjs";
|
|
234
|
+
import {
|
|
235
|
+
FIGMA_KEY,
|
|
236
|
+
FIGMA_NODE,
|
|
237
|
+
PAGE_ID,
|
|
238
|
+
ServiceError,
|
|
239
|
+
confluencePage,
|
|
240
|
+
crashlyticsIssue,
|
|
241
|
+
figmaFrame,
|
|
242
|
+
} from "./service-read.mjs";
|
|
243
|
+
import { listSessions } from "../lib/claude-sessions.mjs";
|
|
125
244
|
import { catalog } from "./commands.mjs";
|
|
126
245
|
import { collect } from "./issues.mjs";
|
|
127
246
|
import {
|
|
@@ -132,16 +251,31 @@ import {
|
|
|
132
251
|
} from "./gc-plan.mjs";
|
|
133
252
|
import {
|
|
134
253
|
launchRepoAllowed,
|
|
254
|
+
launchRepos,
|
|
135
255
|
launchSpec,
|
|
136
256
|
planLaunch,
|
|
137
257
|
planResume,
|
|
138
258
|
questionsDocument,
|
|
259
|
+
registerRepo,
|
|
139
260
|
reposDocument,
|
|
261
|
+
repoId,
|
|
140
262
|
requestsDir,
|
|
263
|
+
suggestedRepos,
|
|
264
|
+
unregisterRepo,
|
|
265
|
+
missingRepos,
|
|
141
266
|
workingTreeState,
|
|
142
267
|
writeRequestFile,
|
|
143
268
|
} from "./launch-request.mjs";
|
|
144
269
|
import { GROUP_NAMES, envelope, selectRuns } from "./runs-index.mjs";
|
|
270
|
+
import {
|
|
271
|
+
CONTRACT_VERSION as JOBS_VERSION,
|
|
272
|
+
JobError,
|
|
273
|
+
jobsDocument,
|
|
274
|
+
previewJob,
|
|
275
|
+
startJob,
|
|
276
|
+
} from "./job-launch.mjs";
|
|
277
|
+
import { guardRegistration, sandboxPrecondition } from "./autopilot-runner.mjs";
|
|
278
|
+
import { codeDigest } from "../lib/code-digest.mjs";
|
|
145
279
|
import { CONTRACT_VERSION as KILL_VERSION, KillError, killPreview, killRun } from "./run-kill.mjs";
|
|
146
280
|
import {
|
|
147
281
|
CONTRACT_VERSION as LOG_VERSION,
|
|
@@ -171,7 +305,7 @@ import { runMain } from "../lib/fatal.mjs";
|
|
|
171
305
|
import { invokedDirectly } from "../lib/invoked-directly.mjs";
|
|
172
306
|
|
|
173
307
|
/** The contract as a whole; pipeline/contract/manifest.json carries the same. */
|
|
174
|
-
export const CONTRACT_VERSION = "1.
|
|
308
|
+
export const CONTRACT_VERSION = "1.6.0";
|
|
175
309
|
/** The majors `?v=` accepts. A new major keeps the previous one here for one minor. */
|
|
176
310
|
export const SUPPORTED_MAJORS = [1];
|
|
177
311
|
export const BIND_HOST = "127.0.0.1";
|
|
@@ -180,12 +314,36 @@ export const MAX_BODY_BYTES = 64 * 1024;
|
|
|
180
314
|
const MAX_DRAIN_BYTES = 1024 * 1024;
|
|
181
315
|
const DEFAULT_IDLE_MINUTES = 15;
|
|
182
316
|
const MAX_IDLE_MINUTES = 24 * 60;
|
|
317
|
+
/** Absolute lifetime: the server exits after this however busy it is. */
|
|
318
|
+
const DEFAULT_MAX_HOURS = 8;
|
|
319
|
+
const MAX_HOURS = 24;
|
|
320
|
+
/** Confirm tokens held at once; the oldest is dropped past this. */
|
|
321
|
+
export const MAX_CONFIRMS = 256;
|
|
322
|
+
/** Connections at once, and how long a phone request may take to send its body. */
|
|
323
|
+
const MAX_CONNECTIONS = 64;
|
|
324
|
+
const PHONE_BODY_TIMEOUT_MS = 5000;
|
|
325
|
+
/** A panel code: single use, valid this long, at most this many outstanding. */
|
|
326
|
+
export const CODE_TTL_MS = 10 * 1000;
|
|
327
|
+
const MAX_CODES = 16;
|
|
328
|
+
const PANEL_CODE = /^[A-Za-z0-9_-]{16,128}$/;
|
|
183
329
|
const RUN_ID = /^[A-Za-z0-9#][A-Za-z0-9#._-]{0,127}$/;
|
|
184
330
|
const PROJECT_KEY = /^[A-Z][A-Z0-9_]*$/;
|
|
185
331
|
const QUESTION_ID = /^[a-z][a-z0-9-]*$/;
|
|
186
332
|
const OPTION_ID = /^[a-z0-9][a-z0-9+_-]*$/;
|
|
187
333
|
const CONTRACT_VERSION_PATTERN = /^1\.[0-9]+\.[0-9]+$/;
|
|
188
334
|
const CONFIRM_TOKEN = /^[A-Za-z0-9_-]{16,128}$/;
|
|
335
|
+
const SESSION_ID = /^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/;
|
|
336
|
+
const AGENT_ID = /^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/;
|
|
337
|
+
const ROUTINE_NAME = /^[a-z0-9][a-z0-9-]{0,39}$/;
|
|
338
|
+
const REPO_NAME = /^[A-Za-z0-9._-]{1,100}(?:\/[A-Za-z0-9._-]{1,100})?$/;
|
|
339
|
+
const REPO_ID = /^[0-9a-f]{16}$/;
|
|
340
|
+
/** Server-sent event streams open at once; one more is refused. */
|
|
341
|
+
export const MAX_STREAMS = 4;
|
|
342
|
+
const STREAM_POLL_MS = 2000;
|
|
343
|
+
const STREAM_HEARTBEAT_MS = 15000;
|
|
344
|
+
const SESSIONS_TTL_MS = 10000;
|
|
345
|
+
const MAX_CHAT_LIMIT = 500;
|
|
346
|
+
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
189
347
|
/** How long a kill or gc confirm token stays valid. */
|
|
190
348
|
export const CONFIRM_TTL_MS = 120 * 1000;
|
|
191
349
|
|
|
@@ -201,6 +359,13 @@ const confirmTtlMs = (() => {
|
|
|
201
359
|
return n >= 1 && n <= CONFIRM_TTL_MS ? n : CONFIRM_TTL_MS;
|
|
202
360
|
})();
|
|
203
361
|
|
|
362
|
+
/** As confirmTtlMs, for panel codes: MA_OFFICE_CODE_TTL_MS can only shorten it (tests). */
|
|
363
|
+
const codeTtlMs = (() => {
|
|
364
|
+
const raw = process.env.MA_OFFICE_CODE_TTL_MS;
|
|
365
|
+
const n = /^[0-9]{1,9}$/.test(raw ?? "") ? Number(raw) : NaN;
|
|
366
|
+
return n >= 1 && n <= CODE_TTL_MS ? n : CODE_TTL_MS;
|
|
367
|
+
})();
|
|
368
|
+
|
|
204
369
|
/** Where token files live. Fixed under HOME, never under LOGS_ROOT. */
|
|
205
370
|
export function tokenDir() {
|
|
206
371
|
return join(homedir(), ".claude", "logs", "multi-agent", "contract-server");
|
|
@@ -226,10 +391,20 @@ const Q = {
|
|
|
226
391
|
redact: { check: (v) => v === "0" || v === "1" },
|
|
227
392
|
group: { check: (v) => GROUP_NAMES.includes(v) },
|
|
228
393
|
measure: { check: (v) => v === "0" || v === "1" },
|
|
394
|
+
all: { check: (v) => v === "0" || v === "1" },
|
|
229
395
|
source: { check: (v) => ["jira", "github", "all"].includes(v) },
|
|
230
396
|
project: { repeat: true, check: (v) => PROJECT_KEY.test(v) },
|
|
231
397
|
max: { check: (v) => /^[0-9]{1,3}$/.test(v) && Number(v) >= 1 && Number(v) <= 200 },
|
|
232
|
-
repo: { check: (v) => v.length <= 4096 && isAbsolute(v) },
|
|
398
|
+
repo: { check: (v) => v.length <= 4096 && (isAbsolute(v) || REPO_NAME.test(v)) },
|
|
399
|
+
before: { check: (v) => /^[0-9]{1,15}$/.test(v) },
|
|
400
|
+
limit: {
|
|
401
|
+
check: (v) => /^[0-9]{1,3}$/.test(v) && Number(v) >= 1 && Number(v) <= MAX_CHAT_LIMIT,
|
|
402
|
+
},
|
|
403
|
+
confirm: { check: (v) => CONFIRM_TOKEN.test(v) },
|
|
404
|
+
page: { check: (v) => PAGE_ID.test(v) || (v.length <= 2048 && /^https:\/\//.test(v)) },
|
|
405
|
+
issue: { check: (v) => v.length <= 2048 && /^https:\/\//.test(v) },
|
|
406
|
+
file: { check: (v) => FIGMA_KEY.test(v) },
|
|
407
|
+
node: { check: (v) => FIGMA_NODE.test(v) },
|
|
233
408
|
tail: { check: (v) => /^[0-9]{1,4}$/.test(v) && Number(v) >= 1 && Number(v) <= MAX_TAIL },
|
|
234
409
|
};
|
|
235
410
|
|
|
@@ -336,8 +511,11 @@ function requireWorkspaceTrust(repoPath, redact) {
|
|
|
336
511
|
* only. Either way the repo must be a configured or registered one.
|
|
337
512
|
*/
|
|
338
513
|
function launchRun({ q, body, redact, channel = "desktop" }) {
|
|
339
|
-
|
|
514
|
+
let repoPath = one(q, "repo");
|
|
340
515
|
if (!repoPath) throw new HttpError(400, "invalid-query", "repo is required");
|
|
516
|
+
// A name from GET /v1/repos is accepted too: a browser reads that list
|
|
517
|
+
// redacted, without paths. Either way the repository must be an allowed one.
|
|
518
|
+
if (!isAbsolute(repoPath)) repoPath = resolveRepo(repoPath);
|
|
341
519
|
if (!existsSync(repoPath) || !statSync(repoPath).isDirectory()) {
|
|
342
520
|
throw new HttpError(400, "invalid-query", "repo is not a directory");
|
|
343
521
|
}
|
|
@@ -430,6 +608,7 @@ const confirms = new Map();
|
|
|
430
608
|
function mintConfirm(route, id, payload) {
|
|
431
609
|
const now = Date.now();
|
|
432
610
|
for (const [t, c] of confirms) if (c.expires <= now) confirms.delete(t);
|
|
611
|
+
while (confirms.size >= MAX_CONFIRMS) confirms.delete(confirms.keys().next().value);
|
|
433
612
|
const token = randomBytes(32).toString("base64url");
|
|
434
613
|
const expires = now + confirmTtlMs;
|
|
435
614
|
confirms.set(token, { route, id, expires, payload });
|
|
@@ -585,6 +764,87 @@ async function killRoute({ params, body, redact }) {
|
|
|
585
764
|
}
|
|
586
765
|
}
|
|
587
766
|
|
|
767
|
+
/**
|
|
768
|
+
* POST /v1/prune-logs: what prune-logs.sh would remove for an age, and a token
|
|
769
|
+
* bound to that age and folder list; then a prune of exactly that list
|
|
770
|
+
* (pruneApply), refused with 409 changed when the folders moved. The age is
|
|
771
|
+
* one of a fixed set.
|
|
772
|
+
*/
|
|
773
|
+
async function pruneRoute({ body }) {
|
|
774
|
+
requireBody(
|
|
775
|
+
body,
|
|
776
|
+
{ confirmToken: isToken, olderThanDays: (v) => PRUNE_AGES.includes(v) },
|
|
777
|
+
"prune-logs-request.schema.json",
|
|
778
|
+
);
|
|
779
|
+
if (body.confirmToken === undefined) {
|
|
780
|
+
if (body.olderThanDays === undefined)
|
|
781
|
+
throw new HttpError(400, "invalid-body", "olderThanDays is required for the preview");
|
|
782
|
+
const { all, ...doc } = await prunePreview(body.olderThanDays, process.env, {
|
|
783
|
+
keepAll: true,
|
|
784
|
+
});
|
|
785
|
+
return {
|
|
786
|
+
...doc,
|
|
787
|
+
...mintConfirm("prune-logs", "", { days: body.olderThanDays, all, count: doc.count }),
|
|
788
|
+
};
|
|
789
|
+
}
|
|
790
|
+
if (body.olderThanDays !== undefined)
|
|
791
|
+
throw new HttpError(
|
|
792
|
+
400,
|
|
793
|
+
"invalid-body",
|
|
794
|
+
"olderThanDays belongs to the preview, not the confirmation",
|
|
795
|
+
);
|
|
796
|
+
const { days, all, count } = redeemConfirm(body.confirmToken, "prune-logs", "");
|
|
797
|
+
try {
|
|
798
|
+
return await pruneApply(days, process.env, { all, count });
|
|
799
|
+
} catch (e) {
|
|
800
|
+
if (e.code === "changed") throw new HttpError(409, "changed", e.message);
|
|
801
|
+
throw e;
|
|
802
|
+
}
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
/**
|
|
806
|
+
* POST /v1/settings: the change a setting would make and a token, then the
|
|
807
|
+
* same script the terminal command runs. The token is bound to that setting
|
|
808
|
+
* and value.
|
|
809
|
+
*/
|
|
810
|
+
async function settingsRoute({ body }) {
|
|
811
|
+
requireBody(
|
|
812
|
+
body,
|
|
813
|
+
{
|
|
814
|
+
confirmToken: isToken,
|
|
815
|
+
setting: (v) => Object.hasOwn(CHOICES, v),
|
|
816
|
+
value: (v) => typeof v === "string",
|
|
817
|
+
},
|
|
818
|
+
"office-settings-request.schema.json",
|
|
819
|
+
);
|
|
820
|
+
if (body.confirmToken === undefined) {
|
|
821
|
+
if (!body.setting || !CHOICES[body.setting].includes(body.value))
|
|
822
|
+
throw new HttpError(400, "invalid-body", "setting and one of its values are required");
|
|
823
|
+
const now = readSettings();
|
|
824
|
+
return {
|
|
825
|
+
...now,
|
|
826
|
+
change: {
|
|
827
|
+
setting: body.setting,
|
|
828
|
+
from: now[body.setting],
|
|
829
|
+
to: body.value,
|
|
830
|
+
...(body.setting === "route" && body.value === "on" && freeChainOf()
|
|
831
|
+
? { freeChain: freeChainOf() }
|
|
832
|
+
: {}),
|
|
833
|
+
},
|
|
834
|
+
...mintConfirm("settings", "", { setting: body.setting, value: body.value }),
|
|
835
|
+
};
|
|
836
|
+
}
|
|
837
|
+
if (body.setting !== undefined || body.value !== undefined)
|
|
838
|
+
throw new HttpError(400, "invalid-body", "the confirmation carries only its token");
|
|
839
|
+
const { setting, value } = redeemConfirm(body.confirmToken, "settings", "");
|
|
840
|
+
try {
|
|
841
|
+
return await applySetting(setting, value);
|
|
842
|
+
} catch (e) {
|
|
843
|
+
if (e instanceof SettingsError) throw new HttpError(409, "not-applied", e.message);
|
|
844
|
+
throw e;
|
|
845
|
+
}
|
|
846
|
+
}
|
|
847
|
+
|
|
588
848
|
function gcRoute({ body, redact }) {
|
|
589
849
|
requireBody(
|
|
590
850
|
body,
|
|
@@ -617,9 +877,72 @@ function gcRoute({ body, redact }) {
|
|
|
617
877
|
return { contractVersion: GC_VERSION, ...out, failed };
|
|
618
878
|
}
|
|
619
879
|
|
|
620
|
-
|
|
880
|
+
/**
|
|
881
|
+
* autopilot-control.mjs run as a child process: its status script, credential
|
|
882
|
+
* probes and launchctl calls are synchronous, and must not hold up every
|
|
883
|
+
* other route while they run. The child reports a refusal as
|
|
884
|
+
* {error: {code, message}} on stdout (MA_CONTROL_JSON_ERRORS).
|
|
885
|
+
*/
|
|
886
|
+
async function control(args) {
|
|
887
|
+
const r = await spawnAsync(process.execPath, [join(HERE, "autopilot-control.mjs"), ...args], {
|
|
888
|
+
env: { ...process.env, MA_CONTROL_JSON_ERRORS: "1" },
|
|
889
|
+
timeoutMs: 180_000,
|
|
890
|
+
maxBytes: 4 * 1024 * 1024,
|
|
891
|
+
});
|
|
892
|
+
let doc;
|
|
893
|
+
try {
|
|
894
|
+
doc = JSON.parse(r.stdout);
|
|
895
|
+
} catch {
|
|
896
|
+
doc = null;
|
|
897
|
+
}
|
|
898
|
+
if (doc?.error && typeof doc.error.code === "string") {
|
|
899
|
+
throw new ControlError(String(doc.error.message ?? "refused"), doc.error.code);
|
|
900
|
+
}
|
|
901
|
+
if (r.status !== 0 || !doc || typeof doc !== "object") {
|
|
902
|
+
throw new ControlError("autopilot-control did not finish");
|
|
903
|
+
}
|
|
904
|
+
return doc;
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
/**
|
|
908
|
+
* For a read whose answer is the same for every caller: a request that
|
|
909
|
+
* arrives while one runs gets that one's answer instead of starting another.
|
|
910
|
+
*/
|
|
911
|
+
const inFlight = new Map();
|
|
912
|
+
const shared = (keyOf, fn) => (ctx) => {
|
|
913
|
+
const key = keyOf(ctx);
|
|
914
|
+
if (!inFlight.has(key)) {
|
|
915
|
+
inFlight.set(
|
|
916
|
+
key,
|
|
917
|
+
Promise.resolve()
|
|
918
|
+
.then(() => fn(ctx))
|
|
919
|
+
.finally(() => inFlight.delete(key)),
|
|
920
|
+
);
|
|
921
|
+
}
|
|
922
|
+
return inFlight.get(key);
|
|
923
|
+
};
|
|
924
|
+
|
|
925
|
+
/** One request at a time per key; a second one while the first runs is 429. */
|
|
926
|
+
const running = new Set();
|
|
927
|
+
const oneAtATime = (key, fn) => async (ctx) => {
|
|
928
|
+
if (running.has(key)) {
|
|
929
|
+
throw new HttpError(
|
|
930
|
+
429,
|
|
931
|
+
"busy",
|
|
932
|
+
"the same request is still running; try again when it finishes",
|
|
933
|
+
);
|
|
934
|
+
}
|
|
935
|
+
running.add(key);
|
|
936
|
+
try {
|
|
937
|
+
return await fn(ctx);
|
|
938
|
+
} finally {
|
|
939
|
+
running.delete(key);
|
|
940
|
+
}
|
|
941
|
+
};
|
|
942
|
+
|
|
943
|
+
async function autopilotGet() {
|
|
621
944
|
try {
|
|
622
|
-
return { ...
|
|
945
|
+
return { ...(await control(["status"])), contractVersion: AUTOPILOT_VERSION };
|
|
623
946
|
} catch (err) {
|
|
624
947
|
if (err instanceof ControlError) throw new HttpError(500, "internal", err.message);
|
|
625
948
|
throw err;
|
|
@@ -628,184 +951,1132 @@ function autopilotGet() {
|
|
|
628
951
|
|
|
629
952
|
async function autopilotOffRoute({ body }) {
|
|
630
953
|
requireBody(body, { now: (v) => typeof v === "boolean" }, "autopilot-off-request.schema.json");
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
954
|
+
try {
|
|
955
|
+
return {
|
|
956
|
+
contractVersion: AUTOPILOT_VERSION,
|
|
957
|
+
...(await control(body.now === true ? ["off", "--now"] : ["off"])),
|
|
958
|
+
};
|
|
959
|
+
} catch (err) {
|
|
960
|
+
if (err instanceof ControlError) throw new HttpError(500, "internal", err.message);
|
|
961
|
+
throw err;
|
|
962
|
+
}
|
|
635
963
|
}
|
|
636
964
|
|
|
637
|
-
const
|
|
638
|
-
envelope(selectRuns({ group: one(q, "group") }).records, { redact });
|
|
639
|
-
const oneRun = ({ params, redact }) => envelope(runRecordFor(params.id), { redact });
|
|
965
|
+
const repoArgs = (repos) => repos.flatMap((r) => ["--repo", r]);
|
|
640
966
|
|
|
641
967
|
/**
|
|
642
|
-
*
|
|
643
|
-
*
|
|
644
|
-
*
|
|
645
|
-
* with the bearer token, and is always served redacted.
|
|
968
|
+
* POST /v1/autopilot/on. Without confirmToken: what would be kept, dropped and
|
|
969
|
+
* probed, and a token bound to exactly that selection. With it: the mode is
|
|
970
|
+
* turned on for that selection (autopilot-control.mjs on).
|
|
646
971
|
*/
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
972
|
+
async function autopilotOnRoute({ body }) {
|
|
973
|
+
requireBody(
|
|
974
|
+
body,
|
|
975
|
+
{
|
|
976
|
+
confirmToken: isToken,
|
|
977
|
+
repos: (v) =>
|
|
978
|
+
Array.isArray(v) &&
|
|
979
|
+
v.length >= 1 &&
|
|
980
|
+
v.length <= 50 &&
|
|
981
|
+
v.every((x) => typeof x === "string" && REPO_NAME.test(x)),
|
|
982
|
+
configureOnly: (v) => typeof v === "boolean",
|
|
658
983
|
},
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
{
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
}
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
{ method: "POST", path: "/v1/autopilot/off", query: [], body: true, handle: autopilotOffRoute },
|
|
693
|
-
{ method: "GET", path: "/v1/phone/runs", query: ["group"], scope: "read", handle: listRuns },
|
|
694
|
-
{ method: "GET", path: "/v1/phone/runs/{id}", query: [], scope: "read", handle: oneRun },
|
|
695
|
-
{
|
|
696
|
-
method: "POST",
|
|
697
|
-
path: "/v1/phone/runs/{id}/answer",
|
|
698
|
-
query: [],
|
|
699
|
-
body: true,
|
|
700
|
-
scope: "answer",
|
|
701
|
-
handle: answerRun,
|
|
702
|
-
},
|
|
703
|
-
{
|
|
704
|
-
method: "POST",
|
|
705
|
-
path: "/v1/phone/launch",
|
|
706
|
-
query: ["repo"],
|
|
707
|
-
body: true,
|
|
708
|
-
scope: "launch",
|
|
709
|
-
handle: (ctx) => {
|
|
710
|
-
if (!ctx.registry.launchEnabled) {
|
|
711
|
-
throw new HttpError(403, "launch-disabled", "launch from a phone is not enabled here");
|
|
984
|
+
"autopilot-on-request.schema.json",
|
|
985
|
+
);
|
|
986
|
+
try {
|
|
987
|
+
if (body.confirmToken === undefined) {
|
|
988
|
+
if (!body.repos) throw new HttpError(400, "invalid-body", "repos is required");
|
|
989
|
+
const plan = await control(["on-preview", ...repoArgs(body.repos)]);
|
|
990
|
+
const payload = { repos: plan.keep, configureOnly: body.configureOnly === true };
|
|
991
|
+
return {
|
|
992
|
+
contractVersion: AUTOPILOT_VERSION,
|
|
993
|
+
preview: { ...plan, configureOnly: payload.configureOnly },
|
|
994
|
+
...mintConfirm("autopilot-on", "", payload),
|
|
995
|
+
};
|
|
996
|
+
}
|
|
997
|
+
if (body.repos !== undefined || body.configureOnly !== undefined) {
|
|
998
|
+
throw new HttpError(
|
|
999
|
+
400,
|
|
1000
|
+
"invalid-body",
|
|
1001
|
+
"the selection belongs to the preview, not the confirmation",
|
|
1002
|
+
);
|
|
1003
|
+
}
|
|
1004
|
+
const payload = redeemConfirm(body.confirmToken, "autopilot-on", "");
|
|
1005
|
+
return {
|
|
1006
|
+
contractVersion: AUTOPILOT_VERSION,
|
|
1007
|
+
...(await control([
|
|
1008
|
+
"on",
|
|
1009
|
+
...repoArgs(payload.repos),
|
|
1010
|
+
...(payload.configureOnly ? ["--configure-only"] : []),
|
|
1011
|
+
])),
|
|
1012
|
+
};
|
|
1013
|
+
} catch (err) {
|
|
1014
|
+
if (err instanceof ControlError) {
|
|
1015
|
+
if (err.code === "internal" || err.code === "launchd-refused") {
|
|
1016
|
+
throw new HttpError(500, "internal", err.message);
|
|
712
1017
|
}
|
|
713
|
-
|
|
714
|
-
},
|
|
715
|
-
},
|
|
716
|
-
].map((r) => ({
|
|
717
|
-
...r,
|
|
718
|
-
phone: r.path.startsWith(PHONE_PREFIX),
|
|
719
|
-
pattern: new RegExp(`^${r.path.replace("{id}", "(?<id>[^/]+)")}$`),
|
|
720
|
-
}));
|
|
721
|
-
|
|
722
|
-
/**
|
|
723
|
-
* The route for a path and method, or the HttpError that says why not. A phone
|
|
724
|
-
* path is looked up among phone routes only and a desktop path among desktop
|
|
725
|
-
* routes only, so neither credential can reach the other family.
|
|
726
|
-
*/
|
|
727
|
-
export function match(method, pathname) {
|
|
728
|
-
const phone = isPhonePath(pathname);
|
|
729
|
-
const hits = [];
|
|
730
|
-
for (const r of ROUTES) {
|
|
731
|
-
if (r.phone !== phone) continue;
|
|
732
|
-
const m = r.pattern.exec(pathname);
|
|
733
|
-
if (m) hits.push({ route: r, params: m.groups ?? {} });
|
|
734
|
-
}
|
|
735
|
-
if (!hits.length) {
|
|
736
|
-
if (phone) throw new HttpError(404, "unknown-verb", "the phone API has no such verb");
|
|
737
|
-
throw new HttpError(404, "not-found", "no such route");
|
|
738
|
-
}
|
|
739
|
-
const hit = hits.find((h) => h.route.method === method);
|
|
740
|
-
if (!hit) {
|
|
741
|
-
throw new HttpError(
|
|
742
|
-
405,
|
|
743
|
-
"method-not-allowed",
|
|
744
|
-
`use ${hits.map((h) => h.route.method).join(", ")}`,
|
|
745
|
-
{
|
|
746
|
-
allow: hits.map((h) => h.route.method).join(", "),
|
|
747
|
-
},
|
|
748
|
-
);
|
|
749
|
-
}
|
|
750
|
-
if (hit.params.id !== undefined) {
|
|
751
|
-
try {
|
|
752
|
-
hit.params.id = decodeURIComponent(hit.params.id);
|
|
753
|
-
} catch {
|
|
754
|
-
throw new HttpError(404, "not-found", "no such run");
|
|
1018
|
+
throw new HttpError(409, err.code, err.message);
|
|
755
1019
|
}
|
|
1020
|
+
throw err;
|
|
756
1021
|
}
|
|
757
|
-
return hit;
|
|
758
1022
|
}
|
|
759
1023
|
|
|
760
|
-
/**
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
1024
|
+
/** POST /v1/runs/{id}/steer: one instruction for the run's next phase. */
|
|
1025
|
+
/**
|
|
1026
|
+
* POST /v1/runs/{id}/steer. Free text for an unattended agent, so it takes
|
|
1027
|
+
* two requests like every high-risk write: the first names the text and gets
|
|
1028
|
+
* a preview (the run, its phase, the unconsumed instruction it would replace)
|
|
1029
|
+
* and a token bound to this run and exactly this text; the second sends only
|
|
1030
|
+
* the token.
|
|
1031
|
+
*/
|
|
1032
|
+
async function steerRoute({ params, body }) {
|
|
1033
|
+
requireBody(
|
|
1034
|
+
body,
|
|
1035
|
+
{ text: (v) => typeof v === "string", confirmToken: isToken },
|
|
1036
|
+
"steer-request.schema.json",
|
|
1037
|
+
);
|
|
1038
|
+
const [record] = runRecordFor(params.id);
|
|
1039
|
+
const file = stateFileOf(record);
|
|
1040
|
+
if (!file) throw new HttpError(409, "not-steerable", "this run has no agent state to steer");
|
|
1041
|
+
try {
|
|
1042
|
+
if (body.confirmToken === undefined) {
|
|
1043
|
+
if (typeof body.text !== "string" || !body.text.trim() || body.text.length > 4000) {
|
|
1044
|
+
throw new HttpError(400, "invalid-body", "text is required, 1 to 4000 characters");
|
|
779
1045
|
}
|
|
780
|
-
|
|
1046
|
+
const { state } = readState(record);
|
|
1047
|
+
if (["complete", "completed", "done"].includes(state.status)) {
|
|
1048
|
+
throw new HttpError(
|
|
1049
|
+
409,
|
|
1050
|
+
"not-steerable",
|
|
1051
|
+
"the run is finished; nothing would read the instruction",
|
|
1052
|
+
);
|
|
1053
|
+
}
|
|
1054
|
+
const prev = state.pendingSteer;
|
|
1055
|
+
return {
|
|
1056
|
+
contractVersion: STEER_VERSION,
|
|
1057
|
+
taskId: record.taskId,
|
|
1058
|
+
preview: {
|
|
1059
|
+
text: scrub(body.text),
|
|
1060
|
+
status: typeof state.status === "string" ? state.status : null,
|
|
1061
|
+
currentPhase: Number.isFinite(Number(state.currentPhase))
|
|
1062
|
+
? Number(state.currentPhase)
|
|
1063
|
+
: null,
|
|
1064
|
+
replaces:
|
|
1065
|
+
prev && !prev.appliedAt && typeof prev.text === "string" ? scrub(prev.text) : null,
|
|
1066
|
+
},
|
|
1067
|
+
...mintConfirm("steer", record.taskId, { text: body.text }),
|
|
1068
|
+
};
|
|
781
1069
|
}
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
1070
|
+
if (body.text !== undefined) {
|
|
1071
|
+
throw new HttpError(
|
|
1072
|
+
400,
|
|
1073
|
+
"invalid-body",
|
|
1074
|
+
"the text belongs to the preview, not the confirmation",
|
|
1075
|
+
);
|
|
1076
|
+
}
|
|
1077
|
+
const { text } = redeemConfirm(body.confirmToken, "steer", record.taskId);
|
|
1078
|
+
return { ...(await queueSteer(file, text)), taskId: record.taskId };
|
|
1079
|
+
} catch (err) {
|
|
1080
|
+
if (err instanceof SteerError) {
|
|
1081
|
+
if (err.code === "finished") throw new HttpError(409, "not-steerable", err.message);
|
|
1082
|
+
throw new HttpError(400, "invalid-body", err.message);
|
|
787
1083
|
}
|
|
1084
|
+
throw err;
|
|
788
1085
|
}
|
|
789
|
-
return q;
|
|
790
1086
|
}
|
|
791
1087
|
|
|
792
|
-
|
|
1088
|
+
/** The sessions `claude agents --json` lists, read at most every SESSIONS_TTL_MS. */
|
|
1089
|
+
let sessionsCache = { at: 0, list: undefined };
|
|
1090
|
+
function cachedSessions() {
|
|
1091
|
+
if (Date.now() - sessionsCache.at > SESSIONS_TTL_MS) {
|
|
1092
|
+
sessionsCache = { at: Date.now(), list: listSessions() };
|
|
1093
|
+
}
|
|
1094
|
+
return sessionsCache.list;
|
|
1095
|
+
}
|
|
793
1096
|
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
pathname === PHONE_PREFIX.slice(0, -1) || pathname.startsWith(PHONE_PREFIX);
|
|
1097
|
+
const activityCache = createActivityCache();
|
|
1098
|
+
const activityNow = () => activitySnapshot({ sessions: cachedSessions(), cache: activityCache });
|
|
797
1099
|
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
const
|
|
801
|
-
|
|
1100
|
+
async function chatRoute({ params, q }) {
|
|
1101
|
+
if (!SESSION_ID.test(params.sessionId)) throw new HttpError(404, "not-found", "no such agent");
|
|
1102
|
+
const agentId = params.agentId === "main" ? null : params.agentId;
|
|
1103
|
+
if (agentId !== null && !AGENT_ID.test(agentId)) {
|
|
1104
|
+
throw new HttpError(404, "not-found", "no such agent");
|
|
1105
|
+
}
|
|
1106
|
+
const page = await agentChat({
|
|
1107
|
+
sessionId: params.sessionId,
|
|
1108
|
+
agentId,
|
|
1109
|
+
before: one(q, "before") === null ? null : Number(one(q, "before")),
|
|
1110
|
+
limit: one(q, "limit") === null ? 200 : Number(one(q, "limit")),
|
|
1111
|
+
});
|
|
1112
|
+
if (!page) throw new HttpError(404, "not-found", "no such agent");
|
|
1113
|
+
return page;
|
|
802
1114
|
}
|
|
803
1115
|
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
1116
|
+
const routinesDocument = () => ({
|
|
1117
|
+
contractVersion: ACTIVITY_VERSION,
|
|
1118
|
+
routines: listRoutines(homedir()).map((r) => ({
|
|
1119
|
+
name: String(r?.name ?? ""),
|
|
1120
|
+
description: typeof r?.description === "string" ? scrub(r.description) : "",
|
|
1121
|
+
createdAt: typeof r?.createdAt === "string" ? r.createdAt : null,
|
|
1122
|
+
source: typeof r?.source === "string" ? scrub(r.source) : null,
|
|
1123
|
+
})),
|
|
1124
|
+
});
|
|
1125
|
+
|
|
1126
|
+
const routinesGet = () => {
|
|
1127
|
+
try {
|
|
1128
|
+
return routinesDocument();
|
|
1129
|
+
} catch (err) {
|
|
1130
|
+
if (err instanceof RoutineError) throw new HttpError(500, "internal", err.message);
|
|
1131
|
+
throw err;
|
|
1132
|
+
}
|
|
1133
|
+
};
|
|
1134
|
+
|
|
1135
|
+
/** DELETE /v1/routines/{name}: a preview and token, then ?confirm=<token> removes it. */
|
|
1136
|
+
function routineDeleteRoute({ params, q }) {
|
|
1137
|
+
const name = params.name;
|
|
1138
|
+
if (!ROUTINE_NAME.test(name)) throw new HttpError(404, "not-found", "no such routine");
|
|
1139
|
+
try {
|
|
1140
|
+
const token = one(q, "confirm");
|
|
1141
|
+
if (token === null) {
|
|
1142
|
+
const entry = routinesDocument().routines.find((r) => r.name === name);
|
|
1143
|
+
if (!entry) throw new HttpError(404, "not-found", "no such routine");
|
|
1144
|
+
return {
|
|
1145
|
+
contractVersion: ACTIVITY_VERSION,
|
|
1146
|
+
preview: entry,
|
|
1147
|
+
...mintConfirm("routine-delete", name, null),
|
|
1148
|
+
};
|
|
1149
|
+
}
|
|
1150
|
+
redeemConfirm(token, "routine-delete", name);
|
|
1151
|
+
return { contractVersion: ACTIVITY_VERSION, removed: removeRoutine(homedir(), name) };
|
|
1152
|
+
} catch (err) {
|
|
1153
|
+
if (err instanceof RoutineError) throw new HttpError(409, "not-removable", err.message);
|
|
1154
|
+
throw err;
|
|
1155
|
+
}
|
|
1156
|
+
}
|
|
1157
|
+
|
|
1158
|
+
const notAllowed = () =>
|
|
1159
|
+
new HttpError(
|
|
1160
|
+
403,
|
|
1161
|
+
"repo-not-allowed",
|
|
1162
|
+
"repo is neither a configured autopilot repo nor one registered with launch-request.mjs register-repo",
|
|
1163
|
+
);
|
|
1164
|
+
|
|
1165
|
+
/**
|
|
1166
|
+
* The checkout a panel request names: an absolute path or a name from
|
|
1167
|
+
* GET /v1/repos, and either way one of the repositories launch accepts.
|
|
1168
|
+
*/
|
|
1169
|
+
function resolveRepo(ref) {
|
|
1170
|
+
if (!ref) throw new HttpError(400, "invalid-query", "repo is required");
|
|
1171
|
+
if (isAbsolute(ref)) {
|
|
1172
|
+
if (!launchRepoAllowed(ref)) throw notAllowed();
|
|
1173
|
+
return realOr(ref);
|
|
1174
|
+
}
|
|
1175
|
+
const hits = launchRepos().filter((r) => r.name === ref);
|
|
1176
|
+
if (hits.length > 1) {
|
|
1177
|
+
throw new HttpError(409, "repo-ambiguous", "more than one repository has that name");
|
|
1178
|
+
}
|
|
1179
|
+
if (!hits.length) throw notAllowed();
|
|
1180
|
+
return hits[0].path;
|
|
1181
|
+
}
|
|
1182
|
+
|
|
1183
|
+
/**
|
|
1184
|
+
* POST /v1/repos: add a checkout launch accepts. The first request names a
|
|
1185
|
+
* suggestion (an id from GET /v1/repos?all=1) or an absolute path; the
|
|
1186
|
+
* preview says what would be stored and mints a token; the second request
|
|
1187
|
+
* sends only the token. Only a git checkout is accepted from here.
|
|
1188
|
+
*/
|
|
1189
|
+
function repoAddRoute({ body }) {
|
|
1190
|
+
requireBody(
|
|
1191
|
+
body,
|
|
1192
|
+
{
|
|
1193
|
+
confirmToken: isToken,
|
|
1194
|
+
suggestion: (v) => typeof v === "string" && REPO_ID.test(v),
|
|
1195
|
+
path: (v) => typeof v === "string" && v.length <= 4096 && isAbsolute(v),
|
|
1196
|
+
},
|
|
1197
|
+
"repo-request.schema.json",
|
|
1198
|
+
);
|
|
1199
|
+
if (body.confirmToken === undefined) {
|
|
1200
|
+
let path;
|
|
1201
|
+
if (body.suggestion !== undefined && body.path !== undefined) {
|
|
1202
|
+
throw new HttpError(400, "invalid-body", "name a suggestion or a path, not both");
|
|
1203
|
+
}
|
|
1204
|
+
if (body.suggestion !== undefined) {
|
|
1205
|
+
const hit = suggestedRepos().find((r) => r.id === body.suggestion);
|
|
1206
|
+
if (!hit) throw new HttpError(404, "not-found", "no such suggestion");
|
|
1207
|
+
path = hit.path;
|
|
1208
|
+
} else if (body.path !== undefined) {
|
|
1209
|
+
path = realOr(body.path);
|
|
1210
|
+
} else {
|
|
1211
|
+
throw new HttpError(400, "invalid-body", "suggestion or path is required");
|
|
1212
|
+
}
|
|
1213
|
+
if (launchRepoAllowed(path)) {
|
|
1214
|
+
throw new HttpError(409, "already-registered", "launch already accepts that repository");
|
|
1215
|
+
}
|
|
1216
|
+
let real;
|
|
1217
|
+
try {
|
|
1218
|
+
real = registerRepoCheck(path);
|
|
1219
|
+
} catch (err) {
|
|
1220
|
+
throw new HttpError(400, "invalid-body", err.message);
|
|
1221
|
+
}
|
|
1222
|
+
// The page picks a repository by folder name; a second one with the same
|
|
1223
|
+
// name could never be told apart there.
|
|
1224
|
+
if (launchRepos().some((r) => r.name.split("/").pop() === basename(real))) {
|
|
1225
|
+
throw new HttpError(
|
|
1226
|
+
409,
|
|
1227
|
+
"name-taken",
|
|
1228
|
+
"a repository with that folder name is already on the list",
|
|
1229
|
+
);
|
|
1230
|
+
}
|
|
1231
|
+
return {
|
|
1232
|
+
contractVersion: ACTIVITY_VERSION,
|
|
1233
|
+
preview: { name: basename(real) },
|
|
1234
|
+
...mintConfirm("repo-add", "", { path: real }),
|
|
1235
|
+
};
|
|
1236
|
+
}
|
|
1237
|
+
if (body.suggestion !== undefined || body.path !== undefined) {
|
|
1238
|
+
throw new HttpError(
|
|
1239
|
+
400,
|
|
1240
|
+
"invalid-body",
|
|
1241
|
+
"the selection belongs to the preview, not the confirmation",
|
|
1242
|
+
);
|
|
1243
|
+
}
|
|
1244
|
+
const { path } = redeemConfirm(body.confirmToken, "repo-add", "");
|
|
1245
|
+
try {
|
|
1246
|
+
const stored = registerRepo(path, process.env, { requireCheckout: true });
|
|
1247
|
+
return { contractVersion: ACTIVITY_VERSION, registered: { name: basename(stored) } };
|
|
1248
|
+
} catch (err) {
|
|
1249
|
+
// registerRepo's own refusals name no path; anything else (a lock timeout,
|
|
1250
|
+
// a disk error) can, so it stays in the server's log.
|
|
1251
|
+
if (REGISTER_REFUSALS.has(err.message)) throw new HttpError(409, "not-registered", err.message);
|
|
1252
|
+
process.stderr.write(`contract-server: repo-add: ${err.message}\n`);
|
|
1253
|
+
throw new HttpError(409, "not-registered", "the repository could not be stored");
|
|
1254
|
+
}
|
|
1255
|
+
}
|
|
1256
|
+
const REGISTER_REFUSALS = new Set([
|
|
1257
|
+
"the path must be absolute",
|
|
1258
|
+
"the path is not a directory",
|
|
1259
|
+
"the home directory or the root is not a repository",
|
|
1260
|
+
"the path is not a git checkout",
|
|
1261
|
+
]);
|
|
1262
|
+
|
|
1263
|
+
// What registerRepo would refuse, checked without writing.
|
|
1264
|
+
function registerRepoCheck(path) {
|
|
1265
|
+
const real = realOr(path);
|
|
1266
|
+
let st;
|
|
1267
|
+
try {
|
|
1268
|
+
st = statSync(real);
|
|
1269
|
+
} catch {
|
|
1270
|
+
throw new Error("the path is not a directory");
|
|
1271
|
+
}
|
|
1272
|
+
if (!st.isDirectory()) throw new Error("the path is not a directory");
|
|
1273
|
+
if (real === "/" || real === realOr(homedir())) {
|
|
1274
|
+
throw new Error("the home directory or the root is not a repository");
|
|
1275
|
+
}
|
|
1276
|
+
if (!existsSync(join(real, ".git"))) throw new Error("the path is not a git checkout");
|
|
1277
|
+
return real;
|
|
1278
|
+
}
|
|
1279
|
+
|
|
1280
|
+
/** DELETE /v1/repos/{id}: a registered entry, preview then ?confirm=<token>. */
|
|
1281
|
+
function repoRemoveRoute({ params, q }) {
|
|
1282
|
+
const id = params.id;
|
|
1283
|
+
if (!REPO_ID.test(id)) throw new HttpError(404, "not-found", "no such repository");
|
|
1284
|
+
const entry = [
|
|
1285
|
+
...launchRepos()
|
|
1286
|
+
.filter((r) => r.source === "registered")
|
|
1287
|
+
.map((r) => ({ id: repoId(r.path), name: r.name })),
|
|
1288
|
+
...missingRepos().map((r) => ({ id: r.id, name: r.name })),
|
|
1289
|
+
].find((r) => r.id === id);
|
|
1290
|
+
const token = one(q, "confirm");
|
|
1291
|
+
if (token === null) {
|
|
1292
|
+
if (!entry) throw new HttpError(404, "not-found", "no registered repository has that id");
|
|
1293
|
+
return {
|
|
1294
|
+
contractVersion: ACTIVITY_VERSION,
|
|
1295
|
+
preview: { name: entry.name },
|
|
1296
|
+
...mintConfirm("repo-remove", id, null),
|
|
1297
|
+
};
|
|
1298
|
+
}
|
|
1299
|
+
redeemConfirm(token, "repo-remove", id);
|
|
1300
|
+
const removed = unregisterRepo(id);
|
|
1301
|
+
if (!removed) throw new HttpError(404, "not-found", "no registered repository has that id");
|
|
1302
|
+
return { contractVersion: ACTIVITY_VERSION, removed: { name: basename(removed) } };
|
|
1303
|
+
}
|
|
1304
|
+
|
|
1305
|
+
const JOB_STATUS = {
|
|
1306
|
+
"unknown-job": 404,
|
|
1307
|
+
"command-not-installed": 409,
|
|
1308
|
+
"invalid-repo": 400,
|
|
1309
|
+
"invalid-args": 400,
|
|
1310
|
+
"invalid-request": 400,
|
|
1311
|
+
"no-launch-shape": 422,
|
|
1312
|
+
"room-busy": 409,
|
|
1313
|
+
"sandbox-unavailable": 409,
|
|
1314
|
+
"launch-failed": 502,
|
|
1315
|
+
};
|
|
1316
|
+
|
|
1317
|
+
function jobError(err) {
|
|
1318
|
+
if (!(err instanceof JobError)) return err;
|
|
1319
|
+
return new HttpError(JOB_STATUS[err.code] ?? 400, err.code, err.message, {
|
|
1320
|
+
...(err.extra.errors ? { errors: err.extra.errors } : {}),
|
|
1321
|
+
});
|
|
1322
|
+
}
|
|
1323
|
+
|
|
1324
|
+
const knownRun = (id) => selectRuns({ taskId: id }).found;
|
|
1325
|
+
|
|
1326
|
+
/** A session started without agent-guard has no second layer; the office starts none. */
|
|
1327
|
+
function requireGuard(repoPath) {
|
|
1328
|
+
if (guardRegistration({ cwd: repoPath }).ok) return;
|
|
1329
|
+
throw new HttpError(
|
|
1330
|
+
409,
|
|
1331
|
+
"guard-missing",
|
|
1332
|
+
"agent-guard.sh is not registered for every tool, so nothing starts unattended",
|
|
1333
|
+
);
|
|
1334
|
+
}
|
|
1335
|
+
|
|
1336
|
+
/**
|
|
1337
|
+
* POST /v1/jobs: hand a job to someone in the office. The first request names
|
|
1338
|
+
* jobId, repo and args (and answers for a pipeline run); the preview says
|
|
1339
|
+
* which command starts, with what, and what it writes outside this machine,
|
|
1340
|
+
* and mints a token. The second request sends only the token: the server then
|
|
1341
|
+
* checks the unattended profile, the workspace trust and the unattended
|
|
1342
|
+
* sandbox, starts the session in the background and records it for the floor.
|
|
1343
|
+
*/
|
|
1344
|
+
async function jobsPost({ body }) {
|
|
1345
|
+
requireBody(
|
|
1346
|
+
body,
|
|
1347
|
+
{
|
|
1348
|
+
confirmToken: isToken,
|
|
1349
|
+
jobId: (v) => typeof v === "string" && /^[a-z][a-z0-9-]{1,40}$/.test(v),
|
|
1350
|
+
repo: (v) =>
|
|
1351
|
+
typeof v === "string" && v.length <= 4096 && (isAbsolute(v) || REPO_NAME.test(v)),
|
|
1352
|
+
args: (v) => v !== null && typeof v === "object" && !Array.isArray(v),
|
|
1353
|
+
answers: (v) => v !== null && typeof v === "object" && !Array.isArray(v),
|
|
1354
|
+
},
|
|
1355
|
+
"job-request.schema.json",
|
|
1356
|
+
);
|
|
1357
|
+
try {
|
|
1358
|
+
if (body.confirmToken === undefined) {
|
|
1359
|
+
if (!body.jobId || !body.repo) {
|
|
1360
|
+
throw new HttpError(400, "invalid-body", "jobId and repo are required");
|
|
1361
|
+
}
|
|
1362
|
+
const repoPath = resolveRepo(body.repo);
|
|
1363
|
+
requireUnattendedProfile();
|
|
1364
|
+
requireGuard(repoPath);
|
|
1365
|
+
const sessions = cachedSessions();
|
|
1366
|
+
const { job, prompt } = previewJob({
|
|
1367
|
+
jobId: body.jobId,
|
|
1368
|
+
args: body.args ?? {},
|
|
1369
|
+
answers: body.answers,
|
|
1370
|
+
repoPath,
|
|
1371
|
+
knownRun,
|
|
1372
|
+
sessions,
|
|
1373
|
+
});
|
|
1374
|
+
return {
|
|
1375
|
+
contractVersion: JOBS_VERSION,
|
|
1376
|
+
preview: {
|
|
1377
|
+
jobId: job.id,
|
|
1378
|
+
role: job.role,
|
|
1379
|
+
team: job.team,
|
|
1380
|
+
repo: basename(repoPath),
|
|
1381
|
+
prompt,
|
|
1382
|
+
writes: job.writes,
|
|
1383
|
+
},
|
|
1384
|
+
...mintConfirm("job", "", {
|
|
1385
|
+
jobId: job.id,
|
|
1386
|
+
args: body.args ?? {},
|
|
1387
|
+
answers: body.answers,
|
|
1388
|
+
repoPath,
|
|
1389
|
+
}),
|
|
1390
|
+
};
|
|
1391
|
+
}
|
|
1392
|
+
if (["jobId", "repo", "args", "answers"].some((k) => body[k] !== undefined)) {
|
|
1393
|
+
throw new HttpError(
|
|
1394
|
+
400,
|
|
1395
|
+
"invalid-body",
|
|
1396
|
+
"the job belongs to the preview, not the confirmation",
|
|
1397
|
+
);
|
|
1398
|
+
}
|
|
1399
|
+
const p = redeemConfirm(body.confirmToken, "job", "");
|
|
1400
|
+
if (!launchRepoAllowed(p.repoPath)) throw notAllowed();
|
|
1401
|
+
requireUnattendedProfile();
|
|
1402
|
+
requireWorkspaceTrust(p.repoPath, true);
|
|
1403
|
+
requireGuard(p.repoPath);
|
|
1404
|
+
const record = await startJob({
|
|
1405
|
+
jobId: p.jobId,
|
|
1406
|
+
args: p.args,
|
|
1407
|
+
answers: p.answers,
|
|
1408
|
+
repoPath: p.repoPath,
|
|
1409
|
+
repoName: basename(p.repoPath),
|
|
1410
|
+
knownRun,
|
|
1411
|
+
sessions: cachedSessions(),
|
|
1412
|
+
precondition: sandboxPrecondition,
|
|
1413
|
+
});
|
|
1414
|
+
return {
|
|
1415
|
+
contractVersion: JOBS_VERSION,
|
|
1416
|
+
started: {
|
|
1417
|
+
sessionId: record.sessionId,
|
|
1418
|
+
jobId: record.jobId,
|
|
1419
|
+
role: record.role,
|
|
1420
|
+
team: record.team,
|
|
1421
|
+
repo: record.repo,
|
|
1422
|
+
},
|
|
1423
|
+
};
|
|
1424
|
+
} catch (err) {
|
|
1425
|
+
throw jobError(err);
|
|
1426
|
+
}
|
|
1427
|
+
}
|
|
1428
|
+
|
|
1429
|
+
function stackError(err) {
|
|
1430
|
+
if (!(err instanceof StackError)) return err;
|
|
1431
|
+
if (err.code === "unreadable-settings") {
|
|
1432
|
+
return new HttpError(409, "unreadable-settings", err.message);
|
|
1433
|
+
}
|
|
1434
|
+
return new HttpError(400, "invalid-body", err.message);
|
|
1435
|
+
}
|
|
1436
|
+
|
|
1437
|
+
function stackGet({ q }) {
|
|
1438
|
+
const repo = resolveRepo(one(q, "repo"));
|
|
1439
|
+
try {
|
|
1440
|
+
return stackStatus(repo);
|
|
1441
|
+
} catch (err) {
|
|
1442
|
+
throw stackError(err);
|
|
1443
|
+
}
|
|
1444
|
+
}
|
|
1445
|
+
|
|
1446
|
+
/** POST /v1/stack: the planned enabledPlugins and a token, then the write. */
|
|
1447
|
+
function stackPost({ body }) {
|
|
1448
|
+
requireBody(
|
|
1449
|
+
body,
|
|
1450
|
+
{
|
|
1451
|
+
confirmToken: isToken,
|
|
1452
|
+
repo: (v) =>
|
|
1453
|
+
typeof v === "string" && v.length <= 4096 && (isAbsolute(v) || REPO_NAME.test(v)),
|
|
1454
|
+
stacks: (v) =>
|
|
1455
|
+
Array.isArray(v) &&
|
|
1456
|
+
v.length >= 1 &&
|
|
1457
|
+
v.length <= 8 &&
|
|
1458
|
+
v.every((x) => typeof x === "string" && /^[a-z]{2,16}$/.test(x)),
|
|
1459
|
+
},
|
|
1460
|
+
"stack-request.schema.json",
|
|
1461
|
+
);
|
|
1462
|
+
try {
|
|
1463
|
+
if (body.confirmToken === undefined) {
|
|
1464
|
+
if (!body.repo || !body.stacks) {
|
|
1465
|
+
throw new HttpError(400, "invalid-body", "repo and stacks are required");
|
|
1466
|
+
}
|
|
1467
|
+
const repo = resolveRepo(body.repo);
|
|
1468
|
+
const plan = stackSet(repo, body.stacks, { dryRun: true });
|
|
1469
|
+
return {
|
|
1470
|
+
contractVersion: STACK_VERSION,
|
|
1471
|
+
preview: {
|
|
1472
|
+
stacks: plan.stacks,
|
|
1473
|
+
changed: plan.changed,
|
|
1474
|
+
enabledPlugins: plan.enabledPlugins,
|
|
1475
|
+
dropped: plan.dropped,
|
|
1476
|
+
},
|
|
1477
|
+
...mintConfirm("stack", "", { repo, stacks: body.stacks }),
|
|
1478
|
+
};
|
|
1479
|
+
}
|
|
1480
|
+
if (body.repo !== undefined || body.stacks !== undefined) {
|
|
1481
|
+
throw new HttpError(
|
|
1482
|
+
400,
|
|
1483
|
+
"invalid-body",
|
|
1484
|
+
"the selection belongs to the preview, not the confirmation",
|
|
1485
|
+
);
|
|
1486
|
+
}
|
|
1487
|
+
const { repo, stacks } = redeemConfirm(body.confirmToken, "stack", "");
|
|
1488
|
+
return { ...stackSet(repo, stacks), contractVersion: STACK_VERSION };
|
|
1489
|
+
} catch (err) {
|
|
1490
|
+
throw stackError(err);
|
|
1491
|
+
}
|
|
1492
|
+
}
|
|
1493
|
+
|
|
1494
|
+
function resultsRoute({ params }) {
|
|
1495
|
+
if (!RUN_ID.test(params.id)) throw new HttpError(404, "not-found", "no such run");
|
|
1496
|
+
try {
|
|
1497
|
+
return runResults(params.id);
|
|
1498
|
+
} catch (err) {
|
|
1499
|
+
if (err instanceof ResultsError) throw new HttpError(404, "not-found", "no such run");
|
|
1500
|
+
throw err;
|
|
1501
|
+
}
|
|
1502
|
+
}
|
|
1503
|
+
|
|
1504
|
+
function evidenceRoute({ params }) {
|
|
1505
|
+
if (!RUN_ID.test(params.id) || !EVIDENCE_NAME.test(params.name)) {
|
|
1506
|
+
throw new HttpError(404, "not-found", "no such image");
|
|
1507
|
+
}
|
|
1508
|
+
try {
|
|
1509
|
+
return runEvidence(params.id, params.name);
|
|
1510
|
+
} catch (err) {
|
|
1511
|
+
if (err instanceof ResultsError) throw new HttpError(404, "not-found", "no such image");
|
|
1512
|
+
throw err;
|
|
1513
|
+
}
|
|
1514
|
+
}
|
|
1515
|
+
|
|
1516
|
+
function workError(err) {
|
|
1517
|
+
if (!(err instanceof WorkError)) return err;
|
|
1518
|
+
if (err.code === "no-run" || err.code === "bad-key")
|
|
1519
|
+
return new HttpError(404, "not-found", err.message);
|
|
1520
|
+
if (err.code === "no-review" || err.code === "no-findings") {
|
|
1521
|
+
return new HttpError(409, "no-review", err.message);
|
|
1522
|
+
}
|
|
1523
|
+
if (err.code === "not-installed") return new HttpError(409, "not-installed", err.message);
|
|
1524
|
+
if (err.code === "review-incomplete") return new HttpError(409, "review-incomplete", err.message);
|
|
1525
|
+
if (err.code === "changed") return new HttpError(409, "confirm-mismatch", err.message);
|
|
1526
|
+
return new HttpError(502, "upstream-failed", err.message);
|
|
1527
|
+
}
|
|
1528
|
+
|
|
1529
|
+
async function maturityRoute({ params }) {
|
|
1530
|
+
try {
|
|
1531
|
+
return await issueMaturity(params.key);
|
|
1532
|
+
} catch (err) {
|
|
1533
|
+
throw workError(err);
|
|
1534
|
+
}
|
|
1535
|
+
}
|
|
1536
|
+
|
|
1537
|
+
/**
|
|
1538
|
+
* POST /v1/runs/{id}/review-verdict: the verdict post-pr-review.sh would post
|
|
1539
|
+
* and a token, then the post itself. The verdict follows from the findings the
|
|
1540
|
+
* review recorded; no request chooses it. Only a finished review whose every
|
|
1541
|
+
* finding is triaged can be previewed, and the token is bound to the decision
|
|
1542
|
+
* and the findings digest it showed: when either changed before the
|
|
1543
|
+
* confirmation, it is refused with confirm-mismatch.
|
|
1544
|
+
*/
|
|
1545
|
+
async function reviewVerdictRoute({ params, body }) {
|
|
1546
|
+
requireBody(body, { confirmToken: isToken }, "review-verdict-request.schema.json");
|
|
1547
|
+
try {
|
|
1548
|
+
const [record] = runRecordFor(params.id);
|
|
1549
|
+
if (body.confirmToken === undefined) {
|
|
1550
|
+
const preview = reviewVerdictPreview(record.taskId);
|
|
1551
|
+
const bound = { decision: preview.decision, findingsDigest: preview.findingsDigest };
|
|
1552
|
+
return { ...preview, ...mintConfirm("review-verdict", record.taskId, bound) };
|
|
1553
|
+
}
|
|
1554
|
+
const expected = redeemConfirm(body.confirmToken, "review-verdict", record.taskId);
|
|
1555
|
+
return await postReviewVerdict(record.taskId, process.env, expected);
|
|
1556
|
+
} catch (err) {
|
|
1557
|
+
throw workError(err);
|
|
1558
|
+
}
|
|
1559
|
+
}
|
|
1560
|
+
|
|
1561
|
+
/** The toolkit MCP client of this server, started on first use, closed on stop. */
|
|
1562
|
+
let toolkit = null;
|
|
1563
|
+
const toolkitClient = () => (toolkit ??= createToolkitClient());
|
|
1564
|
+
export const closeToolkit = () => {
|
|
1565
|
+
toolkit?.close();
|
|
1566
|
+
toolkit = null;
|
|
1567
|
+
};
|
|
1568
|
+
|
|
1569
|
+
function toolError(err) {
|
|
1570
|
+
if (!(err instanceof ToolError)) return err;
|
|
1571
|
+
if (err.code === "unknown-tool") return new HttpError(404, "not-found", "no such tool");
|
|
1572
|
+
if (err.code === "tool-not-allowed") return new HttpError(403, "tool-not-allowed", err.message);
|
|
1573
|
+
if (err.code === "unpinned") return new HttpError(409, "not-installed", err.message);
|
|
1574
|
+
if (err.code === "invalid-arguments") {
|
|
1575
|
+
return new HttpError(400, "invalid-body", err.message, { errors: err.extra?.errors ?? [] });
|
|
1576
|
+
}
|
|
1577
|
+
if (err.code === "not-registered") return new HttpError(409, "not-installed", err.message);
|
|
1578
|
+
return new HttpError(502, "upstream-failed", err.message);
|
|
1579
|
+
}
|
|
1580
|
+
|
|
1581
|
+
async function toolsRoute() {
|
|
1582
|
+
try {
|
|
1583
|
+
const tools = await toolkitClient().listTools();
|
|
1584
|
+
return { contractVersion: TOOLS_VERSION, version: activeToolkitVersion(), tools };
|
|
1585
|
+
} catch (err) {
|
|
1586
|
+
throw toolError(err);
|
|
1587
|
+
}
|
|
1588
|
+
}
|
|
1589
|
+
|
|
1590
|
+
async function toolCallRoute({ params, body }) {
|
|
1591
|
+
requireBody(
|
|
1592
|
+
body,
|
|
1593
|
+
{ arguments: (v) => v && typeof v === "object" && !Array.isArray(v) },
|
|
1594
|
+
"tool-call-request.schema.json",
|
|
1595
|
+
);
|
|
1596
|
+
if (!TOOL_NAME.test(params.name)) throw new HttpError(404, "not-found", "no such tool");
|
|
1597
|
+
try {
|
|
1598
|
+
return await toolkitClient().callTool(params.name, body.arguments ?? {});
|
|
1599
|
+
} catch (err) {
|
|
1600
|
+
throw toolError(err);
|
|
1601
|
+
}
|
|
1602
|
+
}
|
|
1603
|
+
|
|
1604
|
+
function serviceError(err) {
|
|
1605
|
+
if (!(err instanceof ServiceError)) return err;
|
|
1606
|
+
if (err.code === "bad-input") return new HttpError(400, "invalid-query", err.message);
|
|
1607
|
+
if (err.code === "not-installed" || err.code === "not-configured") {
|
|
1608
|
+
return new HttpError(409, "not-installed", err.message);
|
|
1609
|
+
}
|
|
1610
|
+
if (err.code === "host-not-allowed") return new HttpError(409, "host-not-allowed", err.message);
|
|
1611
|
+
return new HttpError(502, "upstream-failed", err.message);
|
|
1612
|
+
}
|
|
1613
|
+
|
|
1614
|
+
const serviceRoute = (key, fn) =>
|
|
1615
|
+
oneAtATime(key, async (ctx) => {
|
|
1616
|
+
try {
|
|
1617
|
+
return await fn(ctx);
|
|
1618
|
+
} catch (err) {
|
|
1619
|
+
throw serviceError(err);
|
|
1620
|
+
}
|
|
1621
|
+
});
|
|
1622
|
+
|
|
1623
|
+
/**
|
|
1624
|
+
* GET /v1/permissions. Claude Code offers no mechanism that records a pending
|
|
1625
|
+
* permission prompt outside the session that shows it, so there is nothing to
|
|
1626
|
+
* list yet. The shape is fixed now so a client can render it, and `available`
|
|
1627
|
+
* says plainly that nothing is forwarded; pipeline questions are answered
|
|
1628
|
+
* through POST /v1/runs/{id}/answer.
|
|
1629
|
+
*/
|
|
1630
|
+
const permissionsDocument = () => ({
|
|
1631
|
+
contractVersion: TOOLS_VERSION,
|
|
1632
|
+
available: false,
|
|
1633
|
+
reason:
|
|
1634
|
+
"the host does not record pending permission prompts where another process can read them; pipeline questions are in GET /v1/activity and answered with POST /v1/runs/{id}/answer",
|
|
1635
|
+
requests: [],
|
|
1636
|
+
});
|
|
1637
|
+
|
|
1638
|
+
const listRuns = ({ q, redact }) =>
|
|
1639
|
+
envelope(selectRuns({ group: one(q, "group") }).records, { redact });
|
|
1640
|
+
const oneRun = ({ params, redact }) => envelope(runRecordFor(params.id), { redact });
|
|
1641
|
+
|
|
1642
|
+
/**
|
|
1643
|
+
* Every route. `query` names the parameters beyond `v` and `redact` that a
|
|
1644
|
+
* route accepts; `body` marks a route that reads a JSON body. A route with
|
|
1645
|
+
* `scope` is a phone route: it is reached only with a device signature, never
|
|
1646
|
+
* with the bearer token, and is always served redacted.
|
|
1647
|
+
*
|
|
1648
|
+
* `remote` and `risk` are the tags a remote carrier would enforce: the scope a
|
|
1649
|
+
* caller would need (read, answer, launch, local-only) and the confirmation
|
|
1650
|
+
* tier (read, low, high). `stream` marks the server-sent event route,
|
|
1651
|
+
* `selfOrigin` a route a foreign page may never read even when its origin is
|
|
1652
|
+
* allowed (the chat transcript), and `write` a route whose call is logged.
|
|
1653
|
+
*/
|
|
1654
|
+
export const ROUTES = [
|
|
1655
|
+
{ method: "GET", path: "/v1/runs", query: ["group"], handle: listRuns },
|
|
1656
|
+
{ method: "GET", path: "/v1/runs/{id}", query: [], handle: oneRun },
|
|
1657
|
+
{
|
|
1658
|
+
method: "GET",
|
|
1659
|
+
path: "/v1/commands",
|
|
1660
|
+
query: [],
|
|
1661
|
+
handle: () => {
|
|
1662
|
+
const out = catalog();
|
|
1663
|
+
if (!out) throw new HttpError(500, "internal", "no command tree found");
|
|
1664
|
+
return out;
|
|
1665
|
+
},
|
|
1666
|
+
},
|
|
1667
|
+
{ method: "GET", path: "/v1/questions", query: [], handle: () => questionsDocument() },
|
|
1668
|
+
{
|
|
1669
|
+
method: "GET",
|
|
1670
|
+
path: "/v1/issues",
|
|
1671
|
+
query: ["source", "project", "max"],
|
|
1672
|
+
handle: ({ q }) =>
|
|
1673
|
+
collect({
|
|
1674
|
+
source: one(q, "source") ?? "all",
|
|
1675
|
+
projects: q.project ?? [],
|
|
1676
|
+
max: Number(one(q, "max") ?? 50),
|
|
1677
|
+
}),
|
|
1678
|
+
},
|
|
1679
|
+
{
|
|
1680
|
+
method: "GET",
|
|
1681
|
+
path: "/v1/worktrees",
|
|
1682
|
+
query: ["measure"],
|
|
1683
|
+
handle: ({ q, redact }) => buildList({ doMeasure: flag(one(q, "measure")), redact }),
|
|
1684
|
+
},
|
|
1685
|
+
{ method: "GET", path: "/v1/launch-spec", query: [], handle: () => launchSpec() },
|
|
1686
|
+
{
|
|
1687
|
+
method: "GET",
|
|
1688
|
+
path: "/v1/repos",
|
|
1689
|
+
query: ["all"],
|
|
1690
|
+
handle: ({ q, redact }) => reposDocument({ redact, extras: flag(one(q, "all")) }),
|
|
1691
|
+
},
|
|
1692
|
+
{
|
|
1693
|
+
method: "POST",
|
|
1694
|
+
path: "/v1/repos",
|
|
1695
|
+
query: [],
|
|
1696
|
+
body: true,
|
|
1697
|
+
remote: "local-only",
|
|
1698
|
+
risk: "high",
|
|
1699
|
+
handle: repoAddRoute,
|
|
1700
|
+
},
|
|
1701
|
+
{
|
|
1702
|
+
method: "DELETE",
|
|
1703
|
+
path: "/v1/repos/{id}",
|
|
1704
|
+
query: ["confirm"],
|
|
1705
|
+
remote: "local-only",
|
|
1706
|
+
risk: "high",
|
|
1707
|
+
handle: repoRemoveRoute,
|
|
1708
|
+
},
|
|
1709
|
+
{
|
|
1710
|
+
method: "POST",
|
|
1711
|
+
path: "/v1/launch",
|
|
1712
|
+
query: ["repo"],
|
|
1713
|
+
body: true,
|
|
1714
|
+
remote: "launch",
|
|
1715
|
+
risk: "high",
|
|
1716
|
+
handle: launchRun,
|
|
1717
|
+
},
|
|
1718
|
+
{
|
|
1719
|
+
method: "POST",
|
|
1720
|
+
path: "/v1/runs/{id}/answer",
|
|
1721
|
+
query: [],
|
|
1722
|
+
body: true,
|
|
1723
|
+
remote: "answer",
|
|
1724
|
+
risk: "low",
|
|
1725
|
+
handle: answerRun,
|
|
1726
|
+
},
|
|
1727
|
+
{ method: "GET", path: "/v1/runs/{id}/log", query: ["tail"], handle: logRun },
|
|
1728
|
+
{
|
|
1729
|
+
method: "POST",
|
|
1730
|
+
path: "/v1/runs/{id}/resume",
|
|
1731
|
+
query: [],
|
|
1732
|
+
body: true,
|
|
1733
|
+
remote: "launch",
|
|
1734
|
+
risk: "high",
|
|
1735
|
+
handle: resumeRun,
|
|
1736
|
+
},
|
|
1737
|
+
{
|
|
1738
|
+
method: "POST",
|
|
1739
|
+
path: "/v1/runs/{id}/steer",
|
|
1740
|
+
query: [],
|
|
1741
|
+
body: true,
|
|
1742
|
+
remote: "launch",
|
|
1743
|
+
risk: "high",
|
|
1744
|
+
handle: steerRoute,
|
|
1745
|
+
},
|
|
1746
|
+
{
|
|
1747
|
+
method: "POST",
|
|
1748
|
+
path: "/v1/runs/{id}/kill",
|
|
1749
|
+
query: [],
|
|
1750
|
+
body: true,
|
|
1751
|
+
remote: "local-only",
|
|
1752
|
+
risk: "high",
|
|
1753
|
+
handle: killRoute,
|
|
1754
|
+
},
|
|
1755
|
+
{
|
|
1756
|
+
method: "POST",
|
|
1757
|
+
path: "/v1/gc",
|
|
1758
|
+
query: [],
|
|
1759
|
+
body: true,
|
|
1760
|
+
remote: "local-only",
|
|
1761
|
+
risk: "high",
|
|
1762
|
+
handle: gcRoute,
|
|
1763
|
+
},
|
|
1764
|
+
{
|
|
1765
|
+
method: "POST",
|
|
1766
|
+
path: "/v1/prune-logs",
|
|
1767
|
+
query: [],
|
|
1768
|
+
body: true,
|
|
1769
|
+
remote: "local-only",
|
|
1770
|
+
risk: "high",
|
|
1771
|
+
handle: oneAtATime("prune-logs", pruneRoute),
|
|
1772
|
+
},
|
|
1773
|
+
{
|
|
1774
|
+
method: "GET",
|
|
1775
|
+
path: "/v1/settings",
|
|
1776
|
+
query: [],
|
|
1777
|
+
remote: "local-only",
|
|
1778
|
+
handle: () => readSettings(),
|
|
1779
|
+
},
|
|
1780
|
+
{
|
|
1781
|
+
method: "POST",
|
|
1782
|
+
path: "/v1/settings",
|
|
1783
|
+
query: [],
|
|
1784
|
+
body: true,
|
|
1785
|
+
remote: "local-only",
|
|
1786
|
+
risk: "high",
|
|
1787
|
+
handle: oneAtATime("settings", settingsRoute),
|
|
1788
|
+
},
|
|
1789
|
+
{
|
|
1790
|
+
method: "GET",
|
|
1791
|
+
path: "/v1/autopilot",
|
|
1792
|
+
query: [],
|
|
1793
|
+
handle: oneAtATime("autopilot-status", autopilotGet),
|
|
1794
|
+
},
|
|
1795
|
+
{
|
|
1796
|
+
method: "POST",
|
|
1797
|
+
path: "/v1/autopilot/off",
|
|
1798
|
+
query: [],
|
|
1799
|
+
body: true,
|
|
1800
|
+
remote: "launch",
|
|
1801
|
+
risk: "low",
|
|
1802
|
+
handle: oneAtATime("autopilot", autopilotOffRoute),
|
|
1803
|
+
},
|
|
1804
|
+
{
|
|
1805
|
+
method: "POST",
|
|
1806
|
+
path: "/v1/autopilot/on",
|
|
1807
|
+
query: [],
|
|
1808
|
+
body: true,
|
|
1809
|
+
remote: "local-only",
|
|
1810
|
+
risk: "high",
|
|
1811
|
+
handle: oneAtATime("autopilot", autopilotOnRoute),
|
|
1812
|
+
},
|
|
1813
|
+
{ method: "GET", path: "/v1/activity", query: [], handle: () => activityNow() },
|
|
1814
|
+
{
|
|
1815
|
+
method: "GET",
|
|
1816
|
+
path: "/v1/activity/stream",
|
|
1817
|
+
query: [],
|
|
1818
|
+
stream: true,
|
|
1819
|
+
handle: () => activityNow(),
|
|
1820
|
+
},
|
|
1821
|
+
{
|
|
1822
|
+
method: "GET",
|
|
1823
|
+
path: "/v1/agents/{sessionId}/{agentId}/chat",
|
|
1824
|
+
query: ["before", "limit"],
|
|
1825
|
+
selfOrigin: true,
|
|
1826
|
+
handle: chatRoute,
|
|
1827
|
+
},
|
|
1828
|
+
{ method: "GET", path: "/v1/ledger", query: [], handle: () => officeLedger() },
|
|
1829
|
+
{
|
|
1830
|
+
method: "GET",
|
|
1831
|
+
path: "/v1/system",
|
|
1832
|
+
query: [],
|
|
1833
|
+
handle: shared(
|
|
1834
|
+
() => "system",
|
|
1835
|
+
() => systemStatus(),
|
|
1836
|
+
),
|
|
1837
|
+
},
|
|
1838
|
+
{
|
|
1839
|
+
method: "GET",
|
|
1840
|
+
path: "/v1/credentials",
|
|
1841
|
+
query: ["probe"],
|
|
1842
|
+
remote: "local-only",
|
|
1843
|
+
handle: shared(
|
|
1844
|
+
({ q }) => "credentials:" + (one(q, "probe") === "1"),
|
|
1845
|
+
({ q }) => officeCredentials({ probe: one(q, "probe") === "1" }),
|
|
1846
|
+
),
|
|
1847
|
+
},
|
|
1848
|
+
{
|
|
1849
|
+
method: "GET",
|
|
1850
|
+
path: "/v1/memory",
|
|
1851
|
+
query: [],
|
|
1852
|
+
remote: "local-only",
|
|
1853
|
+
handle: shared(
|
|
1854
|
+
() => "memory",
|
|
1855
|
+
() => officeMemory(),
|
|
1856
|
+
),
|
|
1857
|
+
},
|
|
1858
|
+
{ method: "GET", path: "/v1/runs/{id}/results", query: [], handle: resultsRoute },
|
|
1859
|
+
{ method: "GET", path: "/v1/runs/{id}/evidence/{name}", query: [], handle: evidenceRoute },
|
|
1860
|
+
{
|
|
1861
|
+
method: "POST",
|
|
1862
|
+
path: "/v1/runs/{id}/review-verdict",
|
|
1863
|
+
query: [],
|
|
1864
|
+
body: true,
|
|
1865
|
+
remote: "launch",
|
|
1866
|
+
risk: "high",
|
|
1867
|
+
handle: oneAtATime("review-verdict", reviewVerdictRoute),
|
|
1868
|
+
},
|
|
1869
|
+
{
|
|
1870
|
+
method: "GET",
|
|
1871
|
+
path: "/v1/issues/{key}/maturity",
|
|
1872
|
+
query: [],
|
|
1873
|
+
handle: oneAtATime("maturity", maturityRoute),
|
|
1874
|
+
},
|
|
1875
|
+
{
|
|
1876
|
+
method: "GET",
|
|
1877
|
+
path: "/v1/prs",
|
|
1878
|
+
query: [],
|
|
1879
|
+
handle: oneAtATime("prs", ({ redact }) => pullRequests({ redact })),
|
|
1880
|
+
},
|
|
1881
|
+
{ method: "GET", path: "/v1/tools", query: [], handle: toolsRoute },
|
|
1882
|
+
{
|
|
1883
|
+
method: "POST",
|
|
1884
|
+
path: "/v1/tools/{name}/call",
|
|
1885
|
+
query: [],
|
|
1886
|
+
body: true,
|
|
1887
|
+
remote: "local-only",
|
|
1888
|
+
risk: "high",
|
|
1889
|
+
handle: toolCallRoute,
|
|
1890
|
+
},
|
|
1891
|
+
{
|
|
1892
|
+
method: "GET",
|
|
1893
|
+
path: "/v1/services/confluence",
|
|
1894
|
+
query: ["page"],
|
|
1895
|
+
handle: serviceRoute("confluence", ({ q }) => {
|
|
1896
|
+
if (one(q, "page") === null) throw new HttpError(400, "invalid-query", "page is required");
|
|
1897
|
+
return confluencePage(one(q, "page"));
|
|
1898
|
+
}),
|
|
1899
|
+
},
|
|
1900
|
+
{
|
|
1901
|
+
method: "GET",
|
|
1902
|
+
path: "/v1/services/crashlytics",
|
|
1903
|
+
query: ["issue"],
|
|
1904
|
+
handle: serviceRoute("crashlytics", ({ q }) => {
|
|
1905
|
+
if (one(q, "issue") === null) throw new HttpError(400, "invalid-query", "issue is required");
|
|
1906
|
+
return crashlyticsIssue(one(q, "issue"));
|
|
1907
|
+
}),
|
|
1908
|
+
},
|
|
1909
|
+
{
|
|
1910
|
+
method: "GET",
|
|
1911
|
+
path: "/v1/services/figma",
|
|
1912
|
+
query: ["file", "node"],
|
|
1913
|
+
handle: serviceRoute("figma", ({ q }) => {
|
|
1914
|
+
if (one(q, "file") === null || one(q, "node") === null) {
|
|
1915
|
+
throw new HttpError(400, "invalid-query", "file and node are required");
|
|
1916
|
+
}
|
|
1917
|
+
return figmaFrame(one(q, "file"), one(q, "node"));
|
|
1918
|
+
}),
|
|
1919
|
+
},
|
|
1920
|
+
{ method: "GET", path: "/v1/permissions", query: [], handle: permissionsDocument },
|
|
1921
|
+
{ method: "GET", path: "/v1/routines", query: [], handle: routinesGet },
|
|
1922
|
+
{
|
|
1923
|
+
method: "DELETE",
|
|
1924
|
+
path: "/v1/routines/{name}",
|
|
1925
|
+
query: ["confirm"],
|
|
1926
|
+
remote: "local-only",
|
|
1927
|
+
risk: "high",
|
|
1928
|
+
handle: routineDeleteRoute,
|
|
1929
|
+
},
|
|
1930
|
+
{ method: "GET", path: "/v1/stack", query: ["repo"], handle: stackGet },
|
|
1931
|
+
{ method: "GET", path: "/v1/jobs", query: [], handle: () => jobsDocument() },
|
|
1932
|
+
{
|
|
1933
|
+
method: "POST",
|
|
1934
|
+
path: "/v1/jobs",
|
|
1935
|
+
query: [],
|
|
1936
|
+
body: true,
|
|
1937
|
+
remote: "local-only",
|
|
1938
|
+
risk: "high",
|
|
1939
|
+
handle: jobsPost,
|
|
1940
|
+
},
|
|
1941
|
+
{
|
|
1942
|
+
method: "POST",
|
|
1943
|
+
path: "/v1/stack",
|
|
1944
|
+
query: [],
|
|
1945
|
+
body: true,
|
|
1946
|
+
remote: "local-only",
|
|
1947
|
+
risk: "high",
|
|
1948
|
+
handle: stackPost,
|
|
1949
|
+
},
|
|
1950
|
+
{ method: "GET", path: "/v1/phone/runs", query: ["group"], scope: "read", handle: listRuns },
|
|
1951
|
+
{ method: "GET", path: "/v1/phone/runs/{id}", query: [], scope: "read", handle: oneRun },
|
|
1952
|
+
{
|
|
1953
|
+
method: "POST",
|
|
1954
|
+
path: "/v1/phone/runs/{id}/answer",
|
|
1955
|
+
query: [],
|
|
1956
|
+
body: true,
|
|
1957
|
+
scope: "answer",
|
|
1958
|
+
risk: "low",
|
|
1959
|
+
handle: answerRun,
|
|
1960
|
+
},
|
|
1961
|
+
{
|
|
1962
|
+
method: "POST",
|
|
1963
|
+
path: "/v1/phone/launch",
|
|
1964
|
+
query: ["repo"],
|
|
1965
|
+
body: true,
|
|
1966
|
+
scope: "launch",
|
|
1967
|
+
risk: "high",
|
|
1968
|
+
handle: (ctx) => {
|
|
1969
|
+
if (!ctx.registry.launchEnabled) {
|
|
1970
|
+
throw new HttpError(403, "launch-disabled", "launch from a phone is not enabled here");
|
|
1971
|
+
}
|
|
1972
|
+
return launchRun({ ...ctx, channel: "phone" });
|
|
1973
|
+
},
|
|
1974
|
+
},
|
|
1975
|
+
].map((r) => ({
|
|
1976
|
+
...r,
|
|
1977
|
+
remote: r.remote ?? r.scope ?? "read",
|
|
1978
|
+
risk: r.risk ?? "read",
|
|
1979
|
+
write: r.method !== "GET",
|
|
1980
|
+
phone: r.path.startsWith(PHONE_PREFIX),
|
|
1981
|
+
params: [...r.path.matchAll(/\{([A-Za-z]+)\}/g)].map((m) => m[1]),
|
|
1982
|
+
pattern: new RegExp(`^${r.path.replace(/\{([A-Za-z]+)\}/g, "(?<$1>[^/]+)")}$`),
|
|
1983
|
+
}));
|
|
1984
|
+
|
|
1985
|
+
/**
|
|
1986
|
+
* The route for a path and method, or the HttpError that says why not. A phone
|
|
1987
|
+
* path is looked up among phone routes only and a desktop path among desktop
|
|
1988
|
+
* routes only, so neither credential can reach the other family.
|
|
1989
|
+
*/
|
|
1990
|
+
export function match(method, pathname) {
|
|
1991
|
+
const phone = isPhonePath(pathname);
|
|
1992
|
+
const hits = [];
|
|
1993
|
+
for (const r of ROUTES) {
|
|
1994
|
+
if (r.phone !== phone) continue;
|
|
1995
|
+
const m = r.pattern.exec(pathname);
|
|
1996
|
+
if (m) hits.push({ route: r, params: m.groups ?? {} });
|
|
1997
|
+
}
|
|
1998
|
+
if (!hits.length) {
|
|
1999
|
+
if (phone) throw new HttpError(404, "unknown-verb", "the phone API has no such verb");
|
|
2000
|
+
throw new HttpError(404, "not-found", "no such route");
|
|
2001
|
+
}
|
|
2002
|
+
const hit = hits.find((h) => h.route.method === method);
|
|
2003
|
+
if (!hit) {
|
|
2004
|
+
throw new HttpError(
|
|
2005
|
+
405,
|
|
2006
|
+
"method-not-allowed",
|
|
2007
|
+
`use ${hits.map((h) => h.route.method).join(", ")}`,
|
|
2008
|
+
{
|
|
2009
|
+
allow: hits.map((h) => h.route.method).join(", "),
|
|
2010
|
+
},
|
|
2011
|
+
);
|
|
2012
|
+
}
|
|
2013
|
+
for (const [k, v] of Object.entries(hit.params)) {
|
|
2014
|
+
try {
|
|
2015
|
+
hit.params[k] = decodeURIComponent(v);
|
|
2016
|
+
} catch {
|
|
2017
|
+
throw new HttpError(404, "not-found", k === "id" ? "no such run" : "no such resource");
|
|
2018
|
+
}
|
|
2019
|
+
}
|
|
2020
|
+
return hit;
|
|
2021
|
+
}
|
|
2022
|
+
|
|
2023
|
+
/** Query parameters as {name: [values]}, after every rule a route declares. */
|
|
2024
|
+
export function parseQuery(route, searchParams) {
|
|
2025
|
+
const q = {};
|
|
2026
|
+
for (const [k, v] of searchParams) {
|
|
2027
|
+
if (k !== "v" && k !== "redact" && !route.query.includes(k)) {
|
|
2028
|
+
throw new HttpError(400, "invalid-query", `unknown query parameter ${k}`);
|
|
2029
|
+
}
|
|
2030
|
+
(q[k] ??= []).push(v);
|
|
2031
|
+
}
|
|
2032
|
+
for (const [k, values] of Object.entries(q)) {
|
|
2033
|
+
if (k === "v") {
|
|
2034
|
+
if (
|
|
2035
|
+
values.length !== 1 ||
|
|
2036
|
+
!SUPPORTED_MAJORS.includes(Number(values[0])) ||
|
|
2037
|
+
!/^[0-9]+$/.test(values[0])
|
|
2038
|
+
) {
|
|
2039
|
+
throw new HttpError(400, "unsupported-version", "this server does not serve that major", {
|
|
2040
|
+
supported: SUPPORTED_MAJORS,
|
|
2041
|
+
});
|
|
2042
|
+
}
|
|
2043
|
+
continue;
|
|
2044
|
+
}
|
|
2045
|
+
const rule = Q[k];
|
|
2046
|
+
if (values.length > 1 && !rule.repeat)
|
|
2047
|
+
throw new HttpError(400, "invalid-query", `${k} is given twice`);
|
|
2048
|
+
for (const v of values) {
|
|
2049
|
+
if (!rule.check(v)) throw new HttpError(400, "invalid-query", `${k} has an invalid value`);
|
|
2050
|
+
}
|
|
2051
|
+
}
|
|
2052
|
+
return q;
|
|
2053
|
+
}
|
|
2054
|
+
|
|
2055
|
+
const sha = (s) => createHash("sha256").update(s).digest();
|
|
2056
|
+
|
|
2057
|
+
/** Does this path belong to the phone family? */
|
|
2058
|
+
export const isPhonePath = (pathname) =>
|
|
2059
|
+
pathname === PHONE_PREFIX.slice(0, -1) || pathname.startsWith(PHONE_PREFIX);
|
|
2060
|
+
|
|
2061
|
+
/** Constant-time bearer check: equal-length digests, whatever the input. */
|
|
2062
|
+
export function authorized(header, tokenDigest) {
|
|
2063
|
+
const m = /^Bearer ([A-Za-z0-9_-]{1,512})$/.exec(String(header ?? ""));
|
|
2064
|
+
return timingSafeEqual(sha(m ? m[1] : ""), tokenDigest) && Boolean(m);
|
|
2065
|
+
}
|
|
2066
|
+
|
|
2067
|
+
function readRawBody(req, timeoutMs = 0) {
|
|
2068
|
+
return new Promise((resolve, reject) => {
|
|
2069
|
+
const chunks = [];
|
|
807
2070
|
let size = 0;
|
|
808
2071
|
let over = false;
|
|
2072
|
+
if (timeoutMs > 0) {
|
|
2073
|
+
const timer = setTimeout(
|
|
2074
|
+
() => reject(new HttpError(408, "invalid-body", "the body did not arrive in time")),
|
|
2075
|
+
timeoutMs,
|
|
2076
|
+
);
|
|
2077
|
+
req.on("end", () => clearTimeout(timer));
|
|
2078
|
+
req.on("close", () => clearTimeout(timer));
|
|
2079
|
+
}
|
|
809
2080
|
req.on("data", (chunk) => {
|
|
810
2081
|
size += chunk.length;
|
|
811
2082
|
if (size > MAX_BODY_BYTES) over = true;
|
|
@@ -846,7 +2117,13 @@ function drain(req) {
|
|
|
846
2117
|
}
|
|
847
2118
|
|
|
848
2119
|
function parseArgs(argv) {
|
|
849
|
-
const opts = {
|
|
2120
|
+
const opts = {
|
|
2121
|
+
port: 0,
|
|
2122
|
+
idleMinutes: DEFAULT_IDLE_MINUTES,
|
|
2123
|
+
maxHours: DEFAULT_MAX_HOURS,
|
|
2124
|
+
origins: [],
|
|
2125
|
+
printToken: false,
|
|
2126
|
+
};
|
|
850
2127
|
for (let i = 0; i < argv.length; i++) {
|
|
851
2128
|
const a = argv[i];
|
|
852
2129
|
const value = () => {
|
|
@@ -863,10 +2140,16 @@ function parseArgs(argv) {
|
|
|
863
2140
|
opts.port = Number(p);
|
|
864
2141
|
} else if (a === "--idle-minutes") {
|
|
865
2142
|
const n = Number(value());
|
|
866
|
-
if (!Number.isFinite(n) || n
|
|
867
|
-
throw new Error(`--idle-minutes is
|
|
2143
|
+
if (!Number.isFinite(n) || n < 0 || n > MAX_IDLE_MINUTES) {
|
|
2144
|
+
throw new Error(`--idle-minutes is 0 (never) to ${MAX_IDLE_MINUTES}`);
|
|
868
2145
|
}
|
|
869
2146
|
opts.idleMinutes = n;
|
|
2147
|
+
} else if (a === "--max-hours") {
|
|
2148
|
+
const n = Number(value());
|
|
2149
|
+
if (!Number.isFinite(n) || n < 0 || n > MAX_HOURS) {
|
|
2150
|
+
throw new Error(`--max-hours is 0 (no limit) to ${MAX_HOURS}`);
|
|
2151
|
+
}
|
|
2152
|
+
opts.maxHours = n;
|
|
870
2153
|
} else if (a === "--allow-origin") {
|
|
871
2154
|
const o = value();
|
|
872
2155
|
let parsed;
|
|
@@ -904,6 +2187,154 @@ function writeTokenFile(record) {
|
|
|
904
2187
|
return file;
|
|
905
2188
|
}
|
|
906
2189
|
|
|
2190
|
+
export const OFFICE_PATH = "/office";
|
|
2191
|
+
/** Bearer, no Origin: mints a one-time panel code (office-control.mjs). */
|
|
2192
|
+
export const OFFICE_CODE_PATH = "/office/code";
|
|
2193
|
+
/** No bearer, own Origin only: trades a panel code for the token, once. */
|
|
2194
|
+
export const OFFICE_EXCHANGE_PATH = "/office/exchange";
|
|
2195
|
+
/** Bearer, own Origin or none: the installed page's build and whether this server is stale. */
|
|
2196
|
+
export const OFFICE_VERSION_PATH = "/office/version";
|
|
2197
|
+
/** Bearer, own Origin or none: a fresh panel code, so the page can reload itself. */
|
|
2198
|
+
export const OFFICE_RELOAD_PATH = "/office/reload";
|
|
2199
|
+
|
|
2200
|
+
const serverDigest = () => codeDigest(dirname(fileURLToPath(import.meta.url)));
|
|
2201
|
+
/** The server's code (scripts/ and lib/) as it was at start; a reinstall changes the files, not the process. */
|
|
2202
|
+
const SERVER_DIGEST = serverDigest();
|
|
2203
|
+
|
|
2204
|
+
/** The build id office-build.mjs stamps into the installed page, or null. */
|
|
2205
|
+
export function officeBuild(env = process.env) {
|
|
2206
|
+
const file = officePage(env);
|
|
2207
|
+
if (!file) return null;
|
|
2208
|
+
try {
|
|
2209
|
+
return /const BUILD = "([0-9a-f]{12})";/.exec(readFileSync(file, "utf8"))?.[1] ?? null;
|
|
2210
|
+
} catch {
|
|
2211
|
+
return null;
|
|
2212
|
+
}
|
|
2213
|
+
}
|
|
2214
|
+
|
|
2215
|
+
function readPrefs(env) {
|
|
2216
|
+
try {
|
|
2217
|
+
return JSON.parse(
|
|
2218
|
+
readFileSync(
|
|
2219
|
+
env.MULTI_AGENT_PREFS || join(homedir(), ".claude", "multi-agent-preferences.json"),
|
|
2220
|
+
"utf8",
|
|
2221
|
+
),
|
|
2222
|
+
);
|
|
2223
|
+
} catch {
|
|
2224
|
+
return null;
|
|
2225
|
+
}
|
|
2226
|
+
}
|
|
2227
|
+
|
|
2228
|
+
function prefsOfficePage(env) {
|
|
2229
|
+
return readPrefs(env)?.global?.office?.page ?? null;
|
|
2230
|
+
}
|
|
2231
|
+
|
|
2232
|
+
/**
|
|
2233
|
+
* The page language: "tr" when prefs.global.outputLanguage is Turkish, else
|
|
2234
|
+
* "en". Only these two values ever reach the page, so the stamp needs no
|
|
2235
|
+
* escaping.
|
|
2236
|
+
*/
|
|
2237
|
+
export function officeLang(env = process.env) {
|
|
2238
|
+
const lang = readPrefs(env)?.global?.outputLanguage;
|
|
2239
|
+
return typeof lang === "string" && /^tr\b/i.test(lang) ? "tr" : "en";
|
|
2240
|
+
}
|
|
2241
|
+
|
|
2242
|
+
/**
|
|
2243
|
+
* The panel page, when one is on disk: MA_OFFICE_PAGE, else
|
|
2244
|
+
* prefs.global.office.page, else (neither set) the copy beside this package.
|
|
2245
|
+
* A configured path is the only candidate, so pointing it elsewhere never
|
|
2246
|
+
* falls back. Absolute, regular files only; none is not an error, the route
|
|
2247
|
+
* is 404.
|
|
2248
|
+
*/
|
|
2249
|
+
export function officePage(env = process.env) {
|
|
2250
|
+
const configured = env.MA_OFFICE_PAGE || prefsOfficePage(env);
|
|
2251
|
+
const candidates = configured ? [configured] : [join(HERE, "..", "office", "index.html")];
|
|
2252
|
+
for (const p of candidates) {
|
|
2253
|
+
if (typeof p !== "string" || !isAbsolute(p)) continue;
|
|
2254
|
+
try {
|
|
2255
|
+
if (statSync(p).isFile()) return p;
|
|
2256
|
+
} catch {
|
|
2257
|
+
// Not there: the route answers 404.
|
|
2258
|
+
}
|
|
2259
|
+
}
|
|
2260
|
+
return null;
|
|
2261
|
+
}
|
|
2262
|
+
|
|
2263
|
+
/** Sprites of the office floor, served beside the page under this prefix. */
|
|
2264
|
+
export const OFFICE_ASSET_PREFIX = "/office/assets/";
|
|
2265
|
+
const OFFICE_ASSET_NAME = /^[A-Za-z0-9_]{1,48}\.png$/;
|
|
2266
|
+
const OFFICE_ASSET_MAX_BYTES = 256 * 1024;
|
|
2267
|
+
const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
|
2268
|
+
|
|
2269
|
+
/**
|
|
2270
|
+
* A sprite of the installed page: the raw name matches a plain file name
|
|
2271
|
+
* pattern (no separator, no escape), the file is a regular PNG of at most
|
|
2272
|
+
* 256 KiB whose real path is directly inside the page's assets directory.
|
|
2273
|
+
* Anything else is null, which the route answers with 404.
|
|
2274
|
+
*/
|
|
2275
|
+
export function officeAsset(name, env = process.env) {
|
|
2276
|
+
if (!OFFICE_ASSET_NAME.test(name)) return null;
|
|
2277
|
+
const page = officePage(env);
|
|
2278
|
+
if (!page) return null;
|
|
2279
|
+
try {
|
|
2280
|
+
const dir = realpathSync(join(dirname(page), "assets"));
|
|
2281
|
+
const file = realpathSync(join(dir, name));
|
|
2282
|
+
if (dirname(file) !== dir) return null;
|
|
2283
|
+
const st = statSync(file);
|
|
2284
|
+
if (!st.isFile() || st.size > OFFICE_ASSET_MAX_BYTES) return null;
|
|
2285
|
+
const body = readFileSync(file);
|
|
2286
|
+
return body.subarray(0, 8).equals(PNG_SIGNATURE) ? body : null;
|
|
2287
|
+
} catch {
|
|
2288
|
+
return null;
|
|
2289
|
+
}
|
|
2290
|
+
}
|
|
2291
|
+
|
|
2292
|
+
/** The panel's CSP: nothing but its own nonce'd inline code, same-origin calls and images. */
|
|
2293
|
+
export const officeCsp = (nonce) =>
|
|
2294
|
+
[
|
|
2295
|
+
"default-src 'none'",
|
|
2296
|
+
`script-src 'nonce-${nonce}'`,
|
|
2297
|
+
`style-src 'nonce-${nonce}'`,
|
|
2298
|
+
"img-src 'self' data:",
|
|
2299
|
+
"connect-src 'self'",
|
|
2300
|
+
"frame-ancestors 'none'",
|
|
2301
|
+
"base-uri 'none'",
|
|
2302
|
+
"form-action 'none'",
|
|
2303
|
+
].join("; ");
|
|
2304
|
+
|
|
2305
|
+
/** Where every write route leaves its line. */
|
|
2306
|
+
export function actionsLog() {
|
|
2307
|
+
return join(homedir(), ".claude", "logs", "multi-agent", "office-actions.jsonl");
|
|
2308
|
+
}
|
|
2309
|
+
|
|
2310
|
+
/**
|
|
2311
|
+
* One line per write: who (the local bearer holder, and whether the request
|
|
2312
|
+
* came from the panel page), what (route, path parameters, preview or
|
|
2313
|
+
* confirm), when and the outcome; a tool call adds the sha256 of its
|
|
2314
|
+
* arguments. Never a body, a query value or a token.
|
|
2315
|
+
* A log that cannot be written does not fail the request.
|
|
2316
|
+
*/
|
|
2317
|
+
function logAction({ route, params, own, confirm, status, error, argsSha256 }) {
|
|
2318
|
+
const line = {
|
|
2319
|
+
at: new Date().toISOString(),
|
|
2320
|
+
who: "local",
|
|
2321
|
+
via: own ? "panel" : "client",
|
|
2322
|
+
route,
|
|
2323
|
+
params,
|
|
2324
|
+
step: confirm ? "confirm" : "request",
|
|
2325
|
+
status,
|
|
2326
|
+
...(argsSha256 ? { argsSha256 } : {}),
|
|
2327
|
+
...(error ? { error } : {}),
|
|
2328
|
+
};
|
|
2329
|
+
try {
|
|
2330
|
+
const file = actionsLog();
|
|
2331
|
+
mkdirSync(dirname(file), { recursive: true, mode: 0o700 });
|
|
2332
|
+
appendFileSync(file, JSON.stringify(line) + "\n", { mode: 0o600 });
|
|
2333
|
+
} catch {
|
|
2334
|
+
// The action itself already happened or was refused; the log is best effort.
|
|
2335
|
+
}
|
|
2336
|
+
}
|
|
2337
|
+
|
|
907
2338
|
/**
|
|
908
2339
|
* Start listening. Resolves with the ready record once the port is known.
|
|
909
2340
|
*
|
|
@@ -915,11 +2346,28 @@ export function serve(opts) {
|
|
|
915
2346
|
const origins = new Set(opts.origins ?? []);
|
|
916
2347
|
const idleMs = Math.round((opts.idleMinutes ?? DEFAULT_IDLE_MINUTES) * 60000);
|
|
917
2348
|
let hosts = new Set();
|
|
2349
|
+
let selfOrigins = new Set();
|
|
2350
|
+
const streams = new Set();
|
|
918
2351
|
let startSec = Infinity;
|
|
919
2352
|
const nonces = createNonceStore({ file: noncesPath() });
|
|
920
2353
|
let tokenFile = null;
|
|
921
2354
|
let idleTimer = null;
|
|
2355
|
+
let lifeTimer = null;
|
|
922
2356
|
let stopping = false;
|
|
2357
|
+
const codes = new Map();
|
|
2358
|
+
const mintCode = () => {
|
|
2359
|
+
const now = Date.now();
|
|
2360
|
+
for (const [c, exp] of codes) if (exp <= now) codes.delete(c);
|
|
2361
|
+
while (codes.size >= MAX_CODES) codes.delete(codes.keys().next().value);
|
|
2362
|
+
const code = randomBytes(32).toString("base64url");
|
|
2363
|
+
codes.set(code, now + codeTtlMs);
|
|
2364
|
+
return { code, expiresAt: new Date(now + codeTtlMs).toISOString() };
|
|
2365
|
+
};
|
|
2366
|
+
const redeemCode = (code) => {
|
|
2367
|
+
const exp = typeof code === "string" && PANEL_CODE.test(code) ? codes.get(code) : undefined;
|
|
2368
|
+
if (exp !== undefined) codes.delete(code);
|
|
2369
|
+
return exp !== undefined && exp > Date.now();
|
|
2370
|
+
};
|
|
923
2371
|
|
|
924
2372
|
// requireHostHeader off: a request without Host reaches the Host check below
|
|
925
2373
|
// and is refused there with a contract error body, like any other wrong Host.
|
|
@@ -929,6 +2377,7 @@ export function serve(opts) {
|
|
|
929
2377
|
requireHostHeader: false,
|
|
930
2378
|
});
|
|
931
2379
|
server.maxHeadersCount = 50;
|
|
2380
|
+
server.maxConnections = MAX_CONNECTIONS;
|
|
932
2381
|
|
|
933
2382
|
const send = (res, status, obj, extra = {}) => {
|
|
934
2383
|
const body = serialize(obj);
|
|
@@ -937,11 +2386,37 @@ export function serve(opts) {
|
|
|
937
2386
|
"Content-Length": Buffer.byteLength(body),
|
|
938
2387
|
"Cache-Control": "no-store",
|
|
939
2388
|
"X-Content-Type-Options": "nosniff",
|
|
2389
|
+
"Referrer-Policy": "no-referrer",
|
|
2390
|
+
"Content-Security-Policy": "default-src 'none'; frame-ancestors 'none'",
|
|
940
2391
|
"X-Contract-Version": CONTRACT_VERSION,
|
|
941
2392
|
...extra,
|
|
942
2393
|
});
|
|
943
2394
|
res.end(body);
|
|
944
2395
|
};
|
|
2396
|
+
|
|
2397
|
+
/**
|
|
2398
|
+
* The panel, with a fresh nonce in its CSP and in each inline script and
|
|
2399
|
+
* style tag, so no other inline code runs, and its language stamped in.
|
|
2400
|
+
* 404 when no page is installed.
|
|
2401
|
+
*/
|
|
2402
|
+
const serveOffice = (res, headOnly) => {
|
|
2403
|
+
const file = officePage();
|
|
2404
|
+
if (!file) throw new HttpError(404, "not-found", "no panel is installed on this machine");
|
|
2405
|
+
const nonce = randomBytes(18).toString("base64url");
|
|
2406
|
+
const html = readFileSync(file, "utf8")
|
|
2407
|
+
.replaceAll("{{NONCE}}", nonce)
|
|
2408
|
+
.replaceAll("{{LANG}}", officeLang());
|
|
2409
|
+
res.writeHead(200, {
|
|
2410
|
+
"Content-Type": "text/html; charset=utf-8",
|
|
2411
|
+
"Content-Length": Buffer.byteLength(html),
|
|
2412
|
+
"Cache-Control": "no-store",
|
|
2413
|
+
"X-Content-Type-Options": "nosniff",
|
|
2414
|
+
"Referrer-Policy": "no-referrer",
|
|
2415
|
+
"Cross-Origin-Opener-Policy": "same-origin",
|
|
2416
|
+
"Content-Security-Policy": officeCsp(nonce),
|
|
2417
|
+
});
|
|
2418
|
+
res.end(headOnly ? undefined : html);
|
|
2419
|
+
};
|
|
945
2420
|
const fail = (res, err, extra) => {
|
|
946
2421
|
const body = { contractVersion: CONTRACT_VERSION, error: err.code, message: err.message };
|
|
947
2422
|
if (err.extra?.errors) body.errors = err.extra.errors;
|
|
@@ -957,7 +2432,10 @@ export function serve(opts) {
|
|
|
957
2432
|
if (stopping) return;
|
|
958
2433
|
stopping = true;
|
|
959
2434
|
clearTimeout(idleTimer);
|
|
2435
|
+
clearTimeout(lifeTimer);
|
|
960
2436
|
removeTokenFile();
|
|
2437
|
+
for (const st of streams) clearInterval(st.timer);
|
|
2438
|
+
closeToolkit();
|
|
961
2439
|
process.stderr.write(`contract-server: stopping (${why})\n`);
|
|
962
2440
|
server.close(() => process.exit(0));
|
|
963
2441
|
server.closeAllConnections();
|
|
@@ -965,7 +2443,7 @@ export function serve(opts) {
|
|
|
965
2443
|
};
|
|
966
2444
|
const arm = () => {
|
|
967
2445
|
clearTimeout(idleTimer);
|
|
968
|
-
idleTimer = setTimeout(() => stop("idle"), idleMs);
|
|
2446
|
+
if (idleMs > 0) idleTimer = setTimeout(() => stop("idle"), idleMs);
|
|
969
2447
|
};
|
|
970
2448
|
|
|
971
2449
|
/**
|
|
@@ -977,7 +2455,7 @@ export function serve(opts) {
|
|
|
977
2455
|
const audit = { deviceId: null, method: req.method, verb: verbOf(url.pathname) };
|
|
978
2456
|
let insecure = null;
|
|
979
2457
|
try {
|
|
980
|
-
const raw = await readRawBody(req);
|
|
2458
|
+
const raw = await readRawBody(req, PHONE_BODY_TIMEOUT_MS);
|
|
981
2459
|
const registry = readRegistry();
|
|
982
2460
|
insecure = registry.insecure;
|
|
983
2461
|
const v = verifyRequest({
|
|
@@ -1019,6 +2497,7 @@ export function serve(opts) {
|
|
|
1019
2497
|
if (!(err instanceof HttpError))
|
|
1020
2498
|
process.stderr.write(`contract-server: ${err?.message ?? err}\n`);
|
|
1021
2499
|
fail(res, e, cors);
|
|
2500
|
+
if (e.status === 408) req.destroy();
|
|
1022
2501
|
const outcome = e.code === "unknown-device" && insecure ? insecure : e.code;
|
|
1023
2502
|
appendAudit({ ...audit, status: e.status, outcome });
|
|
1024
2503
|
}
|
|
@@ -1028,22 +2507,24 @@ export function serve(opts) {
|
|
|
1028
2507
|
arm();
|
|
1029
2508
|
res.on("finish", arm);
|
|
1030
2509
|
const origin = req.headers.origin;
|
|
2510
|
+
const own = origin !== undefined && selfOrigins.has(origin);
|
|
1031
2511
|
const cors =
|
|
1032
|
-
origin && origins.has(origin)
|
|
2512
|
+
origin && !own && origins.has(origin)
|
|
1033
2513
|
? { "Access-Control-Allow-Origin": origin, Vary: "Origin" }
|
|
1034
2514
|
: {};
|
|
2515
|
+
let audit = null;
|
|
1035
2516
|
try {
|
|
1036
2517
|
if (!hosts.has(String(req.headers.host ?? "").toLowerCase())) {
|
|
1037
2518
|
throw new HttpError(403, "forbidden-host", "the Host header does not name this server");
|
|
1038
2519
|
}
|
|
1039
|
-
if (origin !== undefined && !origins.has(origin)) {
|
|
2520
|
+
if (origin !== undefined && !own && !origins.has(origin)) {
|
|
1040
2521
|
throw new HttpError(403, "forbidden-origin", "this origin is not allowed");
|
|
1041
2522
|
}
|
|
1042
2523
|
if (req.method === "OPTIONS" && origin !== undefined) {
|
|
1043
2524
|
drain(req);
|
|
1044
2525
|
res.writeHead(204, {
|
|
1045
2526
|
...cors,
|
|
1046
|
-
"Access-Control-Allow-Methods": "GET
|
|
2527
|
+
"Access-Control-Allow-Methods": "GET",
|
|
1047
2528
|
"Access-Control-Allow-Headers": "Authorization, Content-Type",
|
|
1048
2529
|
"Access-Control-Max-Age": "600",
|
|
1049
2530
|
"Cache-Control": "no-store",
|
|
@@ -1056,10 +2537,110 @@ export function serve(opts) {
|
|
|
1056
2537
|
await servePhone(req, res, url, cors);
|
|
1057
2538
|
return;
|
|
1058
2539
|
}
|
|
2540
|
+
if (url.pathname === OFFICE_PATH && (req.method === "GET" || req.method === "HEAD")) {
|
|
2541
|
+
drain(req);
|
|
2542
|
+
if (url.search) throw new HttpError(400, "invalid-query", "the panel takes no query");
|
|
2543
|
+
serveOffice(res, req.method === "HEAD");
|
|
2544
|
+
return;
|
|
2545
|
+
}
|
|
2546
|
+
if (url.pathname === "/favicon.ico" && (req.method === "GET" || req.method === "HEAD")) {
|
|
2547
|
+
drain(req);
|
|
2548
|
+
res.writeHead(204, {
|
|
2549
|
+
"Cache-Control": "max-age=86400",
|
|
2550
|
+
"X-Content-Type-Options": "nosniff",
|
|
2551
|
+
});
|
|
2552
|
+
res.end();
|
|
2553
|
+
return;
|
|
2554
|
+
}
|
|
2555
|
+
if (url.pathname.startsWith(OFFICE_ASSET_PREFIX)) {
|
|
2556
|
+
drain(req);
|
|
2557
|
+
if (req.method !== "GET" && req.method !== "HEAD") {
|
|
2558
|
+
throw new HttpError(405, "method-not-allowed", "use GET", { allow: "GET" });
|
|
2559
|
+
}
|
|
2560
|
+
if (url.search) throw new HttpError(400, "invalid-query", "an asset takes no query");
|
|
2561
|
+
const body = officeAsset(url.pathname.slice(OFFICE_ASSET_PREFIX.length));
|
|
2562
|
+
if (!body) throw new HttpError(404, "not-found", "no such asset");
|
|
2563
|
+
res.writeHead(200, {
|
|
2564
|
+
"Content-Type": "image/png",
|
|
2565
|
+
"Content-Length": body.length,
|
|
2566
|
+
"Cache-Control": "max-age=3600",
|
|
2567
|
+
"X-Content-Type-Options": "nosniff",
|
|
2568
|
+
"Cross-Origin-Resource-Policy": "same-origin",
|
|
2569
|
+
"Content-Security-Policy": "default-src 'none'",
|
|
2570
|
+
});
|
|
2571
|
+
res.end(req.method === "HEAD" ? undefined : body);
|
|
2572
|
+
return;
|
|
2573
|
+
}
|
|
2574
|
+
if (url.pathname === OFFICE_EXCHANGE_PATH) {
|
|
2575
|
+
if (req.method !== "POST" || url.search) {
|
|
2576
|
+
drain(req);
|
|
2577
|
+
throw new HttpError(405, "method-not-allowed", "use POST", { allow: "POST" });
|
|
2578
|
+
}
|
|
2579
|
+
if (!own) {
|
|
2580
|
+
drain(req);
|
|
2581
|
+
throw new HttpError(403, "forbidden-origin", "only the panel page exchanges a code");
|
|
2582
|
+
}
|
|
2583
|
+
if (!/^application\/json(\s*;.*)?$/i.test(String(req.headers["content-type"] ?? ""))) {
|
|
2584
|
+
drain(req);
|
|
2585
|
+
throw new HttpError(415, "unsupported-media-type", "the body must be application/json");
|
|
2586
|
+
}
|
|
2587
|
+
const b = await readBody(req);
|
|
2588
|
+
if (!redeemCode(b?.code)) {
|
|
2589
|
+
throw new HttpError(
|
|
2590
|
+
401,
|
|
2591
|
+
"unauthorized",
|
|
2592
|
+
"the code is unknown, used or expired; run /multi-agent:office-on again",
|
|
2593
|
+
);
|
|
2594
|
+
}
|
|
2595
|
+
send(res, 200, { contractVersion: CONTRACT_VERSION, token });
|
|
2596
|
+
return;
|
|
2597
|
+
}
|
|
1059
2598
|
if (!authorized(req.headers.authorization, tokenDigest)) {
|
|
1060
2599
|
throw new HttpError(401, "unauthorized", "a bearer token is required");
|
|
1061
2600
|
}
|
|
2601
|
+
if (url.pathname === OFFICE_VERSION_PATH || url.pathname === OFFICE_RELOAD_PATH) {
|
|
2602
|
+
drain(req);
|
|
2603
|
+
if (origin !== undefined && !own) {
|
|
2604
|
+
throw new HttpError(403, "forbidden-origin", "only the panel page asks this");
|
|
2605
|
+
}
|
|
2606
|
+
if (url.pathname === OFFICE_VERSION_PATH) {
|
|
2607
|
+
if (req.method !== "GET") {
|
|
2608
|
+
throw new HttpError(405, "method-not-allowed", "use GET", { allow: "GET" });
|
|
2609
|
+
}
|
|
2610
|
+
send(res, 200, {
|
|
2611
|
+
contractVersion: CONTRACT_VERSION,
|
|
2612
|
+
build: officeBuild(),
|
|
2613
|
+
serverStale: serverDigest() !== SERVER_DIGEST,
|
|
2614
|
+
});
|
|
2615
|
+
return;
|
|
2616
|
+
}
|
|
2617
|
+
if (req.method !== "POST") {
|
|
2618
|
+
throw new HttpError(405, "method-not-allowed", "use POST", { allow: "POST" });
|
|
2619
|
+
}
|
|
2620
|
+
// The page already holds the token; a code lets it reload itself onto
|
|
2621
|
+
// the page now installed without the token ever entering a URL.
|
|
2622
|
+
send(res, 200, { contractVersion: CONTRACT_VERSION, ...mintCode() });
|
|
2623
|
+
return;
|
|
2624
|
+
}
|
|
2625
|
+
if (url.pathname === OFFICE_CODE_PATH) {
|
|
2626
|
+
drain(req);
|
|
2627
|
+
if (req.method !== "POST") {
|
|
2628
|
+
throw new HttpError(405, "method-not-allowed", "use POST", { allow: "POST" });
|
|
2629
|
+
}
|
|
2630
|
+
if (origin !== undefined) {
|
|
2631
|
+
throw new HttpError(403, "forbidden-origin", "a page may not mint a panel code");
|
|
2632
|
+
}
|
|
2633
|
+
send(res, 200, { contractVersion: CONTRACT_VERSION, ...mintCode() });
|
|
2634
|
+
return;
|
|
2635
|
+
}
|
|
1062
2636
|
const { route, params } = match(req.method, url.pathname);
|
|
2637
|
+
// A write, and a read of a transcript, are refused to every page but the
|
|
2638
|
+
// panel this server serves: an allowed origin may read the contract, it
|
|
2639
|
+
// may not drive this machine or read what an agent said.
|
|
2640
|
+
if ((route.write || route.selfOrigin) && origin !== undefined && !own) {
|
|
2641
|
+
throw new HttpError(403, "forbidden-origin", "this origin may not call this route");
|
|
2642
|
+
}
|
|
2643
|
+
if (route.write) audit = { route: `${route.method} ${route.path}`, params, own };
|
|
1063
2644
|
const q = parseQuery(route, url.searchParams);
|
|
1064
2645
|
const redact = flag(one(q, "redact")) || origin !== undefined;
|
|
1065
2646
|
let body = null;
|
|
@@ -1073,34 +2654,109 @@ export function serve(opts) {
|
|
|
1073
2654
|
} else {
|
|
1074
2655
|
drain(req);
|
|
1075
2656
|
}
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
2657
|
+
if (audit) audit.confirm = Boolean(body?.confirmToken ?? one(q, "confirm"));
|
|
2658
|
+
if (audit && route.path === "/v1/tools/{name}/call") {
|
|
2659
|
+
audit.argsSha256 = createHash("sha256")
|
|
2660
|
+
.update(JSON.stringify(body?.arguments ?? {}))
|
|
2661
|
+
.digest("hex");
|
|
2662
|
+
}
|
|
2663
|
+
if (route.stream) {
|
|
2664
|
+
serveStream(req, res, route, cors);
|
|
1080
2665
|
return;
|
|
1081
2666
|
}
|
|
1082
|
-
|
|
1083
|
-
|
|
2667
|
+
const doc = await route.handle({ q, params, body, redact });
|
|
2668
|
+
if (audit) logAction({ ...audit, status: 200 });
|
|
2669
|
+
send(res, 200, doc, cors);
|
|
2670
|
+
} catch (err) {
|
|
2671
|
+
const e = err instanceof HttpError ? err : new HttpError(500, "internal", "internal error");
|
|
2672
|
+
if (!(err instanceof HttpError))
|
|
2673
|
+
process.stderr.write(`contract-server: ${err?.message ?? err}\n`);
|
|
2674
|
+
if (audit) logAction({ ...audit, status: e.status, error: e.code });
|
|
2675
|
+
fail(res, e, cors);
|
|
1084
2676
|
}
|
|
1085
2677
|
});
|
|
1086
2678
|
|
|
2679
|
+
/**
|
|
2680
|
+
* GET /v1/activity/stream: the activity document as server-sent events, sent
|
|
2681
|
+
* when it changes and checked every STREAM_POLL_MS, with a comment line as a
|
|
2682
|
+
* heartbeat. Each event re-arms the idle clock, so an open panel keeps the
|
|
2683
|
+
* server up and a closed one lets it stop. At most MAX_STREAMS at once.
|
|
2684
|
+
*/
|
|
2685
|
+
const serveStream = (req, res, route, cors) => {
|
|
2686
|
+
if (streams.size >= MAX_STREAMS) {
|
|
2687
|
+
throw new HttpError(429, "too-many-streams", `at most ${MAX_STREAMS} streams at once`);
|
|
2688
|
+
}
|
|
2689
|
+
res.writeHead(200, {
|
|
2690
|
+
"Content-Type": "text/event-stream; charset=utf-8",
|
|
2691
|
+
"Cache-Control": "no-store",
|
|
2692
|
+
Connection: "keep-alive",
|
|
2693
|
+
"X-Content-Type-Options": "nosniff",
|
|
2694
|
+
"Referrer-Policy": "no-referrer",
|
|
2695
|
+
"X-Contract-Version": CONTRACT_VERSION,
|
|
2696
|
+
...cors,
|
|
2697
|
+
});
|
|
2698
|
+
let last = null;
|
|
2699
|
+
let busy = false;
|
|
2700
|
+
let lastSent = Date.now();
|
|
2701
|
+
const tick = async () => {
|
|
2702
|
+
if (busy) return;
|
|
2703
|
+
busy = true;
|
|
2704
|
+
try {
|
|
2705
|
+
const doc = await route.handle({});
|
|
2706
|
+
const { generatedAt: _g, ...stable } = doc;
|
|
2707
|
+
const key = JSON.stringify(stable);
|
|
2708
|
+
if (key !== last) {
|
|
2709
|
+
last = key;
|
|
2710
|
+
res.write(`event: activity\ndata: ${JSON.stringify(doc)}\n\n`);
|
|
2711
|
+
lastSent = Date.now();
|
|
2712
|
+
arm();
|
|
2713
|
+
} else if (Date.now() - lastSent >= STREAM_HEARTBEAT_MS) {
|
|
2714
|
+
res.write(": keep-alive\n\n");
|
|
2715
|
+
lastSent = Date.now();
|
|
2716
|
+
arm();
|
|
2717
|
+
}
|
|
2718
|
+
} catch (err) {
|
|
2719
|
+
process.stderr.write(`contract-server: stream: ${err?.message ?? err}\n`);
|
|
2720
|
+
res.write(`event: error\ndata: {"error":"internal"}\n\n`);
|
|
2721
|
+
} finally {
|
|
2722
|
+
busy = false;
|
|
2723
|
+
}
|
|
2724
|
+
};
|
|
2725
|
+
const timer = setInterval(tick, STREAM_POLL_MS);
|
|
2726
|
+
const entry = { res, timer };
|
|
2727
|
+
streams.add(entry);
|
|
2728
|
+
const close = () => {
|
|
2729
|
+
clearInterval(timer);
|
|
2730
|
+
streams.delete(entry);
|
|
2731
|
+
};
|
|
2732
|
+
// The response's close, not the request's: a request emits close as soon
|
|
2733
|
+
// as its (empty) body has been read.
|
|
2734
|
+
res.on("close", close);
|
|
2735
|
+
tick();
|
|
2736
|
+
};
|
|
2737
|
+
|
|
1087
2738
|
return new Promise((resolve, reject) => {
|
|
1088
2739
|
server.once("error", reject);
|
|
1089
2740
|
server.listen(opts.port ?? 0, BIND_HOST, () => {
|
|
1090
2741
|
const { port } = server.address();
|
|
1091
2742
|
hosts = new Set([`${BIND_HOST}:${port}`, `localhost:${port}`]);
|
|
2743
|
+
selfOrigins = new Set([`http://${BIND_HOST}:${port}`, `http://localhost:${port}`]);
|
|
1092
2744
|
const record = {
|
|
1093
2745
|
contractVersion: CONTRACT_VERSION,
|
|
1094
2746
|
url: `http://${BIND_HOST}:${port}`,
|
|
1095
2747
|
port,
|
|
1096
2748
|
pid: process.pid,
|
|
1097
2749
|
idleMinutes: opts.idleMinutes ?? DEFAULT_IDLE_MINUTES,
|
|
2750
|
+
maxHours: opts.maxHours ?? DEFAULT_MAX_HOURS,
|
|
1098
2751
|
};
|
|
1099
2752
|
const started = new Date();
|
|
1100
2753
|
startSec = Math.floor(started.getTime() / 1000);
|
|
1101
2754
|
tokenFile = writeTokenFile({ ...record, token, startedAt: started.toISOString() });
|
|
1102
2755
|
for (const sig of ["SIGTERM", "SIGINT", "SIGHUP"]) process.on(sig, () => stop(sig));
|
|
1103
2756
|
arm();
|
|
2757
|
+
const maxHours = opts.maxHours ?? DEFAULT_MAX_HOURS;
|
|
2758
|
+
if (maxHours > 0)
|
|
2759
|
+
lifeTimer = setTimeout(() => stop("lifetime"), Math.round(maxHours * 3600000));
|
|
1104
2760
|
resolve({ ...record, tokenFile, ...(opts.printToken ? { token } : {}) });
|
|
1105
2761
|
});
|
|
1106
2762
|
});
|
|
@@ -1116,7 +2772,7 @@ async function main(argv) {
|
|
|
1116
2772
|
}
|
|
1117
2773
|
if (opts.help) {
|
|
1118
2774
|
process.stdout.write(
|
|
1119
|
-
"Usage: contract-server.mjs [--port N] [--idle-minutes N] [--allow-origin <origin>]... [--print-token]\n",
|
|
2775
|
+
"Usage: contract-server.mjs [--port N] [--idle-minutes N] [--max-hours N] [--allow-origin <origin>]... [--print-token]\n",
|
|
1120
2776
|
);
|
|
1121
2777
|
return 0;
|
|
1122
2778
|
}
|