@mmerterden/multi-agent-pipeline 20.28.0 → 20.30.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 +19 -0
- package/README.md +19 -6
- package/README.tr.md +5 -5
- package/docs/architecture.md +3 -3
- package/docs/ecosystem.md +5 -5
- package/docs/facts.json +7 -7
- package/install/catalog-history.json +1 -1
- package/install/claude.mjs +122 -1
- package/install/codex.mjs +2 -0
- package/install/copilot.mjs +13 -0
- package/install/templates/claude-hooks.json +30 -1
- package/manifest.json +189 -77
- package/package.json +2 -1
- package/pipeline/commands/multi-agent/analysis/SKILL.md +3 -1
- 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 +50 -0
- package/pipeline/commands/multi-agent/office-status/SKILL.md +29 -0
- package/pipeline/commands/multi-agent/resume/SKILL.md +2 -1
- 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 +3 -3
- package/pipeline/contract/CHANGELOG.md +62 -0
- package/pipeline/contract/README.md +52 -6
- 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/manifest.json +367 -22
- package/pipeline/contract/types/index.d.ts +531 -4
- package/pipeline/lib/hook-registration.mjs +252 -0
- package/pipeline/lib/prefs-value.mjs +44 -0
- package/pipeline/lib/run-completion.mjs +148 -0
- package/pipeline/multi-agent-refs/analysis/render.md +4 -4
- package/pipeline/multi-agent-refs/cross-cli-contract.md +5 -4
- package/pipeline/multi-agent-refs/features/doctor.md +11 -0
- package/pipeline/multi-agent-refs/features/office-threat-model.md +91 -0
- 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/operations.md +17 -0
- package/pipeline/multi-agent-refs/phases/phase-0-init.md +6 -2
- package/pipeline/multi-agent-refs/phases/phase-1-plan.md +2 -2
- package/pipeline/multi-agent-refs/phases/phase-2-dev.md +1 -1
- package/pipeline/multi-agent-refs/phases/phase-3-review.md +2 -2
- package/pipeline/multi-agent-refs/phases/phase-4-commit.md +7 -6
- package/pipeline/multi-agent-refs/phases/phase-5-report.md +5 -4
- package/pipeline/office/NOTICE.md +42 -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_TABLE.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/DOUBLE_BOOKSHELF.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/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/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/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 +5319 -0
- package/pipeline/preferences-template.json +7 -0
- package/pipeline/rules/figma-pipeline.md +2 -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 +429 -0
- package/pipeline/schemas/agent-state.schema.json +20 -5
- 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 +33 -3
- package/pipeline/schemas/issue-maturity.schema.json +68 -0
- package/pipeline/schemas/ledger.schema.json +202 -0
- package/pipeline/schemas/permissions.schema.json +47 -0
- package/pipeline/schemas/phases.json +3 -2
- package/pipeline/schemas/phases.schema.json +5 -1
- package/pipeline/schemas/prefs.schema.json +56 -0
- package/pipeline/schemas/prs.schema.json +73 -0
- 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 +1 -1
- 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/tool-call-request.schema.json +19 -0
- package/pipeline/schemas/tool-call.schema.json +47 -0
- package/pipeline/schemas/tools.schema.json +60 -0
- package/pipeline/scripts/_active-run.mjs +158 -0
- package/pipeline/scripts/_autopilot-outcomes.mjs +1 -0
- package/pipeline/scripts/_cost.mjs +66 -1
- package/pipeline/scripts/_guard_rules.py +605 -0
- package/pipeline/scripts/_office-smoke-lib.sh +115 -0
- package/pipeline/scripts/_spawn-async.mjs +73 -0
- package/pipeline/scripts/_transcript-tail.mjs +274 -0
- package/pipeline/scripts/activity-snapshot.mjs +1027 -0
- package/pipeline/scripts/agent-guard.py +118 -7
- package/pipeline/scripts/agent-guard.sh +50 -3
- package/pipeline/scripts/argv-secret-scan.mjs +114 -0
- package/pipeline/scripts/autopilot-control.mjs +214 -9
- package/pipeline/scripts/autopilot-runner.mjs +8 -2
- package/pipeline/scripts/confirm-command.mjs +70 -0
- package/pipeline/scripts/contract-server.mjs +1178 -48
- package/pipeline/scripts/cost-analyze.mjs +2 -53
- package/pipeline/scripts/cost-table.json +1 -0
- package/pipeline/scripts/doctor.mjs +54 -0
- package/pipeline/scripts/evidence-gate.mjs +8 -4
- package/pipeline/scripts/gate-ledger.mjs +163 -7
- package/pipeline/scripts/office-build.mjs +89 -0
- package/pipeline/scripts/office-control.mjs +383 -0
- package/pipeline/scripts/office-ledger.mjs +409 -0
- package/pipeline/scripts/plan-coverage-gate.mjs +9 -8
- package/pipeline/scripts/pre-commit-check.sh +12 -0
- package/pipeline/scripts/routine-registry.mjs +42 -6
- package/pipeline/scripts/run-results.mjs +373 -0
- package/pipeline/scripts/run-scope-guard.mjs +188 -0
- package/pipeline/scripts/run-scope-guard.sh +23 -0
- package/pipeline/scripts/run-stop-guard.mjs +246 -0
- package/pipeline/scripts/run-stop-guard.sh +25 -0
- 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/toolkit-tools.mjs +599 -0
- package/pipeline/scripts/uninstall.mjs +4 -0
- package/pipeline/scripts/validate-state.mjs +26 -0
- package/pipeline/scripts/work-items.mjs +304 -0
- package/pipeline/scripts/write-state.mjs +114 -0
- package/pipeline/skills/.skill-manifest.json +19 -7
- 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 +22 -0
- package/pipeline/skills/shared/core/multi-agent-office-status/SKILL.md +17 -0
- 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/skills-index.md +4 -1
|
@@ -29,6 +29,40 @@
|
|
|
29
29
|
* POST /v1/gc gc-plan.mjs preview, then apply (two steps)
|
|
30
30
|
* GET /v1/autopilot autopilot-control.mjs status
|
|
31
31
|
* POST /v1/autopilot/off autopilot-control.mjs off [--now]
|
|
32
|
+
* POST /v1/autopilot/on autopilot-control.mjs on-preview, then on (two steps)
|
|
33
|
+
* POST /v1/runs/{id}/steer steer-run.mjs: queue one instruction for the next phase
|
|
34
|
+
* GET /v1/activity activity-snapshot.mjs --json
|
|
35
|
+
* GET /v1/activity/stream the same document as server-sent events, on change
|
|
36
|
+
* GET /v1/agents/{sessionId}/{agentId}/chat activity-snapshot.mjs chat
|
|
37
|
+
* GET /v1/ledger office-ledger.mjs --json
|
|
38
|
+
* GET /v1/runs/{id}/results run-results.mjs: diff, findings, tests, evidence, analysis
|
|
39
|
+
* GET /v1/runs/{id}/evidence/{name} run-results.mjs --evidence: one screenshot
|
|
40
|
+
* POST /v1/runs/{id}/review-verdict work-items.mjs verdict, then --post (two steps)
|
|
41
|
+
* GET /v1/issues/{key}/maturity work-items.mjs maturity (lib/issue-fetcher.sh)
|
|
42
|
+
* GET /v1/prs work-items.mjs prs
|
|
43
|
+
* GET /v1/tools toolkit-tools.mjs list (the registered toolkit MCP server)
|
|
44
|
+
* POST /v1/tools/{name}/call toolkit-tools.mjs call
|
|
45
|
+
* GET /v1/services/{confluence,crashlytics,figma} service-read.mjs
|
|
46
|
+
* GET /v1/permissions pending host permission prompts (none can be read yet)
|
|
47
|
+
* GET /v1/routines routine-registry.mjs list --json
|
|
48
|
+
* DELETE /v1/routines/{name} routine-registry.mjs remove (two steps)
|
|
49
|
+
* GET /v1/stack?repo=R stack-toggle.mjs status
|
|
50
|
+
* POST /v1/stack stack-toggle.mjs set (two steps)
|
|
51
|
+
*
|
|
52
|
+
* Every route carries two tags for a remote carrier to enforce later, and
|
|
53
|
+
* manifest.json repeats them: `remote` is the scope it would need (read,
|
|
54
|
+
* answer, launch, or local-only for anything that widens what runs on this
|
|
55
|
+
* machine: autopilot on, stack, routine removal, kill, gc), and `risk` is the
|
|
56
|
+
* confirmation tier it would need (read, low, high). Locally the bearer token
|
|
57
|
+
* is the only credential either way.
|
|
58
|
+
*
|
|
59
|
+
* GET /office serves the browser panel from MA_OFFICE_PAGE, else
|
|
60
|
+
* prefs.global.office.page, else the installed copy (office/index.html next
|
|
61
|
+
* to this scripts directory), and is 404 when none exists. The page carries
|
|
62
|
+
* no token: it trades a one-time code from its URL fragment for one. It and
|
|
63
|
+
* its sprites under /office/assets/ are the only routes that need none, and
|
|
64
|
+
* its response carries a per-response CSP nonce for its inline script and
|
|
65
|
+
* style.
|
|
32
66
|
*
|
|
33
67
|
* Phone routes, the same handlers behind a device signature instead of the
|
|
34
68
|
* bearer token, always redacted (features/phone-api.md):
|
|
@@ -45,7 +79,8 @@
|
|
|
45
79
|
* `claude agents --json` (read, for processAlive) and `claude stop <session>`,
|
|
46
80
|
* for a kill or `autopilot/off` with now.
|
|
47
81
|
*
|
|
48
|
-
* Kill
|
|
82
|
+
* Kill, gc, autopilot on, stack and routine removal change what this machine
|
|
83
|
+
* keeps or runs, so each takes two requests. The first returns a
|
|
49
84
|
* preview and a confirm token: 32 random bytes, kept in this process's memory
|
|
50
85
|
* only, bound to the route and the run id, valid for CONFIRM_TTL_MS and for one
|
|
51
86
|
* use. The second sends the token back and acts on exactly what the preview
|
|
@@ -63,8 +98,11 @@
|
|
|
63
98
|
*
|
|
64
99
|
* 1. Host must be 127.0.0.1:<port> or localhost:<port>, which is what stops
|
|
65
100
|
* a DNS-rebound page from reaching the socket under another name (403);
|
|
66
|
-
* 2. an Origin header must be
|
|
67
|
-
*
|
|
101
|
+
* 2. an Origin header must be this server's own origin (the panel it
|
|
102
|
+
* serves) or one of --allow-origin; any other is refused, and no CORS
|
|
103
|
+
* header is sent to it (403). A write (POST, DELETE) and the chat route
|
|
104
|
+
* accept no Origin but the server's own: a page somewhere else can never
|
|
105
|
+
* drive one, even an allowed one;
|
|
68
106
|
* 3. an allowed preflight is answered, before authentication, since a
|
|
69
107
|
* browser sends no credentials on one (204);
|
|
70
108
|
* 4. a path under /v1/phone/ leaves here for the device-signature checks in
|
|
@@ -79,21 +117,33 @@
|
|
|
79
117
|
*
|
|
80
118
|
* `?redact=1` is `--redact`. A request that carries an Origin is a browser
|
|
81
119
|
* page, which can relay what it reads anywhere, so it is always served
|
|
82
|
-
* redacted whatever the query says.
|
|
120
|
+
* redacted whatever the query says. The bearer token is read from the
|
|
121
|
+
* Authorization header only: never a cookie, never a query parameter.
|
|
122
|
+
*
|
|
123
|
+
* Every write route appends one line (who, what, when, outcome; never a
|
|
124
|
+
* body or a token) to ~/.claude/logs/multi-agent/office-actions.jsonl.
|
|
83
125
|
*
|
|
84
126
|
* Lifetime. The server exits after --idle-minutes without a request (15 by
|
|
85
|
-
* default)
|
|
86
|
-
*
|
|
127
|
+
* default), after --max-hours however busy it is (8 by default; an open event
|
|
128
|
+
* stream does not extend it), and on SIGTERM, SIGINT or SIGHUP, removing its
|
|
129
|
+
* token file either way.
|
|
130
|
+
*
|
|
131
|
+
* Panel bootstrap. `POST /office/code` (bearer, no Origin) mints a code that
|
|
132
|
+
* is valid for 10 seconds and one use; office-control.mjs opens the browser
|
|
133
|
+
* with it in the URL fragment. The page trades it for the token with
|
|
134
|
+
* `POST /office/exchange` (no bearer, own Origin only); a second use, an
|
|
135
|
+
* expired code or an unknown one is 401. The token itself never appears in a
|
|
136
|
+
* URL. It is started on demand by `/multi-agent:serve` or by a client, and is
|
|
87
137
|
* never installed as a service.
|
|
88
138
|
*
|
|
89
139
|
* Zero runtime dependencies (ADR-0004): node:http, node:crypto and the
|
|
90
140
|
* producers' own modules.
|
|
91
141
|
*
|
|
92
142
|
* Usage:
|
|
93
|
-
* node contract-server.mjs [--port N] [--idle-minutes N] [--allow-origin <origin>]... [--print-token]
|
|
143
|
+
* node contract-server.mjs [--port N] [--idle-minutes N] [--max-hours N] [--allow-origin <origin>]... [--print-token]
|
|
94
144
|
*
|
|
95
145
|
* Stdout: one JSON line once listening - {contractVersion, url, port, pid,
|
|
96
|
-
* tokenFile, idleMinutes[, token]}.
|
|
146
|
+
* tokenFile, idleMinutes, maxHours[, token]}.
|
|
97
147
|
* Exit: 0 stopped (idle or signal), 1 could not listen, 2 usage error.
|
|
98
148
|
*
|
|
99
149
|
* @module pipeline/scripts/contract-server
|
|
@@ -102,6 +152,7 @@
|
|
|
102
152
|
import { Buffer } from "node:buffer";
|
|
103
153
|
import { createHash, randomBytes, randomUUID, timingSafeEqual } from "node:crypto";
|
|
104
154
|
import {
|
|
155
|
+
appendFileSync,
|
|
105
156
|
chmodSync,
|
|
106
157
|
existsSync,
|
|
107
158
|
mkdirSync,
|
|
@@ -113,15 +164,51 @@ import {
|
|
|
113
164
|
} from "node:fs";
|
|
114
165
|
import { createServer } from "node:http";
|
|
115
166
|
import { homedir } from "node:os";
|
|
116
|
-
import { isAbsolute, join, sep } from "node:path";
|
|
117
|
-
import {
|
|
167
|
+
import { dirname, isAbsolute, join, sep } from "node:path";
|
|
168
|
+
import { fileURLToPath } from "node:url";
|
|
169
|
+
import { clearInterval, clearTimeout, setInterval, setTimeout } from "node:timers";
|
|
118
170
|
import { answerStateFile } from "./answer-question.mjs";
|
|
171
|
+
import { CONTRACT_VERSION as AUTOPILOT_VERSION, ControlError } from "./autopilot-control.mjs";
|
|
172
|
+
import { spawnAsync } from "./_spawn-async.mjs";
|
|
173
|
+
import {
|
|
174
|
+
CONTRACT_VERSION as ACTIVITY_VERSION,
|
|
175
|
+
activitySnapshot,
|
|
176
|
+
agentChat,
|
|
177
|
+
createActivityCache,
|
|
178
|
+
} from "./activity-snapshot.mjs";
|
|
179
|
+
import { RoutineError, listRoutines, removeRoutine } from "./routine-registry.mjs";
|
|
180
|
+
import {
|
|
181
|
+
CONTRACT_VERSION as STACK_VERSION,
|
|
182
|
+
StackError,
|
|
183
|
+
stackSet,
|
|
184
|
+
stackStatus,
|
|
185
|
+
} from "./stack-toggle.mjs";
|
|
186
|
+
import { CONTRACT_VERSION as STEER_VERSION, SteerError, queueSteer } from "./steer-run.mjs";
|
|
187
|
+
import { officeLedger } from "./office-ledger.mjs";
|
|
188
|
+
import { EVIDENCE_NAME, ResultsError, runEvidence, runResults } from "./run-results.mjs";
|
|
189
|
+
import {
|
|
190
|
+
WorkError,
|
|
191
|
+
issueMaturity,
|
|
192
|
+
postReviewVerdict,
|
|
193
|
+
pullRequests,
|
|
194
|
+
reviewVerdictPreview,
|
|
195
|
+
} from "./work-items.mjs";
|
|
119
196
|
import {
|
|
120
|
-
CONTRACT_VERSION as
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
} from "./
|
|
197
|
+
CONTRACT_VERSION as TOOLS_VERSION,
|
|
198
|
+
TOOL_NAME,
|
|
199
|
+
ToolError,
|
|
200
|
+
createToolkitClient,
|
|
201
|
+
} from "./toolkit-tools.mjs";
|
|
202
|
+
import {
|
|
203
|
+
FIGMA_KEY,
|
|
204
|
+
FIGMA_NODE,
|
|
205
|
+
PAGE_ID,
|
|
206
|
+
ServiceError,
|
|
207
|
+
confluencePage,
|
|
208
|
+
crashlyticsIssue,
|
|
209
|
+
figmaFrame,
|
|
210
|
+
} from "./service-read.mjs";
|
|
211
|
+
import { listSessions } from "../lib/claude-sessions.mjs";
|
|
125
212
|
import { catalog } from "./commands.mjs";
|
|
126
213
|
import { collect } from "./issues.mjs";
|
|
127
214
|
import {
|
|
@@ -132,6 +219,7 @@ import {
|
|
|
132
219
|
} from "./gc-plan.mjs";
|
|
133
220
|
import {
|
|
134
221
|
launchRepoAllowed,
|
|
222
|
+
launchRepos,
|
|
135
223
|
launchSpec,
|
|
136
224
|
planLaunch,
|
|
137
225
|
planResume,
|
|
@@ -171,7 +259,7 @@ import { runMain } from "../lib/fatal.mjs";
|
|
|
171
259
|
import { invokedDirectly } from "../lib/invoked-directly.mjs";
|
|
172
260
|
|
|
173
261
|
/** The contract as a whole; pipeline/contract/manifest.json carries the same. */
|
|
174
|
-
export const CONTRACT_VERSION = "1.
|
|
262
|
+
export const CONTRACT_VERSION = "1.4.0";
|
|
175
263
|
/** The majors `?v=` accepts. A new major keeps the previous one here for one minor. */
|
|
176
264
|
export const SUPPORTED_MAJORS = [1];
|
|
177
265
|
export const BIND_HOST = "127.0.0.1";
|
|
@@ -180,12 +268,35 @@ export const MAX_BODY_BYTES = 64 * 1024;
|
|
|
180
268
|
const MAX_DRAIN_BYTES = 1024 * 1024;
|
|
181
269
|
const DEFAULT_IDLE_MINUTES = 15;
|
|
182
270
|
const MAX_IDLE_MINUTES = 24 * 60;
|
|
271
|
+
/** Absolute lifetime: the server exits after this however busy it is. */
|
|
272
|
+
const DEFAULT_MAX_HOURS = 8;
|
|
273
|
+
const MAX_HOURS = 24;
|
|
274
|
+
/** Confirm tokens held at once; the oldest is dropped past this. */
|
|
275
|
+
export const MAX_CONFIRMS = 256;
|
|
276
|
+
/** Connections at once, and how long a phone request may take to send its body. */
|
|
277
|
+
const MAX_CONNECTIONS = 64;
|
|
278
|
+
const PHONE_BODY_TIMEOUT_MS = 5000;
|
|
279
|
+
/** A panel code: single use, valid this long, at most this many outstanding. */
|
|
280
|
+
export const CODE_TTL_MS = 10 * 1000;
|
|
281
|
+
const MAX_CODES = 16;
|
|
282
|
+
const PANEL_CODE = /^[A-Za-z0-9_-]{16,128}$/;
|
|
183
283
|
const RUN_ID = /^[A-Za-z0-9#][A-Za-z0-9#._-]{0,127}$/;
|
|
184
284
|
const PROJECT_KEY = /^[A-Z][A-Z0-9_]*$/;
|
|
185
285
|
const QUESTION_ID = /^[a-z][a-z0-9-]*$/;
|
|
186
286
|
const OPTION_ID = /^[a-z0-9][a-z0-9+_-]*$/;
|
|
187
287
|
const CONTRACT_VERSION_PATTERN = /^1\.[0-9]+\.[0-9]+$/;
|
|
188
288
|
const CONFIRM_TOKEN = /^[A-Za-z0-9_-]{16,128}$/;
|
|
289
|
+
const SESSION_ID = /^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/;
|
|
290
|
+
const AGENT_ID = /^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/;
|
|
291
|
+
const ROUTINE_NAME = /^[a-z0-9][a-z0-9-]{0,39}$/;
|
|
292
|
+
const REPO_NAME = /^[A-Za-z0-9._-]{1,100}(?:\/[A-Za-z0-9._-]{1,100})?$/;
|
|
293
|
+
/** Server-sent event streams open at once; one more is refused. */
|
|
294
|
+
export const MAX_STREAMS = 4;
|
|
295
|
+
const STREAM_POLL_MS = 2000;
|
|
296
|
+
const STREAM_HEARTBEAT_MS = 15000;
|
|
297
|
+
const SESSIONS_TTL_MS = 10000;
|
|
298
|
+
const MAX_CHAT_LIMIT = 500;
|
|
299
|
+
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
189
300
|
/** How long a kill or gc confirm token stays valid. */
|
|
190
301
|
export const CONFIRM_TTL_MS = 120 * 1000;
|
|
191
302
|
|
|
@@ -201,6 +312,13 @@ const confirmTtlMs = (() => {
|
|
|
201
312
|
return n >= 1 && n <= CONFIRM_TTL_MS ? n : CONFIRM_TTL_MS;
|
|
202
313
|
})();
|
|
203
314
|
|
|
315
|
+
/** As confirmTtlMs, for panel codes: MA_OFFICE_CODE_TTL_MS can only shorten it (tests). */
|
|
316
|
+
const codeTtlMs = (() => {
|
|
317
|
+
const raw = process.env.MA_OFFICE_CODE_TTL_MS;
|
|
318
|
+
const n = /^[0-9]{1,9}$/.test(raw ?? "") ? Number(raw) : NaN;
|
|
319
|
+
return n >= 1 && n <= CODE_TTL_MS ? n : CODE_TTL_MS;
|
|
320
|
+
})();
|
|
321
|
+
|
|
204
322
|
/** Where token files live. Fixed under HOME, never under LOGS_ROOT. */
|
|
205
323
|
export function tokenDir() {
|
|
206
324
|
return join(homedir(), ".claude", "logs", "multi-agent", "contract-server");
|
|
@@ -229,7 +347,16 @@ const Q = {
|
|
|
229
347
|
source: { check: (v) => ["jira", "github", "all"].includes(v) },
|
|
230
348
|
project: { repeat: true, check: (v) => PROJECT_KEY.test(v) },
|
|
231
349
|
max: { check: (v) => /^[0-9]{1,3}$/.test(v) && Number(v) >= 1 && Number(v) <= 200 },
|
|
232
|
-
repo: { check: (v) => v.length <= 4096 && isAbsolute(v) },
|
|
350
|
+
repo: { check: (v) => v.length <= 4096 && (isAbsolute(v) || REPO_NAME.test(v)) },
|
|
351
|
+
before: { check: (v) => /^[0-9]{1,15}$/.test(v) },
|
|
352
|
+
limit: {
|
|
353
|
+
check: (v) => /^[0-9]{1,3}$/.test(v) && Number(v) >= 1 && Number(v) <= MAX_CHAT_LIMIT,
|
|
354
|
+
},
|
|
355
|
+
confirm: { check: (v) => CONFIRM_TOKEN.test(v) },
|
|
356
|
+
page: { check: (v) => PAGE_ID.test(v) || (v.length <= 2048 && /^https:\/\//.test(v)) },
|
|
357
|
+
issue: { check: (v) => v.length <= 2048 && /^https:\/\//.test(v) },
|
|
358
|
+
file: { check: (v) => FIGMA_KEY.test(v) },
|
|
359
|
+
node: { check: (v) => FIGMA_NODE.test(v) },
|
|
233
360
|
tail: { check: (v) => /^[0-9]{1,4}$/.test(v) && Number(v) >= 1 && Number(v) <= MAX_TAIL },
|
|
234
361
|
};
|
|
235
362
|
|
|
@@ -336,8 +463,11 @@ function requireWorkspaceTrust(repoPath, redact) {
|
|
|
336
463
|
* only. Either way the repo must be a configured or registered one.
|
|
337
464
|
*/
|
|
338
465
|
function launchRun({ q, body, redact, channel = "desktop" }) {
|
|
339
|
-
|
|
466
|
+
let repoPath = one(q, "repo");
|
|
340
467
|
if (!repoPath) throw new HttpError(400, "invalid-query", "repo is required");
|
|
468
|
+
// A name from GET /v1/repos is accepted too: a browser reads that list
|
|
469
|
+
// redacted, without paths. Either way the repository must be an allowed one.
|
|
470
|
+
if (!isAbsolute(repoPath)) repoPath = resolveRepo(repoPath);
|
|
341
471
|
if (!existsSync(repoPath) || !statSync(repoPath).isDirectory()) {
|
|
342
472
|
throw new HttpError(400, "invalid-query", "repo is not a directory");
|
|
343
473
|
}
|
|
@@ -430,6 +560,7 @@ const confirms = new Map();
|
|
|
430
560
|
function mintConfirm(route, id, payload) {
|
|
431
561
|
const now = Date.now();
|
|
432
562
|
for (const [t, c] of confirms) if (c.expires <= now) confirms.delete(t);
|
|
563
|
+
while (confirms.size >= MAX_CONFIRMS) confirms.delete(confirms.keys().next().value);
|
|
433
564
|
const token = randomBytes(32).toString("base64url");
|
|
434
565
|
const expires = now + confirmTtlMs;
|
|
435
566
|
confirms.set(token, { route, id, expires, payload });
|
|
@@ -617,9 +748,54 @@ function gcRoute({ body, redact }) {
|
|
|
617
748
|
return { contractVersion: GC_VERSION, ...out, failed };
|
|
618
749
|
}
|
|
619
750
|
|
|
620
|
-
|
|
751
|
+
/**
|
|
752
|
+
* autopilot-control.mjs run as a child process: its status script, credential
|
|
753
|
+
* probes and launchctl calls are synchronous, and must not hold up every
|
|
754
|
+
* other route while they run. The child reports a refusal as
|
|
755
|
+
* {error: {code, message}} on stdout (MA_CONTROL_JSON_ERRORS).
|
|
756
|
+
*/
|
|
757
|
+
async function control(args) {
|
|
758
|
+
const r = await spawnAsync(process.execPath, [join(HERE, "autopilot-control.mjs"), ...args], {
|
|
759
|
+
env: { ...process.env, MA_CONTROL_JSON_ERRORS: "1" },
|
|
760
|
+
timeoutMs: 180_000,
|
|
761
|
+
maxBytes: 4 * 1024 * 1024,
|
|
762
|
+
});
|
|
763
|
+
let doc;
|
|
764
|
+
try {
|
|
765
|
+
doc = JSON.parse(r.stdout);
|
|
766
|
+
} catch {
|
|
767
|
+
doc = null;
|
|
768
|
+
}
|
|
769
|
+
if (doc?.error && typeof doc.error.code === "string") {
|
|
770
|
+
throw new ControlError(String(doc.error.message ?? "refused"), doc.error.code);
|
|
771
|
+
}
|
|
772
|
+
if (r.status !== 0 || !doc || typeof doc !== "object") {
|
|
773
|
+
throw new ControlError("autopilot-control did not finish");
|
|
774
|
+
}
|
|
775
|
+
return doc;
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
/** One request at a time per key; a second one while the first runs is 429. */
|
|
779
|
+
const running = new Set();
|
|
780
|
+
const oneAtATime = (key, fn) => async (ctx) => {
|
|
781
|
+
if (running.has(key)) {
|
|
782
|
+
throw new HttpError(
|
|
783
|
+
429,
|
|
784
|
+
"busy",
|
|
785
|
+
"the same request is still running; try again when it finishes",
|
|
786
|
+
);
|
|
787
|
+
}
|
|
788
|
+
running.add(key);
|
|
789
|
+
try {
|
|
790
|
+
return await fn(ctx);
|
|
791
|
+
} finally {
|
|
792
|
+
running.delete(key);
|
|
793
|
+
}
|
|
794
|
+
};
|
|
795
|
+
|
|
796
|
+
async function autopilotGet() {
|
|
621
797
|
try {
|
|
622
|
-
return { ...
|
|
798
|
+
return { ...(await control(["status"])), contractVersion: AUTOPILOT_VERSION };
|
|
623
799
|
} catch (err) {
|
|
624
800
|
if (err instanceof ControlError) throw new HttpError(500, "internal", err.message);
|
|
625
801
|
throw err;
|
|
@@ -628,12 +804,443 @@ function autopilotGet() {
|
|
|
628
804
|
|
|
629
805
|
async function autopilotOffRoute({ body }) {
|
|
630
806
|
requireBody(body, { now: (v) => typeof v === "boolean" }, "autopilot-off-request.schema.json");
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
807
|
+
try {
|
|
808
|
+
return {
|
|
809
|
+
contractVersion: AUTOPILOT_VERSION,
|
|
810
|
+
...(await control(body.now === true ? ["off", "--now"] : ["off"])),
|
|
811
|
+
};
|
|
812
|
+
} catch (err) {
|
|
813
|
+
if (err instanceof ControlError) throw new HttpError(500, "internal", err.message);
|
|
814
|
+
throw err;
|
|
815
|
+
}
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
const repoArgs = (repos) => repos.flatMap((r) => ["--repo", r]);
|
|
819
|
+
|
|
820
|
+
/**
|
|
821
|
+
* POST /v1/autopilot/on. Without confirmToken: what would be kept, dropped and
|
|
822
|
+
* probed, and a token bound to exactly that selection. With it: the mode is
|
|
823
|
+
* turned on for that selection (autopilot-control.mjs on).
|
|
824
|
+
*/
|
|
825
|
+
async function autopilotOnRoute({ body }) {
|
|
826
|
+
requireBody(
|
|
827
|
+
body,
|
|
828
|
+
{
|
|
829
|
+
confirmToken: isToken,
|
|
830
|
+
repos: (v) =>
|
|
831
|
+
Array.isArray(v) &&
|
|
832
|
+
v.length >= 1 &&
|
|
833
|
+
v.length <= 50 &&
|
|
834
|
+
v.every((x) => typeof x === "string" && REPO_NAME.test(x)),
|
|
835
|
+
configureOnly: (v) => typeof v === "boolean",
|
|
836
|
+
},
|
|
837
|
+
"autopilot-on-request.schema.json",
|
|
838
|
+
);
|
|
839
|
+
try {
|
|
840
|
+
if (body.confirmToken === undefined) {
|
|
841
|
+
if (!body.repos) throw new HttpError(400, "invalid-body", "repos is required");
|
|
842
|
+
const plan = await control(["on-preview", ...repoArgs(body.repos)]);
|
|
843
|
+
const payload = { repos: plan.keep, configureOnly: body.configureOnly === true };
|
|
844
|
+
return {
|
|
845
|
+
contractVersion: AUTOPILOT_VERSION,
|
|
846
|
+
preview: { ...plan, configureOnly: payload.configureOnly },
|
|
847
|
+
...mintConfirm("autopilot-on", "", payload),
|
|
848
|
+
};
|
|
849
|
+
}
|
|
850
|
+
if (body.repos !== undefined || body.configureOnly !== undefined) {
|
|
851
|
+
throw new HttpError(
|
|
852
|
+
400,
|
|
853
|
+
"invalid-body",
|
|
854
|
+
"the selection belongs to the preview, not the confirmation",
|
|
855
|
+
);
|
|
856
|
+
}
|
|
857
|
+
const payload = redeemConfirm(body.confirmToken, "autopilot-on", "");
|
|
858
|
+
return {
|
|
859
|
+
contractVersion: AUTOPILOT_VERSION,
|
|
860
|
+
...(await control([
|
|
861
|
+
"on",
|
|
862
|
+
...repoArgs(payload.repos),
|
|
863
|
+
...(payload.configureOnly ? ["--configure-only"] : []),
|
|
864
|
+
])),
|
|
865
|
+
};
|
|
866
|
+
} catch (err) {
|
|
867
|
+
if (err instanceof ControlError) {
|
|
868
|
+
if (err.code === "internal" || err.code === "launchd-refused") {
|
|
869
|
+
throw new HttpError(500, "internal", err.message);
|
|
870
|
+
}
|
|
871
|
+
throw new HttpError(409, err.code, err.message);
|
|
872
|
+
}
|
|
873
|
+
throw err;
|
|
874
|
+
}
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
/** POST /v1/runs/{id}/steer: one instruction for the run's next phase. */
|
|
878
|
+
/**
|
|
879
|
+
* POST /v1/runs/{id}/steer. Free text for an unattended agent, so it takes
|
|
880
|
+
* two requests like every high-risk write: the first names the text and gets
|
|
881
|
+
* a preview (the run, its phase, the unconsumed instruction it would replace)
|
|
882
|
+
* and a token bound to this run and exactly this text; the second sends only
|
|
883
|
+
* the token.
|
|
884
|
+
*/
|
|
885
|
+
async function steerRoute({ params, body }) {
|
|
886
|
+
requireBody(
|
|
887
|
+
body,
|
|
888
|
+
{ text: (v) => typeof v === "string", confirmToken: isToken },
|
|
889
|
+
"steer-request.schema.json",
|
|
890
|
+
);
|
|
891
|
+
const [record] = runRecordFor(params.id);
|
|
892
|
+
const file = stateFileOf(record);
|
|
893
|
+
if (!file) throw new HttpError(409, "not-steerable", "this run has no agent state to steer");
|
|
894
|
+
try {
|
|
895
|
+
if (body.confirmToken === undefined) {
|
|
896
|
+
if (typeof body.text !== "string" || !body.text.trim() || body.text.length > 4000) {
|
|
897
|
+
throw new HttpError(400, "invalid-body", "text is required, 1 to 4000 characters");
|
|
898
|
+
}
|
|
899
|
+
const { state } = readState(record);
|
|
900
|
+
if (["complete", "completed", "done"].includes(state.status)) {
|
|
901
|
+
throw new HttpError(
|
|
902
|
+
409,
|
|
903
|
+
"not-steerable",
|
|
904
|
+
"the run is finished; nothing would read the instruction",
|
|
905
|
+
);
|
|
906
|
+
}
|
|
907
|
+
const prev = state.pendingSteer;
|
|
908
|
+
return {
|
|
909
|
+
contractVersion: STEER_VERSION,
|
|
910
|
+
taskId: record.taskId,
|
|
911
|
+
preview: {
|
|
912
|
+
text: scrub(body.text),
|
|
913
|
+
status: typeof state.status === "string" ? state.status : null,
|
|
914
|
+
currentPhase: Number.isFinite(Number(state.currentPhase))
|
|
915
|
+
? Number(state.currentPhase)
|
|
916
|
+
: null,
|
|
917
|
+
replaces:
|
|
918
|
+
prev && !prev.appliedAt && typeof prev.text === "string" ? scrub(prev.text) : null,
|
|
919
|
+
},
|
|
920
|
+
...mintConfirm("steer", record.taskId, { text: body.text }),
|
|
921
|
+
};
|
|
922
|
+
}
|
|
923
|
+
if (body.text !== undefined) {
|
|
924
|
+
throw new HttpError(
|
|
925
|
+
400,
|
|
926
|
+
"invalid-body",
|
|
927
|
+
"the text belongs to the preview, not the confirmation",
|
|
928
|
+
);
|
|
929
|
+
}
|
|
930
|
+
const { text } = redeemConfirm(body.confirmToken, "steer", record.taskId);
|
|
931
|
+
return { ...(await queueSteer(file, text)), taskId: record.taskId };
|
|
932
|
+
} catch (err) {
|
|
933
|
+
if (err instanceof SteerError) {
|
|
934
|
+
if (err.code === "finished") throw new HttpError(409, "not-steerable", err.message);
|
|
935
|
+
throw new HttpError(400, "invalid-body", err.message);
|
|
936
|
+
}
|
|
937
|
+
throw err;
|
|
938
|
+
}
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
/** The sessions `claude agents --json` lists, read at most every SESSIONS_TTL_MS. */
|
|
942
|
+
let sessionsCache = { at: 0, list: undefined };
|
|
943
|
+
function cachedSessions() {
|
|
944
|
+
if (Date.now() - sessionsCache.at > SESSIONS_TTL_MS) {
|
|
945
|
+
sessionsCache = { at: Date.now(), list: listSessions() };
|
|
946
|
+
}
|
|
947
|
+
return sessionsCache.list;
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
const activityCache = createActivityCache();
|
|
951
|
+
const activityNow = () => activitySnapshot({ sessions: cachedSessions(), cache: activityCache });
|
|
952
|
+
|
|
953
|
+
async function chatRoute({ params, q }) {
|
|
954
|
+
if (!SESSION_ID.test(params.sessionId)) throw new HttpError(404, "not-found", "no such agent");
|
|
955
|
+
const agentId = params.agentId === "main" ? null : params.agentId;
|
|
956
|
+
if (agentId !== null && !AGENT_ID.test(agentId)) {
|
|
957
|
+
throw new HttpError(404, "not-found", "no such agent");
|
|
958
|
+
}
|
|
959
|
+
const page = await agentChat({
|
|
960
|
+
sessionId: params.sessionId,
|
|
961
|
+
agentId,
|
|
962
|
+
before: one(q, "before") === null ? null : Number(one(q, "before")),
|
|
963
|
+
limit: one(q, "limit") === null ? 200 : Number(one(q, "limit")),
|
|
964
|
+
});
|
|
965
|
+
if (!page) throw new HttpError(404, "not-found", "no such agent");
|
|
966
|
+
return page;
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
const routinesDocument = () => ({
|
|
970
|
+
contractVersion: ACTIVITY_VERSION,
|
|
971
|
+
routines: listRoutines(homedir()).map((r) => ({
|
|
972
|
+
name: String(r?.name ?? ""),
|
|
973
|
+
description: typeof r?.description === "string" ? scrub(r.description) : "",
|
|
974
|
+
createdAt: typeof r?.createdAt === "string" ? r.createdAt : null,
|
|
975
|
+
source: typeof r?.source === "string" ? scrub(r.source) : null,
|
|
976
|
+
})),
|
|
977
|
+
});
|
|
978
|
+
|
|
979
|
+
const routinesGet = () => {
|
|
980
|
+
try {
|
|
981
|
+
return routinesDocument();
|
|
982
|
+
} catch (err) {
|
|
983
|
+
if (err instanceof RoutineError) throw new HttpError(500, "internal", err.message);
|
|
984
|
+
throw err;
|
|
985
|
+
}
|
|
986
|
+
};
|
|
987
|
+
|
|
988
|
+
/** DELETE /v1/routines/{name}: a preview and token, then ?confirm=<token> removes it. */
|
|
989
|
+
function routineDeleteRoute({ params, q }) {
|
|
990
|
+
const name = params.name;
|
|
991
|
+
if (!ROUTINE_NAME.test(name)) throw new HttpError(404, "not-found", "no such routine");
|
|
992
|
+
try {
|
|
993
|
+
const token = one(q, "confirm");
|
|
994
|
+
if (token === null) {
|
|
995
|
+
const entry = routinesDocument().routines.find((r) => r.name === name);
|
|
996
|
+
if (!entry) throw new HttpError(404, "not-found", "no such routine");
|
|
997
|
+
return {
|
|
998
|
+
contractVersion: ACTIVITY_VERSION,
|
|
999
|
+
preview: entry,
|
|
1000
|
+
...mintConfirm("routine-delete", name, null),
|
|
1001
|
+
};
|
|
1002
|
+
}
|
|
1003
|
+
redeemConfirm(token, "routine-delete", name);
|
|
1004
|
+
return { contractVersion: ACTIVITY_VERSION, removed: removeRoutine(homedir(), name) };
|
|
1005
|
+
} catch (err) {
|
|
1006
|
+
if (err instanceof RoutineError) throw new HttpError(409, "not-removable", err.message);
|
|
1007
|
+
throw err;
|
|
1008
|
+
}
|
|
1009
|
+
}
|
|
1010
|
+
|
|
1011
|
+
const notAllowed = () =>
|
|
1012
|
+
new HttpError(
|
|
1013
|
+
403,
|
|
1014
|
+
"repo-not-allowed",
|
|
1015
|
+
"repo is neither a configured autopilot repo nor one registered with launch-request.mjs register-repo",
|
|
1016
|
+
);
|
|
1017
|
+
|
|
1018
|
+
/**
|
|
1019
|
+
* The checkout a panel request names: an absolute path or a name from
|
|
1020
|
+
* GET /v1/repos, and either way one of the repositories launch accepts.
|
|
1021
|
+
*/
|
|
1022
|
+
function resolveRepo(ref) {
|
|
1023
|
+
if (!ref) throw new HttpError(400, "invalid-query", "repo is required");
|
|
1024
|
+
if (isAbsolute(ref)) {
|
|
1025
|
+
if (!launchRepoAllowed(ref)) throw notAllowed();
|
|
1026
|
+
return realOr(ref);
|
|
1027
|
+
}
|
|
1028
|
+
const hits = launchRepos().filter((r) => r.name === ref);
|
|
1029
|
+
if (hits.length > 1) {
|
|
1030
|
+
throw new HttpError(409, "repo-ambiguous", "more than one repository has that name");
|
|
1031
|
+
}
|
|
1032
|
+
if (!hits.length) throw notAllowed();
|
|
1033
|
+
return hits[0].path;
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1036
|
+
function stackError(err) {
|
|
1037
|
+
if (!(err instanceof StackError)) return err;
|
|
1038
|
+
if (err.code === "unreadable-settings") {
|
|
1039
|
+
return new HttpError(409, "unreadable-settings", err.message);
|
|
1040
|
+
}
|
|
1041
|
+
return new HttpError(400, "invalid-body", err.message);
|
|
1042
|
+
}
|
|
1043
|
+
|
|
1044
|
+
function stackGet({ q }) {
|
|
1045
|
+
const repo = resolveRepo(one(q, "repo"));
|
|
1046
|
+
try {
|
|
1047
|
+
return stackStatus(repo);
|
|
1048
|
+
} catch (err) {
|
|
1049
|
+
throw stackError(err);
|
|
1050
|
+
}
|
|
1051
|
+
}
|
|
1052
|
+
|
|
1053
|
+
/** POST /v1/stack: the planned enabledPlugins and a token, then the write. */
|
|
1054
|
+
function stackPost({ body }) {
|
|
1055
|
+
requireBody(
|
|
1056
|
+
body,
|
|
1057
|
+
{
|
|
1058
|
+
confirmToken: isToken,
|
|
1059
|
+
repo: (v) =>
|
|
1060
|
+
typeof v === "string" && v.length <= 4096 && (isAbsolute(v) || REPO_NAME.test(v)),
|
|
1061
|
+
stacks: (v) =>
|
|
1062
|
+
Array.isArray(v) &&
|
|
1063
|
+
v.length >= 1 &&
|
|
1064
|
+
v.length <= 8 &&
|
|
1065
|
+
v.every((x) => typeof x === "string" && /^[a-z]{2,16}$/.test(x)),
|
|
1066
|
+
},
|
|
1067
|
+
"stack-request.schema.json",
|
|
1068
|
+
);
|
|
1069
|
+
try {
|
|
1070
|
+
if (body.confirmToken === undefined) {
|
|
1071
|
+
if (!body.repo || !body.stacks) {
|
|
1072
|
+
throw new HttpError(400, "invalid-body", "repo and stacks are required");
|
|
1073
|
+
}
|
|
1074
|
+
const repo = resolveRepo(body.repo);
|
|
1075
|
+
const plan = stackSet(repo, body.stacks, { dryRun: true });
|
|
1076
|
+
return {
|
|
1077
|
+
contractVersion: STACK_VERSION,
|
|
1078
|
+
preview: {
|
|
1079
|
+
stacks: plan.stacks,
|
|
1080
|
+
changed: plan.changed,
|
|
1081
|
+
enabledPlugins: plan.enabledPlugins,
|
|
1082
|
+
dropped: plan.dropped,
|
|
1083
|
+
},
|
|
1084
|
+
...mintConfirm("stack", "", { repo, stacks: body.stacks }),
|
|
1085
|
+
};
|
|
1086
|
+
}
|
|
1087
|
+
if (body.repo !== undefined || body.stacks !== undefined) {
|
|
1088
|
+
throw new HttpError(
|
|
1089
|
+
400,
|
|
1090
|
+
"invalid-body",
|
|
1091
|
+
"the selection belongs to the preview, not the confirmation",
|
|
1092
|
+
);
|
|
1093
|
+
}
|
|
1094
|
+
const { repo, stacks } = redeemConfirm(body.confirmToken, "stack", "");
|
|
1095
|
+
return { ...stackSet(repo, stacks), contractVersion: STACK_VERSION };
|
|
1096
|
+
} catch (err) {
|
|
1097
|
+
throw stackError(err);
|
|
1098
|
+
}
|
|
1099
|
+
}
|
|
1100
|
+
|
|
1101
|
+
function resultsRoute({ params }) {
|
|
1102
|
+
if (!RUN_ID.test(params.id)) throw new HttpError(404, "not-found", "no such run");
|
|
1103
|
+
try {
|
|
1104
|
+
return runResults(params.id);
|
|
1105
|
+
} catch (err) {
|
|
1106
|
+
if (err instanceof ResultsError) throw new HttpError(404, "not-found", "no such run");
|
|
1107
|
+
throw err;
|
|
1108
|
+
}
|
|
1109
|
+
}
|
|
1110
|
+
|
|
1111
|
+
function evidenceRoute({ params }) {
|
|
1112
|
+
if (!RUN_ID.test(params.id) || !EVIDENCE_NAME.test(params.name)) {
|
|
1113
|
+
throw new HttpError(404, "not-found", "no such image");
|
|
1114
|
+
}
|
|
1115
|
+
try {
|
|
1116
|
+
return runEvidence(params.id, params.name);
|
|
1117
|
+
} catch (err) {
|
|
1118
|
+
if (err instanceof ResultsError) throw new HttpError(404, "not-found", "no such image");
|
|
1119
|
+
throw err;
|
|
1120
|
+
}
|
|
1121
|
+
}
|
|
1122
|
+
|
|
1123
|
+
function workError(err) {
|
|
1124
|
+
if (!(err instanceof WorkError)) return err;
|
|
1125
|
+
if (err.code === "no-run" || err.code === "bad-key")
|
|
1126
|
+
return new HttpError(404, "not-found", err.message);
|
|
1127
|
+
if (err.code === "no-review" || err.code === "no-findings") {
|
|
1128
|
+
return new HttpError(409, "no-review", err.message);
|
|
1129
|
+
}
|
|
1130
|
+
if (err.code === "not-installed") return new HttpError(409, "not-installed", err.message);
|
|
1131
|
+
if (err.code === "review-incomplete") return new HttpError(409, "review-incomplete", err.message);
|
|
1132
|
+
if (err.code === "changed") return new HttpError(409, "confirm-mismatch", err.message);
|
|
1133
|
+
return new HttpError(502, "upstream-failed", err.message);
|
|
1134
|
+
}
|
|
1135
|
+
|
|
1136
|
+
async function maturityRoute({ params }) {
|
|
1137
|
+
try {
|
|
1138
|
+
return await issueMaturity(params.key);
|
|
1139
|
+
} catch (err) {
|
|
1140
|
+
throw workError(err);
|
|
1141
|
+
}
|
|
1142
|
+
}
|
|
1143
|
+
|
|
1144
|
+
/**
|
|
1145
|
+
* POST /v1/runs/{id}/review-verdict: the verdict post-pr-review.sh would post
|
|
1146
|
+
* and a token, then the post itself. The verdict follows from the findings the
|
|
1147
|
+
* review recorded; no request chooses it. Only a finished review whose every
|
|
1148
|
+
* finding is triaged can be previewed, and the token is bound to the decision
|
|
1149
|
+
* and the findings digest it showed: when either changed before the
|
|
1150
|
+
* confirmation, it is refused with confirm-mismatch.
|
|
1151
|
+
*/
|
|
1152
|
+
async function reviewVerdictRoute({ params, body }) {
|
|
1153
|
+
requireBody(body, { confirmToken: isToken }, "review-verdict-request.schema.json");
|
|
1154
|
+
try {
|
|
1155
|
+
const [record] = runRecordFor(params.id);
|
|
1156
|
+
if (body.confirmToken === undefined) {
|
|
1157
|
+
const preview = reviewVerdictPreview(record.taskId);
|
|
1158
|
+
const bound = { decision: preview.decision, findingsDigest: preview.findingsDigest };
|
|
1159
|
+
return { ...preview, ...mintConfirm("review-verdict", record.taskId, bound) };
|
|
1160
|
+
}
|
|
1161
|
+
const expected = redeemConfirm(body.confirmToken, "review-verdict", record.taskId);
|
|
1162
|
+
return await postReviewVerdict(record.taskId, process.env, expected);
|
|
1163
|
+
} catch (err) {
|
|
1164
|
+
throw workError(err);
|
|
1165
|
+
}
|
|
1166
|
+
}
|
|
1167
|
+
|
|
1168
|
+
/** The toolkit MCP client of this server, started on first use, closed on stop. */
|
|
1169
|
+
let toolkit = null;
|
|
1170
|
+
const toolkitClient = () => (toolkit ??= createToolkitClient());
|
|
1171
|
+
export const closeToolkit = () => {
|
|
1172
|
+
toolkit?.close();
|
|
1173
|
+
toolkit = null;
|
|
1174
|
+
};
|
|
1175
|
+
|
|
1176
|
+
function toolError(err) {
|
|
1177
|
+
if (!(err instanceof ToolError)) return err;
|
|
1178
|
+
if (err.code === "unknown-tool") return new HttpError(404, "not-found", "no such tool");
|
|
1179
|
+
if (err.code === "tool-not-allowed") return new HttpError(403, "tool-not-allowed", err.message);
|
|
1180
|
+
if (err.code === "unpinned") return new HttpError(409, "not-installed", err.message);
|
|
1181
|
+
if (err.code === "invalid-arguments") {
|
|
1182
|
+
return new HttpError(400, "invalid-body", err.message, { errors: err.extra?.errors ?? [] });
|
|
1183
|
+
}
|
|
1184
|
+
if (err.code === "not-registered") return new HttpError(409, "not-installed", err.message);
|
|
1185
|
+
return new HttpError(502, "upstream-failed", err.message);
|
|
1186
|
+
}
|
|
1187
|
+
|
|
1188
|
+
async function toolsRoute() {
|
|
1189
|
+
try {
|
|
1190
|
+
return { contractVersion: TOOLS_VERSION, tools: await toolkitClient().listTools() };
|
|
1191
|
+
} catch (err) {
|
|
1192
|
+
throw toolError(err);
|
|
1193
|
+
}
|
|
1194
|
+
}
|
|
1195
|
+
|
|
1196
|
+
async function toolCallRoute({ params, body }) {
|
|
1197
|
+
requireBody(
|
|
1198
|
+
body,
|
|
1199
|
+
{ arguments: (v) => v && typeof v === "object" && !Array.isArray(v) },
|
|
1200
|
+
"tool-call-request.schema.json",
|
|
1201
|
+
);
|
|
1202
|
+
if (!TOOL_NAME.test(params.name)) throw new HttpError(404, "not-found", "no such tool");
|
|
1203
|
+
try {
|
|
1204
|
+
return await toolkitClient().callTool(params.name, body.arguments ?? {});
|
|
1205
|
+
} catch (err) {
|
|
1206
|
+
throw toolError(err);
|
|
1207
|
+
}
|
|
1208
|
+
}
|
|
1209
|
+
|
|
1210
|
+
function serviceError(err) {
|
|
1211
|
+
if (!(err instanceof ServiceError)) return err;
|
|
1212
|
+
if (err.code === "bad-input") return new HttpError(400, "invalid-query", err.message);
|
|
1213
|
+
if (err.code === "not-installed" || err.code === "not-configured") {
|
|
1214
|
+
return new HttpError(409, "not-installed", err.message);
|
|
1215
|
+
}
|
|
1216
|
+
if (err.code === "host-not-allowed") return new HttpError(409, "host-not-allowed", err.message);
|
|
1217
|
+
return new HttpError(502, "upstream-failed", err.message);
|
|
635
1218
|
}
|
|
636
1219
|
|
|
1220
|
+
const serviceRoute = (key, fn) =>
|
|
1221
|
+
oneAtATime(key, async (ctx) => {
|
|
1222
|
+
try {
|
|
1223
|
+
return await fn(ctx);
|
|
1224
|
+
} catch (err) {
|
|
1225
|
+
throw serviceError(err);
|
|
1226
|
+
}
|
|
1227
|
+
});
|
|
1228
|
+
|
|
1229
|
+
/**
|
|
1230
|
+
* GET /v1/permissions. Claude Code offers no mechanism that records a pending
|
|
1231
|
+
* permission prompt outside the session that shows it, so there is nothing to
|
|
1232
|
+
* list yet. The shape is fixed now so a client can render it, and `available`
|
|
1233
|
+
* says plainly that nothing is forwarded; pipeline questions are answered
|
|
1234
|
+
* through POST /v1/runs/{id}/answer.
|
|
1235
|
+
*/
|
|
1236
|
+
const permissionsDocument = () => ({
|
|
1237
|
+
contractVersion: TOOLS_VERSION,
|
|
1238
|
+
available: false,
|
|
1239
|
+
reason:
|
|
1240
|
+
"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",
|
|
1241
|
+
requests: [],
|
|
1242
|
+
});
|
|
1243
|
+
|
|
637
1244
|
const listRuns = ({ q, redact }) =>
|
|
638
1245
|
envelope(selectRuns({ group: one(q, "group") }).records, { redact });
|
|
639
1246
|
const oneRun = ({ params, redact }) => envelope(runRecordFor(params.id), { redact });
|
|
@@ -643,6 +1250,12 @@ const oneRun = ({ params, redact }) => envelope(runRecordFor(params.id), { redac
|
|
|
643
1250
|
* route accepts; `body` marks a route that reads a JSON body. A route with
|
|
644
1251
|
* `scope` is a phone route: it is reached only with a device signature, never
|
|
645
1252
|
* with the bearer token, and is always served redacted.
|
|
1253
|
+
*
|
|
1254
|
+
* `remote` and `risk` are the tags a remote carrier would enforce: the scope a
|
|
1255
|
+
* caller would need (read, answer, launch, local-only) and the confirmation
|
|
1256
|
+
* tier (read, low, high). `stream` marks the server-sent event route,
|
|
1257
|
+
* `selfOrigin` a route a foreign page may never read even when its origin is
|
|
1258
|
+
* allowed (the chat transcript), and `write` a route whose call is logged.
|
|
646
1259
|
*/
|
|
647
1260
|
export const ROUTES = [
|
|
648
1261
|
{ method: "GET", path: "/v1/runs", query: ["group"], handle: listRuns },
|
|
@@ -682,14 +1295,183 @@ export const ROUTES = [
|
|
|
682
1295
|
query: [],
|
|
683
1296
|
handle: ({ redact }) => reposDocument({ redact }),
|
|
684
1297
|
},
|
|
685
|
-
{
|
|
686
|
-
|
|
1298
|
+
{
|
|
1299
|
+
method: "POST",
|
|
1300
|
+
path: "/v1/launch",
|
|
1301
|
+
query: ["repo"],
|
|
1302
|
+
body: true,
|
|
1303
|
+
remote: "launch",
|
|
1304
|
+
risk: "high",
|
|
1305
|
+
handle: launchRun,
|
|
1306
|
+
},
|
|
1307
|
+
{
|
|
1308
|
+
method: "POST",
|
|
1309
|
+
path: "/v1/runs/{id}/answer",
|
|
1310
|
+
query: [],
|
|
1311
|
+
body: true,
|
|
1312
|
+
remote: "answer",
|
|
1313
|
+
risk: "low",
|
|
1314
|
+
handle: answerRun,
|
|
1315
|
+
},
|
|
687
1316
|
{ method: "GET", path: "/v1/runs/{id}/log", query: ["tail"], handle: logRun },
|
|
688
|
-
{
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
1317
|
+
{
|
|
1318
|
+
method: "POST",
|
|
1319
|
+
path: "/v1/runs/{id}/resume",
|
|
1320
|
+
query: [],
|
|
1321
|
+
body: true,
|
|
1322
|
+
remote: "launch",
|
|
1323
|
+
risk: "high",
|
|
1324
|
+
handle: resumeRun,
|
|
1325
|
+
},
|
|
1326
|
+
{
|
|
1327
|
+
method: "POST",
|
|
1328
|
+
path: "/v1/runs/{id}/steer",
|
|
1329
|
+
query: [],
|
|
1330
|
+
body: true,
|
|
1331
|
+
remote: "launch",
|
|
1332
|
+
risk: "high",
|
|
1333
|
+
handle: steerRoute,
|
|
1334
|
+
},
|
|
1335
|
+
{
|
|
1336
|
+
method: "POST",
|
|
1337
|
+
path: "/v1/runs/{id}/kill",
|
|
1338
|
+
query: [],
|
|
1339
|
+
body: true,
|
|
1340
|
+
remote: "local-only",
|
|
1341
|
+
risk: "high",
|
|
1342
|
+
handle: killRoute,
|
|
1343
|
+
},
|
|
1344
|
+
{
|
|
1345
|
+
method: "POST",
|
|
1346
|
+
path: "/v1/gc",
|
|
1347
|
+
query: [],
|
|
1348
|
+
body: true,
|
|
1349
|
+
remote: "local-only",
|
|
1350
|
+
risk: "high",
|
|
1351
|
+
handle: gcRoute,
|
|
1352
|
+
},
|
|
1353
|
+
{
|
|
1354
|
+
method: "GET",
|
|
1355
|
+
path: "/v1/autopilot",
|
|
1356
|
+
query: [],
|
|
1357
|
+
handle: oneAtATime("autopilot-status", autopilotGet),
|
|
1358
|
+
},
|
|
1359
|
+
{
|
|
1360
|
+
method: "POST",
|
|
1361
|
+
path: "/v1/autopilot/off",
|
|
1362
|
+
query: [],
|
|
1363
|
+
body: true,
|
|
1364
|
+
remote: "launch",
|
|
1365
|
+
risk: "low",
|
|
1366
|
+
handle: oneAtATime("autopilot", autopilotOffRoute),
|
|
1367
|
+
},
|
|
1368
|
+
{
|
|
1369
|
+
method: "POST",
|
|
1370
|
+
path: "/v1/autopilot/on",
|
|
1371
|
+
query: [],
|
|
1372
|
+
body: true,
|
|
1373
|
+
remote: "local-only",
|
|
1374
|
+
risk: "high",
|
|
1375
|
+
handle: oneAtATime("autopilot", autopilotOnRoute),
|
|
1376
|
+
},
|
|
1377
|
+
{ method: "GET", path: "/v1/activity", query: [], handle: () => activityNow() },
|
|
1378
|
+
{
|
|
1379
|
+
method: "GET",
|
|
1380
|
+
path: "/v1/activity/stream",
|
|
1381
|
+
query: [],
|
|
1382
|
+
stream: true,
|
|
1383
|
+
handle: () => activityNow(),
|
|
1384
|
+
},
|
|
1385
|
+
{
|
|
1386
|
+
method: "GET",
|
|
1387
|
+
path: "/v1/agents/{sessionId}/{agentId}/chat",
|
|
1388
|
+
query: ["before", "limit"],
|
|
1389
|
+
selfOrigin: true,
|
|
1390
|
+
handle: chatRoute,
|
|
1391
|
+
},
|
|
1392
|
+
{ method: "GET", path: "/v1/ledger", query: [], handle: () => officeLedger() },
|
|
1393
|
+
{ method: "GET", path: "/v1/runs/{id}/results", query: [], handle: resultsRoute },
|
|
1394
|
+
{ method: "GET", path: "/v1/runs/{id}/evidence/{name}", query: [], handle: evidenceRoute },
|
|
1395
|
+
{
|
|
1396
|
+
method: "POST",
|
|
1397
|
+
path: "/v1/runs/{id}/review-verdict",
|
|
1398
|
+
query: [],
|
|
1399
|
+
body: true,
|
|
1400
|
+
remote: "launch",
|
|
1401
|
+
risk: "high",
|
|
1402
|
+
handle: oneAtATime("review-verdict", reviewVerdictRoute),
|
|
1403
|
+
},
|
|
1404
|
+
{
|
|
1405
|
+
method: "GET",
|
|
1406
|
+
path: "/v1/issues/{key}/maturity",
|
|
1407
|
+
query: [],
|
|
1408
|
+
handle: oneAtATime("maturity", maturityRoute),
|
|
1409
|
+
},
|
|
1410
|
+
{
|
|
1411
|
+
method: "GET",
|
|
1412
|
+
path: "/v1/prs",
|
|
1413
|
+
query: [],
|
|
1414
|
+
handle: oneAtATime("prs", ({ redact }) => pullRequests({ redact })),
|
|
1415
|
+
},
|
|
1416
|
+
{ method: "GET", path: "/v1/tools", query: [], handle: toolsRoute },
|
|
1417
|
+
{
|
|
1418
|
+
method: "POST",
|
|
1419
|
+
path: "/v1/tools/{name}/call",
|
|
1420
|
+
query: [],
|
|
1421
|
+
body: true,
|
|
1422
|
+
remote: "local-only",
|
|
1423
|
+
risk: "high",
|
|
1424
|
+
handle: toolCallRoute,
|
|
1425
|
+
},
|
|
1426
|
+
{
|
|
1427
|
+
method: "GET",
|
|
1428
|
+
path: "/v1/services/confluence",
|
|
1429
|
+
query: ["page"],
|
|
1430
|
+
handle: serviceRoute("confluence", ({ q }) => {
|
|
1431
|
+
if (one(q, "page") === null) throw new HttpError(400, "invalid-query", "page is required");
|
|
1432
|
+
return confluencePage(one(q, "page"));
|
|
1433
|
+
}),
|
|
1434
|
+
},
|
|
1435
|
+
{
|
|
1436
|
+
method: "GET",
|
|
1437
|
+
path: "/v1/services/crashlytics",
|
|
1438
|
+
query: ["issue"],
|
|
1439
|
+
handle: serviceRoute("crashlytics", ({ q }) => {
|
|
1440
|
+
if (one(q, "issue") === null) throw new HttpError(400, "invalid-query", "issue is required");
|
|
1441
|
+
return crashlyticsIssue(one(q, "issue"));
|
|
1442
|
+
}),
|
|
1443
|
+
},
|
|
1444
|
+
{
|
|
1445
|
+
method: "GET",
|
|
1446
|
+
path: "/v1/services/figma",
|
|
1447
|
+
query: ["file", "node"],
|
|
1448
|
+
handle: serviceRoute("figma", ({ q }) => {
|
|
1449
|
+
if (one(q, "file") === null || one(q, "node") === null) {
|
|
1450
|
+
throw new HttpError(400, "invalid-query", "file and node are required");
|
|
1451
|
+
}
|
|
1452
|
+
return figmaFrame(one(q, "file"), one(q, "node"));
|
|
1453
|
+
}),
|
|
1454
|
+
},
|
|
1455
|
+
{ method: "GET", path: "/v1/permissions", query: [], handle: permissionsDocument },
|
|
1456
|
+
{ method: "GET", path: "/v1/routines", query: [], handle: routinesGet },
|
|
1457
|
+
{
|
|
1458
|
+
method: "DELETE",
|
|
1459
|
+
path: "/v1/routines/{name}",
|
|
1460
|
+
query: ["confirm"],
|
|
1461
|
+
remote: "local-only",
|
|
1462
|
+
risk: "high",
|
|
1463
|
+
handle: routineDeleteRoute,
|
|
1464
|
+
},
|
|
1465
|
+
{ method: "GET", path: "/v1/stack", query: ["repo"], handle: stackGet },
|
|
1466
|
+
{
|
|
1467
|
+
method: "POST",
|
|
1468
|
+
path: "/v1/stack",
|
|
1469
|
+
query: [],
|
|
1470
|
+
body: true,
|
|
1471
|
+
remote: "local-only",
|
|
1472
|
+
risk: "high",
|
|
1473
|
+
handle: stackPost,
|
|
1474
|
+
},
|
|
693
1475
|
{ method: "GET", path: "/v1/phone/runs", query: ["group"], scope: "read", handle: listRuns },
|
|
694
1476
|
{ method: "GET", path: "/v1/phone/runs/{id}", query: [], scope: "read", handle: oneRun },
|
|
695
1477
|
{
|
|
@@ -698,6 +1480,7 @@ export const ROUTES = [
|
|
|
698
1480
|
query: [],
|
|
699
1481
|
body: true,
|
|
700
1482
|
scope: "answer",
|
|
1483
|
+
risk: "low",
|
|
701
1484
|
handle: answerRun,
|
|
702
1485
|
},
|
|
703
1486
|
{
|
|
@@ -706,6 +1489,7 @@ export const ROUTES = [
|
|
|
706
1489
|
query: ["repo"],
|
|
707
1490
|
body: true,
|
|
708
1491
|
scope: "launch",
|
|
1492
|
+
risk: "high",
|
|
709
1493
|
handle: (ctx) => {
|
|
710
1494
|
if (!ctx.registry.launchEnabled) {
|
|
711
1495
|
throw new HttpError(403, "launch-disabled", "launch from a phone is not enabled here");
|
|
@@ -715,8 +1499,12 @@ export const ROUTES = [
|
|
|
715
1499
|
},
|
|
716
1500
|
].map((r) => ({
|
|
717
1501
|
...r,
|
|
1502
|
+
remote: r.remote ?? r.scope ?? "read",
|
|
1503
|
+
risk: r.risk ?? "read",
|
|
1504
|
+
write: r.method !== "GET",
|
|
718
1505
|
phone: r.path.startsWith(PHONE_PREFIX),
|
|
719
|
-
|
|
1506
|
+
params: [...r.path.matchAll(/\{([A-Za-z]+)\}/g)].map((m) => m[1]),
|
|
1507
|
+
pattern: new RegExp(`^${r.path.replace(/\{([A-Za-z]+)\}/g, "(?<$1>[^/]+)")}$`),
|
|
720
1508
|
}));
|
|
721
1509
|
|
|
722
1510
|
/**
|
|
@@ -747,11 +1535,11 @@ export function match(method, pathname) {
|
|
|
747
1535
|
},
|
|
748
1536
|
);
|
|
749
1537
|
}
|
|
750
|
-
|
|
1538
|
+
for (const [k, v] of Object.entries(hit.params)) {
|
|
751
1539
|
try {
|
|
752
|
-
hit.params
|
|
1540
|
+
hit.params[k] = decodeURIComponent(v);
|
|
753
1541
|
} catch {
|
|
754
|
-
throw new HttpError(404, "not-found", "no such run");
|
|
1542
|
+
throw new HttpError(404, "not-found", k === "id" ? "no such run" : "no such resource");
|
|
755
1543
|
}
|
|
756
1544
|
}
|
|
757
1545
|
return hit;
|
|
@@ -801,11 +1589,19 @@ export function authorized(header, tokenDigest) {
|
|
|
801
1589
|
return timingSafeEqual(sha(m ? m[1] : ""), tokenDigest) && Boolean(m);
|
|
802
1590
|
}
|
|
803
1591
|
|
|
804
|
-
function readRawBody(req) {
|
|
1592
|
+
function readRawBody(req, timeoutMs = 0) {
|
|
805
1593
|
return new Promise((resolve, reject) => {
|
|
806
1594
|
const chunks = [];
|
|
807
1595
|
let size = 0;
|
|
808
1596
|
let over = false;
|
|
1597
|
+
if (timeoutMs > 0) {
|
|
1598
|
+
const timer = setTimeout(
|
|
1599
|
+
() => reject(new HttpError(408, "invalid-body", "the body did not arrive in time")),
|
|
1600
|
+
timeoutMs,
|
|
1601
|
+
);
|
|
1602
|
+
req.on("end", () => clearTimeout(timer));
|
|
1603
|
+
req.on("close", () => clearTimeout(timer));
|
|
1604
|
+
}
|
|
809
1605
|
req.on("data", (chunk) => {
|
|
810
1606
|
size += chunk.length;
|
|
811
1607
|
if (size > MAX_BODY_BYTES) over = true;
|
|
@@ -846,7 +1642,13 @@ function drain(req) {
|
|
|
846
1642
|
}
|
|
847
1643
|
|
|
848
1644
|
function parseArgs(argv) {
|
|
849
|
-
const opts = {
|
|
1645
|
+
const opts = {
|
|
1646
|
+
port: 0,
|
|
1647
|
+
idleMinutes: DEFAULT_IDLE_MINUTES,
|
|
1648
|
+
maxHours: DEFAULT_MAX_HOURS,
|
|
1649
|
+
origins: [],
|
|
1650
|
+
printToken: false,
|
|
1651
|
+
};
|
|
850
1652
|
for (let i = 0; i < argv.length; i++) {
|
|
851
1653
|
const a = argv[i];
|
|
852
1654
|
const value = () => {
|
|
@@ -867,6 +1669,12 @@ function parseArgs(argv) {
|
|
|
867
1669
|
throw new Error(`--idle-minutes is more than 0 and at most ${MAX_IDLE_MINUTES}`);
|
|
868
1670
|
}
|
|
869
1671
|
opts.idleMinutes = n;
|
|
1672
|
+
} else if (a === "--max-hours") {
|
|
1673
|
+
const n = Number(value());
|
|
1674
|
+
if (!Number.isFinite(n) || n <= 0 || n > MAX_HOURS) {
|
|
1675
|
+
throw new Error(`--max-hours is more than 0 and at most ${MAX_HOURS}`);
|
|
1676
|
+
}
|
|
1677
|
+
opts.maxHours = n;
|
|
870
1678
|
} else if (a === "--allow-origin") {
|
|
871
1679
|
const o = value();
|
|
872
1680
|
let parsed;
|
|
@@ -904,6 +1712,135 @@ function writeTokenFile(record) {
|
|
|
904
1712
|
return file;
|
|
905
1713
|
}
|
|
906
1714
|
|
|
1715
|
+
export const OFFICE_PATH = "/office";
|
|
1716
|
+
/** Bearer, no Origin: mints a one-time panel code (office-control.mjs). */
|
|
1717
|
+
export const OFFICE_CODE_PATH = "/office/code";
|
|
1718
|
+
/** No bearer, own Origin only: trades a panel code for the token, once. */
|
|
1719
|
+
export const OFFICE_EXCHANGE_PATH = "/office/exchange";
|
|
1720
|
+
|
|
1721
|
+
function readPrefs(env) {
|
|
1722
|
+
try {
|
|
1723
|
+
return JSON.parse(
|
|
1724
|
+
readFileSync(
|
|
1725
|
+
env.MULTI_AGENT_PREFS || join(homedir(), ".claude", "multi-agent-preferences.json"),
|
|
1726
|
+
"utf8",
|
|
1727
|
+
),
|
|
1728
|
+
);
|
|
1729
|
+
} catch {
|
|
1730
|
+
return null;
|
|
1731
|
+
}
|
|
1732
|
+
}
|
|
1733
|
+
|
|
1734
|
+
function prefsOfficePage(env) {
|
|
1735
|
+
return readPrefs(env)?.global?.office?.page ?? null;
|
|
1736
|
+
}
|
|
1737
|
+
|
|
1738
|
+
/**
|
|
1739
|
+
* The page language: "tr" when prefs.global.outputLanguage is Turkish, else
|
|
1740
|
+
* "en". Only these two values ever reach the page, so the stamp needs no
|
|
1741
|
+
* escaping.
|
|
1742
|
+
*/
|
|
1743
|
+
export function officeLang(env = process.env) {
|
|
1744
|
+
const lang = readPrefs(env)?.global?.outputLanguage;
|
|
1745
|
+
return typeof lang === "string" && /^tr\b/i.test(lang) ? "tr" : "en";
|
|
1746
|
+
}
|
|
1747
|
+
|
|
1748
|
+
/**
|
|
1749
|
+
* The panel page, when one is on disk: MA_OFFICE_PAGE, else
|
|
1750
|
+
* prefs.global.office.page, else (neither set) the copy beside this package.
|
|
1751
|
+
* A configured path is the only candidate, so pointing it elsewhere never
|
|
1752
|
+
* falls back. Absolute, regular files only; none is not an error, the route
|
|
1753
|
+
* is 404.
|
|
1754
|
+
*/
|
|
1755
|
+
export function officePage(env = process.env) {
|
|
1756
|
+
const configured = env.MA_OFFICE_PAGE || prefsOfficePage(env);
|
|
1757
|
+
const candidates = configured ? [configured] : [join(HERE, "..", "office", "index.html")];
|
|
1758
|
+
for (const p of candidates) {
|
|
1759
|
+
if (typeof p !== "string" || !isAbsolute(p)) continue;
|
|
1760
|
+
try {
|
|
1761
|
+
if (statSync(p).isFile()) return p;
|
|
1762
|
+
} catch {
|
|
1763
|
+
// Not there: the route answers 404.
|
|
1764
|
+
}
|
|
1765
|
+
}
|
|
1766
|
+
return null;
|
|
1767
|
+
}
|
|
1768
|
+
|
|
1769
|
+
/** Sprites of the office floor, served beside the page under this prefix. */
|
|
1770
|
+
export const OFFICE_ASSET_PREFIX = "/office/assets/";
|
|
1771
|
+
const OFFICE_ASSET_NAME = /^[A-Za-z0-9_]{1,48}\.png$/;
|
|
1772
|
+
const OFFICE_ASSET_MAX_BYTES = 256 * 1024;
|
|
1773
|
+
const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
|
1774
|
+
|
|
1775
|
+
/**
|
|
1776
|
+
* A sprite of the installed page: the raw name matches a plain file name
|
|
1777
|
+
* pattern (no separator, no escape), the file is a regular PNG of at most
|
|
1778
|
+
* 256 KiB whose real path is directly inside the page's assets directory.
|
|
1779
|
+
* Anything else is null, which the route answers with 404.
|
|
1780
|
+
*/
|
|
1781
|
+
export function officeAsset(name, env = process.env) {
|
|
1782
|
+
if (!OFFICE_ASSET_NAME.test(name)) return null;
|
|
1783
|
+
const page = officePage(env);
|
|
1784
|
+
if (!page) return null;
|
|
1785
|
+
try {
|
|
1786
|
+
const dir = realpathSync(join(dirname(page), "assets"));
|
|
1787
|
+
const file = realpathSync(join(dir, name));
|
|
1788
|
+
if (dirname(file) !== dir) return null;
|
|
1789
|
+
const st = statSync(file);
|
|
1790
|
+
if (!st.isFile() || st.size > OFFICE_ASSET_MAX_BYTES) return null;
|
|
1791
|
+
const body = readFileSync(file);
|
|
1792
|
+
return body.subarray(0, 8).equals(PNG_SIGNATURE) ? body : null;
|
|
1793
|
+
} catch {
|
|
1794
|
+
return null;
|
|
1795
|
+
}
|
|
1796
|
+
}
|
|
1797
|
+
|
|
1798
|
+
/** The panel's CSP: nothing but its own nonce'd inline code, same-origin calls and images. */
|
|
1799
|
+
export const officeCsp = (nonce) =>
|
|
1800
|
+
[
|
|
1801
|
+
"default-src 'none'",
|
|
1802
|
+
`script-src 'nonce-${nonce}'`,
|
|
1803
|
+
`style-src 'nonce-${nonce}'`,
|
|
1804
|
+
"img-src 'self' data:",
|
|
1805
|
+
"connect-src 'self'",
|
|
1806
|
+
"frame-ancestors 'none'",
|
|
1807
|
+
"base-uri 'none'",
|
|
1808
|
+
"form-action 'none'",
|
|
1809
|
+
].join("; ");
|
|
1810
|
+
|
|
1811
|
+
/** Where every write route leaves its line. */
|
|
1812
|
+
export function actionsLog() {
|
|
1813
|
+
return join(homedir(), ".claude", "logs", "multi-agent", "office-actions.jsonl");
|
|
1814
|
+
}
|
|
1815
|
+
|
|
1816
|
+
/**
|
|
1817
|
+
* One line per write: who (the local bearer holder, and whether the request
|
|
1818
|
+
* came from the panel page), what (route, path parameters, preview or
|
|
1819
|
+
* confirm), when and the outcome; a tool call adds the sha256 of its
|
|
1820
|
+
* arguments. Never a body, a query value or a token.
|
|
1821
|
+
* A log that cannot be written does not fail the request.
|
|
1822
|
+
*/
|
|
1823
|
+
function logAction({ route, params, own, confirm, status, error, argsSha256 }) {
|
|
1824
|
+
const line = {
|
|
1825
|
+
at: new Date().toISOString(),
|
|
1826
|
+
who: "local",
|
|
1827
|
+
via: own ? "panel" : "client",
|
|
1828
|
+
route,
|
|
1829
|
+
params,
|
|
1830
|
+
step: confirm ? "confirm" : "request",
|
|
1831
|
+
status,
|
|
1832
|
+
...(argsSha256 ? { argsSha256 } : {}),
|
|
1833
|
+
...(error ? { error } : {}),
|
|
1834
|
+
};
|
|
1835
|
+
try {
|
|
1836
|
+
const file = actionsLog();
|
|
1837
|
+
mkdirSync(dirname(file), { recursive: true, mode: 0o700 });
|
|
1838
|
+
appendFileSync(file, JSON.stringify(line) + "\n", { mode: 0o600 });
|
|
1839
|
+
} catch {
|
|
1840
|
+
// The action itself already happened or was refused; the log is best effort.
|
|
1841
|
+
}
|
|
1842
|
+
}
|
|
1843
|
+
|
|
907
1844
|
/**
|
|
908
1845
|
* Start listening. Resolves with the ready record once the port is known.
|
|
909
1846
|
*
|
|
@@ -915,11 +1852,28 @@ export function serve(opts) {
|
|
|
915
1852
|
const origins = new Set(opts.origins ?? []);
|
|
916
1853
|
const idleMs = Math.round((opts.idleMinutes ?? DEFAULT_IDLE_MINUTES) * 60000);
|
|
917
1854
|
let hosts = new Set();
|
|
1855
|
+
let selfOrigins = new Set();
|
|
1856
|
+
const streams = new Set();
|
|
918
1857
|
let startSec = Infinity;
|
|
919
1858
|
const nonces = createNonceStore({ file: noncesPath() });
|
|
920
1859
|
let tokenFile = null;
|
|
921
1860
|
let idleTimer = null;
|
|
1861
|
+
let lifeTimer = null;
|
|
922
1862
|
let stopping = false;
|
|
1863
|
+
const codes = new Map();
|
|
1864
|
+
const mintCode = () => {
|
|
1865
|
+
const now = Date.now();
|
|
1866
|
+
for (const [c, exp] of codes) if (exp <= now) codes.delete(c);
|
|
1867
|
+
while (codes.size >= MAX_CODES) codes.delete(codes.keys().next().value);
|
|
1868
|
+
const code = randomBytes(32).toString("base64url");
|
|
1869
|
+
codes.set(code, now + codeTtlMs);
|
|
1870
|
+
return { code, expiresAt: new Date(now + codeTtlMs).toISOString() };
|
|
1871
|
+
};
|
|
1872
|
+
const redeemCode = (code) => {
|
|
1873
|
+
const exp = typeof code === "string" && PANEL_CODE.test(code) ? codes.get(code) : undefined;
|
|
1874
|
+
if (exp !== undefined) codes.delete(code);
|
|
1875
|
+
return exp !== undefined && exp > Date.now();
|
|
1876
|
+
};
|
|
923
1877
|
|
|
924
1878
|
// requireHostHeader off: a request without Host reaches the Host check below
|
|
925
1879
|
// and is refused there with a contract error body, like any other wrong Host.
|
|
@@ -929,6 +1883,7 @@ export function serve(opts) {
|
|
|
929
1883
|
requireHostHeader: false,
|
|
930
1884
|
});
|
|
931
1885
|
server.maxHeadersCount = 50;
|
|
1886
|
+
server.maxConnections = MAX_CONNECTIONS;
|
|
932
1887
|
|
|
933
1888
|
const send = (res, status, obj, extra = {}) => {
|
|
934
1889
|
const body = serialize(obj);
|
|
@@ -937,11 +1892,37 @@ export function serve(opts) {
|
|
|
937
1892
|
"Content-Length": Buffer.byteLength(body),
|
|
938
1893
|
"Cache-Control": "no-store",
|
|
939
1894
|
"X-Content-Type-Options": "nosniff",
|
|
1895
|
+
"Referrer-Policy": "no-referrer",
|
|
1896
|
+
"Content-Security-Policy": "default-src 'none'; frame-ancestors 'none'",
|
|
940
1897
|
"X-Contract-Version": CONTRACT_VERSION,
|
|
941
1898
|
...extra,
|
|
942
1899
|
});
|
|
943
1900
|
res.end(body);
|
|
944
1901
|
};
|
|
1902
|
+
|
|
1903
|
+
/**
|
|
1904
|
+
* The panel, with a fresh nonce in its CSP and in each inline script and
|
|
1905
|
+
* style tag, so no other inline code runs, and its language stamped in.
|
|
1906
|
+
* 404 when no page is installed.
|
|
1907
|
+
*/
|
|
1908
|
+
const serveOffice = (res, headOnly) => {
|
|
1909
|
+
const file = officePage();
|
|
1910
|
+
if (!file) throw new HttpError(404, "not-found", "no panel is installed on this machine");
|
|
1911
|
+
const nonce = randomBytes(18).toString("base64url");
|
|
1912
|
+
const html = readFileSync(file, "utf8")
|
|
1913
|
+
.replaceAll("{{NONCE}}", nonce)
|
|
1914
|
+
.replaceAll("{{LANG}}", officeLang());
|
|
1915
|
+
res.writeHead(200, {
|
|
1916
|
+
"Content-Type": "text/html; charset=utf-8",
|
|
1917
|
+
"Content-Length": Buffer.byteLength(html),
|
|
1918
|
+
"Cache-Control": "no-store",
|
|
1919
|
+
"X-Content-Type-Options": "nosniff",
|
|
1920
|
+
"Referrer-Policy": "no-referrer",
|
|
1921
|
+
"Cross-Origin-Opener-Policy": "same-origin",
|
|
1922
|
+
"Content-Security-Policy": officeCsp(nonce),
|
|
1923
|
+
});
|
|
1924
|
+
res.end(headOnly ? undefined : html);
|
|
1925
|
+
};
|
|
945
1926
|
const fail = (res, err, extra) => {
|
|
946
1927
|
const body = { contractVersion: CONTRACT_VERSION, error: err.code, message: err.message };
|
|
947
1928
|
if (err.extra?.errors) body.errors = err.extra.errors;
|
|
@@ -957,7 +1938,10 @@ export function serve(opts) {
|
|
|
957
1938
|
if (stopping) return;
|
|
958
1939
|
stopping = true;
|
|
959
1940
|
clearTimeout(idleTimer);
|
|
1941
|
+
clearTimeout(lifeTimer);
|
|
960
1942
|
removeTokenFile();
|
|
1943
|
+
for (const st of streams) clearInterval(st.timer);
|
|
1944
|
+
closeToolkit();
|
|
961
1945
|
process.stderr.write(`contract-server: stopping (${why})\n`);
|
|
962
1946
|
server.close(() => process.exit(0));
|
|
963
1947
|
server.closeAllConnections();
|
|
@@ -977,7 +1961,7 @@ export function serve(opts) {
|
|
|
977
1961
|
const audit = { deviceId: null, method: req.method, verb: verbOf(url.pathname) };
|
|
978
1962
|
let insecure = null;
|
|
979
1963
|
try {
|
|
980
|
-
const raw = await readRawBody(req);
|
|
1964
|
+
const raw = await readRawBody(req, PHONE_BODY_TIMEOUT_MS);
|
|
981
1965
|
const registry = readRegistry();
|
|
982
1966
|
insecure = registry.insecure;
|
|
983
1967
|
const v = verifyRequest({
|
|
@@ -1019,6 +2003,7 @@ export function serve(opts) {
|
|
|
1019
2003
|
if (!(err instanceof HttpError))
|
|
1020
2004
|
process.stderr.write(`contract-server: ${err?.message ?? err}\n`);
|
|
1021
2005
|
fail(res, e, cors);
|
|
2006
|
+
if (e.status === 408) req.destroy();
|
|
1022
2007
|
const outcome = e.code === "unknown-device" && insecure ? insecure : e.code;
|
|
1023
2008
|
appendAudit({ ...audit, status: e.status, outcome });
|
|
1024
2009
|
}
|
|
@@ -1028,22 +2013,24 @@ export function serve(opts) {
|
|
|
1028
2013
|
arm();
|
|
1029
2014
|
res.on("finish", arm);
|
|
1030
2015
|
const origin = req.headers.origin;
|
|
2016
|
+
const own = origin !== undefined && selfOrigins.has(origin);
|
|
1031
2017
|
const cors =
|
|
1032
|
-
origin && origins.has(origin)
|
|
2018
|
+
origin && !own && origins.has(origin)
|
|
1033
2019
|
? { "Access-Control-Allow-Origin": origin, Vary: "Origin" }
|
|
1034
2020
|
: {};
|
|
2021
|
+
let audit = null;
|
|
1035
2022
|
try {
|
|
1036
2023
|
if (!hosts.has(String(req.headers.host ?? "").toLowerCase())) {
|
|
1037
2024
|
throw new HttpError(403, "forbidden-host", "the Host header does not name this server");
|
|
1038
2025
|
}
|
|
1039
|
-
if (origin !== undefined && !origins.has(origin)) {
|
|
2026
|
+
if (origin !== undefined && !own && !origins.has(origin)) {
|
|
1040
2027
|
throw new HttpError(403, "forbidden-origin", "this origin is not allowed");
|
|
1041
2028
|
}
|
|
1042
2029
|
if (req.method === "OPTIONS" && origin !== undefined) {
|
|
1043
2030
|
drain(req);
|
|
1044
2031
|
res.writeHead(204, {
|
|
1045
2032
|
...cors,
|
|
1046
|
-
"Access-Control-Allow-Methods": "GET
|
|
2033
|
+
"Access-Control-Allow-Methods": "GET",
|
|
1047
2034
|
"Access-Control-Allow-Headers": "Authorization, Content-Type",
|
|
1048
2035
|
"Access-Control-Max-Age": "600",
|
|
1049
2036
|
"Cache-Control": "no-store",
|
|
@@ -1056,10 +2043,77 @@ export function serve(opts) {
|
|
|
1056
2043
|
await servePhone(req, res, url, cors);
|
|
1057
2044
|
return;
|
|
1058
2045
|
}
|
|
2046
|
+
if (url.pathname === OFFICE_PATH && (req.method === "GET" || req.method === "HEAD")) {
|
|
2047
|
+
drain(req);
|
|
2048
|
+
if (url.search) throw new HttpError(400, "invalid-query", "the panel takes no query");
|
|
2049
|
+
serveOffice(res, req.method === "HEAD");
|
|
2050
|
+
return;
|
|
2051
|
+
}
|
|
2052
|
+
if (url.pathname.startsWith(OFFICE_ASSET_PREFIX)) {
|
|
2053
|
+
drain(req);
|
|
2054
|
+
if (req.method !== "GET" && req.method !== "HEAD") {
|
|
2055
|
+
throw new HttpError(405, "method-not-allowed", "use GET", { allow: "GET" });
|
|
2056
|
+
}
|
|
2057
|
+
if (url.search) throw new HttpError(400, "invalid-query", "an asset takes no query");
|
|
2058
|
+
const body = officeAsset(url.pathname.slice(OFFICE_ASSET_PREFIX.length));
|
|
2059
|
+
if (!body) throw new HttpError(404, "not-found", "no such asset");
|
|
2060
|
+
res.writeHead(200, {
|
|
2061
|
+
"Content-Type": "image/png",
|
|
2062
|
+
"Content-Length": body.length,
|
|
2063
|
+
"Cache-Control": "max-age=3600",
|
|
2064
|
+
"X-Content-Type-Options": "nosniff",
|
|
2065
|
+
"Cross-Origin-Resource-Policy": "same-origin",
|
|
2066
|
+
"Content-Security-Policy": "default-src 'none'",
|
|
2067
|
+
});
|
|
2068
|
+
res.end(req.method === "HEAD" ? undefined : body);
|
|
2069
|
+
return;
|
|
2070
|
+
}
|
|
2071
|
+
if (url.pathname === OFFICE_EXCHANGE_PATH) {
|
|
2072
|
+
if (req.method !== "POST" || url.search) {
|
|
2073
|
+
drain(req);
|
|
2074
|
+
throw new HttpError(405, "method-not-allowed", "use POST", { allow: "POST" });
|
|
2075
|
+
}
|
|
2076
|
+
if (!own) {
|
|
2077
|
+
drain(req);
|
|
2078
|
+
throw new HttpError(403, "forbidden-origin", "only the panel page exchanges a code");
|
|
2079
|
+
}
|
|
2080
|
+
if (!/^application\/json(\s*;.*)?$/i.test(String(req.headers["content-type"] ?? ""))) {
|
|
2081
|
+
drain(req);
|
|
2082
|
+
throw new HttpError(415, "unsupported-media-type", "the body must be application/json");
|
|
2083
|
+
}
|
|
2084
|
+
const b = await readBody(req);
|
|
2085
|
+
if (!redeemCode(b?.code)) {
|
|
2086
|
+
throw new HttpError(
|
|
2087
|
+
401,
|
|
2088
|
+
"unauthorized",
|
|
2089
|
+
"the code is unknown, used or expired; run /multi-agent:office-on again",
|
|
2090
|
+
);
|
|
2091
|
+
}
|
|
2092
|
+
send(res, 200, { contractVersion: CONTRACT_VERSION, token });
|
|
2093
|
+
return;
|
|
2094
|
+
}
|
|
1059
2095
|
if (!authorized(req.headers.authorization, tokenDigest)) {
|
|
1060
2096
|
throw new HttpError(401, "unauthorized", "a bearer token is required");
|
|
1061
2097
|
}
|
|
2098
|
+
if (url.pathname === OFFICE_CODE_PATH) {
|
|
2099
|
+
drain(req);
|
|
2100
|
+
if (req.method !== "POST") {
|
|
2101
|
+
throw new HttpError(405, "method-not-allowed", "use POST", { allow: "POST" });
|
|
2102
|
+
}
|
|
2103
|
+
if (origin !== undefined) {
|
|
2104
|
+
throw new HttpError(403, "forbidden-origin", "a page may not mint a panel code");
|
|
2105
|
+
}
|
|
2106
|
+
send(res, 200, { contractVersion: CONTRACT_VERSION, ...mintCode() });
|
|
2107
|
+
return;
|
|
2108
|
+
}
|
|
1062
2109
|
const { route, params } = match(req.method, url.pathname);
|
|
2110
|
+
// A write, and a read of a transcript, are refused to every page but the
|
|
2111
|
+
// panel this server serves: an allowed origin may read the contract, it
|
|
2112
|
+
// may not drive this machine or read what an agent said.
|
|
2113
|
+
if ((route.write || route.selfOrigin) && origin !== undefined && !own) {
|
|
2114
|
+
throw new HttpError(403, "forbidden-origin", "this origin may not call this route");
|
|
2115
|
+
}
|
|
2116
|
+
if (route.write) audit = { route: `${route.method} ${route.path}`, params, own };
|
|
1063
2117
|
const q = parseQuery(route, url.searchParams);
|
|
1064
2118
|
const redact = flag(one(q, "redact")) || origin !== undefined;
|
|
1065
2119
|
let body = null;
|
|
@@ -1073,34 +2127,110 @@ export function serve(opts) {
|
|
|
1073
2127
|
} else {
|
|
1074
2128
|
drain(req);
|
|
1075
2129
|
}
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
2130
|
+
if (audit) audit.confirm = Boolean(body?.confirmToken ?? one(q, "confirm"));
|
|
2131
|
+
if (audit && route.path === "/v1/tools/{name}/call") {
|
|
2132
|
+
audit.argsSha256 = createHash("sha256")
|
|
2133
|
+
.update(JSON.stringify(body?.arguments ?? {}))
|
|
2134
|
+
.digest("hex");
|
|
2135
|
+
}
|
|
2136
|
+
if (route.stream) {
|
|
2137
|
+
serveStream(req, res, route, cors);
|
|
1080
2138
|
return;
|
|
1081
2139
|
}
|
|
1082
|
-
|
|
1083
|
-
|
|
2140
|
+
const doc = await route.handle({ q, params, body, redact });
|
|
2141
|
+
if (audit) logAction({ ...audit, status: 200 });
|
|
2142
|
+
send(res, 200, doc, cors);
|
|
2143
|
+
} catch (err) {
|
|
2144
|
+
const e = err instanceof HttpError ? err : new HttpError(500, "internal", "internal error");
|
|
2145
|
+
if (!(err instanceof HttpError))
|
|
2146
|
+
process.stderr.write(`contract-server: ${err?.message ?? err}\n`);
|
|
2147
|
+
if (audit) logAction({ ...audit, status: e.status, error: e.code });
|
|
2148
|
+
fail(res, e, cors);
|
|
1084
2149
|
}
|
|
1085
2150
|
});
|
|
1086
2151
|
|
|
2152
|
+
/**
|
|
2153
|
+
* GET /v1/activity/stream: the activity document as server-sent events, sent
|
|
2154
|
+
* when it changes and checked every STREAM_POLL_MS, with a comment line as a
|
|
2155
|
+
* heartbeat. Each event re-arms the idle clock, so an open panel keeps the
|
|
2156
|
+
* server up and a closed one lets it stop. At most MAX_STREAMS at once.
|
|
2157
|
+
*/
|
|
2158
|
+
const serveStream = (req, res, route, cors) => {
|
|
2159
|
+
if (streams.size >= MAX_STREAMS) {
|
|
2160
|
+
throw new HttpError(429, "too-many-streams", `at most ${MAX_STREAMS} streams at once`);
|
|
2161
|
+
}
|
|
2162
|
+
res.writeHead(200, {
|
|
2163
|
+
"Content-Type": "text/event-stream; charset=utf-8",
|
|
2164
|
+
"Cache-Control": "no-store",
|
|
2165
|
+
Connection: "keep-alive",
|
|
2166
|
+
"X-Content-Type-Options": "nosniff",
|
|
2167
|
+
"Referrer-Policy": "no-referrer",
|
|
2168
|
+
"X-Contract-Version": CONTRACT_VERSION,
|
|
2169
|
+
...cors,
|
|
2170
|
+
});
|
|
2171
|
+
let last = null;
|
|
2172
|
+
let busy = false;
|
|
2173
|
+
let lastSent = Date.now();
|
|
2174
|
+
const tick = async () => {
|
|
2175
|
+
if (busy) return;
|
|
2176
|
+
busy = true;
|
|
2177
|
+
try {
|
|
2178
|
+
const doc = await route.handle({});
|
|
2179
|
+
const { generatedAt: _g, ...stable } = doc;
|
|
2180
|
+
const key = JSON.stringify(stable);
|
|
2181
|
+
if (key !== last) {
|
|
2182
|
+
last = key;
|
|
2183
|
+
res.write(`event: activity\ndata: ${JSON.stringify(doc)}\n\n`);
|
|
2184
|
+
lastSent = Date.now();
|
|
2185
|
+
arm();
|
|
2186
|
+
} else if (Date.now() - lastSent >= STREAM_HEARTBEAT_MS) {
|
|
2187
|
+
res.write(": keep-alive\n\n");
|
|
2188
|
+
lastSent = Date.now();
|
|
2189
|
+
arm();
|
|
2190
|
+
}
|
|
2191
|
+
} catch (err) {
|
|
2192
|
+
process.stderr.write(`contract-server: stream: ${err?.message ?? err}\n`);
|
|
2193
|
+
res.write(`event: error\ndata: {"error":"internal"}\n\n`);
|
|
2194
|
+
} finally {
|
|
2195
|
+
busy = false;
|
|
2196
|
+
}
|
|
2197
|
+
};
|
|
2198
|
+
const timer = setInterval(tick, STREAM_POLL_MS);
|
|
2199
|
+
const entry = { res, timer };
|
|
2200
|
+
streams.add(entry);
|
|
2201
|
+
const close = () => {
|
|
2202
|
+
clearInterval(timer);
|
|
2203
|
+
streams.delete(entry);
|
|
2204
|
+
};
|
|
2205
|
+
// The response's close, not the request's: a request emits close as soon
|
|
2206
|
+
// as its (empty) body has been read.
|
|
2207
|
+
res.on("close", close);
|
|
2208
|
+
tick();
|
|
2209
|
+
};
|
|
2210
|
+
|
|
1087
2211
|
return new Promise((resolve, reject) => {
|
|
1088
2212
|
server.once("error", reject);
|
|
1089
2213
|
server.listen(opts.port ?? 0, BIND_HOST, () => {
|
|
1090
2214
|
const { port } = server.address();
|
|
1091
2215
|
hosts = new Set([`${BIND_HOST}:${port}`, `localhost:${port}`]);
|
|
2216
|
+
selfOrigins = new Set([`http://${BIND_HOST}:${port}`, `http://localhost:${port}`]);
|
|
1092
2217
|
const record = {
|
|
1093
2218
|
contractVersion: CONTRACT_VERSION,
|
|
1094
2219
|
url: `http://${BIND_HOST}:${port}`,
|
|
1095
2220
|
port,
|
|
1096
2221
|
pid: process.pid,
|
|
1097
2222
|
idleMinutes: opts.idleMinutes ?? DEFAULT_IDLE_MINUTES,
|
|
2223
|
+
maxHours: opts.maxHours ?? DEFAULT_MAX_HOURS,
|
|
1098
2224
|
};
|
|
1099
2225
|
const started = new Date();
|
|
1100
2226
|
startSec = Math.floor(started.getTime() / 1000);
|
|
1101
2227
|
tokenFile = writeTokenFile({ ...record, token, startedAt: started.toISOString() });
|
|
1102
2228
|
for (const sig of ["SIGTERM", "SIGINT", "SIGHUP"]) process.on(sig, () => stop(sig));
|
|
1103
2229
|
arm();
|
|
2230
|
+
lifeTimer = setTimeout(
|
|
2231
|
+
() => stop("lifetime"),
|
|
2232
|
+
Math.round((opts.maxHours ?? DEFAULT_MAX_HOURS) * 3600000),
|
|
2233
|
+
);
|
|
1104
2234
|
resolve({ ...record, tokenFile, ...(opts.printToken ? { token } : {}) });
|
|
1105
2235
|
});
|
|
1106
2236
|
});
|
|
@@ -1116,7 +2246,7 @@ async function main(argv) {
|
|
|
1116
2246
|
}
|
|
1117
2247
|
if (opts.help) {
|
|
1118
2248
|
process.stdout.write(
|
|
1119
|
-
"Usage: contract-server.mjs [--port N] [--idle-minutes N] [--allow-origin <origin>]... [--print-token]\n",
|
|
2249
|
+
"Usage: contract-server.mjs [--port N] [--idle-minutes N] [--max-hours N] [--allow-origin <origin>]... [--print-token]\n",
|
|
1120
2250
|
);
|
|
1121
2251
|
return 0;
|
|
1122
2252
|
}
|