@openparachute/agent 0.2.3-rc.5 → 0.2.3-rc.6
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/agent-defs.ts +9 -0
- package/src/auth.ts +79 -0
- package/src/daemon.ts +205 -2
- package/src/step-up.ts +316 -0
- package/src/terminal-ui.ts +73 -0
- package/web/ui/dist/assets/index-CAQMmePW.js +60 -0
- package/web/ui/dist/assets/{index-tvKbxee4.css → index-Dhr5Kl_d.css} +1 -1
- package/web/ui/dist/index.html +2 -2
- package/web/ui/dist/assets/index-CqIi6rPT.js +0 -60
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.6",
|
|
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,
|
|
@@ -1190,6 +1201,12 @@ export async function authorizeTerminalUpgrade(
|
|
|
1190
1201
|
const denied = await requireScope(req, url, SCOPE_TERMINAL, true);
|
|
1191
1202
|
if (denied) return { ok: false, response: denied };
|
|
1192
1203
|
|
|
1204
|
+
// STEP-UP required (agent#80): a terminal is a raw host shell — the single most
|
|
1205
|
+
// dangerous capability. allowQueryParam: true so the WS presents the step-up
|
|
1206
|
+
// token as `?step_up=` (it can't set the `X-Step-Up-Token` header).
|
|
1207
|
+
const step = requireStepUp(req, url, true);
|
|
1208
|
+
if (!step.ok) return { ok: false, response: step.response };
|
|
1209
|
+
|
|
1193
1210
|
// tmux session name convention: `<name>-agent`. Attach a viewer pty to THIS
|
|
1194
1211
|
// session; the session itself is created by the spawn path.
|
|
1195
1212
|
const session = `${agentName}-agent`;
|
|
@@ -1791,6 +1808,169 @@ export function createFetchHandler(
|
|
|
1791
1808
|
}
|
|
1792
1809
|
}
|
|
1793
1810
|
|
|
1811
|
+
// ---------------------------------------------------------------------
|
|
1812
|
+
// STEP-UP AUTH (PIN) — second factor for high-privilege actions (agent#80).
|
|
1813
|
+
//
|
|
1814
|
+
// The dangerous `agent:admin` actions (set credentials, open a terminal,
|
|
1815
|
+
// spawn a `filesystem: full` agent) require a step-up token IN ADDITION to
|
|
1816
|
+
// the `agent:admin` Bearer. This block is the PIN setup + exchange surface;
|
|
1817
|
+
// the gating lives at each dangerous endpoint (via `requireStepUp`).
|
|
1818
|
+
//
|
|
1819
|
+
// GET /api/step-up → { configured } — is a PIN set? (UI: setup vs prompt)
|
|
1820
|
+
// POST /api/step-up { pin } → validate PIN (rate-limited) → { stepUpToken, expires_at }
|
|
1821
|
+
// POST /api/step-up/pin { newPin, currentPin? } → set/rotate the PIN
|
|
1822
|
+
//
|
|
1823
|
+
// All `agent:admin`-gated (the operator's cookie-minted Bearer). The PIN is
|
|
1824
|
+
// hashed+salted server-side (step-up.ts); it is NEVER returned or logged.
|
|
1825
|
+
// Externally hub strips `/agent`, so these are `<hub>/agent/api/step-up`.
|
|
1826
|
+
// ---------------------------------------------------------------------
|
|
1827
|
+
if (url.pathname === "/api/step-up" && req.method === "GET") {
|
|
1828
|
+
const denied = await requireScope(req, url, SCOPE_ADMIN);
|
|
1829
|
+
if (denied) return denied;
|
|
1830
|
+
// Whether a PIN is configured — the UI branches setup-flow vs PIN-prompt.
|
|
1831
|
+
return json({ configured: isStepUpConfigured() });
|
|
1832
|
+
}
|
|
1833
|
+
|
|
1834
|
+
if (url.pathname === "/api/step-up" && req.method === "POST") {
|
|
1835
|
+
// Exchange: validate the PIN, then mint a short-lived step-up token. The
|
|
1836
|
+
// session must already hold `agent:admin` (this is a SECOND factor on top,
|
|
1837
|
+
// never a substitute — the token carries no scope of its own).
|
|
1838
|
+
let claims;
|
|
1839
|
+
try {
|
|
1840
|
+
const token = extractToken(req, url);
|
|
1841
|
+
if (!token) return json({ error: "unauthorized", message: "Bearer token required" }, 401);
|
|
1842
|
+
claims = await validateHubJwt(token);
|
|
1843
|
+
} catch (err) {
|
|
1844
|
+
return json(
|
|
1845
|
+
{ error: "unauthorized", message: err instanceof Error ? err.message : "invalid token" },
|
|
1846
|
+
401,
|
|
1847
|
+
);
|
|
1848
|
+
}
|
|
1849
|
+
if (!grantsScope(claims.scopes, SCOPE_ADMIN)) {
|
|
1850
|
+
return json(
|
|
1851
|
+
{ error: "insufficient_scope", message: `requires ${SCOPE_ADMIN}`, granted: claims.scopes },
|
|
1852
|
+
403,
|
|
1853
|
+
);
|
|
1854
|
+
}
|
|
1855
|
+
// No PIN configured yet — there's nothing to exchange. Tell the UI to run
|
|
1856
|
+
// its first-time setup (distinct from a wrong-PIN 401).
|
|
1857
|
+
if (!isStepUpConfigured()) {
|
|
1858
|
+
return json(
|
|
1859
|
+
{ error: "step_up_not_configured", message: "set a step-up PIN first (POST /api/step-up/pin)" },
|
|
1860
|
+
409,
|
|
1861
|
+
);
|
|
1862
|
+
}
|
|
1863
|
+
let body: { pin?: unknown };
|
|
1864
|
+
try {
|
|
1865
|
+
body = (await req.json()) as typeof body;
|
|
1866
|
+
} catch {
|
|
1867
|
+
return json({ error: "invalid JSON body" }, 400);
|
|
1868
|
+
}
|
|
1869
|
+
if (typeof body.pin !== "string" || body.pin.length === 0) {
|
|
1870
|
+
return json({ error: "body.pin (non-empty string) is required" }, 400);
|
|
1871
|
+
}
|
|
1872
|
+
// Rate-limit BEFORE the (expensive, brute-forceable) argon2 verify, keyed by
|
|
1873
|
+
// the operator subject — a stolen-cookie attacker can't grind the PIN. A
|
|
1874
|
+
// DENIED attempt returns 429 (the limiter does not count it again).
|
|
1875
|
+
const limited = stepUpLimiter.checkAndRecord(`step-up:${claims.sub}`);
|
|
1876
|
+
if (!limited.allowed) {
|
|
1877
|
+
return new Response(
|
|
1878
|
+
JSON.stringify({
|
|
1879
|
+
error: "rate_limited",
|
|
1880
|
+
message: "too many PIN attempts — wait before retrying",
|
|
1881
|
+
retry_after_seconds: limited.retryAfterSeconds,
|
|
1882
|
+
}),
|
|
1883
|
+
{
|
|
1884
|
+
status: 429,
|
|
1885
|
+
headers: {
|
|
1886
|
+
"content-type": "application/json",
|
|
1887
|
+
"retry-after": String(limited.retryAfterSeconds ?? 60),
|
|
1888
|
+
},
|
|
1889
|
+
},
|
|
1890
|
+
);
|
|
1891
|
+
}
|
|
1892
|
+
const ok = await verifyStepUpPin(body.pin);
|
|
1893
|
+
if (!ok) {
|
|
1894
|
+
// Wrong PIN — 401. The attempt already counted toward the lockout above.
|
|
1895
|
+
// Never echo the PIN back.
|
|
1896
|
+
return json({ error: "invalid_pin", message: "incorrect PIN" }, 401);
|
|
1897
|
+
}
|
|
1898
|
+
// Correct PIN — clear the attempt bucket (a fresh window for the next time)
|
|
1899
|
+
// and mint a reusable, short-TTL step-up token.
|
|
1900
|
+
stepUpLimiter.clear(`step-up:${claims.sub}`);
|
|
1901
|
+
const { token: stepUpToken, expiresAt } = mintStepUpToken();
|
|
1902
|
+
return json({ stepUpToken, expires_at: new Date(expiresAt).toISOString() });
|
|
1903
|
+
}
|
|
1904
|
+
|
|
1905
|
+
if (url.pathname === "/api/step-up/pin" && req.method === "POST") {
|
|
1906
|
+
// Set (first time) or rotate the step-up PIN. agent:admin-gated; if a PIN
|
|
1907
|
+
// already exists, the CURRENT PIN must be supplied + verified (rotation
|
|
1908
|
+
// needs the old PIN, so a hijacked session can't silently replace it).
|
|
1909
|
+
let claims;
|
|
1910
|
+
try {
|
|
1911
|
+
const token = extractToken(req, url);
|
|
1912
|
+
if (!token) return json({ error: "unauthorized", message: "Bearer token required" }, 401);
|
|
1913
|
+
claims = await validateHubJwt(token);
|
|
1914
|
+
} catch (err) {
|
|
1915
|
+
return json(
|
|
1916
|
+
{ error: "unauthorized", message: err instanceof Error ? err.message : "invalid token" },
|
|
1917
|
+
401,
|
|
1918
|
+
);
|
|
1919
|
+
}
|
|
1920
|
+
if (!grantsScope(claims.scopes, SCOPE_ADMIN)) {
|
|
1921
|
+
return json(
|
|
1922
|
+
{ error: "insufficient_scope", message: `requires ${SCOPE_ADMIN}`, granted: claims.scopes },
|
|
1923
|
+
403,
|
|
1924
|
+
);
|
|
1925
|
+
}
|
|
1926
|
+
let body: { newPin?: unknown; currentPin?: unknown };
|
|
1927
|
+
try {
|
|
1928
|
+
body = (await req.json()) as typeof body;
|
|
1929
|
+
} catch {
|
|
1930
|
+
return json({ error: "invalid JSON body" }, 400);
|
|
1931
|
+
}
|
|
1932
|
+
if (!isValidPinFormat(body.newPin)) {
|
|
1933
|
+
return json({ error: "body.newPin must be 4–12 digits" }, 400);
|
|
1934
|
+
}
|
|
1935
|
+
// Rotation: a PIN already exists → require + verify the current one (rate-limited).
|
|
1936
|
+
// SHARES the exchange bucket (same `step-up:<sub>` key) on purpose: both verify
|
|
1937
|
+
// the PIN, so an attacker can't get a fresh grind window by alternating endpoints.
|
|
1938
|
+
if (isStepUpConfigured()) {
|
|
1939
|
+
const limited = stepUpLimiter.checkAndRecord(`step-up:${claims.sub}`);
|
|
1940
|
+
if (!limited.allowed) {
|
|
1941
|
+
return new Response(
|
|
1942
|
+
JSON.stringify({
|
|
1943
|
+
error: "rate_limited",
|
|
1944
|
+
message: "too many PIN attempts — wait before retrying",
|
|
1945
|
+
retry_after_seconds: limited.retryAfterSeconds,
|
|
1946
|
+
}),
|
|
1947
|
+
{
|
|
1948
|
+
status: 429,
|
|
1949
|
+
headers: {
|
|
1950
|
+
"content-type": "application/json",
|
|
1951
|
+
"retry-after": String(limited.retryAfterSeconds ?? 60),
|
|
1952
|
+
},
|
|
1953
|
+
},
|
|
1954
|
+
);
|
|
1955
|
+
}
|
|
1956
|
+
if (typeof body.currentPin !== "string" || !(await verifyStepUpPin(body.currentPin))) {
|
|
1957
|
+
return json(
|
|
1958
|
+
{ error: "invalid_pin", message: "the current PIN is required to change it" },
|
|
1959
|
+
401,
|
|
1960
|
+
);
|
|
1961
|
+
}
|
|
1962
|
+
stepUpLimiter.clear(`step-up:${claims.sub}`);
|
|
1963
|
+
}
|
|
1964
|
+
try {
|
|
1965
|
+
await setStepUpPin(body.newPin);
|
|
1966
|
+
} catch (err) {
|
|
1967
|
+
if (err instanceof StepUpPinFormatError) return json({ error: err.message }, 400);
|
|
1968
|
+
return json({ error: `failed to set PIN: ${(err as Error).message}` }, 500);
|
|
1969
|
+
}
|
|
1970
|
+
// Echo back only the fact of the write — never the PIN.
|
|
1971
|
+
return json({ ok: true, configured: true });
|
|
1972
|
+
}
|
|
1973
|
+
|
|
1794
1974
|
// ---------------------------------------------------------------------
|
|
1795
1975
|
// Claude OAuth credential store (design §6) — the per-channel secret a
|
|
1796
1976
|
// launched agent session runs on (`CLAUDE_CODE_OAUTH_TOKEN`). Same
|
|
@@ -1810,11 +1990,15 @@ export function createFetchHandler(
|
|
|
1810
1990
|
|
|
1811
1991
|
if (req.method === "GET") {
|
|
1812
1992
|
// Inspect WITHOUT leaking the secret: whether a default is set + which
|
|
1813
|
-
// channels carry an override (names only).
|
|
1993
|
+
// channels carry an override (names only). A status read — no step-up.
|
|
1814
1994
|
return json(describeClaudeCredentials(defaultStateDir()));
|
|
1815
1995
|
}
|
|
1816
1996
|
|
|
1817
|
-
// POST — set the default / operator-level token.
|
|
1997
|
+
// POST — set the default / operator-level token. STEP-UP required (agent#80):
|
|
1998
|
+
// setting a credential can exfiltrate the operator's Claude token.
|
|
1999
|
+
const step = requireStepUp(req, url);
|
|
2000
|
+
if (!step.ok) return step.response;
|
|
2001
|
+
|
|
1818
2002
|
let credBody: { token?: unknown };
|
|
1819
2003
|
try {
|
|
1820
2004
|
credBody = (await req.json()) as typeof credBody;
|
|
@@ -1837,6 +2021,10 @@ export function createFetchHandler(
|
|
|
1837
2021
|
if (credMatch && (req.method === "POST" || req.method === "DELETE")) {
|
|
1838
2022
|
const denied = await requireScope(req, url, SCOPE_ADMIN);
|
|
1839
2023
|
if (denied) return denied;
|
|
2024
|
+
// STEP-UP required (agent#80): both set + remove of a per-channel Claude
|
|
2025
|
+
// credential are high-privilege credential-store mutations.
|
|
2026
|
+
const step = requireStepUp(req, url);
|
|
2027
|
+
if (!step.ok) return step.response;
|
|
1840
2028
|
const channel = decodeURIComponent(credMatch[1]!);
|
|
1841
2029
|
|
|
1842
2030
|
if (req.method === "DELETE") {
|
|
@@ -1891,9 +2079,15 @@ export function createFetchHandler(
|
|
|
1891
2079
|
|
|
1892
2080
|
if (req.method === "GET") {
|
|
1893
2081
|
// Inspect WITHOUT leaking values: names per channel + the default layer.
|
|
2082
|
+
// A status read — no step-up.
|
|
1894
2083
|
return json(describeChannelEnv(defaultStateDir()));
|
|
1895
2084
|
}
|
|
1896
2085
|
|
|
2086
|
+
// STEP-UP required (agent#80): set/remove of an env secret (GH_TOKEN,
|
|
2087
|
+
// CLOUDFLARE_API_TOKEN, …) is a credential-store mutation.
|
|
2088
|
+
const step = requireStepUp(req, url);
|
|
2089
|
+
if (!step.ok) return step.response;
|
|
2090
|
+
|
|
1897
2091
|
let envBody: { channel?: unknown; name?: unknown; value?: unknown };
|
|
1898
2092
|
try {
|
|
1899
2093
|
envBody = (await req.json()) as typeof envBody;
|
|
@@ -1987,6 +2181,15 @@ export function createFetchHandler(
|
|
|
1987
2181
|
throw err;
|
|
1988
2182
|
}
|
|
1989
2183
|
|
|
2184
|
+
// STEP-UP required (agent#80) ONLY for the dangerous filesystem case: a
|
|
2185
|
+
// `filesystem: "full"` agent runs UNSANDBOXED with read access to the whole
|
|
2186
|
+
// disk. Ordinary sandboxed (workspace-confined) spawns stay frictionless —
|
|
2187
|
+
// gate just the high-blast-radius case.
|
|
2188
|
+
if (spec.filesystem === "full") {
|
|
2189
|
+
const step = requireStepUp(req, url);
|
|
2190
|
+
if (!step.ok) return step.response;
|
|
2191
|
+
}
|
|
2192
|
+
|
|
1990
2193
|
// CHANNEL EXCLUSION: a channel routes inbound to at most one agent. Refuse a
|
|
1991
2194
|
// spawn for a DIFFERENT programmatic agent onto an already-occupied wake channel
|
|
1992
2195
|
// (re-spawning the SAME name onto its OWN channel is the idempotent-replace path).
|