@openparachute/agent 0.2.3-rc.6 → 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 +1 -1
- package/src/daemon.ts +30 -1
- package/src/preflight.ts +139 -0
- package/src/spawn-agent.ts +16 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@openparachute/agent",
|
|
3
|
-
"version": "0.2.3-rc.
|
|
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/daemon.ts
CHANGED
|
@@ -114,6 +114,7 @@ import {
|
|
|
114
114
|
import { TERMINAL_UI_HTML } from "./terminal-ui.ts";
|
|
115
115
|
import { serveTerminalAsset } from "./terminal-assets.ts";
|
|
116
116
|
import { isSpaPath, serveSpa, spaDistDir } from "./spa-serve.ts";
|
|
117
|
+
import { runBootPreflight, type PreflightResult } from "./preflight.ts";
|
|
117
118
|
import {
|
|
118
119
|
buildSpecFromBody,
|
|
119
120
|
setupProgrammaticSpawn,
|
|
@@ -1311,6 +1312,13 @@ export function createFetchHandler(
|
|
|
1311
1312
|
url: string;
|
|
1312
1313
|
tokenPresent: boolean;
|
|
1313
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;
|
|
1314
1322
|
},
|
|
1315
1323
|
): (req: Request, server?: { upgrade: (req: Request, opts: { data: TerminalWsData }) => boolean }) => Promise<Response> {
|
|
1316
1324
|
// The per-channel turn-event SSE registry — subscribers of the live "watch it
|
|
@@ -1503,6 +1511,10 @@ export function createFetchHandler(
|
|
|
1503
1511
|
// (`programmatic · idle|working|queued:N`) instead of `mcp_sessions` — a
|
|
1504
1512
|
// programmatic agent has no live subscriber, so SSE/MCP counts don't describe it.
|
|
1505
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;
|
|
1506
1518
|
return json({
|
|
1507
1519
|
status: "ok",
|
|
1508
1520
|
channels: [...channels.values()].map((c) => ({
|
|
@@ -1521,6 +1533,15 @@ export function createFetchHandler(
|
|
|
1521
1533
|
status: s.state === "queued" ? `queued:${s.queued}` : s.state,
|
|
1522
1534
|
};
|
|
1523
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
|
+
: {}),
|
|
1524
1545
|
});
|
|
1525
1546
|
}
|
|
1526
1547
|
|
|
@@ -3233,6 +3254,14 @@ function main(): void {
|
|
|
3233
3254
|
mkdirSync(STATE_DIR, { recursive: true });
|
|
3234
3255
|
mkdirSync(INBOX_DIR, { recursive: true });
|
|
3235
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
|
+
|
|
3236
3265
|
// Verify the one MCP SDK internal our HTTP-MCP delivery accounting reads
|
|
3237
3266
|
// (`_streamMapping['_GET_stream']`, see assertMcpSdkStreamContract). A screaming
|
|
3238
3267
|
// boot error on SDK drift beats discovering it as silent message loss later.
|
|
@@ -3349,7 +3378,7 @@ function main(): void {
|
|
|
3349
3378
|
buildInstantiateDeps(channels, registry, deliveryState, programmatic, attachedQueue),
|
|
3350
3379
|
);
|
|
3351
3380
|
|
|
3352
|
-
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 });
|
|
3353
3382
|
const server = Bun.serve<TerminalWsData, never>({
|
|
3354
3383
|
port: PORT,
|
|
3355
3384
|
hostname: "127.0.0.1",
|
package/src/preflight.ts
ADDED
|
@@ -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
|
+
}
|
package/src/spawn-agent.ts
CHANGED
|
@@ -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.
|