@openparachute/agent 0.2.2 → 0.2.3-rc.11
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/.parachute/module.json +3 -3
- package/package.json +4 -1
- package/src/agent-defs.ts +9 -0
- package/src/auth.ts +182 -14
- package/src/backends/programmatic.ts +35 -2
- package/src/backends/registry.ts +159 -40
- package/src/backends/types.ts +44 -0
- package/src/daemon.ts +317 -12
- package/src/def-vault-triggers.ts +317 -0
- package/src/preflight.ts +139 -0
- package/src/spawn-agent.ts +16 -0
- package/src/step-up.ts +316 -0
- package/src/terminal-ui.ts +73 -0
- package/src/transports/http-ui.ts +10 -8
- package/src/transports/vault.ts +48 -27
- package/src/ui-kit.ts +6 -3
- package/src/ui-ticket.ts +121 -0
- package/web/ui/dist/assets/index-Dhr5Kl_d.css +1 -0
- package/web/ui/dist/assets/index-Di5MmFZR.js +60 -0
- package/web/ui/dist/index.html +2 -2
- package/src/_parked/interactive-spawn.test.ts +0 -324
- package/src/_parked/interactive-spawn.ts +0 -701
- package/src/agent-defs.test.ts +0 -1504
- package/src/agent-mcp-config.test.ts +0 -115
- package/src/agents.test.ts +0 -360
- package/src/auth.test.ts +0 -46
- package/src/backends/attached-queue.test.ts +0 -376
- package/src/backends/programmatic.test.ts +0 -1715
- package/src/backends/registry.test.ts +0 -1494
- package/src/backends/stream-json.test.ts +0 -570
- package/src/channel-backend-wiring.test.ts +0 -237
- package/src/credentials.test.ts +0 -274
- package/src/cron.test.ts +0 -342
- package/src/daemon-agent-def-api.test.ts +0 -166
- package/src/daemon-agent-defs-api.test.ts +0 -953
- package/src/daemon-agent-env-api.test.ts +0 -338
- package/src/daemon-attached-queue-store.test.ts +0 -65
- package/src/daemon-config-api.test.ts +0 -962
- package/src/daemon-jobs-api.test.ts +0 -271
- package/src/daemon-vault-chat.test.ts +0 -250
- package/src/daemon.test.ts +0 -746
- package/src/def-vaults.test.ts +0 -136
- package/src/delivery-state.test.ts +0 -110
- package/src/effective-env.test.ts +0 -114
- package/src/grants.test.ts +0 -638
- package/src/hub-jwt.test.ts +0 -161
- package/src/jobs.test.ts +0 -245
- package/src/mcp-http.test.ts +0 -265
- package/src/mint-token.test.ts +0 -152
- package/src/module-manifest.test.ts +0 -158
- package/src/programmatic-wiring.test.ts +0 -838
- package/src/registry.test.ts +0 -227
- package/src/resolve-port.test.ts +0 -64
- package/src/routing.test.ts +0 -184
- package/src/runner.test.ts +0 -506
- package/src/sandbox/config.test.ts +0 -150
- package/src/sandbox/egress.test.ts +0 -113
- package/src/sandbox/live-seatbelt.test.ts +0 -277
- package/src/sandbox/mounts.test.ts +0 -154
- package/src/sandbox/sandbox.test.ts +0 -168
- package/src/services-manifest.test.ts +0 -106
- package/src/spa-serve.test.ts +0 -116
- package/src/spawn-agent-cli.test.ts +0 -172
- package/src/spawn-agent.test.ts +0 -1218
- package/src/spawn-deps.test.ts +0 -54
- package/src/terminal-assets.test.ts +0 -50
- package/src/terminal.test.ts +0 -530
- package/src/transports/http-ui.test.ts +0 -455
- package/src/transports/telegram.test.ts +0 -174
- package/src/transports/vault.test.ts +0 -2011
- package/src/ui-kit.test.ts +0 -178
- package/web/ui/dist/assets/index-C-iWdFFV.css +0 -1
- package/web/ui/dist/assets/index-VFETBk0a.js +0 -60
- package/web/ui/tsconfig.json +0 -21
package/src/daemon.ts
CHANGED
|
@@ -66,6 +66,7 @@ import {
|
|
|
66
66
|
DEFAULT_HUB_ORIGIN,
|
|
67
67
|
} from "./def-vaults.ts";
|
|
68
68
|
import { mintScopedToken, vaultScope } from "./mint-token.ts";
|
|
69
|
+
import { registerAllDefVaultTriggers } from "./def-vault-triggers.ts";
|
|
69
70
|
import { GrantsClient } from "./grants.ts";
|
|
70
71
|
import { resolveEffectiveEnv } from "./effective-env.ts";
|
|
71
72
|
import { VaultJobStore, validateJob, vaultTransportFor, type Job } from "./jobs.ts";
|
|
@@ -85,6 +86,10 @@ import { ClientRegistry, sseFrame } from "./routing.ts";
|
|
|
85
86
|
import { DeliveryState } from "./delivery-state.ts";
|
|
86
87
|
import {
|
|
87
88
|
requireScope,
|
|
89
|
+
mintSseTicket,
|
|
90
|
+
requireSseTicket,
|
|
91
|
+
requireStepUp,
|
|
92
|
+
grantsScope,
|
|
88
93
|
extractToken,
|
|
89
94
|
json as authJson,
|
|
90
95
|
SCOPE_READ,
|
|
@@ -93,6 +98,16 @@ import {
|
|
|
93
98
|
SCOPE_ADMIN,
|
|
94
99
|
SCOPE_TERMINAL,
|
|
95
100
|
} from "./auth.ts";
|
|
101
|
+
import {
|
|
102
|
+
isStepUpConfigured,
|
|
103
|
+
isValidPinFormat,
|
|
104
|
+
setStepUpPin,
|
|
105
|
+
verifyStepUpPin,
|
|
106
|
+
mintStepUpToken,
|
|
107
|
+
stepUpLimiter,
|
|
108
|
+
StepUpPinFormatError,
|
|
109
|
+
} from "./step-up.ts";
|
|
110
|
+
import { mintTicket } from "./ui-ticket.ts";
|
|
96
111
|
import {
|
|
97
112
|
createTerminalWsHandlers,
|
|
98
113
|
type TerminalWsData,
|
|
@@ -100,6 +115,7 @@ import {
|
|
|
100
115
|
import { TERMINAL_UI_HTML } from "./terminal-ui.ts";
|
|
101
116
|
import { serveTerminalAsset } from "./terminal-assets.ts";
|
|
102
117
|
import { isSpaPath, serveSpa, spaDistDir } from "./spa-serve.ts";
|
|
118
|
+
import { runBootPreflight, type PreflightResult } from "./preflight.ts";
|
|
103
119
|
import {
|
|
104
120
|
buildSpecFromBody,
|
|
105
121
|
setupProgrammaticSpawn,
|
|
@@ -326,6 +342,9 @@ export function contextFor(
|
|
|
326
342
|
// Phase 1: carry inbound file attachments through to the turn (the programmatic
|
|
327
343
|
// backend stages them into the agent's private workspace so the turn can Read them).
|
|
328
344
|
...(msg.attachments && msg.attachments.length > 0 ? { attachments: msg.attachments } : {}),
|
|
345
|
+
// agent#162: carry the inbound sender so the drain can derive the run-context
|
|
346
|
+
// `fired-by` (a scheduled `runner:<jobId>` fire vs an interactive/delegated message).
|
|
347
|
+
...(msg.meta?.sender ? { sender: msg.meta.sender } : {}),
|
|
329
348
|
});
|
|
330
349
|
return;
|
|
331
350
|
}
|
|
@@ -350,6 +369,9 @@ export function contextFor(
|
|
|
350
369
|
// Phase 1: carry inbound attachments through the pending buffer too, so a turn
|
|
351
370
|
// that runs on register() still stages them.
|
|
352
371
|
...(msg.attachments && msg.attachments.length > 0 ? { attachments: msg.attachments } : {}),
|
|
372
|
+
// agent#162: carry the sender through the pending buffer too, so a turn that runs on
|
|
373
|
+
// register() still derives the right run-context `fired-by`.
|
|
374
|
+
...(msg.meta?.sender ? { sender: msg.meta.sender } : {}),
|
|
353
375
|
});
|
|
354
376
|
if (outcome === "queued") return;
|
|
355
377
|
// outcome === "unknown" — not an expected programmatic channel. It may still be a
|
|
@@ -719,7 +741,7 @@ export function buildWriteThread(channels: Map<string, Channel>): WriteThread {
|
|
|
719
741
|
}
|
|
720
742
|
// Only a transport with a durable store implements writeThread (the VaultTransport).
|
|
721
743
|
if (!ch.transport.writeThread) return;
|
|
722
|
-
await ch.transport.writeThread({
|
|
744
|
+
const written = await ch.transport.writeThread({
|
|
723
745
|
channel: thread.channel,
|
|
724
746
|
...(thread.name ? { name: thread.name } : {}),
|
|
725
747
|
...(thread.definition ? { definition: thread.definition } : {}),
|
|
@@ -743,6 +765,13 @@ export function buildWriteThread(channels: Map<string, Channel>): WriteThread {
|
|
|
743
765
|
...(thread.sameTurn ? { sameTurn: true } : {}),
|
|
744
766
|
...(thread.phase ? { phase: thread.phase } : {}),
|
|
745
767
|
});
|
|
768
|
+
// Surface the WRITTEN note id so the drain can set a RESOLVABLE callback `source_thread`
|
|
769
|
+
// (agent#124). `writeThread` returns `{ sent: [id] }` — the id is the actual note an
|
|
770
|
+
// orchestrator can pull with `query-notes { id }` for BOTH modes (single-threaded: the
|
|
771
|
+
// deterministic `Threads/<safeChannel>/<safeName>` note; multi-threaded: the per-fire
|
|
772
|
+
// `Threads/<safeChannel>/<uuid>` note). Empty/absent → undefined (the drain falls back).
|
|
773
|
+
const id = written?.sent?.[0];
|
|
774
|
+
return id ? { id } : undefined;
|
|
746
775
|
};
|
|
747
776
|
}
|
|
748
777
|
|
|
@@ -1124,8 +1153,13 @@ function redirect(location: string): Response {
|
|
|
1124
1153
|
// is `agent:write`.
|
|
1125
1154
|
//
|
|
1126
1155
|
// Layer 2 — human / chat UI — gates the http-ui transport's `send` (POST,
|
|
1127
|
-
// `agent:send
|
|
1128
|
-
//
|
|
1156
|
+
// `agent:send`, Bearer) with `requireScope`. The browser SSE streams
|
|
1157
|
+
// (`/ui/events`, `/api/channels/<ch>/turn-events`, `agent:read`) gate on a
|
|
1158
|
+
// ONE-TIME ticket (`requireSseTicket`) instead of a `?token=<JWT>` query —
|
|
1159
|
+
// `EventSource` can't set a header, and a JWT in a URL leaks into access logs
|
|
1160
|
+
// (agent#25). The page mints the ticket at `POST /api/ui/sse-ticket` (Bearer,
|
|
1161
|
+
// agent:read) and opens `…?ticket=<nonce>`; the ticket is single-use + ≤60s and
|
|
1162
|
+
// carries only the minting token's scopes.
|
|
1129
1163
|
//
|
|
1130
1164
|
// Discovery + the page itself (/health, /.parachute/config[/schema], /ui) stay
|
|
1131
1165
|
// OPEN — non-sensitive, and /ui must load to bootstrap its token fetch.
|
|
@@ -1182,6 +1216,12 @@ export async function authorizeTerminalUpgrade(
|
|
|
1182
1216
|
const denied = await requireScope(req, url, SCOPE_TERMINAL, true);
|
|
1183
1217
|
if (denied) return { ok: false, response: denied };
|
|
1184
1218
|
|
|
1219
|
+
// STEP-UP required (agent#80): a terminal is a raw host shell — the single most
|
|
1220
|
+
// dangerous capability. allowQueryParam: true so the WS presents the step-up
|
|
1221
|
+
// token as `?step_up=` (it can't set the `X-Step-Up-Token` header).
|
|
1222
|
+
const step = requireStepUp(req, url, true);
|
|
1223
|
+
if (!step.ok) return { ok: false, response: step.response };
|
|
1224
|
+
|
|
1185
1225
|
// tmux session name convention: `<name>-agent`. Attach a viewer pty to THIS
|
|
1186
1226
|
// session; the session itself is created by the spawn path.
|
|
1187
1227
|
const session = `${agentName}-agent`;
|
|
@@ -1286,6 +1326,13 @@ export function createFetchHandler(
|
|
|
1286
1326
|
url: string;
|
|
1287
1327
|
tokenPresent: boolean;
|
|
1288
1328
|
}>;
|
|
1329
|
+
/**
|
|
1330
|
+
* The boot dependency-PREFLIGHT result (agent#156) — surfaced on `/health` so the
|
|
1331
|
+
* admin UI can show that programmatic turns will fail until the missing deps
|
|
1332
|
+
* (`bwrap`/`rg`/`socat`/`claude`) are installed. `main` passes the boot check;
|
|
1333
|
+
* absent (a plain createFetchHandler / tests) → omitted from `/health`.
|
|
1334
|
+
*/
|
|
1335
|
+
preflight?: PreflightResult;
|
|
1289
1336
|
},
|
|
1290
1337
|
): (req: Request, server?: { upgrade: (req: Request, opts: { data: TerminalWsData }) => boolean }) => Promise<Response> {
|
|
1291
1338
|
// The per-channel turn-event SSE registry — subscribers of the live "watch it
|
|
@@ -1478,6 +1525,10 @@ export function createFetchHandler(
|
|
|
1478
1525
|
// (`programmatic · idle|working|queued:N`) instead of `mcp_sessions` — a
|
|
1479
1526
|
// programmatic agent has no live subscriber, so SSE/MCP counts don't describe it.
|
|
1480
1527
|
if (url.pathname === "/health") {
|
|
1528
|
+
// Surface the boot dependency-preflight (agent#156) so the admin UI can show
|
|
1529
|
+
// that programmatic turns will fail until the missing deps are installed. Only
|
|
1530
|
+
// present when `main` passed the boot check (absent in a plain handler/tests).
|
|
1531
|
+
const preflight = opts?.preflight;
|
|
1481
1532
|
return json({
|
|
1482
1533
|
status: "ok",
|
|
1483
1534
|
channels: [...channels.values()].map((c) => ({
|
|
@@ -1496,6 +1547,15 @@ export function createFetchHandler(
|
|
|
1496
1547
|
status: s.state === "queued" ? `queued:${s.queued}` : s.state,
|
|
1497
1548
|
};
|
|
1498
1549
|
}),
|
|
1550
|
+
...(preflight
|
|
1551
|
+
? {
|
|
1552
|
+
dependencies: {
|
|
1553
|
+
ok: preflight.ok,
|
|
1554
|
+
// The binary names missing on PATH — what programmatic turns need installed.
|
|
1555
|
+
missing: preflight.missing.map((d) => d.bin),
|
|
1556
|
+
},
|
|
1557
|
+
}
|
|
1558
|
+
: {}),
|
|
1499
1559
|
});
|
|
1500
1560
|
}
|
|
1501
1561
|
|
|
@@ -1783,6 +1843,169 @@ export function createFetchHandler(
|
|
|
1783
1843
|
}
|
|
1784
1844
|
}
|
|
1785
1845
|
|
|
1846
|
+
// ---------------------------------------------------------------------
|
|
1847
|
+
// STEP-UP AUTH (PIN) — second factor for high-privilege actions (agent#80).
|
|
1848
|
+
//
|
|
1849
|
+
// The dangerous `agent:admin` actions (set credentials, open a terminal,
|
|
1850
|
+
// spawn a `filesystem: full` agent) require a step-up token IN ADDITION to
|
|
1851
|
+
// the `agent:admin` Bearer. This block is the PIN setup + exchange surface;
|
|
1852
|
+
// the gating lives at each dangerous endpoint (via `requireStepUp`).
|
|
1853
|
+
//
|
|
1854
|
+
// GET /api/step-up → { configured } — is a PIN set? (UI: setup vs prompt)
|
|
1855
|
+
// POST /api/step-up { pin } → validate PIN (rate-limited) → { stepUpToken, expires_at }
|
|
1856
|
+
// POST /api/step-up/pin { newPin, currentPin? } → set/rotate the PIN
|
|
1857
|
+
//
|
|
1858
|
+
// All `agent:admin`-gated (the operator's cookie-minted Bearer). The PIN is
|
|
1859
|
+
// hashed+salted server-side (step-up.ts); it is NEVER returned or logged.
|
|
1860
|
+
// Externally hub strips `/agent`, so these are `<hub>/agent/api/step-up`.
|
|
1861
|
+
// ---------------------------------------------------------------------
|
|
1862
|
+
if (url.pathname === "/api/step-up" && req.method === "GET") {
|
|
1863
|
+
const denied = await requireScope(req, url, SCOPE_ADMIN);
|
|
1864
|
+
if (denied) return denied;
|
|
1865
|
+
// Whether a PIN is configured — the UI branches setup-flow vs PIN-prompt.
|
|
1866
|
+
return json({ configured: isStepUpConfigured() });
|
|
1867
|
+
}
|
|
1868
|
+
|
|
1869
|
+
if (url.pathname === "/api/step-up" && req.method === "POST") {
|
|
1870
|
+
// Exchange: validate the PIN, then mint a short-lived step-up token. The
|
|
1871
|
+
// session must already hold `agent:admin` (this is a SECOND factor on top,
|
|
1872
|
+
// never a substitute — the token carries no scope of its own).
|
|
1873
|
+
let claims;
|
|
1874
|
+
try {
|
|
1875
|
+
const token = extractToken(req, url);
|
|
1876
|
+
if (!token) return json({ error: "unauthorized", message: "Bearer token required" }, 401);
|
|
1877
|
+
claims = await validateHubJwt(token);
|
|
1878
|
+
} catch (err) {
|
|
1879
|
+
return json(
|
|
1880
|
+
{ error: "unauthorized", message: err instanceof Error ? err.message : "invalid token" },
|
|
1881
|
+
401,
|
|
1882
|
+
);
|
|
1883
|
+
}
|
|
1884
|
+
if (!grantsScope(claims.scopes, SCOPE_ADMIN)) {
|
|
1885
|
+
return json(
|
|
1886
|
+
{ error: "insufficient_scope", message: `requires ${SCOPE_ADMIN}`, granted: claims.scopes },
|
|
1887
|
+
403,
|
|
1888
|
+
);
|
|
1889
|
+
}
|
|
1890
|
+
// No PIN configured yet — there's nothing to exchange. Tell the UI to run
|
|
1891
|
+
// its first-time setup (distinct from a wrong-PIN 401).
|
|
1892
|
+
if (!isStepUpConfigured()) {
|
|
1893
|
+
return json(
|
|
1894
|
+
{ error: "step_up_not_configured", message: "set a step-up PIN first (POST /api/step-up/pin)" },
|
|
1895
|
+
409,
|
|
1896
|
+
);
|
|
1897
|
+
}
|
|
1898
|
+
let body: { pin?: unknown };
|
|
1899
|
+
try {
|
|
1900
|
+
body = (await req.json()) as typeof body;
|
|
1901
|
+
} catch {
|
|
1902
|
+
return json({ error: "invalid JSON body" }, 400);
|
|
1903
|
+
}
|
|
1904
|
+
if (typeof body.pin !== "string" || body.pin.length === 0) {
|
|
1905
|
+
return json({ error: "body.pin (non-empty string) is required" }, 400);
|
|
1906
|
+
}
|
|
1907
|
+
// Rate-limit BEFORE the (expensive, brute-forceable) argon2 verify, keyed by
|
|
1908
|
+
// the operator subject — a stolen-cookie attacker can't grind the PIN. A
|
|
1909
|
+
// DENIED attempt returns 429 (the limiter does not count it again).
|
|
1910
|
+
const limited = stepUpLimiter.checkAndRecord(`step-up:${claims.sub}`);
|
|
1911
|
+
if (!limited.allowed) {
|
|
1912
|
+
return new Response(
|
|
1913
|
+
JSON.stringify({
|
|
1914
|
+
error: "rate_limited",
|
|
1915
|
+
message: "too many PIN attempts — wait before retrying",
|
|
1916
|
+
retry_after_seconds: limited.retryAfterSeconds,
|
|
1917
|
+
}),
|
|
1918
|
+
{
|
|
1919
|
+
status: 429,
|
|
1920
|
+
headers: {
|
|
1921
|
+
"content-type": "application/json",
|
|
1922
|
+
"retry-after": String(limited.retryAfterSeconds ?? 60),
|
|
1923
|
+
},
|
|
1924
|
+
},
|
|
1925
|
+
);
|
|
1926
|
+
}
|
|
1927
|
+
const ok = await verifyStepUpPin(body.pin);
|
|
1928
|
+
if (!ok) {
|
|
1929
|
+
// Wrong PIN — 401. The attempt already counted toward the lockout above.
|
|
1930
|
+
// Never echo the PIN back.
|
|
1931
|
+
return json({ error: "invalid_pin", message: "incorrect PIN" }, 401);
|
|
1932
|
+
}
|
|
1933
|
+
// Correct PIN — clear the attempt bucket (a fresh window for the next time)
|
|
1934
|
+
// and mint a reusable, short-TTL step-up token.
|
|
1935
|
+
stepUpLimiter.clear(`step-up:${claims.sub}`);
|
|
1936
|
+
const { token: stepUpToken, expiresAt } = mintStepUpToken();
|
|
1937
|
+
return json({ stepUpToken, expires_at: new Date(expiresAt).toISOString() });
|
|
1938
|
+
}
|
|
1939
|
+
|
|
1940
|
+
if (url.pathname === "/api/step-up/pin" && req.method === "POST") {
|
|
1941
|
+
// Set (first time) or rotate the step-up PIN. agent:admin-gated; if a PIN
|
|
1942
|
+
// already exists, the CURRENT PIN must be supplied + verified (rotation
|
|
1943
|
+
// needs the old PIN, so a hijacked session can't silently replace it).
|
|
1944
|
+
let claims;
|
|
1945
|
+
try {
|
|
1946
|
+
const token = extractToken(req, url);
|
|
1947
|
+
if (!token) return json({ error: "unauthorized", message: "Bearer token required" }, 401);
|
|
1948
|
+
claims = await validateHubJwt(token);
|
|
1949
|
+
} catch (err) {
|
|
1950
|
+
return json(
|
|
1951
|
+
{ error: "unauthorized", message: err instanceof Error ? err.message : "invalid token" },
|
|
1952
|
+
401,
|
|
1953
|
+
);
|
|
1954
|
+
}
|
|
1955
|
+
if (!grantsScope(claims.scopes, SCOPE_ADMIN)) {
|
|
1956
|
+
return json(
|
|
1957
|
+
{ error: "insufficient_scope", message: `requires ${SCOPE_ADMIN}`, granted: claims.scopes },
|
|
1958
|
+
403,
|
|
1959
|
+
);
|
|
1960
|
+
}
|
|
1961
|
+
let body: { newPin?: unknown; currentPin?: unknown };
|
|
1962
|
+
try {
|
|
1963
|
+
body = (await req.json()) as typeof body;
|
|
1964
|
+
} catch {
|
|
1965
|
+
return json({ error: "invalid JSON body" }, 400);
|
|
1966
|
+
}
|
|
1967
|
+
if (!isValidPinFormat(body.newPin)) {
|
|
1968
|
+
return json({ error: "body.newPin must be 4–12 digits" }, 400);
|
|
1969
|
+
}
|
|
1970
|
+
// Rotation: a PIN already exists → require + verify the current one (rate-limited).
|
|
1971
|
+
// SHARES the exchange bucket (same `step-up:<sub>` key) on purpose: both verify
|
|
1972
|
+
// the PIN, so an attacker can't get a fresh grind window by alternating endpoints.
|
|
1973
|
+
if (isStepUpConfigured()) {
|
|
1974
|
+
const limited = stepUpLimiter.checkAndRecord(`step-up:${claims.sub}`);
|
|
1975
|
+
if (!limited.allowed) {
|
|
1976
|
+
return new Response(
|
|
1977
|
+
JSON.stringify({
|
|
1978
|
+
error: "rate_limited",
|
|
1979
|
+
message: "too many PIN attempts — wait before retrying",
|
|
1980
|
+
retry_after_seconds: limited.retryAfterSeconds,
|
|
1981
|
+
}),
|
|
1982
|
+
{
|
|
1983
|
+
status: 429,
|
|
1984
|
+
headers: {
|
|
1985
|
+
"content-type": "application/json",
|
|
1986
|
+
"retry-after": String(limited.retryAfterSeconds ?? 60),
|
|
1987
|
+
},
|
|
1988
|
+
},
|
|
1989
|
+
);
|
|
1990
|
+
}
|
|
1991
|
+
if (typeof body.currentPin !== "string" || !(await verifyStepUpPin(body.currentPin))) {
|
|
1992
|
+
return json(
|
|
1993
|
+
{ error: "invalid_pin", message: "the current PIN is required to change it" },
|
|
1994
|
+
401,
|
|
1995
|
+
);
|
|
1996
|
+
}
|
|
1997
|
+
stepUpLimiter.clear(`step-up:${claims.sub}`);
|
|
1998
|
+
}
|
|
1999
|
+
try {
|
|
2000
|
+
await setStepUpPin(body.newPin);
|
|
2001
|
+
} catch (err) {
|
|
2002
|
+
if (err instanceof StepUpPinFormatError) return json({ error: err.message }, 400);
|
|
2003
|
+
return json({ error: `failed to set PIN: ${(err as Error).message}` }, 500);
|
|
2004
|
+
}
|
|
2005
|
+
// Echo back only the fact of the write — never the PIN.
|
|
2006
|
+
return json({ ok: true, configured: true });
|
|
2007
|
+
}
|
|
2008
|
+
|
|
1786
2009
|
// ---------------------------------------------------------------------
|
|
1787
2010
|
// Claude OAuth credential store (design §6) — the per-channel secret a
|
|
1788
2011
|
// launched agent session runs on (`CLAUDE_CODE_OAUTH_TOKEN`). Same
|
|
@@ -1802,11 +2025,15 @@ export function createFetchHandler(
|
|
|
1802
2025
|
|
|
1803
2026
|
if (req.method === "GET") {
|
|
1804
2027
|
// Inspect WITHOUT leaking the secret: whether a default is set + which
|
|
1805
|
-
// channels carry an override (names only).
|
|
2028
|
+
// channels carry an override (names only). A status read — no step-up.
|
|
1806
2029
|
return json(describeClaudeCredentials(defaultStateDir()));
|
|
1807
2030
|
}
|
|
1808
2031
|
|
|
1809
|
-
// POST — set the default / operator-level token.
|
|
2032
|
+
// POST — set the default / operator-level token. STEP-UP required (agent#80):
|
|
2033
|
+
// setting a credential can exfiltrate the operator's Claude token.
|
|
2034
|
+
const step = requireStepUp(req, url);
|
|
2035
|
+
if (!step.ok) return step.response;
|
|
2036
|
+
|
|
1810
2037
|
let credBody: { token?: unknown };
|
|
1811
2038
|
try {
|
|
1812
2039
|
credBody = (await req.json()) as typeof credBody;
|
|
@@ -1829,6 +2056,10 @@ export function createFetchHandler(
|
|
|
1829
2056
|
if (credMatch && (req.method === "POST" || req.method === "DELETE")) {
|
|
1830
2057
|
const denied = await requireScope(req, url, SCOPE_ADMIN);
|
|
1831
2058
|
if (denied) return denied;
|
|
2059
|
+
// STEP-UP required (agent#80): both set + remove of a per-channel Claude
|
|
2060
|
+
// credential are high-privilege credential-store mutations.
|
|
2061
|
+
const step = requireStepUp(req, url);
|
|
2062
|
+
if (!step.ok) return step.response;
|
|
1832
2063
|
const channel = decodeURIComponent(credMatch[1]!);
|
|
1833
2064
|
|
|
1834
2065
|
if (req.method === "DELETE") {
|
|
@@ -1883,9 +2114,15 @@ export function createFetchHandler(
|
|
|
1883
2114
|
|
|
1884
2115
|
if (req.method === "GET") {
|
|
1885
2116
|
// Inspect WITHOUT leaking values: names per channel + the default layer.
|
|
2117
|
+
// A status read — no step-up.
|
|
1886
2118
|
return json(describeChannelEnv(defaultStateDir()));
|
|
1887
2119
|
}
|
|
1888
2120
|
|
|
2121
|
+
// STEP-UP required (agent#80): set/remove of an env secret (GH_TOKEN,
|
|
2122
|
+
// CLOUDFLARE_API_TOKEN, …) is a credential-store mutation.
|
|
2123
|
+
const step = requireStepUp(req, url);
|
|
2124
|
+
if (!step.ok) return step.response;
|
|
2125
|
+
|
|
1889
2126
|
let envBody: { channel?: unknown; name?: unknown; value?: unknown };
|
|
1890
2127
|
try {
|
|
1891
2128
|
envBody = (await req.json()) as typeof envBody;
|
|
@@ -1979,6 +2216,15 @@ export function createFetchHandler(
|
|
|
1979
2216
|
throw err;
|
|
1980
2217
|
}
|
|
1981
2218
|
|
|
2219
|
+
// STEP-UP required (agent#80) ONLY for the dangerous filesystem case: a
|
|
2220
|
+
// `filesystem: "full"` agent runs UNSANDBOXED with read access to the whole
|
|
2221
|
+
// disk. Ordinary sandboxed (workspace-confined) spawns stay frictionless —
|
|
2222
|
+
// gate just the high-blast-radius case.
|
|
2223
|
+
if (spec.filesystem === "full") {
|
|
2224
|
+
const step = requireStepUp(req, url);
|
|
2225
|
+
if (!step.ok) return step.response;
|
|
2226
|
+
}
|
|
2227
|
+
|
|
1982
2228
|
// CHANNEL EXCLUSION: a channel routes inbound to at most one agent. Refuse a
|
|
1983
2229
|
// spawn for a DIFFERENT programmatic agent onto an already-occupied wake channel
|
|
1984
2230
|
// (re-spawning the SAME name onto its OWN channel is the idempotent-replace path).
|
|
@@ -2120,6 +2366,14 @@ export function createFetchHandler(
|
|
|
2120
2366
|
// ---------------------------------------------------------------------
|
|
2121
2367
|
if (url.pathname === "/api/agent-defs" && (req.method === "GET" || req.method === "POST")) {
|
|
2122
2368
|
// GET is READ-scoped (a listing, no secrets); POST is admin (it mints/writes).
|
|
2369
|
+
//
|
|
2370
|
+
// NOTE (step-up, agent#80/#154): POST is intentionally NOT step-up-gated, even
|
|
2371
|
+
// though a `#agent/definition` note can carry `filesystem: "full"`. Authoring a
|
|
2372
|
+
// def already requires the scope-gated `vault:write` (the daemon writes the note
|
|
2373
|
+
// with a vault token), so a step-up challenge here would gate a capability the
|
|
2374
|
+
// caller already had to hold a write credential to reach — mirrors the carve-out
|
|
2375
|
+
// comment on the vault-native parse path in agent-defs.ts. The `filesystem:full`
|
|
2376
|
+
// SPAWN path (`POST /api/agents`) IS step-up-gated; this AUTHORING path is not.
|
|
2123
2377
|
const scope = req.method === "GET" ? SCOPE_READ : SCOPE_ADMIN;
|
|
2124
2378
|
const denied = await requireScope(req, url, scope);
|
|
2125
2379
|
if (denied) return denied;
|
|
@@ -2273,6 +2527,12 @@ export function createFetchHandler(
|
|
|
2273
2527
|
// GET is READ-scoped to mirror GET /api/agent-defs — the listing is non-sensitive
|
|
2274
2528
|
// ({vault,url,tokenPresent}); `tokenPresent` is a boolean, NEVER the token value.
|
|
2275
2529
|
// POST is admin (it mints a token + writes config).
|
|
2530
|
+
//
|
|
2531
|
+
// NOTE (step-up, agent#80/#154): POST is intentionally NOT step-up-gated. It mints
|
|
2532
|
+
// a VAULT-SCOPED token (`vault:<name>:write`) — a lower blast radius than the
|
|
2533
|
+
// Claude OAuth credential / terminal / full-fs spawn the step-up PIN guards (those
|
|
2534
|
+
// can exfiltrate every token or open a raw host shell). A def-vault token only
|
|
2535
|
+
// reaches the named vault's notes, so `agent:admin` alone is the right bar here.
|
|
2276
2536
|
const scope = req.method === "GET" ? SCOPE_READ : SCOPE_ADMIN;
|
|
2277
2537
|
const denied = await requireScope(req, url, scope);
|
|
2278
2538
|
if (denied) return denied;
|
|
@@ -2745,8 +3005,21 @@ export function createFetchHandler(
|
|
|
2745
3005
|
return json({ ok: true, reloaded: result });
|
|
2746
3006
|
}
|
|
2747
3007
|
|
|
3008
|
+
// One-time SSE ticket mint — POST /api/ui/sse-ticket (agent#25). The chat
|
|
3009
|
+
// page can't put its hub JWT in an EventSource URL without leaking it into
|
|
3010
|
+
// access logs, so it trades the JWT (presented HERE as a Bearer header — no
|
|
3011
|
+
// leak) for a single-use, ≤60s opaque ticket it puts in the SSE URL instead.
|
|
3012
|
+
// Bearer-gated on `agent:read` (the scope both browser SSE streams require);
|
|
3013
|
+
// the minted ticket carries ONLY the token's own validated scopes, so it can
|
|
3014
|
+
// never authorize more than the JWT did. An unauthenticated mint is impossible
|
|
3015
|
+
// — `mintSseTicket` runs the scope gate before issuing anything. Returns
|
|
3016
|
+
// `{ ticket, expires_at }`. Externally `<hub>/agent/api/ui/sse-ticket`.
|
|
3017
|
+
if (req.method === "POST" && url.pathname === "/api/ui/sse-ticket") {
|
|
3018
|
+
return mintSseTicket(req, url, SCOPE_READ, mintTicket);
|
|
3019
|
+
}
|
|
3020
|
+
|
|
2748
3021
|
// Turn-event SSE — GET /api/channels/<ch>/turn-events (chat-facing; gated on
|
|
2749
|
-
//
|
|
3022
|
+
// a one-time SSE ticket carrying `agent:read`). The streaming
|
|
2750
3023
|
// view (design 2026-06-16 build item #1): the chat subscribes here to watch a
|
|
2751
3024
|
// PROGRAMMATIC turn work in real time — interim assistant text + tool_use, then a
|
|
2752
3025
|
// done/error lifecycle event. EPHEMERAL by design: no backlog/replay (the durable
|
|
@@ -2758,11 +3031,12 @@ export function createFetchHandler(
|
|
|
2758
3031
|
{
|
|
2759
3032
|
const turnMatch = url.pathname.match(/^\/api\/channels\/([^/]+)\/turn-events$/);
|
|
2760
3033
|
if (req.method === "GET" && turnMatch) {
|
|
2761
|
-
//
|
|
2762
|
-
//
|
|
2763
|
-
//
|
|
2764
|
-
// stdio-bridge /events SSE uses a
|
|
2765
|
-
|
|
3034
|
+
// Browser EventSource can't set an Authorization header, so this SSE
|
|
3035
|
+
// authenticates via a one-time `?ticket=<nonce>` (agent#25) — minted by
|
|
3036
|
+
// POST /api/ui/sse-ticket (Bearer-gated) and consumed single-use here. The
|
|
3037
|
+
// hub JWT never rides in this URL. (The stdio-bridge /events SSE uses a
|
|
3038
|
+
// Bearer header, so it never needed a query credential at all.)
|
|
3039
|
+
const denied = requireSseTicket(url, SCOPE_READ);
|
|
2766
3040
|
if (denied) return denied;
|
|
2767
3041
|
const channelName = decodeURIComponent(turnMatch[1]!);
|
|
2768
3042
|
const clientId = crypto.randomUUID();
|
|
@@ -3008,6 +3282,14 @@ function main(): void {
|
|
|
3008
3282
|
mkdirSync(STATE_DIR, { recursive: true });
|
|
3009
3283
|
mkdirSync(INBOX_DIR, { recursive: true });
|
|
3010
3284
|
|
|
3285
|
+
// BOOT DEPENDENCY PREFLIGHT (agent#156). A fresh box can't run a programmatic
|
|
3286
|
+
// `claude -p` turn until bwrap/rg/socat + the claude CLI are on PATH — pre-#156
|
|
3287
|
+
// each surfaced only as a failed *turn*, one at a time. Check them ONCE at boot and
|
|
3288
|
+
// log a single clear warning (with the install one-liners) when any is missing. It's
|
|
3289
|
+
// advisory, never fatal: the daemon may run only attached-backend agents that need
|
|
3290
|
+
// none of these, so we warn + keep serving. The result is also surfaced on /health.
|
|
3291
|
+
const preflight = runBootPreflight();
|
|
3292
|
+
|
|
3011
3293
|
// Verify the one MCP SDK internal our HTTP-MCP delivery accounting reads
|
|
3012
3294
|
// (`_streamMapping['_GET_stream']`, see assertMcpSdkStreamContract). A screaming
|
|
3013
3295
|
// boot error on SDK drift beats discovering it as silent message loss later.
|
|
@@ -3124,7 +3406,7 @@ function main(): void {
|
|
|
3124
3406
|
buildInstantiateDeps(channels, registry, deliveryState, programmatic, attachedQueue),
|
|
3125
3407
|
);
|
|
3126
3408
|
|
|
3127
|
-
const fetchHandler = createFetchHandler(channels, registry, { deliveryState, programmatic, attachedQueue, turnEvents, jobStore, runner, agentDefs });
|
|
3409
|
+
const fetchHandler = createFetchHandler(channels, registry, { deliveryState, programmatic, attachedQueue, turnEvents, jobStore, runner, agentDefs, preflight });
|
|
3128
3410
|
const server = Bun.serve<TerminalWsData, never>({
|
|
3129
3411
|
port: PORT,
|
|
3130
3412
|
hostname: "127.0.0.1",
|
|
@@ -3277,6 +3559,29 @@ function main(): void {
|
|
|
3277
3559
|
const bindings = await resolveDefVaults({ hubOrigin: getHubOrigin(), managerBearer });
|
|
3278
3560
|
for (const b of bindings) agentDefs.addVault(b);
|
|
3279
3561
|
if (bindings.length === 0) return; // nothing bound — vault-native path idle.
|
|
3562
|
+
|
|
3563
|
+
// AUTO-REGISTER the per-def-vault runtime triggers (agent#157) so "define an
|
|
3564
|
+
// agent → it runs" needs NO manual trigger setup + NO restart-to-pick-up:
|
|
3565
|
+
// - the def-watch create/edit triggers, BARE-keyed (`agent/definition`) so a
|
|
3566
|
+
// created/edited def auto-fires the rescan — upsert-by-name REPLACES any
|
|
3567
|
+
// stale `#agent/definition`-keyed `conn_agentdefs-*` row the hub provisioned;
|
|
3568
|
+
// - the inbound trigger (`agent/message/inbound` + has_metadata:[agent]) so a
|
|
3569
|
+
// new inbound note wakes the agent without a hand-registered trigger.
|
|
3570
|
+
// Mints the admin (triggers API) + agent:send (webhook bearer) tokens the same
|
|
3571
|
+
// way the hub's Connections engine does (attenuated to the operator bearer).
|
|
3572
|
+
// Best-effort: a mint refusal / unreachable vault is logged, never fatal — the
|
|
3573
|
+
// 60s loadAll poll below stays the correctness floor. Skipped with no operator
|
|
3574
|
+
// bearer (can't mint) — the vault-native path still runs own-vault.
|
|
3575
|
+
if (managerBearer) {
|
|
3576
|
+
await registerAllDefVaultTriggers(bindings, { hubOrigin: getHubOrigin(), managerBearer }).catch(
|
|
3577
|
+
(err) => {
|
|
3578
|
+
console.warn(
|
|
3579
|
+
`parachute-agent: def-vault trigger auto-registration failed (continuing): ${(err as Error).message}`,
|
|
3580
|
+
);
|
|
3581
|
+
},
|
|
3582
|
+
);
|
|
3583
|
+
}
|
|
3584
|
+
|
|
3280
3585
|
const n = await agentDefs.loadAll();
|
|
3281
3586
|
console.log(
|
|
3282
3587
|
`parachute-agent: vault-native agent defs — ${n} instantiated from ${bindings.length} def-vault(s).`,
|