@mmerterden/multi-agent-pipeline 20.30.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 +80 -0
- package/README.md +45 -0
- package/README.tr.md +59 -0
- package/docs/facts.json +3 -3
- package/install/_unattended-profile.mjs +61 -0
- package/install/catalog-history.json +1 -1
- package/install/claude.mjs +9 -2
- package/install/index.mjs +10 -1
- package/install/templates/claude-hooks.json +12 -1
- package/manifest.json +123 -82
- package/package.json +2 -1
- package/pipeline/commands/multi-agent/analysis/SKILL.md +4 -4
- package/pipeline/commands/multi-agent/office-on/SKILL.md +8 -7
- package/pipeline/commands/multi-agent/office-status/SKILL.md +1 -1
- 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/sync/SKILL.md +12 -0
- package/pipeline/commands/multi-agent/test-gen/SKILL.md +29 -3
- package/pipeline/contract/CHANGELOG.md +46 -0
- package/pipeline/contract/README.md +22 -8
- 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 +153 -19
- package/pipeline/contract/types/index.d.ts +304 -6
- 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/features/office-threat-model.md +24 -6
- package/pipeline/multi-agent-refs/features/unattended-security.md +1 -1
- package/pipeline/multi-agent-refs/help/en.md +2 -2
- package/pipeline/multi-agent-refs/help/tr.md +2 -2
- 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 +4 -1
- package/pipeline/office/assets/ARCHIVE_BOXES.png +0 -0
- package/pipeline/office/assets/COFFEE_MACHINE.png +0 -0
- package/pipeline/office/assets/COUNTER_SINK.png +0 -0
- package/pipeline/office/assets/DEVICE_STAND.png +0 -0
- package/pipeline/office/assets/FILING_CABINET.png +0 -0
- package/pipeline/office/assets/FRIDGE.png +0 -0
- package/pipeline/office/assets/MAILBOX.png +0 -0
- package/pipeline/office/assets/MOP_BUCKET.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/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/index.html +3996 -884
- package/pipeline/office/toolkit.json +3 -0
- package/pipeline/schemas/activity.schema.json +188 -4
- package/pipeline/schemas/agent-state.schema.json +493 -115
- package/pipeline/schemas/contract-error.schema.json +12 -1
- 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/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/phases.json +10 -2
- package/pipeline/schemas/phases.schema.json +86 -19
- package/pipeline/schemas/prs.schema.json +5 -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/runs-index.schema.json +257 -46
- package/pipeline/schemas/system-status.schema.json +111 -0
- package/pipeline/schemas/tools.schema.json +5 -0
- package/pipeline/schemas/unattended-profile.json +7 -2
- package/pipeline/scripts/_active-run.mjs +2 -1
- package/pipeline/scripts/_spawn-async.mjs +9 -5
- package/pipeline/scripts/activity-snapshot.mjs +219 -23
- package/pipeline/scripts/autopilot-runner.mjs +7 -5
- package/pipeline/scripts/contract-server.mjs +551 -25
- 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 +148 -11
- package/pipeline/scripts/office-control.mjs +34 -7
- package/pipeline/scripts/office-credentials.mjs +106 -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/runs-index.mjs +62 -2
- 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 +33 -7
- package/pipeline/scripts/transcript-index.mjs +4 -1
- package/pipeline/scripts/work-items.mjs +191 -22
- package/pipeline/skills/.skill-manifest.json +6 -6
- package/pipeline/skills/.skills-index.json +1 -1
- package/pipeline/skills/shared/README.md +1 -1
- package/pipeline/skills/shared/core/multi-agent-office-on/SKILL.md +3 -4
- 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-test-gen/SKILL.md +26 -3
- package/pipeline/skills/skills-index.md +1 -1
|
@@ -20,13 +20,19 @@
|
|
|
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]
|
|
32
38
|
* POST /v1/autopilot/on autopilot-control.mjs on-preview, then on (two steps)
|
|
@@ -35,6 +41,9 @@
|
|
|
35
41
|
* GET /v1/activity/stream the same document as server-sent events, on change
|
|
36
42
|
* GET /v1/agents/{sessionId}/{agentId}/chat activity-snapshot.mjs chat
|
|
37
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
|
|
38
47
|
* GET /v1/runs/{id}/results run-results.mjs: diff, findings, tests, evidence, analysis
|
|
39
48
|
* GET /v1/runs/{id}/evidence/{name} run-results.mjs --evidence: one screenshot
|
|
40
49
|
* POST /v1/runs/{id}/review-verdict work-items.mjs verdict, then --post (two steps)
|
|
@@ -48,6 +57,8 @@
|
|
|
48
57
|
* DELETE /v1/routines/{name} routine-registry.mjs remove (two steps)
|
|
49
58
|
* GET /v1/stack?repo=R stack-toggle.mjs status
|
|
50
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)
|
|
51
62
|
*
|
|
52
63
|
* Every route carries two tags for a remote carrier to enforce later, and
|
|
53
64
|
* manifest.json repeats them: `remote` is the scope it would need (read,
|
|
@@ -60,7 +71,8 @@
|
|
|
60
71
|
* prefs.global.office.page, else the installed copy (office/index.html next
|
|
61
72
|
* to this scripts directory), and is 404 when none exists. The page carries
|
|
62
73
|
* 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
|
|
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
|
|
64
76
|
* its response carries a per-response CSP nonce for its inline script and
|
|
65
77
|
* style.
|
|
66
78
|
*
|
|
@@ -72,12 +84,16 @@
|
|
|
72
84
|
* POST /v1/phone/runs/{id}/answer scope answer
|
|
73
85
|
* POST /v1/phone/launch?repo=P scope launch, and only after `phone-devices.mjs launch on`
|
|
74
86
|
*
|
|
75
|
-
* There is deliberately no route that merges, marks ready, runs a
|
|
76
|
-
*
|
|
77
|
-
* and environment launch.json declares
|
|
78
|
-
*
|
|
79
|
-
*
|
|
80
|
-
*
|
|
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.
|
|
81
97
|
*
|
|
82
98
|
* Kill, gc, autopilot on, stack and routine removal change what this machine
|
|
83
99
|
* keeps or runs, so each takes two requests. The first returns a
|
|
@@ -124,16 +140,20 @@
|
|
|
124
140
|
* body or a token) to ~/.claude/logs/multi-agent/office-actions.jsonl.
|
|
125
141
|
*
|
|
126
142
|
* Lifetime. The server exits after --idle-minutes without a request (15 by
|
|
127
|
-
* default), after --max-hours however busy it is (8 by default
|
|
128
|
-
* stream does not extend it), and on SIGTERM, SIGINT or
|
|
129
|
-
* token file either way.
|
|
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.
|
|
130
147
|
*
|
|
131
148
|
* Panel bootstrap. `POST /office/code` (bearer, no Origin) mints a code that
|
|
132
149
|
* is valid for 10 seconds and one use; office-control.mjs opens the browser
|
|
133
150
|
* with it in the URL fragment. The page trades it for the token with
|
|
134
151
|
* `POST /office/exchange` (no bearer, own Origin only); a second use, an
|
|
135
152
|
* expired code or an unknown one is 401. The token itself never appears in a
|
|
136
|
-
* URL.
|
|
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
|
|
137
157
|
* never installed as a service.
|
|
138
158
|
*
|
|
139
159
|
* Zero runtime dependencies (ADR-0004): node:http, node:crypto and the
|
|
@@ -164,7 +184,7 @@ import {
|
|
|
164
184
|
} from "node:fs";
|
|
165
185
|
import { createServer } from "node:http";
|
|
166
186
|
import { homedir } from "node:os";
|
|
167
|
-
import { dirname, isAbsolute, join, sep } from "node:path";
|
|
187
|
+
import { basename, dirname, isAbsolute, join, sep } from "node:path";
|
|
168
188
|
import { fileURLToPath } from "node:url";
|
|
169
189
|
import { clearInterval, clearTimeout, setInterval, setTimeout } from "node:timers";
|
|
170
190
|
import { answerStateFile } from "./answer-question.mjs";
|
|
@@ -185,6 +205,17 @@ import {
|
|
|
185
205
|
} from "./stack-toggle.mjs";
|
|
186
206
|
import { CONTRACT_VERSION as STEER_VERSION, SteerError, queueSteer } from "./steer-run.mjs";
|
|
187
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";
|
|
188
219
|
import { EVIDENCE_NAME, ResultsError, runEvidence, runResults } from "./run-results.mjs";
|
|
189
220
|
import {
|
|
190
221
|
WorkError,
|
|
@@ -195,6 +226,7 @@ import {
|
|
|
195
226
|
} from "./work-items.mjs";
|
|
196
227
|
import {
|
|
197
228
|
CONTRACT_VERSION as TOOLS_VERSION,
|
|
229
|
+
activeToolkitVersion,
|
|
198
230
|
TOOL_NAME,
|
|
199
231
|
ToolError,
|
|
200
232
|
createToolkitClient,
|
|
@@ -224,12 +256,26 @@ import {
|
|
|
224
256
|
planLaunch,
|
|
225
257
|
planResume,
|
|
226
258
|
questionsDocument,
|
|
259
|
+
registerRepo,
|
|
227
260
|
reposDocument,
|
|
261
|
+
repoId,
|
|
228
262
|
requestsDir,
|
|
263
|
+
suggestedRepos,
|
|
264
|
+
unregisterRepo,
|
|
265
|
+
missingRepos,
|
|
229
266
|
workingTreeState,
|
|
230
267
|
writeRequestFile,
|
|
231
268
|
} from "./launch-request.mjs";
|
|
232
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";
|
|
233
279
|
import { CONTRACT_VERSION as KILL_VERSION, KillError, killPreview, killRun } from "./run-kill.mjs";
|
|
234
280
|
import {
|
|
235
281
|
CONTRACT_VERSION as LOG_VERSION,
|
|
@@ -259,7 +305,7 @@ import { runMain } from "../lib/fatal.mjs";
|
|
|
259
305
|
import { invokedDirectly } from "../lib/invoked-directly.mjs";
|
|
260
306
|
|
|
261
307
|
/** The contract as a whole; pipeline/contract/manifest.json carries the same. */
|
|
262
|
-
export const CONTRACT_VERSION = "1.
|
|
308
|
+
export const CONTRACT_VERSION = "1.6.0";
|
|
263
309
|
/** The majors `?v=` accepts. A new major keeps the previous one here for one minor. */
|
|
264
310
|
export const SUPPORTED_MAJORS = [1];
|
|
265
311
|
export const BIND_HOST = "127.0.0.1";
|
|
@@ -290,6 +336,7 @@ const SESSION_ID = /^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/;
|
|
|
290
336
|
const AGENT_ID = /^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/;
|
|
291
337
|
const ROUTINE_NAME = /^[a-z0-9][a-z0-9-]{0,39}$/;
|
|
292
338
|
const REPO_NAME = /^[A-Za-z0-9._-]{1,100}(?:\/[A-Za-z0-9._-]{1,100})?$/;
|
|
339
|
+
const REPO_ID = /^[0-9a-f]{16}$/;
|
|
293
340
|
/** Server-sent event streams open at once; one more is refused. */
|
|
294
341
|
export const MAX_STREAMS = 4;
|
|
295
342
|
const STREAM_POLL_MS = 2000;
|
|
@@ -344,6 +391,7 @@ const Q = {
|
|
|
344
391
|
redact: { check: (v) => v === "0" || v === "1" },
|
|
345
392
|
group: { check: (v) => GROUP_NAMES.includes(v) },
|
|
346
393
|
measure: { check: (v) => v === "0" || v === "1" },
|
|
394
|
+
all: { check: (v) => v === "0" || v === "1" },
|
|
347
395
|
source: { check: (v) => ["jira", "github", "all"].includes(v) },
|
|
348
396
|
project: { repeat: true, check: (v) => PROJECT_KEY.test(v) },
|
|
349
397
|
max: { check: (v) => /^[0-9]{1,3}$/.test(v) && Number(v) >= 1 && Number(v) <= 200 },
|
|
@@ -716,6 +764,87 @@ async function killRoute({ params, body, redact }) {
|
|
|
716
764
|
}
|
|
717
765
|
}
|
|
718
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
|
+
|
|
719
848
|
function gcRoute({ body, redact }) {
|
|
720
849
|
requireBody(
|
|
721
850
|
body,
|
|
@@ -775,6 +904,24 @@ async function control(args) {
|
|
|
775
904
|
return doc;
|
|
776
905
|
}
|
|
777
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
|
+
|
|
778
925
|
/** One request at a time per key; a second one while the first runs is 429. */
|
|
779
926
|
const running = new Set();
|
|
780
927
|
const oneAtATime = (key, fn) => async (ctx) => {
|
|
@@ -1033,6 +1180,252 @@ function resolveRepo(ref) {
|
|
|
1033
1180
|
return hits[0].path;
|
|
1034
1181
|
}
|
|
1035
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
|
+
|
|
1036
1429
|
function stackError(err) {
|
|
1037
1430
|
if (!(err instanceof StackError)) return err;
|
|
1038
1431
|
if (err.code === "unreadable-settings") {
|
|
@@ -1187,7 +1580,8 @@ function toolError(err) {
|
|
|
1187
1580
|
|
|
1188
1581
|
async function toolsRoute() {
|
|
1189
1582
|
try {
|
|
1190
|
-
|
|
1583
|
+
const tools = await toolkitClient().listTools();
|
|
1584
|
+
return { contractVersion: TOOLS_VERSION, version: activeToolkitVersion(), tools };
|
|
1191
1585
|
} catch (err) {
|
|
1192
1586
|
throw toolError(err);
|
|
1193
1587
|
}
|
|
@@ -1292,8 +1686,25 @@ export const ROUTES = [
|
|
|
1292
1686
|
{
|
|
1293
1687
|
method: "GET",
|
|
1294
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",
|
|
1295
1695
|
query: [],
|
|
1296
|
-
|
|
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,
|
|
1297
1708
|
},
|
|
1298
1709
|
{
|
|
1299
1710
|
method: "POST",
|
|
@@ -1350,6 +1761,31 @@ export const ROUTES = [
|
|
|
1350
1761
|
risk: "high",
|
|
1351
1762
|
handle: gcRoute,
|
|
1352
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
|
+
},
|
|
1353
1789
|
{
|
|
1354
1790
|
method: "GET",
|
|
1355
1791
|
path: "/v1/autopilot",
|
|
@@ -1390,6 +1826,35 @@ export const ROUTES = [
|
|
|
1390
1826
|
handle: chatRoute,
|
|
1391
1827
|
},
|
|
1392
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
|
+
},
|
|
1393
1858
|
{ method: "GET", path: "/v1/runs/{id}/results", query: [], handle: resultsRoute },
|
|
1394
1859
|
{ method: "GET", path: "/v1/runs/{id}/evidence/{name}", query: [], handle: evidenceRoute },
|
|
1395
1860
|
{
|
|
@@ -1463,6 +1928,16 @@ export const ROUTES = [
|
|
|
1463
1928
|
handle: routineDeleteRoute,
|
|
1464
1929
|
},
|
|
1465
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
|
+
},
|
|
1466
1941
|
{
|
|
1467
1942
|
method: "POST",
|
|
1468
1943
|
path: "/v1/stack",
|
|
@@ -1665,14 +2140,14 @@ function parseArgs(argv) {
|
|
|
1665
2140
|
opts.port = Number(p);
|
|
1666
2141
|
} else if (a === "--idle-minutes") {
|
|
1667
2142
|
const n = Number(value());
|
|
1668
|
-
if (!Number.isFinite(n) || n
|
|
1669
|
-
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}`);
|
|
1670
2145
|
}
|
|
1671
2146
|
opts.idleMinutes = n;
|
|
1672
2147
|
} else if (a === "--max-hours") {
|
|
1673
2148
|
const n = Number(value());
|
|
1674
|
-
if (!Number.isFinite(n) || n
|
|
1675
|
-
throw new Error(`--max-hours is
|
|
2149
|
+
if (!Number.isFinite(n) || n < 0 || n > MAX_HOURS) {
|
|
2150
|
+
throw new Error(`--max-hours is 0 (no limit) to ${MAX_HOURS}`);
|
|
1676
2151
|
}
|
|
1677
2152
|
opts.maxHours = n;
|
|
1678
2153
|
} else if (a === "--allow-origin") {
|
|
@@ -1717,6 +2192,25 @@ export const OFFICE_PATH = "/office";
|
|
|
1717
2192
|
export const OFFICE_CODE_PATH = "/office/code";
|
|
1718
2193
|
/** No bearer, own Origin only: trades a panel code for the token, once. */
|
|
1719
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
|
+
}
|
|
1720
2214
|
|
|
1721
2215
|
function readPrefs(env) {
|
|
1722
2216
|
try {
|
|
@@ -1949,7 +2443,7 @@ export function serve(opts) {
|
|
|
1949
2443
|
};
|
|
1950
2444
|
const arm = () => {
|
|
1951
2445
|
clearTimeout(idleTimer);
|
|
1952
|
-
idleTimer = setTimeout(() => stop("idle"), idleMs);
|
|
2446
|
+
if (idleMs > 0) idleTimer = setTimeout(() => stop("idle"), idleMs);
|
|
1953
2447
|
};
|
|
1954
2448
|
|
|
1955
2449
|
/**
|
|
@@ -2049,6 +2543,15 @@ export function serve(opts) {
|
|
|
2049
2543
|
serveOffice(res, req.method === "HEAD");
|
|
2050
2544
|
return;
|
|
2051
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
|
+
}
|
|
2052
2555
|
if (url.pathname.startsWith(OFFICE_ASSET_PREFIX)) {
|
|
2053
2556
|
drain(req);
|
|
2054
2557
|
if (req.method !== "GET" && req.method !== "HEAD") {
|
|
@@ -2095,6 +2598,30 @@ export function serve(opts) {
|
|
|
2095
2598
|
if (!authorized(req.headers.authorization, tokenDigest)) {
|
|
2096
2599
|
throw new HttpError(401, "unauthorized", "a bearer token is required");
|
|
2097
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
|
+
}
|
|
2098
2625
|
if (url.pathname === OFFICE_CODE_PATH) {
|
|
2099
2626
|
drain(req);
|
|
2100
2627
|
if (req.method !== "POST") {
|
|
@@ -2227,10 +2754,9 @@ export function serve(opts) {
|
|
|
2227
2754
|
tokenFile = writeTokenFile({ ...record, token, startedAt: started.toISOString() });
|
|
2228
2755
|
for (const sig of ["SIGTERM", "SIGINT", "SIGHUP"]) process.on(sig, () => stop(sig));
|
|
2229
2756
|
arm();
|
|
2230
|
-
|
|
2231
|
-
|
|
2232
|
-
Math.round(
|
|
2233
|
-
);
|
|
2757
|
+
const maxHours = opts.maxHours ?? DEFAULT_MAX_HOURS;
|
|
2758
|
+
if (maxHours > 0)
|
|
2759
|
+
lifeTimer = setTimeout(() => stop("lifetime"), Math.round(maxHours * 3600000));
|
|
2234
2760
|
resolve({ ...record, tokenFile, ...(opts.printToken ? { token } : {}) });
|
|
2235
2761
|
});
|
|
2236
2762
|
});
|