@openparachute/agent 0.2.3-rc.5 → 0.2.3-rc.7

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openparachute/agent",
3
- "version": "0.2.3-rc.5",
3
+ "version": "0.2.3-rc.7",
4
4
  "description": "Vault-native agents for Claude Code — a #agent/definition note + an inbound message becomes a sandboxed claude turn; the reply is written back as a note. Messaging gateway on :1941.",
5
5
  "license": "AGPL-3.0",
6
6
  "type": "module",
package/src/agent-defs.ts CHANGED
@@ -389,6 +389,15 @@ export function parseAgentDef(note: {
389
389
  }
390
390
 
391
391
  // Filesystem read scope.
392
+ //
393
+ // NOTE (step-up, agent#80): `filesystem: "full"` is the dangerous, full-disk
394
+ // case. The step-up PIN gate is enforced on the HTTP spawn path only
395
+ // (`POST /api/agents` in daemon.ts). This VAULT-NATIVE path (a #agent/definition
396
+ // note with `filesystem: full`) is NOT step-up-gated — registering it requires
397
+ // `vault:write` to author the note, which is itself separately scope-gated, so a
398
+ // step-up challenge here would gate a capability the caller already had to hold a
399
+ // write credential to reach. If the threat model is ever revisited (e.g. less-
400
+ // trusted note authors), this is the gap to close.
392
401
  const filesystem = metaStr(meta.filesystem);
393
402
  if (filesystem !== undefined) {
394
403
  if (filesystem !== "workspace" && filesystem !== "full") {
package/src/auth.ts CHANGED
@@ -40,6 +40,7 @@
40
40
  import { validateHubJwt, HubJwtError } from "./hub-jwt.ts";
41
41
  import { extractBearer } from "@openparachute/scope-guard";
42
42
  import { consumeTicket } from "./ui-ticket.ts";
43
+ import { isStepUpTokenValid, isStepUpConfigured } from "./step-up.ts";
43
44
 
44
45
  /** Agent scopes, declared here so callers share one spelling. */
45
46
  export const SCOPE_READ = "agent:read" as const;
@@ -227,3 +228,81 @@ export function requireSseTicket(url: URL, scope: string): Response | null {
227
228
  }
228
229
  return null;
229
230
  }
231
+
232
+ /**
233
+ * The header a request carries the step-up token on (agent#80). The terminal
234
+ * WebSocket — which `new WebSocket()` can't set a header on — uses the
235
+ * `?step_up=` query param instead (mirroring the `?token=` exception).
236
+ */
237
+ export const STEP_UP_TOKEN_HEADER = "x-step-up-token";
238
+
239
+ /** Extract a presented step-up token: the header first, then `?step_up=` when allowed. */
240
+ export function extractStepUpToken(req: Request, url: URL, allowQueryParam = false): string | null {
241
+ const header = req.headers.get(STEP_UP_TOKEN_HEADER);
242
+ if (header && header.length > 0) return header;
243
+ if (allowQueryParam) {
244
+ const q = url.searchParams.get("step_up");
245
+ if (q && q.length > 0) return q;
246
+ }
247
+ return null;
248
+ }
249
+
250
+ /**
251
+ * SECOND-FACTOR gate (agent#80) for the genuinely dangerous `agent:admin` actions:
252
+ * set/rotate credentials, open a terminal, spawn a `filesystem: full` agent. The
253
+ * caller runs {@link requireScope}(`agent:admin`) FIRST; this asserts — IN ADDITION —
254
+ * a valid step-up token (the operator entered their PIN recently).
255
+ *
256
+ * - Step-up NOT configured (no PIN set) → returns `{ ok: false, reason: "setup" }`.
257
+ * The caller maps it to `403 { error: "step_up_required", reason: "setup" }` so
258
+ * the UI runs its FIRST-TIME PIN-setup flow before the action.
259
+ * - PIN configured + valid token → `{ ok: true }` (the action proceeds).
260
+ * - PIN configured + missing/expired token → `{ ok: false, reason: "token" }` →
261
+ * `403 { error: "step_up_required" }` so the UI PROMPTS for the PIN.
262
+ *
263
+ * The 403 is deliberately DISTINCT from `requireScope`'s 401 (no/invalid Bearer):
264
+ * a 401 means "re-authenticate", a 403 `step_up_required` means "enter your PIN".
265
+ * The step-up token NEVER widens scope — the request already passed `agent:admin`;
266
+ * this is purely a recency re-confirm on top.
267
+ *
268
+ * `allowQueryParam: true` accepts `?step_up=` for the terminal WebSocket only.
269
+ * Pure in-memory token check — no I/O on the gated request path, no secret logged.
270
+ */
271
+ export function requireStepUp(
272
+ req: Request,
273
+ url: URL,
274
+ allowQueryParam = false,
275
+ opts?: { configured?: () => boolean; valid?: (token: string | null) => boolean },
276
+ ): { ok: true } | { ok: false; response: Response } {
277
+ const isConfigured = opts?.configured ?? (() => isStepUpConfigured());
278
+ const isValid = opts?.valid ?? ((t: string | null) => isStepUpTokenValid(t));
279
+ if (!isConfigured()) {
280
+ // No PIN yet — the UI must set one before this action can proceed.
281
+ return {
282
+ ok: false,
283
+ response: json(
284
+ {
285
+ error: "step_up_required",
286
+ reason: "setup",
287
+ message: "set a step-up PIN before performing this action",
288
+ },
289
+ 403,
290
+ ),
291
+ };
292
+ }
293
+ const token = extractStepUpToken(req, url, allowQueryParam);
294
+ if (!isValid(token)) {
295
+ return {
296
+ ok: false,
297
+ response: json(
298
+ {
299
+ error: "step_up_required",
300
+ reason: "token",
301
+ message: "enter your step-up PIN to confirm this action",
302
+ },
303
+ 403,
304
+ ),
305
+ };
306
+ }
307
+ return { ok: true };
308
+ }
package/src/daemon.ts CHANGED
@@ -87,6 +87,8 @@ import {
87
87
  requireScope,
88
88
  mintSseTicket,
89
89
  requireSseTicket,
90
+ requireStepUp,
91
+ grantsScope,
90
92
  extractToken,
91
93
  json as authJson,
92
94
  SCOPE_READ,
@@ -95,6 +97,15 @@ import {
95
97
  SCOPE_ADMIN,
96
98
  SCOPE_TERMINAL,
97
99
  } from "./auth.ts";
100
+ import {
101
+ isStepUpConfigured,
102
+ isValidPinFormat,
103
+ setStepUpPin,
104
+ verifyStepUpPin,
105
+ mintStepUpToken,
106
+ stepUpLimiter,
107
+ StepUpPinFormatError,
108
+ } from "./step-up.ts";
98
109
  import { mintTicket } from "./ui-ticket.ts";
99
110
  import {
100
111
  createTerminalWsHandlers,
@@ -103,6 +114,7 @@ import {
103
114
  import { TERMINAL_UI_HTML } from "./terminal-ui.ts";
104
115
  import { serveTerminalAsset } from "./terminal-assets.ts";
105
116
  import { isSpaPath, serveSpa, spaDistDir } from "./spa-serve.ts";
117
+ import { runBootPreflight, type PreflightResult } from "./preflight.ts";
106
118
  import {
107
119
  buildSpecFromBody,
108
120
  setupProgrammaticSpawn,
@@ -1190,6 +1202,12 @@ export async function authorizeTerminalUpgrade(
1190
1202
  const denied = await requireScope(req, url, SCOPE_TERMINAL, true);
1191
1203
  if (denied) return { ok: false, response: denied };
1192
1204
 
1205
+ // STEP-UP required (agent#80): a terminal is a raw host shell — the single most
1206
+ // dangerous capability. allowQueryParam: true so the WS presents the step-up
1207
+ // token as `?step_up=` (it can't set the `X-Step-Up-Token` header).
1208
+ const step = requireStepUp(req, url, true);
1209
+ if (!step.ok) return { ok: false, response: step.response };
1210
+
1193
1211
  // tmux session name convention: `<name>-agent`. Attach a viewer pty to THIS
1194
1212
  // session; the session itself is created by the spawn path.
1195
1213
  const session = `${agentName}-agent`;
@@ -1294,6 +1312,13 @@ export function createFetchHandler(
1294
1312
  url: string;
1295
1313
  tokenPresent: boolean;
1296
1314
  }>;
1315
+ /**
1316
+ * The boot dependency-PREFLIGHT result (agent#156) — surfaced on `/health` so the
1317
+ * admin UI can show that programmatic turns will fail until the missing deps
1318
+ * (`bwrap`/`rg`/`socat`/`claude`) are installed. `main` passes the boot check;
1319
+ * absent (a plain createFetchHandler / tests) → omitted from `/health`.
1320
+ */
1321
+ preflight?: PreflightResult;
1297
1322
  },
1298
1323
  ): (req: Request, server?: { upgrade: (req: Request, opts: { data: TerminalWsData }) => boolean }) => Promise<Response> {
1299
1324
  // The per-channel turn-event SSE registry — subscribers of the live "watch it
@@ -1486,6 +1511,10 @@ export function createFetchHandler(
1486
1511
  // (`programmatic · idle|working|queued:N`) instead of `mcp_sessions` — a
1487
1512
  // programmatic agent has no live subscriber, so SSE/MCP counts don't describe it.
1488
1513
  if (url.pathname === "/health") {
1514
+ // Surface the boot dependency-preflight (agent#156) so the admin UI can show
1515
+ // that programmatic turns will fail until the missing deps are installed. Only
1516
+ // present when `main` passed the boot check (absent in a plain handler/tests).
1517
+ const preflight = opts?.preflight;
1489
1518
  return json({
1490
1519
  status: "ok",
1491
1520
  channels: [...channels.values()].map((c) => ({
@@ -1504,6 +1533,15 @@ export function createFetchHandler(
1504
1533
  status: s.state === "queued" ? `queued:${s.queued}` : s.state,
1505
1534
  };
1506
1535
  }),
1536
+ ...(preflight
1537
+ ? {
1538
+ dependencies: {
1539
+ ok: preflight.ok,
1540
+ // The binary names missing on PATH — what programmatic turns need installed.
1541
+ missing: preflight.missing.map((d) => d.bin),
1542
+ },
1543
+ }
1544
+ : {}),
1507
1545
  });
1508
1546
  }
1509
1547
 
@@ -1791,6 +1829,169 @@ export function createFetchHandler(
1791
1829
  }
1792
1830
  }
1793
1831
 
1832
+ // ---------------------------------------------------------------------
1833
+ // STEP-UP AUTH (PIN) — second factor for high-privilege actions (agent#80).
1834
+ //
1835
+ // The dangerous `agent:admin` actions (set credentials, open a terminal,
1836
+ // spawn a `filesystem: full` agent) require a step-up token IN ADDITION to
1837
+ // the `agent:admin` Bearer. This block is the PIN setup + exchange surface;
1838
+ // the gating lives at each dangerous endpoint (via `requireStepUp`).
1839
+ //
1840
+ // GET /api/step-up → { configured } — is a PIN set? (UI: setup vs prompt)
1841
+ // POST /api/step-up { pin } → validate PIN (rate-limited) → { stepUpToken, expires_at }
1842
+ // POST /api/step-up/pin { newPin, currentPin? } → set/rotate the PIN
1843
+ //
1844
+ // All `agent:admin`-gated (the operator's cookie-minted Bearer). The PIN is
1845
+ // hashed+salted server-side (step-up.ts); it is NEVER returned or logged.
1846
+ // Externally hub strips `/agent`, so these are `<hub>/agent/api/step-up`.
1847
+ // ---------------------------------------------------------------------
1848
+ if (url.pathname === "/api/step-up" && req.method === "GET") {
1849
+ const denied = await requireScope(req, url, SCOPE_ADMIN);
1850
+ if (denied) return denied;
1851
+ // Whether a PIN is configured — the UI branches setup-flow vs PIN-prompt.
1852
+ return json({ configured: isStepUpConfigured() });
1853
+ }
1854
+
1855
+ if (url.pathname === "/api/step-up" && req.method === "POST") {
1856
+ // Exchange: validate the PIN, then mint a short-lived step-up token. The
1857
+ // session must already hold `agent:admin` (this is a SECOND factor on top,
1858
+ // never a substitute — the token carries no scope of its own).
1859
+ let claims;
1860
+ try {
1861
+ const token = extractToken(req, url);
1862
+ if (!token) return json({ error: "unauthorized", message: "Bearer token required" }, 401);
1863
+ claims = await validateHubJwt(token);
1864
+ } catch (err) {
1865
+ return json(
1866
+ { error: "unauthorized", message: err instanceof Error ? err.message : "invalid token" },
1867
+ 401,
1868
+ );
1869
+ }
1870
+ if (!grantsScope(claims.scopes, SCOPE_ADMIN)) {
1871
+ return json(
1872
+ { error: "insufficient_scope", message: `requires ${SCOPE_ADMIN}`, granted: claims.scopes },
1873
+ 403,
1874
+ );
1875
+ }
1876
+ // No PIN configured yet — there's nothing to exchange. Tell the UI to run
1877
+ // its first-time setup (distinct from a wrong-PIN 401).
1878
+ if (!isStepUpConfigured()) {
1879
+ return json(
1880
+ { error: "step_up_not_configured", message: "set a step-up PIN first (POST /api/step-up/pin)" },
1881
+ 409,
1882
+ );
1883
+ }
1884
+ let body: { pin?: unknown };
1885
+ try {
1886
+ body = (await req.json()) as typeof body;
1887
+ } catch {
1888
+ return json({ error: "invalid JSON body" }, 400);
1889
+ }
1890
+ if (typeof body.pin !== "string" || body.pin.length === 0) {
1891
+ return json({ error: "body.pin (non-empty string) is required" }, 400);
1892
+ }
1893
+ // Rate-limit BEFORE the (expensive, brute-forceable) argon2 verify, keyed by
1894
+ // the operator subject — a stolen-cookie attacker can't grind the PIN. A
1895
+ // DENIED attempt returns 429 (the limiter does not count it again).
1896
+ const limited = stepUpLimiter.checkAndRecord(`step-up:${claims.sub}`);
1897
+ if (!limited.allowed) {
1898
+ return new Response(
1899
+ JSON.stringify({
1900
+ error: "rate_limited",
1901
+ message: "too many PIN attempts — wait before retrying",
1902
+ retry_after_seconds: limited.retryAfterSeconds,
1903
+ }),
1904
+ {
1905
+ status: 429,
1906
+ headers: {
1907
+ "content-type": "application/json",
1908
+ "retry-after": String(limited.retryAfterSeconds ?? 60),
1909
+ },
1910
+ },
1911
+ );
1912
+ }
1913
+ const ok = await verifyStepUpPin(body.pin);
1914
+ if (!ok) {
1915
+ // Wrong PIN — 401. The attempt already counted toward the lockout above.
1916
+ // Never echo the PIN back.
1917
+ return json({ error: "invalid_pin", message: "incorrect PIN" }, 401);
1918
+ }
1919
+ // Correct PIN — clear the attempt bucket (a fresh window for the next time)
1920
+ // and mint a reusable, short-TTL step-up token.
1921
+ stepUpLimiter.clear(`step-up:${claims.sub}`);
1922
+ const { token: stepUpToken, expiresAt } = mintStepUpToken();
1923
+ return json({ stepUpToken, expires_at: new Date(expiresAt).toISOString() });
1924
+ }
1925
+
1926
+ if (url.pathname === "/api/step-up/pin" && req.method === "POST") {
1927
+ // Set (first time) or rotate the step-up PIN. agent:admin-gated; if a PIN
1928
+ // already exists, the CURRENT PIN must be supplied + verified (rotation
1929
+ // needs the old PIN, so a hijacked session can't silently replace it).
1930
+ let claims;
1931
+ try {
1932
+ const token = extractToken(req, url);
1933
+ if (!token) return json({ error: "unauthorized", message: "Bearer token required" }, 401);
1934
+ claims = await validateHubJwt(token);
1935
+ } catch (err) {
1936
+ return json(
1937
+ { error: "unauthorized", message: err instanceof Error ? err.message : "invalid token" },
1938
+ 401,
1939
+ );
1940
+ }
1941
+ if (!grantsScope(claims.scopes, SCOPE_ADMIN)) {
1942
+ return json(
1943
+ { error: "insufficient_scope", message: `requires ${SCOPE_ADMIN}`, granted: claims.scopes },
1944
+ 403,
1945
+ );
1946
+ }
1947
+ let body: { newPin?: unknown; currentPin?: unknown };
1948
+ try {
1949
+ body = (await req.json()) as typeof body;
1950
+ } catch {
1951
+ return json({ error: "invalid JSON body" }, 400);
1952
+ }
1953
+ if (!isValidPinFormat(body.newPin)) {
1954
+ return json({ error: "body.newPin must be 4–12 digits" }, 400);
1955
+ }
1956
+ // Rotation: a PIN already exists → require + verify the current one (rate-limited).
1957
+ // SHARES the exchange bucket (same `step-up:<sub>` key) on purpose: both verify
1958
+ // the PIN, so an attacker can't get a fresh grind window by alternating endpoints.
1959
+ if (isStepUpConfigured()) {
1960
+ const limited = stepUpLimiter.checkAndRecord(`step-up:${claims.sub}`);
1961
+ if (!limited.allowed) {
1962
+ return new Response(
1963
+ JSON.stringify({
1964
+ error: "rate_limited",
1965
+ message: "too many PIN attempts — wait before retrying",
1966
+ retry_after_seconds: limited.retryAfterSeconds,
1967
+ }),
1968
+ {
1969
+ status: 429,
1970
+ headers: {
1971
+ "content-type": "application/json",
1972
+ "retry-after": String(limited.retryAfterSeconds ?? 60),
1973
+ },
1974
+ },
1975
+ );
1976
+ }
1977
+ if (typeof body.currentPin !== "string" || !(await verifyStepUpPin(body.currentPin))) {
1978
+ return json(
1979
+ { error: "invalid_pin", message: "the current PIN is required to change it" },
1980
+ 401,
1981
+ );
1982
+ }
1983
+ stepUpLimiter.clear(`step-up:${claims.sub}`);
1984
+ }
1985
+ try {
1986
+ await setStepUpPin(body.newPin);
1987
+ } catch (err) {
1988
+ if (err instanceof StepUpPinFormatError) return json({ error: err.message }, 400);
1989
+ return json({ error: `failed to set PIN: ${(err as Error).message}` }, 500);
1990
+ }
1991
+ // Echo back only the fact of the write — never the PIN.
1992
+ return json({ ok: true, configured: true });
1993
+ }
1994
+
1794
1995
  // ---------------------------------------------------------------------
1795
1996
  // Claude OAuth credential store (design §6) — the per-channel secret a
1796
1997
  // launched agent session runs on (`CLAUDE_CODE_OAUTH_TOKEN`). Same
@@ -1810,11 +2011,15 @@ export function createFetchHandler(
1810
2011
 
1811
2012
  if (req.method === "GET") {
1812
2013
  // Inspect WITHOUT leaking the secret: whether a default is set + which
1813
- // channels carry an override (names only).
2014
+ // channels carry an override (names only). A status read — no step-up.
1814
2015
  return json(describeClaudeCredentials(defaultStateDir()));
1815
2016
  }
1816
2017
 
1817
- // POST — set the default / operator-level token.
2018
+ // POST — set the default / operator-level token. STEP-UP required (agent#80):
2019
+ // setting a credential can exfiltrate the operator's Claude token.
2020
+ const step = requireStepUp(req, url);
2021
+ if (!step.ok) return step.response;
2022
+
1818
2023
  let credBody: { token?: unknown };
1819
2024
  try {
1820
2025
  credBody = (await req.json()) as typeof credBody;
@@ -1837,6 +2042,10 @@ export function createFetchHandler(
1837
2042
  if (credMatch && (req.method === "POST" || req.method === "DELETE")) {
1838
2043
  const denied = await requireScope(req, url, SCOPE_ADMIN);
1839
2044
  if (denied) return denied;
2045
+ // STEP-UP required (agent#80): both set + remove of a per-channel Claude
2046
+ // credential are high-privilege credential-store mutations.
2047
+ const step = requireStepUp(req, url);
2048
+ if (!step.ok) return step.response;
1840
2049
  const channel = decodeURIComponent(credMatch[1]!);
1841
2050
 
1842
2051
  if (req.method === "DELETE") {
@@ -1891,9 +2100,15 @@ export function createFetchHandler(
1891
2100
 
1892
2101
  if (req.method === "GET") {
1893
2102
  // Inspect WITHOUT leaking values: names per channel + the default layer.
2103
+ // A status read — no step-up.
1894
2104
  return json(describeChannelEnv(defaultStateDir()));
1895
2105
  }
1896
2106
 
2107
+ // STEP-UP required (agent#80): set/remove of an env secret (GH_TOKEN,
2108
+ // CLOUDFLARE_API_TOKEN, …) is a credential-store mutation.
2109
+ const step = requireStepUp(req, url);
2110
+ if (!step.ok) return step.response;
2111
+
1897
2112
  let envBody: { channel?: unknown; name?: unknown; value?: unknown };
1898
2113
  try {
1899
2114
  envBody = (await req.json()) as typeof envBody;
@@ -1987,6 +2202,15 @@ export function createFetchHandler(
1987
2202
  throw err;
1988
2203
  }
1989
2204
 
2205
+ // STEP-UP required (agent#80) ONLY for the dangerous filesystem case: a
2206
+ // `filesystem: "full"` agent runs UNSANDBOXED with read access to the whole
2207
+ // disk. Ordinary sandboxed (workspace-confined) spawns stay frictionless —
2208
+ // gate just the high-blast-radius case.
2209
+ if (spec.filesystem === "full") {
2210
+ const step = requireStepUp(req, url);
2211
+ if (!step.ok) return step.response;
2212
+ }
2213
+
1990
2214
  // CHANNEL EXCLUSION: a channel routes inbound to at most one agent. Refuse a
1991
2215
  // spawn for a DIFFERENT programmatic agent onto an already-occupied wake channel
1992
2216
  // (re-spawning the SAME name onto its OWN channel is the idempotent-replace path).
@@ -3030,6 +3254,14 @@ function main(): void {
3030
3254
  mkdirSync(STATE_DIR, { recursive: true });
3031
3255
  mkdirSync(INBOX_DIR, { recursive: true });
3032
3256
 
3257
+ // BOOT DEPENDENCY PREFLIGHT (agent#156). A fresh box can't run a programmatic
3258
+ // `claude -p` turn until bwrap/rg/socat + the claude CLI are on PATH — pre-#156
3259
+ // each surfaced only as a failed *turn*, one at a time. Check them ONCE at boot and
3260
+ // log a single clear warning (with the install one-liners) when any is missing. It's
3261
+ // advisory, never fatal: the daemon may run only attached-backend agents that need
3262
+ // none of these, so we warn + keep serving. The result is also surfaced on /health.
3263
+ const preflight = runBootPreflight();
3264
+
3033
3265
  // Verify the one MCP SDK internal our HTTP-MCP delivery accounting reads
3034
3266
  // (`_streamMapping['_GET_stream']`, see assertMcpSdkStreamContract). A screaming
3035
3267
  // boot error on SDK drift beats discovering it as silent message loss later.
@@ -3146,7 +3378,7 @@ function main(): void {
3146
3378
  buildInstantiateDeps(channels, registry, deliveryState, programmatic, attachedQueue),
3147
3379
  );
3148
3380
 
3149
- const fetchHandler = createFetchHandler(channels, registry, { deliveryState, programmatic, attachedQueue, turnEvents, jobStore, runner, agentDefs });
3381
+ const fetchHandler = createFetchHandler(channels, registry, { deliveryState, programmatic, attachedQueue, turnEvents, jobStore, runner, agentDefs, preflight });
3150
3382
  const server = Bun.serve<TerminalWsData, never>({
3151
3383
  port: PORT,
3152
3384
  hostname: "127.0.0.1",
@@ -0,0 +1,139 @@
1
+ /**
2
+ * Boot-time dependency PREFLIGHT (agent#156).
3
+ *
4
+ * A freshly-provisioned box can't run a programmatic `claude -p` turn until the
5
+ * sandbox deps (`bwrap`, `rg`, `socat`) AND the `claude` CLI are installed — but
6
+ * pre-#156 each missing piece surfaced ONLY as a failed *turn*, one at a time, so
7
+ * an operator discovered them serially (install bwrap → next turn fails on rg →
8
+ * install rg → next turn fails on claude → …).
9
+ *
10
+ * This lifts the check to DAEMON BOOT: resolve each required binary on PATH ONCE
11
+ * and log a single clear warning naming exactly what's missing + the one-liner to
12
+ * fix it. It is a WARNING, never a crash — the daemon may run only `attached`-backend
13
+ * agents (which don't spawn `claude -p` and need no sandbox/claude), so a missing
14
+ * dep means "programmatic turns will fail until …", not "the daemon can't start."
15
+ *
16
+ * Deliberately NOT a full doctor framework — a focused boot preflight + clear log is
17
+ * the whole of #156. (`spawn-deps.ts`'s turn-time check still stands as the last line
18
+ * of defence for a dep removed AFTER boot.)
19
+ */
20
+
21
+ /**
22
+ * One required external binary the programmatic backend needs on PATH, with the
23
+ * one-liner that installs it on a fresh Debian/Ubuntu box (the #156 reproduction).
24
+ */
25
+ interface RequiredDep {
26
+ /** The binary name resolved on PATH (`Bun.which`). */
27
+ bin: string;
28
+ /** Human label for the warning. */
29
+ label: string;
30
+ /** The install hint shown when it's missing. */
31
+ hint: string;
32
+ /**
33
+ * True when this dep is only required on LINUX. On macOS the sandbox uses Seatbelt
34
+ * (built in, no helper binaries), so the bubblewrap egress-proxy deps (`bwrap`,
35
+ * `socat`) aren't needed — flagging them on a Mac deploy (the documented preferred
36
+ * self-host path) would be a false-positive that trains operators to ignore the
37
+ * preflight. So they're checked on Linux only. (`rg` is NOT linux-only: the runtime's
38
+ * deny-path scan needs a real ripgrep on macOS too. `claude` is needed everywhere.)
39
+ */
40
+ linuxOnly?: boolean;
41
+ }
42
+
43
+ /**
44
+ * The deps a programmatic `claude -p` turn needs. `bwrap`/`socat` are the LINUX
45
+ * bubblewrap sandbox deps the runtime shells out to (bubblewrap is the containment,
46
+ * socat bridges the egress proxy) — not needed under macOS Seatbelt, so `linuxOnly`.
47
+ * `rg` (ripgrep) does the deny-path scan on EVERY platform (the macOS sandbox needs a
48
+ * real `rg` too). `claude` is the CLI the turn runs, required everywhere. The platform
49
+ * filter is applied in {@link checkProgrammaticDeps}.
50
+ */
51
+ export const REQUIRED_DEPS: readonly RequiredDep[] = [
52
+ { bin: "bwrap", label: "bubblewrap (bwrap)", hint: "apt install bubblewrap", linuxOnly: true },
53
+ { bin: "rg", label: "ripgrep (rg)", hint: "apt install ripgrep" },
54
+ { bin: "socat", label: "socat", hint: "apt install socat", linuxOnly: true },
55
+ {
56
+ bin: "claude",
57
+ label: "Claude Code CLI (claude)",
58
+ hint: "curl -fsSL https://claude.ai/install.sh | bash (native build — no node/npm needed)",
59
+ },
60
+ ] as const;
61
+
62
+ /** A resolver from binary name → absolute path (or null when not on PATH). Injectable for tests. */
63
+ export type WhichFn = (bin: string) => string | null;
64
+
65
+ /** The default resolver — Bun.which against the daemon's PATH. */
66
+ export const realWhich: WhichFn = (bin) => Bun.which(bin);
67
+
68
+ /** Which {@link REQUIRED_DEPS} apply on the given platform (drops `linuxOnly` deps off Linux). */
69
+ export function depsForPlatform(platform: NodeJS.Platform = process.platform): RequiredDep[] {
70
+ return REQUIRED_DEPS.filter((d) => !d.linuxOnly || platform === "linux");
71
+ }
72
+
73
+ /** The outcome of {@link checkProgrammaticDeps}: which required deps are missing + a ready-to-log warning. */
74
+ export interface PreflightResult {
75
+ /** The deps NOT resolvable on PATH (empty = all present). */
76
+ missing: RequiredDep[];
77
+ /** True when every required dep resolved (nothing to warn about). */
78
+ ok: boolean;
79
+ /**
80
+ * The formatted multi-line warning to log, or null when nothing is missing. Lists
81
+ * each missing dep + its install one-liner, framed as "programmatic turns will fail
82
+ * until …" (attached-backend agents are unaffected).
83
+ */
84
+ warning: string | null;
85
+ }
86
+
87
+ /**
88
+ * PURE check: resolve each platform-applicable {@link REQUIRED_DEPS} binary via `which`
89
+ * and build the missing-deps result + warning text. No I/O beyond the injected `which`;
90
+ * no logging (the caller logs). Cheap + idempotent — safe to call at boot. `platform` is
91
+ * injectable so a test can assert the macOS filter without running on a Mac.
92
+ */
93
+ export function checkProgrammaticDeps(
94
+ which: WhichFn = realWhich,
95
+ platform: NodeJS.Platform = process.platform,
96
+ ): PreflightResult {
97
+ const missing = depsForPlatform(platform).filter((d) => {
98
+ try {
99
+ return !which(d.bin);
100
+ } catch {
101
+ // A which() fault is treated as "can't confirm it's present" → report it missing
102
+ // (better a spurious advisory than silently swallowing a real gap).
103
+ return true;
104
+ }
105
+ });
106
+ if (missing.length === 0) return { missing: [], ok: true, warning: null };
107
+ const lines = missing.map((d) => ` - ${d.label}: ${d.hint}`);
108
+ const warning =
109
+ `parachute-agent: PREFLIGHT — ${missing.length} dependency/dependencies for programmatic ` +
110
+ `(claude -p) turns is/are NOT on PATH. Programmatic-backend turns will FAIL until installed ` +
111
+ `(attached-backend agents are unaffected):\n${lines.join("\n")}`;
112
+ return { missing, ok: false, warning };
113
+ }
114
+
115
+ /**
116
+ * Run the boot preflight: check the deps and LOG the warning once (via `console.warn`)
117
+ * when anything is missing. Returns the {@link PreflightResult} so the caller can also
118
+ * surface the missing-deps state elsewhere (e.g. `/health`). Never throws — the daemon
119
+ * keeps booting regardless.
120
+ */
121
+ export function runBootPreflight(
122
+ which: WhichFn = realWhich,
123
+ platform: NodeJS.Platform = process.platform,
124
+ ): PreflightResult {
125
+ let result: PreflightResult;
126
+ try {
127
+ result = checkProgrammaticDeps(which, platform);
128
+ } catch (err) {
129
+ // Defensive: the preflight must never break boot. An unexpected fault is reported
130
+ // HONESTLY (ok:false + the error in the warning) rather than a false "all clear" —
131
+ // but it's still non-fatal; the daemon boots and the turn-time check in
132
+ // spawn-deps.ts remains the real guard.
133
+ const msg = `parachute-agent: boot preflight errored (continuing, dependency state UNKNOWN): ${(err as Error).message}`;
134
+ console.error(msg);
135
+ return { missing: [], ok: false, warning: msg };
136
+ }
137
+ if (result.warning) console.warn(result.warning);
138
+ return result;
139
+ }
@@ -428,6 +428,22 @@ export function buildAgentChildEnv(
428
428
  }
429
429
  if (!out.PATH) out.PATH = "/usr/local/bin:/usr/bin:/bin";
430
430
 
431
+ // IS_SANDBOX=1 — signal to claude that it is running INSIDE a sandbox (agent#155).
432
+ // The programmatic turn always launches inside a bwrap/Seatbelt sandbox (that IS the
433
+ // containment), so `--dangerously-skip-permissions` is safe — but Claude Code REFUSES
434
+ // that flag under root/sudo ("cannot be used with root/sudo privileges for security
435
+ // reasons") UNLESS `IS_SANDBOX` is set, which makes EVERY turn error on a daemon that
436
+ // runs as root (e.g. the friends/team box). Setting it here makes the fix permanent +
437
+ // automatic (it was being worked around per-deploy via the env store, which is lost on
438
+ // reset). It defaults to "1" for every sandboxed turn but honors an explicit operator
439
+ // value from `channelEnv` (already laid down above) — so an operator who deliberately
440
+ // sets it can still override. NB: IS_SANDBOX is NOT in SANDBOX_ENV_ALLOWLIST and is not
441
+ // set by `seedAgentHome`, so it survives `mergeSandboxLaunchEnv` un-clobbered — it can
442
+ // never be reset to empty by the env-merge layering.
443
+ if (typeof out.IS_SANDBOX !== "string" || out.IS_SANDBOX.length === 0) {
444
+ out.IS_SANDBOX = "1";
445
+ }
446
+
431
447
  // The interactive subscription credential (design §6). Explicitly the ONLY
432
448
  // Claude auth var set; ANTHROPIC_API_KEY is intentionally absent. Set LAST so no
433
449
  // channel-injected var can ever override the session's managed auth.