@openparachute/agent 0.2.0 → 0.2.3-rc.10
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 +8 -1
- package/src/agent-defs.ts +9 -0
- package/src/auth.ts +182 -14
- package/src/backends/registry.ts +65 -27
- package/src/daemon.ts +311 -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 +40 -22
- 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 +15 -0
- 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/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,
|
|
@@ -719,7 +735,7 @@ export function buildWriteThread(channels: Map<string, Channel>): WriteThread {
|
|
|
719
735
|
}
|
|
720
736
|
// Only a transport with a durable store implements writeThread (the VaultTransport).
|
|
721
737
|
if (!ch.transport.writeThread) return;
|
|
722
|
-
await ch.transport.writeThread({
|
|
738
|
+
const written = await ch.transport.writeThread({
|
|
723
739
|
channel: thread.channel,
|
|
724
740
|
...(thread.name ? { name: thread.name } : {}),
|
|
725
741
|
...(thread.definition ? { definition: thread.definition } : {}),
|
|
@@ -743,6 +759,13 @@ export function buildWriteThread(channels: Map<string, Channel>): WriteThread {
|
|
|
743
759
|
...(thread.sameTurn ? { sameTurn: true } : {}),
|
|
744
760
|
...(thread.phase ? { phase: thread.phase } : {}),
|
|
745
761
|
});
|
|
762
|
+
// Surface the WRITTEN note id so the drain can set a RESOLVABLE callback `source_thread`
|
|
763
|
+
// (agent#124). `writeThread` returns `{ sent: [id] }` — the id is the actual note an
|
|
764
|
+
// orchestrator can pull with `query-notes { id }` for BOTH modes (single-threaded: the
|
|
765
|
+
// deterministic `Threads/<safeChannel>/<safeName>` note; multi-threaded: the per-fire
|
|
766
|
+
// `Threads/<safeChannel>/<uuid>` note). Empty/absent → undefined (the drain falls back).
|
|
767
|
+
const id = written?.sent?.[0];
|
|
768
|
+
return id ? { id } : undefined;
|
|
746
769
|
};
|
|
747
770
|
}
|
|
748
771
|
|
|
@@ -1124,8 +1147,13 @@ function redirect(location: string): Response {
|
|
|
1124
1147
|
// is `agent:write`.
|
|
1125
1148
|
//
|
|
1126
1149
|
// Layer 2 — human / chat UI — gates the http-ui transport's `send` (POST,
|
|
1127
|
-
// `agent:send
|
|
1128
|
-
//
|
|
1150
|
+
// `agent:send`, Bearer) with `requireScope`. The browser SSE streams
|
|
1151
|
+
// (`/ui/events`, `/api/channels/<ch>/turn-events`, `agent:read`) gate on a
|
|
1152
|
+
// ONE-TIME ticket (`requireSseTicket`) instead of a `?token=<JWT>` query —
|
|
1153
|
+
// `EventSource` can't set a header, and a JWT in a URL leaks into access logs
|
|
1154
|
+
// (agent#25). The page mints the ticket at `POST /api/ui/sse-ticket` (Bearer,
|
|
1155
|
+
// agent:read) and opens `…?ticket=<nonce>`; the ticket is single-use + ≤60s and
|
|
1156
|
+
// carries only the minting token's scopes.
|
|
1129
1157
|
//
|
|
1130
1158
|
// Discovery + the page itself (/health, /.parachute/config[/schema], /ui) stay
|
|
1131
1159
|
// OPEN — non-sensitive, and /ui must load to bootstrap its token fetch.
|
|
@@ -1182,6 +1210,12 @@ export async function authorizeTerminalUpgrade(
|
|
|
1182
1210
|
const denied = await requireScope(req, url, SCOPE_TERMINAL, true);
|
|
1183
1211
|
if (denied) return { ok: false, response: denied };
|
|
1184
1212
|
|
|
1213
|
+
// STEP-UP required (agent#80): a terminal is a raw host shell — the single most
|
|
1214
|
+
// dangerous capability. allowQueryParam: true so the WS presents the step-up
|
|
1215
|
+
// token as `?step_up=` (it can't set the `X-Step-Up-Token` header).
|
|
1216
|
+
const step = requireStepUp(req, url, true);
|
|
1217
|
+
if (!step.ok) return { ok: false, response: step.response };
|
|
1218
|
+
|
|
1185
1219
|
// tmux session name convention: `<name>-agent`. Attach a viewer pty to THIS
|
|
1186
1220
|
// session; the session itself is created by the spawn path.
|
|
1187
1221
|
const session = `${agentName}-agent`;
|
|
@@ -1286,6 +1320,13 @@ export function createFetchHandler(
|
|
|
1286
1320
|
url: string;
|
|
1287
1321
|
tokenPresent: boolean;
|
|
1288
1322
|
}>;
|
|
1323
|
+
/**
|
|
1324
|
+
* The boot dependency-PREFLIGHT result (agent#156) — surfaced on `/health` so the
|
|
1325
|
+
* admin UI can show that programmatic turns will fail until the missing deps
|
|
1326
|
+
* (`bwrap`/`rg`/`socat`/`claude`) are installed. `main` passes the boot check;
|
|
1327
|
+
* absent (a plain createFetchHandler / tests) → omitted from `/health`.
|
|
1328
|
+
*/
|
|
1329
|
+
preflight?: PreflightResult;
|
|
1289
1330
|
},
|
|
1290
1331
|
): (req: Request, server?: { upgrade: (req: Request, opts: { data: TerminalWsData }) => boolean }) => Promise<Response> {
|
|
1291
1332
|
// The per-channel turn-event SSE registry — subscribers of the live "watch it
|
|
@@ -1478,6 +1519,10 @@ export function createFetchHandler(
|
|
|
1478
1519
|
// (`programmatic · idle|working|queued:N`) instead of `mcp_sessions` — a
|
|
1479
1520
|
// programmatic agent has no live subscriber, so SSE/MCP counts don't describe it.
|
|
1480
1521
|
if (url.pathname === "/health") {
|
|
1522
|
+
// Surface the boot dependency-preflight (agent#156) so the admin UI can show
|
|
1523
|
+
// that programmatic turns will fail until the missing deps are installed. Only
|
|
1524
|
+
// present when `main` passed the boot check (absent in a plain handler/tests).
|
|
1525
|
+
const preflight = opts?.preflight;
|
|
1481
1526
|
return json({
|
|
1482
1527
|
status: "ok",
|
|
1483
1528
|
channels: [...channels.values()].map((c) => ({
|
|
@@ -1496,6 +1541,15 @@ export function createFetchHandler(
|
|
|
1496
1541
|
status: s.state === "queued" ? `queued:${s.queued}` : s.state,
|
|
1497
1542
|
};
|
|
1498
1543
|
}),
|
|
1544
|
+
...(preflight
|
|
1545
|
+
? {
|
|
1546
|
+
dependencies: {
|
|
1547
|
+
ok: preflight.ok,
|
|
1548
|
+
// The binary names missing on PATH — what programmatic turns need installed.
|
|
1549
|
+
missing: preflight.missing.map((d) => d.bin),
|
|
1550
|
+
},
|
|
1551
|
+
}
|
|
1552
|
+
: {}),
|
|
1499
1553
|
});
|
|
1500
1554
|
}
|
|
1501
1555
|
|
|
@@ -1783,6 +1837,169 @@ export function createFetchHandler(
|
|
|
1783
1837
|
}
|
|
1784
1838
|
}
|
|
1785
1839
|
|
|
1840
|
+
// ---------------------------------------------------------------------
|
|
1841
|
+
// STEP-UP AUTH (PIN) — second factor for high-privilege actions (agent#80).
|
|
1842
|
+
//
|
|
1843
|
+
// The dangerous `agent:admin` actions (set credentials, open a terminal,
|
|
1844
|
+
// spawn a `filesystem: full` agent) require a step-up token IN ADDITION to
|
|
1845
|
+
// the `agent:admin` Bearer. This block is the PIN setup + exchange surface;
|
|
1846
|
+
// the gating lives at each dangerous endpoint (via `requireStepUp`).
|
|
1847
|
+
//
|
|
1848
|
+
// GET /api/step-up → { configured } — is a PIN set? (UI: setup vs prompt)
|
|
1849
|
+
// POST /api/step-up { pin } → validate PIN (rate-limited) → { stepUpToken, expires_at }
|
|
1850
|
+
// POST /api/step-up/pin { newPin, currentPin? } → set/rotate the PIN
|
|
1851
|
+
//
|
|
1852
|
+
// All `agent:admin`-gated (the operator's cookie-minted Bearer). The PIN is
|
|
1853
|
+
// hashed+salted server-side (step-up.ts); it is NEVER returned or logged.
|
|
1854
|
+
// Externally hub strips `/agent`, so these are `<hub>/agent/api/step-up`.
|
|
1855
|
+
// ---------------------------------------------------------------------
|
|
1856
|
+
if (url.pathname === "/api/step-up" && req.method === "GET") {
|
|
1857
|
+
const denied = await requireScope(req, url, SCOPE_ADMIN);
|
|
1858
|
+
if (denied) return denied;
|
|
1859
|
+
// Whether a PIN is configured — the UI branches setup-flow vs PIN-prompt.
|
|
1860
|
+
return json({ configured: isStepUpConfigured() });
|
|
1861
|
+
}
|
|
1862
|
+
|
|
1863
|
+
if (url.pathname === "/api/step-up" && req.method === "POST") {
|
|
1864
|
+
// Exchange: validate the PIN, then mint a short-lived step-up token. The
|
|
1865
|
+
// session must already hold `agent:admin` (this is a SECOND factor on top,
|
|
1866
|
+
// never a substitute — the token carries no scope of its own).
|
|
1867
|
+
let claims;
|
|
1868
|
+
try {
|
|
1869
|
+
const token = extractToken(req, url);
|
|
1870
|
+
if (!token) return json({ error: "unauthorized", message: "Bearer token required" }, 401);
|
|
1871
|
+
claims = await validateHubJwt(token);
|
|
1872
|
+
} catch (err) {
|
|
1873
|
+
return json(
|
|
1874
|
+
{ error: "unauthorized", message: err instanceof Error ? err.message : "invalid token" },
|
|
1875
|
+
401,
|
|
1876
|
+
);
|
|
1877
|
+
}
|
|
1878
|
+
if (!grantsScope(claims.scopes, SCOPE_ADMIN)) {
|
|
1879
|
+
return json(
|
|
1880
|
+
{ error: "insufficient_scope", message: `requires ${SCOPE_ADMIN}`, granted: claims.scopes },
|
|
1881
|
+
403,
|
|
1882
|
+
);
|
|
1883
|
+
}
|
|
1884
|
+
// No PIN configured yet — there's nothing to exchange. Tell the UI to run
|
|
1885
|
+
// its first-time setup (distinct from a wrong-PIN 401).
|
|
1886
|
+
if (!isStepUpConfigured()) {
|
|
1887
|
+
return json(
|
|
1888
|
+
{ error: "step_up_not_configured", message: "set a step-up PIN first (POST /api/step-up/pin)" },
|
|
1889
|
+
409,
|
|
1890
|
+
);
|
|
1891
|
+
}
|
|
1892
|
+
let body: { pin?: unknown };
|
|
1893
|
+
try {
|
|
1894
|
+
body = (await req.json()) as typeof body;
|
|
1895
|
+
} catch {
|
|
1896
|
+
return json({ error: "invalid JSON body" }, 400);
|
|
1897
|
+
}
|
|
1898
|
+
if (typeof body.pin !== "string" || body.pin.length === 0) {
|
|
1899
|
+
return json({ error: "body.pin (non-empty string) is required" }, 400);
|
|
1900
|
+
}
|
|
1901
|
+
// Rate-limit BEFORE the (expensive, brute-forceable) argon2 verify, keyed by
|
|
1902
|
+
// the operator subject — a stolen-cookie attacker can't grind the PIN. A
|
|
1903
|
+
// DENIED attempt returns 429 (the limiter does not count it again).
|
|
1904
|
+
const limited = stepUpLimiter.checkAndRecord(`step-up:${claims.sub}`);
|
|
1905
|
+
if (!limited.allowed) {
|
|
1906
|
+
return new Response(
|
|
1907
|
+
JSON.stringify({
|
|
1908
|
+
error: "rate_limited",
|
|
1909
|
+
message: "too many PIN attempts — wait before retrying",
|
|
1910
|
+
retry_after_seconds: limited.retryAfterSeconds,
|
|
1911
|
+
}),
|
|
1912
|
+
{
|
|
1913
|
+
status: 429,
|
|
1914
|
+
headers: {
|
|
1915
|
+
"content-type": "application/json",
|
|
1916
|
+
"retry-after": String(limited.retryAfterSeconds ?? 60),
|
|
1917
|
+
},
|
|
1918
|
+
},
|
|
1919
|
+
);
|
|
1920
|
+
}
|
|
1921
|
+
const ok = await verifyStepUpPin(body.pin);
|
|
1922
|
+
if (!ok) {
|
|
1923
|
+
// Wrong PIN — 401. The attempt already counted toward the lockout above.
|
|
1924
|
+
// Never echo the PIN back.
|
|
1925
|
+
return json({ error: "invalid_pin", message: "incorrect PIN" }, 401);
|
|
1926
|
+
}
|
|
1927
|
+
// Correct PIN — clear the attempt bucket (a fresh window for the next time)
|
|
1928
|
+
// and mint a reusable, short-TTL step-up token.
|
|
1929
|
+
stepUpLimiter.clear(`step-up:${claims.sub}`);
|
|
1930
|
+
const { token: stepUpToken, expiresAt } = mintStepUpToken();
|
|
1931
|
+
return json({ stepUpToken, expires_at: new Date(expiresAt).toISOString() });
|
|
1932
|
+
}
|
|
1933
|
+
|
|
1934
|
+
if (url.pathname === "/api/step-up/pin" && req.method === "POST") {
|
|
1935
|
+
// Set (first time) or rotate the step-up PIN. agent:admin-gated; if a PIN
|
|
1936
|
+
// already exists, the CURRENT PIN must be supplied + verified (rotation
|
|
1937
|
+
// needs the old PIN, so a hijacked session can't silently replace it).
|
|
1938
|
+
let claims;
|
|
1939
|
+
try {
|
|
1940
|
+
const token = extractToken(req, url);
|
|
1941
|
+
if (!token) return json({ error: "unauthorized", message: "Bearer token required" }, 401);
|
|
1942
|
+
claims = await validateHubJwt(token);
|
|
1943
|
+
} catch (err) {
|
|
1944
|
+
return json(
|
|
1945
|
+
{ error: "unauthorized", message: err instanceof Error ? err.message : "invalid token" },
|
|
1946
|
+
401,
|
|
1947
|
+
);
|
|
1948
|
+
}
|
|
1949
|
+
if (!grantsScope(claims.scopes, SCOPE_ADMIN)) {
|
|
1950
|
+
return json(
|
|
1951
|
+
{ error: "insufficient_scope", message: `requires ${SCOPE_ADMIN}`, granted: claims.scopes },
|
|
1952
|
+
403,
|
|
1953
|
+
);
|
|
1954
|
+
}
|
|
1955
|
+
let body: { newPin?: unknown; currentPin?: unknown };
|
|
1956
|
+
try {
|
|
1957
|
+
body = (await req.json()) as typeof body;
|
|
1958
|
+
} catch {
|
|
1959
|
+
return json({ error: "invalid JSON body" }, 400);
|
|
1960
|
+
}
|
|
1961
|
+
if (!isValidPinFormat(body.newPin)) {
|
|
1962
|
+
return json({ error: "body.newPin must be 4–12 digits" }, 400);
|
|
1963
|
+
}
|
|
1964
|
+
// Rotation: a PIN already exists → require + verify the current one (rate-limited).
|
|
1965
|
+
// SHARES the exchange bucket (same `step-up:<sub>` key) on purpose: both verify
|
|
1966
|
+
// the PIN, so an attacker can't get a fresh grind window by alternating endpoints.
|
|
1967
|
+
if (isStepUpConfigured()) {
|
|
1968
|
+
const limited = stepUpLimiter.checkAndRecord(`step-up:${claims.sub}`);
|
|
1969
|
+
if (!limited.allowed) {
|
|
1970
|
+
return new Response(
|
|
1971
|
+
JSON.stringify({
|
|
1972
|
+
error: "rate_limited",
|
|
1973
|
+
message: "too many PIN attempts — wait before retrying",
|
|
1974
|
+
retry_after_seconds: limited.retryAfterSeconds,
|
|
1975
|
+
}),
|
|
1976
|
+
{
|
|
1977
|
+
status: 429,
|
|
1978
|
+
headers: {
|
|
1979
|
+
"content-type": "application/json",
|
|
1980
|
+
"retry-after": String(limited.retryAfterSeconds ?? 60),
|
|
1981
|
+
},
|
|
1982
|
+
},
|
|
1983
|
+
);
|
|
1984
|
+
}
|
|
1985
|
+
if (typeof body.currentPin !== "string" || !(await verifyStepUpPin(body.currentPin))) {
|
|
1986
|
+
return json(
|
|
1987
|
+
{ error: "invalid_pin", message: "the current PIN is required to change it" },
|
|
1988
|
+
401,
|
|
1989
|
+
);
|
|
1990
|
+
}
|
|
1991
|
+
stepUpLimiter.clear(`step-up:${claims.sub}`);
|
|
1992
|
+
}
|
|
1993
|
+
try {
|
|
1994
|
+
await setStepUpPin(body.newPin);
|
|
1995
|
+
} catch (err) {
|
|
1996
|
+
if (err instanceof StepUpPinFormatError) return json({ error: err.message }, 400);
|
|
1997
|
+
return json({ error: `failed to set PIN: ${(err as Error).message}` }, 500);
|
|
1998
|
+
}
|
|
1999
|
+
// Echo back only the fact of the write — never the PIN.
|
|
2000
|
+
return json({ ok: true, configured: true });
|
|
2001
|
+
}
|
|
2002
|
+
|
|
1786
2003
|
// ---------------------------------------------------------------------
|
|
1787
2004
|
// Claude OAuth credential store (design §6) — the per-channel secret a
|
|
1788
2005
|
// launched agent session runs on (`CLAUDE_CODE_OAUTH_TOKEN`). Same
|
|
@@ -1802,11 +2019,15 @@ export function createFetchHandler(
|
|
|
1802
2019
|
|
|
1803
2020
|
if (req.method === "GET") {
|
|
1804
2021
|
// Inspect WITHOUT leaking the secret: whether a default is set + which
|
|
1805
|
-
// channels carry an override (names only).
|
|
2022
|
+
// channels carry an override (names only). A status read — no step-up.
|
|
1806
2023
|
return json(describeClaudeCredentials(defaultStateDir()));
|
|
1807
2024
|
}
|
|
1808
2025
|
|
|
1809
|
-
// POST — set the default / operator-level token.
|
|
2026
|
+
// POST — set the default / operator-level token. STEP-UP required (agent#80):
|
|
2027
|
+
// setting a credential can exfiltrate the operator's Claude token.
|
|
2028
|
+
const step = requireStepUp(req, url);
|
|
2029
|
+
if (!step.ok) return step.response;
|
|
2030
|
+
|
|
1810
2031
|
let credBody: { token?: unknown };
|
|
1811
2032
|
try {
|
|
1812
2033
|
credBody = (await req.json()) as typeof credBody;
|
|
@@ -1829,6 +2050,10 @@ export function createFetchHandler(
|
|
|
1829
2050
|
if (credMatch && (req.method === "POST" || req.method === "DELETE")) {
|
|
1830
2051
|
const denied = await requireScope(req, url, SCOPE_ADMIN);
|
|
1831
2052
|
if (denied) return denied;
|
|
2053
|
+
// STEP-UP required (agent#80): both set + remove of a per-channel Claude
|
|
2054
|
+
// credential are high-privilege credential-store mutations.
|
|
2055
|
+
const step = requireStepUp(req, url);
|
|
2056
|
+
if (!step.ok) return step.response;
|
|
1832
2057
|
const channel = decodeURIComponent(credMatch[1]!);
|
|
1833
2058
|
|
|
1834
2059
|
if (req.method === "DELETE") {
|
|
@@ -1883,9 +2108,15 @@ export function createFetchHandler(
|
|
|
1883
2108
|
|
|
1884
2109
|
if (req.method === "GET") {
|
|
1885
2110
|
// Inspect WITHOUT leaking values: names per channel + the default layer.
|
|
2111
|
+
// A status read — no step-up.
|
|
1886
2112
|
return json(describeChannelEnv(defaultStateDir()));
|
|
1887
2113
|
}
|
|
1888
2114
|
|
|
2115
|
+
// STEP-UP required (agent#80): set/remove of an env secret (GH_TOKEN,
|
|
2116
|
+
// CLOUDFLARE_API_TOKEN, …) is a credential-store mutation.
|
|
2117
|
+
const step = requireStepUp(req, url);
|
|
2118
|
+
if (!step.ok) return step.response;
|
|
2119
|
+
|
|
1889
2120
|
let envBody: { channel?: unknown; name?: unknown; value?: unknown };
|
|
1890
2121
|
try {
|
|
1891
2122
|
envBody = (await req.json()) as typeof envBody;
|
|
@@ -1979,6 +2210,15 @@ export function createFetchHandler(
|
|
|
1979
2210
|
throw err;
|
|
1980
2211
|
}
|
|
1981
2212
|
|
|
2213
|
+
// STEP-UP required (agent#80) ONLY for the dangerous filesystem case: a
|
|
2214
|
+
// `filesystem: "full"` agent runs UNSANDBOXED with read access to the whole
|
|
2215
|
+
// disk. Ordinary sandboxed (workspace-confined) spawns stay frictionless —
|
|
2216
|
+
// gate just the high-blast-radius case.
|
|
2217
|
+
if (spec.filesystem === "full") {
|
|
2218
|
+
const step = requireStepUp(req, url);
|
|
2219
|
+
if (!step.ok) return step.response;
|
|
2220
|
+
}
|
|
2221
|
+
|
|
1982
2222
|
// CHANNEL EXCLUSION: a channel routes inbound to at most one agent. Refuse a
|
|
1983
2223
|
// spawn for a DIFFERENT programmatic agent onto an already-occupied wake channel
|
|
1984
2224
|
// (re-spawning the SAME name onto its OWN channel is the idempotent-replace path).
|
|
@@ -2120,6 +2360,14 @@ export function createFetchHandler(
|
|
|
2120
2360
|
// ---------------------------------------------------------------------
|
|
2121
2361
|
if (url.pathname === "/api/agent-defs" && (req.method === "GET" || req.method === "POST")) {
|
|
2122
2362
|
// GET is READ-scoped (a listing, no secrets); POST is admin (it mints/writes).
|
|
2363
|
+
//
|
|
2364
|
+
// NOTE (step-up, agent#80/#154): POST is intentionally NOT step-up-gated, even
|
|
2365
|
+
// though a `#agent/definition` note can carry `filesystem: "full"`. Authoring a
|
|
2366
|
+
// def already requires the scope-gated `vault:write` (the daemon writes the note
|
|
2367
|
+
// with a vault token), so a step-up challenge here would gate a capability the
|
|
2368
|
+
// caller already had to hold a write credential to reach — mirrors the carve-out
|
|
2369
|
+
// comment on the vault-native parse path in agent-defs.ts. The `filesystem:full`
|
|
2370
|
+
// SPAWN path (`POST /api/agents`) IS step-up-gated; this AUTHORING path is not.
|
|
2123
2371
|
const scope = req.method === "GET" ? SCOPE_READ : SCOPE_ADMIN;
|
|
2124
2372
|
const denied = await requireScope(req, url, scope);
|
|
2125
2373
|
if (denied) return denied;
|
|
@@ -2273,6 +2521,12 @@ export function createFetchHandler(
|
|
|
2273
2521
|
// GET is READ-scoped to mirror GET /api/agent-defs — the listing is non-sensitive
|
|
2274
2522
|
// ({vault,url,tokenPresent}); `tokenPresent` is a boolean, NEVER the token value.
|
|
2275
2523
|
// POST is admin (it mints a token + writes config).
|
|
2524
|
+
//
|
|
2525
|
+
// NOTE (step-up, agent#80/#154): POST is intentionally NOT step-up-gated. It mints
|
|
2526
|
+
// a VAULT-SCOPED token (`vault:<name>:write`) — a lower blast radius than the
|
|
2527
|
+
// Claude OAuth credential / terminal / full-fs spawn the step-up PIN guards (those
|
|
2528
|
+
// can exfiltrate every token or open a raw host shell). A def-vault token only
|
|
2529
|
+
// reaches the named vault's notes, so `agent:admin` alone is the right bar here.
|
|
2276
2530
|
const scope = req.method === "GET" ? SCOPE_READ : SCOPE_ADMIN;
|
|
2277
2531
|
const denied = await requireScope(req, url, scope);
|
|
2278
2532
|
if (denied) return denied;
|
|
@@ -2745,8 +2999,21 @@ export function createFetchHandler(
|
|
|
2745
2999
|
return json({ ok: true, reloaded: result });
|
|
2746
3000
|
}
|
|
2747
3001
|
|
|
3002
|
+
// One-time SSE ticket mint — POST /api/ui/sse-ticket (agent#25). The chat
|
|
3003
|
+
// page can't put its hub JWT in an EventSource URL without leaking it into
|
|
3004
|
+
// access logs, so it trades the JWT (presented HERE as a Bearer header — no
|
|
3005
|
+
// leak) for a single-use, ≤60s opaque ticket it puts in the SSE URL instead.
|
|
3006
|
+
// Bearer-gated on `agent:read` (the scope both browser SSE streams require);
|
|
3007
|
+
// the minted ticket carries ONLY the token's own validated scopes, so it can
|
|
3008
|
+
// never authorize more than the JWT did. An unauthenticated mint is impossible
|
|
3009
|
+
// — `mintSseTicket` runs the scope gate before issuing anything. Returns
|
|
3010
|
+
// `{ ticket, expires_at }`. Externally `<hub>/agent/api/ui/sse-ticket`.
|
|
3011
|
+
if (req.method === "POST" && url.pathname === "/api/ui/sse-ticket") {
|
|
3012
|
+
return mintSseTicket(req, url, SCOPE_READ, mintTicket);
|
|
3013
|
+
}
|
|
3014
|
+
|
|
2748
3015
|
// Turn-event SSE — GET /api/channels/<ch>/turn-events (chat-facing; gated on
|
|
2749
|
-
//
|
|
3016
|
+
// a one-time SSE ticket carrying `agent:read`). The streaming
|
|
2750
3017
|
// view (design 2026-06-16 build item #1): the chat subscribes here to watch a
|
|
2751
3018
|
// PROGRAMMATIC turn work in real time — interim assistant text + tool_use, then a
|
|
2752
3019
|
// done/error lifecycle event. EPHEMERAL by design: no backlog/replay (the durable
|
|
@@ -2758,11 +3025,12 @@ export function createFetchHandler(
|
|
|
2758
3025
|
{
|
|
2759
3026
|
const turnMatch = url.pathname.match(/^\/api\/channels\/([^/]+)\/turn-events$/);
|
|
2760
3027
|
if (req.method === "GET" && turnMatch) {
|
|
2761
|
-
//
|
|
2762
|
-
//
|
|
2763
|
-
//
|
|
2764
|
-
// stdio-bridge /events SSE uses a
|
|
2765
|
-
|
|
3028
|
+
// Browser EventSource can't set an Authorization header, so this SSE
|
|
3029
|
+
// authenticates via a one-time `?ticket=<nonce>` (agent#25) — minted by
|
|
3030
|
+
// POST /api/ui/sse-ticket (Bearer-gated) and consumed single-use here. The
|
|
3031
|
+
// hub JWT never rides in this URL. (The stdio-bridge /events SSE uses a
|
|
3032
|
+
// Bearer header, so it never needed a query credential at all.)
|
|
3033
|
+
const denied = requireSseTicket(url, SCOPE_READ);
|
|
2766
3034
|
if (denied) return denied;
|
|
2767
3035
|
const channelName = decodeURIComponent(turnMatch[1]!);
|
|
2768
3036
|
const clientId = crypto.randomUUID();
|
|
@@ -3008,6 +3276,14 @@ function main(): void {
|
|
|
3008
3276
|
mkdirSync(STATE_DIR, { recursive: true });
|
|
3009
3277
|
mkdirSync(INBOX_DIR, { recursive: true });
|
|
3010
3278
|
|
|
3279
|
+
// BOOT DEPENDENCY PREFLIGHT (agent#156). A fresh box can't run a programmatic
|
|
3280
|
+
// `claude -p` turn until bwrap/rg/socat + the claude CLI are on PATH — pre-#156
|
|
3281
|
+
// each surfaced only as a failed *turn*, one at a time. Check them ONCE at boot and
|
|
3282
|
+
// log a single clear warning (with the install one-liners) when any is missing. It's
|
|
3283
|
+
// advisory, never fatal: the daemon may run only attached-backend agents that need
|
|
3284
|
+
// none of these, so we warn + keep serving. The result is also surfaced on /health.
|
|
3285
|
+
const preflight = runBootPreflight();
|
|
3286
|
+
|
|
3011
3287
|
// Verify the one MCP SDK internal our HTTP-MCP delivery accounting reads
|
|
3012
3288
|
// (`_streamMapping['_GET_stream']`, see assertMcpSdkStreamContract). A screaming
|
|
3013
3289
|
// boot error on SDK drift beats discovering it as silent message loss later.
|
|
@@ -3124,7 +3400,7 @@ function main(): void {
|
|
|
3124
3400
|
buildInstantiateDeps(channels, registry, deliveryState, programmatic, attachedQueue),
|
|
3125
3401
|
);
|
|
3126
3402
|
|
|
3127
|
-
const fetchHandler = createFetchHandler(channels, registry, { deliveryState, programmatic, attachedQueue, turnEvents, jobStore, runner, agentDefs });
|
|
3403
|
+
const fetchHandler = createFetchHandler(channels, registry, { deliveryState, programmatic, attachedQueue, turnEvents, jobStore, runner, agentDefs, preflight });
|
|
3128
3404
|
const server = Bun.serve<TerminalWsData, never>({
|
|
3129
3405
|
port: PORT,
|
|
3130
3406
|
hostname: "127.0.0.1",
|
|
@@ -3277,6 +3553,29 @@ function main(): void {
|
|
|
3277
3553
|
const bindings = await resolveDefVaults({ hubOrigin: getHubOrigin(), managerBearer });
|
|
3278
3554
|
for (const b of bindings) agentDefs.addVault(b);
|
|
3279
3555
|
if (bindings.length === 0) return; // nothing bound — vault-native path idle.
|
|
3556
|
+
|
|
3557
|
+
// AUTO-REGISTER the per-def-vault runtime triggers (agent#157) so "define an
|
|
3558
|
+
// agent → it runs" needs NO manual trigger setup + NO restart-to-pick-up:
|
|
3559
|
+
// - the def-watch create/edit triggers, BARE-keyed (`agent/definition`) so a
|
|
3560
|
+
// created/edited def auto-fires the rescan — upsert-by-name REPLACES any
|
|
3561
|
+
// stale `#agent/definition`-keyed `conn_agentdefs-*` row the hub provisioned;
|
|
3562
|
+
// - the inbound trigger (`agent/message/inbound` + has_metadata:[agent]) so a
|
|
3563
|
+
// new inbound note wakes the agent without a hand-registered trigger.
|
|
3564
|
+
// Mints the admin (triggers API) + agent:send (webhook bearer) tokens the same
|
|
3565
|
+
// way the hub's Connections engine does (attenuated to the operator bearer).
|
|
3566
|
+
// Best-effort: a mint refusal / unreachable vault is logged, never fatal — the
|
|
3567
|
+
// 60s loadAll poll below stays the correctness floor. Skipped with no operator
|
|
3568
|
+
// bearer (can't mint) — the vault-native path still runs own-vault.
|
|
3569
|
+
if (managerBearer) {
|
|
3570
|
+
await registerAllDefVaultTriggers(bindings, { hubOrigin: getHubOrigin(), managerBearer }).catch(
|
|
3571
|
+
(err) => {
|
|
3572
|
+
console.warn(
|
|
3573
|
+
`parachute-agent: def-vault trigger auto-registration failed (continuing): ${(err as Error).message}`,
|
|
3574
|
+
);
|
|
3575
|
+
},
|
|
3576
|
+
);
|
|
3577
|
+
}
|
|
3578
|
+
|
|
3280
3579
|
const n = await agentDefs.loadAll();
|
|
3281
3580
|
console.log(
|
|
3282
3581
|
`parachute-agent: vault-native agent defs — ${n} instantiated from ${bindings.length} def-vault(s).`,
|