@yagni-app/code 1.0.6 → 1.0.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/README.md +84 -46
- package/dist/claudeCompat.d.ts +59 -0
- package/dist/claudeCompat.js +109 -2
- package/dist/claudePlugins.d.ts +45 -5
- package/dist/claudePlugins.js +129 -21
- package/dist/cli.js +16 -4
- package/dist/doctor.d.ts +21 -8
- package/dist/doctor.js +53 -28
- package/dist/extension/condensedTools.d.ts +12 -1
- package/dist/extension/condensedTools.js +17 -9
- package/dist/extension/index.d.ts +13 -0
- package/dist/extension/index.js +230 -43
- package/dist/extension/mcp/cliConfig.d.ts +1 -1
- package/dist/extension/mcp/cliConfig.js +1 -1
- package/dist/extension/mcp/config.d.ts +24 -2
- package/dist/extension/mcp/config.js +75 -3
- package/dist/extension/mcp/manager.d.ts +3 -1
- package/dist/extension/mcp/manager.js +2 -2
- package/dist/extension/mcp/panel.d.ts +0 -1
- package/dist/extension/mcp/panel.js +13 -3
- package/dist/extension/mcp/startup.js +8 -6
- package/dist/extension/permission/gate.d.ts +97 -2
- package/dist/extension/permission/gate.js +375 -26
- package/dist/extension/permissionRules/bashFileArgs.d.ts +39 -0
- package/dist/extension/permissionRules/bashFileArgs.js +236 -0
- package/dist/extension/permissionRules/engine.d.ts +50 -0
- package/dist/extension/permissionRules/engine.js +238 -0
- package/dist/extension/permissionRules/loadConfig.d.ts +53 -0
- package/dist/extension/permissionRules/loadConfig.js +90 -0
- package/dist/extension/permissionRules/parser.d.ts +38 -0
- package/dist/extension/permissionRules/parser.js +136 -0
- package/dist/extension/permissionRules/pathRules.d.ts +58 -0
- package/dist/extension/permissionRules/pathRules.js +120 -0
- package/dist/extension/permissionRules/shellRules.d.ts +52 -0
- package/dist/extension/permissionRules/shellRules.js +221 -0
- package/dist/extension/pipeline/invocation.d.ts +3 -6
- package/dist/extension/pipeline/invocation.js +3 -6
- package/dist/extension/pipeline/runner.d.ts +0 -1
- package/dist/extension/pipeline/runner.js +6 -14
- package/dist/extension/plugins/inventory.d.ts +88 -0
- package/dist/extension/plugins/inventory.js +144 -0
- package/dist/extension/plugins/panel.d.ts +45 -0
- package/dist/extension/plugins/panel.js +293 -0
- package/dist/extension/sandbox/bash.d.ts +99 -0
- package/dist/extension/sandbox/bash.js +190 -0
- package/dist/extension/sandbox/config.d.ts +114 -0
- package/dist/extension/sandbox/config.js +366 -0
- package/dist/extension/sandbox/manager.d.ts +98 -0
- package/dist/extension/sandbox/manager.js +216 -0
- package/dist/extension/sandbox/panel.d.ts +111 -0
- package/dist/extension/sandbox/panel.js +342 -0
- package/dist/extension/sandbox/session.d.ts +85 -0
- package/dist/extension/sandbox/session.js +775 -0
- package/dist/extension/telemetry/attrs.d.ts +96 -0
- package/dist/extension/telemetry/attrs.js +149 -0
- package/dist/extension/telemetry/config.d.ts +99 -0
- package/dist/extension/telemetry/config.js +193 -0
- package/dist/extension/telemetry/index.d.ts +7 -0
- package/dist/extension/telemetry/index.js +7 -0
- package/dist/extension/telemetry/probe.d.ts +29 -0
- package/dist/extension/telemetry/probe.js +122 -0
- package/dist/extension/telemetry/register.d.ts +40 -0
- package/dist/extension/telemetry/register.js +192 -0
- package/dist/extension/telemetry/sdk.d.ts +63 -0
- package/dist/extension/telemetry/sdk.js +207 -0
- package/dist/extension/telemetry/tracker.d.ts +131 -0
- package/dist/extension/telemetry/tracker.js +551 -0
- package/dist/extension/vendor/IGNORE-LICENSE-MIT +21 -0
- package/dist/extension/vendor/ignore.d.ts +86 -0
- package/dist/extension/vendor/ignore.js +788 -0
- package/dist/goHeadless.d.ts +1 -1
- package/dist/goHeadless.js +2 -2
- package/dist/launch.d.ts +4 -3
- package/dist/launch.js +7 -4
- package/dist/mcpCommand.d.ts +10 -1
- package/dist/mcpCommand.js +42 -10
- package/dist/otel.d.ts +67 -90
- package/dist/otel.js +152 -195
- package/dist/paths.d.ts +13 -0
- package/dist/paths.js +18 -0
- package/dist/pluginCommand.d.ts +43 -0
- package/dist/pluginCommand.js +499 -0
- package/dist/pluginStore.d.ts +170 -0
- package/dist/pluginStore.js +554 -0
- package/package.json +19 -3
|
@@ -0,0 +1,775 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sandbox session wiring: owns the per-session YagniSandboxManager
|
|
3
|
+
* and hooks it into pi — bash tool re-registration with the sandbox
|
|
4
|
+
* spawnHook, user_bash (! commands) routing, session lifecycle, and the
|
|
5
|
+
* network ask flow (interactive prompt in TUI/RPC, fail-closed in
|
|
6
|
+
* print/headless).
|
|
7
|
+
*
|
|
8
|
+
* Registration model: the sandbox re-registers the bash tool ONLY when
|
|
9
|
+
* sandbox.enabled is true in the loaded settings — default-off sessions keep
|
|
10
|
+
* pi's stock bash byte-identical (a duplicate tool name is a hard 400 on
|
|
11
|
+
* some providers). /sandbox (M5) toggles by rewriting settings and asking
|
|
12
|
+
* for a session restart, the same way Claude persists the toggle.
|
|
13
|
+
*/
|
|
14
|
+
import { spawn } from "node:child_process";
|
|
15
|
+
import { existsSync, lstatSync, mkdirSync, readFileSync, renameSync, writeFileSync, chmodSync } from "node:fs";
|
|
16
|
+
import { dirname, join } from "node:path";
|
|
17
|
+
import { Type } from "typebox";
|
|
18
|
+
import { Key } from "@earendil-works/pi-tui";
|
|
19
|
+
import { createBashToolDefinition, getShellConfig, SettingsManager, } from "@earendil-works/pi-coding-agent";
|
|
20
|
+
import { logEvent } from "../errorSink.js";
|
|
21
|
+
import { codeStateHome } from "../stateHome.js";
|
|
22
|
+
import { isDebug } from "../diagnostics.js";
|
|
23
|
+
import { loadSandboxSettings } from "./config.js";
|
|
24
|
+
import { annotateCommandOutput, makeSandboxSpawnHook, preWrappedCommand, shouldUseSandbox, shouldUseSandboxForUserCommand, } from "./bash.js";
|
|
25
|
+
import { YagniSandboxManager } from "./manager.js";
|
|
26
|
+
import { SandboxPanel, buildPanelState } from "./panel.js";
|
|
27
|
+
/**
|
|
28
|
+
* Build the sandbox bash composition: given ANY stock bash definition (pi's
|
|
29
|
+
* own or condensedTools'), return the sandbox-aware version — schema gains
|
|
30
|
+
* dangerouslyDisableSandbox, execute wraps via the manager when the decision
|
|
31
|
+
* says so, and EPERM output is annotated. Identity when the sandbox is
|
|
32
|
+
* disabled at load time (default-off sessions stay byte-identical).
|
|
33
|
+
*
|
|
34
|
+
* Exported so condensedTools consumes it through its wrapBash seam — the
|
|
35
|
+
* M4 e2e lesson: condensed registers bash LAST, so a competing registration
|
|
36
|
+
* here would be overwritten and the sandbox would silently never reach
|
|
37
|
+
* model-driven tool calls.
|
|
38
|
+
*/
|
|
39
|
+
export function makeBashComposition(manager, settings, cwd) {
|
|
40
|
+
return (def) => {
|
|
41
|
+
if (!settings().enabled)
|
|
42
|
+
return def;
|
|
43
|
+
const userShellPath = SettingsManager.create(cwd).getShellPath();
|
|
44
|
+
const { shell: binShell } = getShellConfig(userShellPath);
|
|
45
|
+
const spawnHook = makeSandboxSpawnHook(manager);
|
|
46
|
+
const sandboxBash = createBashToolDefinition(cwd, {
|
|
47
|
+
shellPath: userShellPath,
|
|
48
|
+
spawnHook,
|
|
49
|
+
});
|
|
50
|
+
const schema = Type.Object({
|
|
51
|
+
command: Type.String({ description: "The bash command to execute" }),
|
|
52
|
+
timeout: Type.Optional(Type.Number({ description: "Optional timeout in seconds" })),
|
|
53
|
+
dangerouslyDisableSandbox: Type.Optional(Type.Boolean({
|
|
54
|
+
description: "Set true ONLY when you have evidence this exact command failed due to a sandbox restriction " +
|
|
55
|
+
'("Operation not permitted", a denied path/host, unix-socket errors). The retry still goes ' +
|
|
56
|
+
"through the normal permission flow. Default false; prefer working within the sandbox.",
|
|
57
|
+
})),
|
|
58
|
+
});
|
|
59
|
+
// Claude's getSimpleSandboxSection pattern: the model sees the RESOLVED
|
|
60
|
+
// restrictions inline, plus the retry protocol. Without this the model
|
|
61
|
+
// cannot distinguish sandbox denials from ordinary failures.
|
|
62
|
+
const s = settings();
|
|
63
|
+
const restrictions = [];
|
|
64
|
+
const allowed = s.network?.allowedDomains ?? [];
|
|
65
|
+
restrictions.push(allowed.length > 0
|
|
66
|
+
? `Network: only these domains (wildcards ok): ${allowed.join(", ")}`
|
|
67
|
+
: "Network: no domains pre-allowed — the first contact to each host prompts the user");
|
|
68
|
+
restrictions.push(`Filesystem writes: working directory, /tmp, and paths granted by Edit(...) allow rules`);
|
|
69
|
+
if (s.filesystem?.denyWrite?.length)
|
|
70
|
+
restrictions.push(`Denied writes: ${s.filesystem.denyWrite.join(", ")}`);
|
|
71
|
+
const strictNote = s.allowUnsandboxedCommands === false
|
|
72
|
+
? "All commands MUST run sandboxed — dangerouslyDisableSandbox is disabled by policy."
|
|
73
|
+
: "Default to running commands in the sandbox. Set dangerouslyDisableSandbox: true ONLY with evidence of a sandbox-caused failure (\"Operation not permitted\", denied path/host, unix-socket errors) — the retry still goes through the normal permission flow. The user can adjust restrictions with /sandbox.";
|
|
74
|
+
const wrapped = {
|
|
75
|
+
...def,
|
|
76
|
+
description: `${def.description}\n\n## Command sandbox\nCommands run inside an OS sandbox: ${restrictions.join("; ")}. ${strictNote}`,
|
|
77
|
+
parameters: schema,
|
|
78
|
+
async execute(id, params, signal, onUpdate, ctx) {
|
|
79
|
+
const input = params;
|
|
80
|
+
const s = settings();
|
|
81
|
+
const useSandbox = shouldUseSandbox(input, manager, s);
|
|
82
|
+
logEvent({
|
|
83
|
+
source: "sandbox",
|
|
84
|
+
level: isDebug() ? "debug" : "info",
|
|
85
|
+
event: "tool_execute_decision",
|
|
86
|
+
fields: { useSandbox, initialized: manager.initialized, hasCommand: Boolean(input.command) },
|
|
87
|
+
});
|
|
88
|
+
if (!useSandbox) {
|
|
89
|
+
return def.execute(id, params, signal, onUpdate, ctx);
|
|
90
|
+
}
|
|
91
|
+
const execParams = { ...input, command: await preWrappedCommand(manager, input.command, binShell) };
|
|
92
|
+
let result;
|
|
93
|
+
try {
|
|
94
|
+
result = await sandboxBash.execute(id, execParams, signal, onUpdate, ctx);
|
|
95
|
+
}
|
|
96
|
+
catch (err) {
|
|
97
|
+
if (err instanceof Error && err.message.includes("Operation not permitted")) {
|
|
98
|
+
const annotated = annotateCommandOutput(manager, input.command, err.message);
|
|
99
|
+
throw new Error(annotated);
|
|
100
|
+
}
|
|
101
|
+
throw err;
|
|
102
|
+
}
|
|
103
|
+
finally {
|
|
104
|
+
manager.cleanupAfterCommand();
|
|
105
|
+
}
|
|
106
|
+
if (result?.content) {
|
|
107
|
+
const text = result.content.map((c) => (c.type === "text" ? c.text : "")).join("\n");
|
|
108
|
+
if (text.includes("Operation not permitted")) {
|
|
109
|
+
const annotated = annotateCommandOutput(manager, input.command, text);
|
|
110
|
+
result = { ...result, content: [{ type: "text", text: annotated }] };
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return result;
|
|
114
|
+
},
|
|
115
|
+
};
|
|
116
|
+
return wrapped;
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* Register the sandbox surfaces on the ExtensionAPI. Returns the session
|
|
121
|
+
* handle (manager + accessors) or null when the platform can never run the
|
|
122
|
+
* sandbox (no-op registration, stock bash stays).
|
|
123
|
+
*/
|
|
124
|
+
export function registerSandbox(pi, opts) {
|
|
125
|
+
const platform = process.platform;
|
|
126
|
+
if (platform !== "darwin" && platform !== "linux")
|
|
127
|
+
return null;
|
|
128
|
+
const load = () => loadSandboxSettings({
|
|
129
|
+
cwd: opts.cwd,
|
|
130
|
+
env: opts.env,
|
|
131
|
+
userHome: opts.userHome,
|
|
132
|
+
stateHomeOverride: opts.stateHomeOverride,
|
|
133
|
+
}).settings;
|
|
134
|
+
let currentSettings = load();
|
|
135
|
+
let rules = [];
|
|
136
|
+
// Optional-chained: some harnesses/mocks don't implement flag APIs; the
|
|
137
|
+
// flag simply reads as unset there. Real pi registers it normally.
|
|
138
|
+
pi.registerFlag?.("no-sandbox", {
|
|
139
|
+
description: "Disable OS-level sandboxing for bash commands (this session)",
|
|
140
|
+
type: "boolean",
|
|
141
|
+
default: false,
|
|
142
|
+
});
|
|
143
|
+
// Read LAZILY (per session_start), never at registration: pi applies
|
|
144
|
+
// extension flag values AFTER extension load (applyExtensionFlagValues in
|
|
145
|
+
// agent-session-services), so an eager read at registration time always
|
|
146
|
+
// sees unset — the flag would be silently dead.
|
|
147
|
+
const noSandboxFlag = () => pi.getFlag?.("no-sandbox") === true;
|
|
148
|
+
/** Session-level toggle state: null = follow config; false = forced off. */
|
|
149
|
+
let sessionToggle = null;
|
|
150
|
+
// The session's RESOLVED state home (override-aware) — every persist
|
|
151
|
+
// writes here, never a raw codeStateHome(process.env) that would ignore
|
|
152
|
+
// isolation (the test-leak lesson).
|
|
153
|
+
const resolvedStateHome = opts.stateHomeOverride ?? codeStateHome(null, opts.env, opts.userHome);
|
|
154
|
+
const manager = opts.makeManager
|
|
155
|
+
? opts.makeManager({
|
|
156
|
+
cwd: opts.cwd,
|
|
157
|
+
env: opts.env,
|
|
158
|
+
userHome: opts.userHome,
|
|
159
|
+
stateHomeOverride: opts.stateHomeOverride,
|
|
160
|
+
projectRoot: opts.projectRoot,
|
|
161
|
+
rgPath: opts.rgPath,
|
|
162
|
+
})
|
|
163
|
+
: new YagniSandboxManager({
|
|
164
|
+
cwd: opts.cwd,
|
|
165
|
+
env: opts.env,
|
|
166
|
+
userHome: opts.userHome,
|
|
167
|
+
stateHomeOverride: opts.stateHomeOverride,
|
|
168
|
+
projectRoot: opts.projectRoot,
|
|
169
|
+
rgPath: opts.rgPath,
|
|
170
|
+
});
|
|
171
|
+
// The network ask fires from INSIDE a bash execution (srt's proxy at
|
|
172
|
+
// connect time) — no event ctx in scope. The UI bridge is captured from
|
|
173
|
+
// the most recent tool_call / session_start ctx (same bridge all session)
|
|
174
|
+
// and held here; RPC dialogs and TUI prompts both work through it.
|
|
175
|
+
let uiBridge = null;
|
|
176
|
+
const captureUi = (ctx) => {
|
|
177
|
+
if (ctx?.ui)
|
|
178
|
+
uiBridge = ctx;
|
|
179
|
+
};
|
|
180
|
+
const sessionDomainGrants = new Set();
|
|
181
|
+
const askHandler = async (host) => {
|
|
182
|
+
if (sessionDomainGrants.has(host))
|
|
183
|
+
return true;
|
|
184
|
+
const ui = uiBridge;
|
|
185
|
+
if (!ui?.hasUI)
|
|
186
|
+
return false; // headless: fail closed (never grant silently)
|
|
187
|
+
if (ui.signal?.aborted)
|
|
188
|
+
return false;
|
|
189
|
+
// Claude-parity grant options. Session grant is memory-only and
|
|
190
|
+
// agent-invisible; project/user write the sandbox config file.
|
|
191
|
+
const dialogOpts = {
|
|
192
|
+
title: `Sandbox: allow network access to ${host}?`,
|
|
193
|
+
timeout: 600_000,
|
|
194
|
+
...(ui.signal ? { signal: ui.signal } : {}),
|
|
195
|
+
};
|
|
196
|
+
let choice;
|
|
197
|
+
try {
|
|
198
|
+
choice = await ui.ui.select(`Sandbox: allow network access to ${host}?`, [
|
|
199
|
+
"Allow for this session",
|
|
200
|
+
`Allow for this project (.yagni-code/config.json)`,
|
|
201
|
+
`Allow for all projects (~/.yagni-code/config.json)`,
|
|
202
|
+
"Block",
|
|
203
|
+
], dialogOpts);
|
|
204
|
+
}
|
|
205
|
+
catch {
|
|
206
|
+
return false; // dialog error / timeout: never grant
|
|
207
|
+
}
|
|
208
|
+
if (choice === undefined || choice === "Block")
|
|
209
|
+
return false;
|
|
210
|
+
sessionDomainGrants.add(host);
|
|
211
|
+
if (choice.startsWith("Allow for this project")) {
|
|
212
|
+
try {
|
|
213
|
+
persistDomainGrant(opts.projectRoot ?? opts.cwd, host, resolvedStateHome);
|
|
214
|
+
}
|
|
215
|
+
catch {
|
|
216
|
+
// fail-soft: the session grant already holds; the persistence just
|
|
217
|
+
// did not — the user chose it, so surface it rather than stay silent.
|
|
218
|
+
ui.ui.notify?.("Could not persist the domain grant to the config file — it applies to this session only.", "warning");
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
else if (choice.startsWith("Allow for all projects")) {
|
|
222
|
+
try {
|
|
223
|
+
persistDomainGrant(null, host, resolvedStateHome);
|
|
224
|
+
}
|
|
225
|
+
catch {
|
|
226
|
+
ui.ui.notify?.("Could not persist the domain grant to the config file — it applies to this session only.", "warning");
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
// The grant must reach the live srt config before the retry.
|
|
230
|
+
manager.refreshConfig([...rules]);
|
|
231
|
+
return true;
|
|
232
|
+
};
|
|
233
|
+
// --- bash composition ---
|
|
234
|
+
// ONE wrapped bash definition, built once and consumed by whichever site
|
|
235
|
+
// registers bash for this session: condensedTools' wrapBash seam (the
|
|
236
|
+
// common path — condensed registers LAST and would otherwise overwrite a
|
|
237
|
+
// competing registration, the M4 e2e lesson) or this module's own
|
|
238
|
+
// registration when condensed is inactive (eval mode, classic rows,
|
|
239
|
+
// desktop). The composition is identity when the sandbox is disabled —
|
|
240
|
+
// default-off sessions stay byte-identical.
|
|
241
|
+
const composeBash = makeBashComposition(manager, () => currentSettings, opts.cwd);
|
|
242
|
+
const registerOwnBash = () => {
|
|
243
|
+
if (!currentSettings.enabled)
|
|
244
|
+
return;
|
|
245
|
+
const userShellPath = SettingsManager.create(opts.cwd).getShellPath();
|
|
246
|
+
const stockBash = createBashToolDefinition(opts.cwd, { shellPath: userShellPath });
|
|
247
|
+
pi.registerTool(composeBash(stockBash));
|
|
248
|
+
};
|
|
249
|
+
const handle = {
|
|
250
|
+
manager,
|
|
251
|
+
settings: () => currentSettings,
|
|
252
|
+
setRules: (r) => {
|
|
253
|
+
rules = r;
|
|
254
|
+
},
|
|
255
|
+
grantDomainSession: (domain) => {
|
|
256
|
+
sessionDomainGrants.add(domain);
|
|
257
|
+
manager.refreshConfig([...rules]);
|
|
258
|
+
},
|
|
259
|
+
sessionDomains: () => [...sessionDomainGrants],
|
|
260
|
+
composeBash,
|
|
261
|
+
registerOwnBash,
|
|
262
|
+
};
|
|
263
|
+
// --- user_bash (! commands): identical OS treatment to tool calls ---
|
|
264
|
+
// pi executes the user's command through the returned BashOperations, so
|
|
265
|
+
// wrapping at this seam gives ! commands the same sandbox. Process-group
|
|
266
|
+
// kill + stdio handling derived from pi-sandbox (MIT, carderne/pi-sandbox
|
|
267
|
+
// src/sandbox-runtime.ts, waitForChildProcess) with pi's local ops as the
|
|
268
|
+
// behavioral reference.
|
|
269
|
+
pi.on("user_bash", async (event) => {
|
|
270
|
+
if (!shouldUseSandboxForUserCommand(event.command, manager, currentSettings))
|
|
271
|
+
return;
|
|
272
|
+
const { shell, args } = getShellConfig(SettingsManager.create(opts.cwd).getShellPath());
|
|
273
|
+
const operations = {
|
|
274
|
+
exec: async (command, cwd, { onData, signal, timeout, env }) => {
|
|
275
|
+
if (!existsSync(cwd))
|
|
276
|
+
throw new Error(`Working directory does not exist: ${cwd}`);
|
|
277
|
+
const wrapped = await manager.wrapWithSandbox(command, shell);
|
|
278
|
+
const child = spawn(shell, [...args, wrapped], {
|
|
279
|
+
cwd,
|
|
280
|
+
env: env ?? process.env,
|
|
281
|
+
detached: true,
|
|
282
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
283
|
+
});
|
|
284
|
+
let timedOut = false;
|
|
285
|
+
let timeoutHandle;
|
|
286
|
+
const killGroup = () => {
|
|
287
|
+
if (!child.pid)
|
|
288
|
+
return;
|
|
289
|
+
try {
|
|
290
|
+
process.kill(-child.pid, "SIGKILL");
|
|
291
|
+
}
|
|
292
|
+
catch {
|
|
293
|
+
child.kill("SIGKILL");
|
|
294
|
+
}
|
|
295
|
+
};
|
|
296
|
+
if (timeout !== undefined && timeout > 0) {
|
|
297
|
+
timeoutHandle = setTimeout(() => { timedOut = true; killGroup(); }, timeout * 1000);
|
|
298
|
+
}
|
|
299
|
+
child.stdout?.on("data", onData);
|
|
300
|
+
child.stderr?.on("data", onData);
|
|
301
|
+
signal?.addEventListener("abort", killGroup, { once: true });
|
|
302
|
+
try {
|
|
303
|
+
const exitCode = await waitForChildProcess(child);
|
|
304
|
+
if (signal?.aborted)
|
|
305
|
+
throw new Error("aborted");
|
|
306
|
+
if (timedOut)
|
|
307
|
+
throw new Error(`timeout:${timeout}`);
|
|
308
|
+
return { exitCode };
|
|
309
|
+
}
|
|
310
|
+
finally {
|
|
311
|
+
if (timeoutHandle)
|
|
312
|
+
clearTimeout(timeoutHandle);
|
|
313
|
+
signal?.removeEventListener("abort", killGroup);
|
|
314
|
+
manager.cleanupAfterCommand();
|
|
315
|
+
}
|
|
316
|
+
},
|
|
317
|
+
};
|
|
318
|
+
return { operations };
|
|
319
|
+
});
|
|
320
|
+
// --- session lifecycle ---
|
|
321
|
+
// The UI bridge is captured on every ctx-bearing event so the network ask
|
|
322
|
+
// (fired from inside bash execution, no event ctx) can prompt.
|
|
323
|
+
pi.on("tool_call", async (_event, ctx) => {
|
|
324
|
+
captureUi(ctx);
|
|
325
|
+
return;
|
|
326
|
+
});
|
|
327
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
328
|
+
captureUi(ctx);
|
|
329
|
+
currentSettings = load();
|
|
330
|
+
// --no-sandbox skips init in EVERY mode — headless included. The notify
|
|
331
|
+
// is UI-gated, the SKIP never is (a UI-only skip would silently keep the
|
|
332
|
+
// sandbox on for scripted runs that asked it off).
|
|
333
|
+
if (noSandboxFlag()) {
|
|
334
|
+
if (opts.hasUI(ctx))
|
|
335
|
+
ctx.ui.notify("Sandbox disabled via --no-sandbox for this session", "warning");
|
|
336
|
+
return;
|
|
337
|
+
}
|
|
338
|
+
if (!currentSettings.enabled)
|
|
339
|
+
return;
|
|
340
|
+
manager.setAskHandler(askHandler);
|
|
341
|
+
const err = await manager.initialize(rules);
|
|
342
|
+
if (err) {
|
|
343
|
+
logEvent({
|
|
344
|
+
source: "sandbox",
|
|
345
|
+
level: "warn",
|
|
346
|
+
event: "init_failed",
|
|
347
|
+
fields: { reason: err },
|
|
348
|
+
});
|
|
349
|
+
if (currentSettings.failIfUnavailable) {
|
|
350
|
+
ctx.ui.notify(`Sandbox required by config but unavailable: ${err}. Refusing to run — fix dependencies or disable sandbox.enabled.`, "error");
|
|
351
|
+
throw new Error(`sandbox unavailable (failIfUnavailable): ${err}`);
|
|
352
|
+
}
|
|
353
|
+
ctx.ui.notify(`Sandbox unavailable, commands run unsandboxed: ${err}`, "warning");
|
|
354
|
+
return;
|
|
355
|
+
}
|
|
356
|
+
if (ctx.hasUI) {
|
|
357
|
+
ctx.ui.setStatus?.("sandbox", "sandbox on");
|
|
358
|
+
}
|
|
359
|
+
logEvent({ source: "sandbox", level: "info", event: "initialized", fields: { platform } });
|
|
360
|
+
});
|
|
361
|
+
pi.on("session_shutdown", async () => {
|
|
362
|
+
await manager.reset();
|
|
363
|
+
});
|
|
364
|
+
// --- /sandbox command + toggle surfaces ---
|
|
365
|
+
const formatConfigSummary = () => {
|
|
366
|
+
const s = currentSettings;
|
|
367
|
+
const lines = [
|
|
368
|
+
`enabled: ${s.enabled ? "yes" : "no"}${sessionToggle === false ? " (toggled off this session)" : ""}`,
|
|
369
|
+
`autoAllowBashIfSandboxed: ${s.autoAllowBashIfSandboxed !== false ? "yes" : "no"}`,
|
|
370
|
+
`allowUnsandboxedCommands: ${s.allowUnsandboxedCommands !== false ? "yes" : "no (strict)"}`,
|
|
371
|
+
];
|
|
372
|
+
if (s.network?.allowedDomains?.length)
|
|
373
|
+
lines.push(`allowedDomains: ${s.network.allowedDomains.join(", ")}`);
|
|
374
|
+
else
|
|
375
|
+
lines.push("allowedDomains: (none — first contact prompts)");
|
|
376
|
+
if (s.network?.deniedDomains?.length)
|
|
377
|
+
lines.push(`deniedDomains: ${s.network.deniedDomains.join(", ")}`);
|
|
378
|
+
if (s.network?.allowLocalBinding)
|
|
379
|
+
lines.push("allowLocalBinding: yes (loopback + private ranges)");
|
|
380
|
+
if (s.filesystem?.allowWrite?.length)
|
|
381
|
+
lines.push(`allowWrite: ${s.filesystem.allowWrite.join(", ")}`);
|
|
382
|
+
if (s.filesystem?.denyWrite?.length)
|
|
383
|
+
lines.push(`denyWrite: ${s.filesystem.denyWrite.join(", ")}`);
|
|
384
|
+
if (s.filesystem?.denyRead?.length)
|
|
385
|
+
lines.push(`denyRead: ${s.filesystem.denyRead.join(", ")}`);
|
|
386
|
+
if (sessionDomainGrants.size > 0)
|
|
387
|
+
lines.push(`session grants: ${[...sessionDomainGrants].join(", ")} (memory-only)`);
|
|
388
|
+
if (s.excludedCommands?.length)
|
|
389
|
+
lines.push(`excludedCommands: ${s.excludedCommands.join(", ")}`);
|
|
390
|
+
return lines;
|
|
391
|
+
};
|
|
392
|
+
/**
|
|
393
|
+
* Session-level toggle. OFF is always immediate (manager.reset unwraps the
|
|
394
|
+
* next command). ON is split by how the session started:
|
|
395
|
+
* - config-enabled session: re-initialize (force past nothing — it was on)
|
|
396
|
+
* - default-off session: the wrapped bash may not have registered at load
|
|
397
|
+
* (condensedTools owns the registration in the common TUI and reads disk
|
|
398
|
+
* settings, not this session's toggle). So /sandbox enable PERSISTS
|
|
399
|
+
* sandbox.enabled=true to the user config (Claude's own panel persists
|
|
400
|
+
* its mode choice the same way) and asks for a restart — the honest
|
|
401
|
+
* behavior; a silent no-op would be a security lie.
|
|
402
|
+
*/
|
|
403
|
+
const setSessionToggle = async (on, ctx) => {
|
|
404
|
+
sessionToggle = on;
|
|
405
|
+
if (on) {
|
|
406
|
+
const startedEnabled = load().enabled;
|
|
407
|
+
if (startedEnabled) {
|
|
408
|
+
const err = await manager.initialize(rules, { force: true });
|
|
409
|
+
if (err) {
|
|
410
|
+
ctx.ui.notify(`Sandbox unavailable: ${err}`, "error");
|
|
411
|
+
sessionToggle = null;
|
|
412
|
+
return;
|
|
413
|
+
}
|
|
414
|
+
// NO registerOwnBash here: on a condensed-active session (the common
|
|
415
|
+
// case for a config-enabled start) condensed owns the bash
|
|
416
|
+
// registration and composed the wrap at load; re-initializing the
|
|
417
|
+
// manager is all the toggle needs — composeBash reads the manager
|
|
418
|
+
// live at execution time. Registering a stock-wrapped bash here
|
|
419
|
+
// would REPLACE condensed's registration and silently degrade the
|
|
420
|
+
// transcript renderer for the rest of the session.
|
|
421
|
+
ctx.ui.setStatus?.("sandbox", "sandbox on");
|
|
422
|
+
ctx.ui.notify("Sandbox enabled for this session", "info");
|
|
423
|
+
}
|
|
424
|
+
else {
|
|
425
|
+
// Persist the enable (fail-soft: on write failure tell the user to
|
|
426
|
+
// edit the file — never claim success falsely).
|
|
427
|
+
try {
|
|
428
|
+
persistEnabledFlip(true, resolvedStateHome);
|
|
429
|
+
ctx.ui.setStatus?.("sandbox", "sandbox on (restart to apply)");
|
|
430
|
+
ctx.ui.notify("Sandbox enabled in ~/.yagni-code/config.json — restart the session (or /new) to apply it to the model's bash tool. Your ! commands are NOT sandboxed until then.", "warning");
|
|
431
|
+
}
|
|
432
|
+
catch {
|
|
433
|
+
ctx.ui.notify("Could not write the sandbox enable to ~/.yagni-code/config.json — set \"sandbox\": { \"enabled\": true } there and restart.", "error");
|
|
434
|
+
sessionToggle = null;
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
else {
|
|
439
|
+
await manager.reset();
|
|
440
|
+
ctx.ui.setStatus?.("sandbox", "");
|
|
441
|
+
ctx.ui.notify("Sandbox disabled for this session", "warning");
|
|
442
|
+
}
|
|
443
|
+
};
|
|
444
|
+
/**
|
|
445
|
+
* The interactive /sandbox panel (TUI only). Selections persist to the
|
|
446
|
+
* user config (see persistSandboxMode/persistSandboxOverride for the
|
|
447
|
+
* interim-destination reasoning); the confirmation message is surfaced
|
|
448
|
+
* via notify after the panel closes, mirroring Claude's onComplete flow.
|
|
449
|
+
* enable-from-default-off keeps the honest restart advice.
|
|
450
|
+
*/
|
|
451
|
+
const openSandboxPanel = async (ctx) => {
|
|
452
|
+
const deps = manager.checkDependencies();
|
|
453
|
+
const state = buildPanelState(currentSettings, sessionToggle === false, rules, {
|
|
454
|
+
cwd: opts.cwd,
|
|
455
|
+
userStateHome: resolvedStateHome,
|
|
456
|
+
projectRoot: opts.projectRoot ?? null,
|
|
457
|
+
homeDir: opts.userHome,
|
|
458
|
+
}, [...sessionDomainGrants], deps);
|
|
459
|
+
const actions = {
|
|
460
|
+
onModeSelect: async (choice) => {
|
|
461
|
+
const wasEnabled = load().enabled === true;
|
|
462
|
+
try {
|
|
463
|
+
persistSandboxMode(choice, resolvedStateHome);
|
|
464
|
+
}
|
|
465
|
+
catch {
|
|
466
|
+
return {
|
|
467
|
+
text: `Could not write the sandbox setting to ~/.yagni-code/config.json — edit it directly ("sandbox": { "enabled": ${choice !== "disabled"} }) and restart.`,
|
|
468
|
+
level: "error",
|
|
469
|
+
};
|
|
470
|
+
}
|
|
471
|
+
currentSettings = load();
|
|
472
|
+
if (choice === "disabled") {
|
|
473
|
+
await manager.reset();
|
|
474
|
+
sessionToggle = false;
|
|
475
|
+
ctx.ui.setStatus?.("sandbox", "");
|
|
476
|
+
return { text: "○ Sandbox disabled", level: "info" };
|
|
477
|
+
}
|
|
478
|
+
const message = choice === "auto-allow"
|
|
479
|
+
? "✓ Sandbox enabled with auto-allow for bash commands"
|
|
480
|
+
: "✓ Sandbox enabled with regular bash permissions";
|
|
481
|
+
if (!wasEnabled) {
|
|
482
|
+
// Same honesty as setSessionToggle's enable path: the session's
|
|
483
|
+
// wrapped bash registered (or didn't) at load; a restart applies
|
|
484
|
+
// the new mode to the model's bash tool.
|
|
485
|
+
ctx.ui.setStatus?.("sandbox", "sandbox on (restart to apply)");
|
|
486
|
+
return {
|
|
487
|
+
text: `${message} — saved to user settings (~/.yagni-code/config.json); restart the session (or /new) to apply it to the model's bash tool.`,
|
|
488
|
+
level: "info",
|
|
489
|
+
};
|
|
490
|
+
}
|
|
491
|
+
// Already-enabled session changing autoAllow only: refresh the
|
|
492
|
+
// live merge so the next bash call sees the new decision posture.
|
|
493
|
+
if (manager.initialized)
|
|
494
|
+
manager.refreshConfig(rules);
|
|
495
|
+
ctx.ui.setStatus?.("sandbox", "sandbox on");
|
|
496
|
+
return {
|
|
497
|
+
text: `${message} — saved to user settings (~/.yagni-code/config.json).`,
|
|
498
|
+
level: "info",
|
|
499
|
+
};
|
|
500
|
+
},
|
|
501
|
+
onOverrideSelect: async (choice) => {
|
|
502
|
+
try {
|
|
503
|
+
persistSandboxOverride(choice, resolvedStateHome);
|
|
504
|
+
}
|
|
505
|
+
catch {
|
|
506
|
+
return {
|
|
507
|
+
text: `Could not write the sandbox override to ~/.yagni-code/config.json — edit it directly (\"sandbox\": { \"allowUnsandboxedCommands\": ... }) and restart.`,
|
|
508
|
+
level: "error",
|
|
509
|
+
};
|
|
510
|
+
}
|
|
511
|
+
currentSettings = load();
|
|
512
|
+
if (manager.initialized)
|
|
513
|
+
manager.refreshConfig(rules);
|
|
514
|
+
return {
|
|
515
|
+
text: choice === "open"
|
|
516
|
+
? "✓ Unsandboxed fallback allowed — commands can run outside the sandbox when necessary — saved to user settings"
|
|
517
|
+
: "✓ Strict sandbox mode — all commands must run in the sandbox or be excluded via the excludedCommands option — saved to user settings",
|
|
518
|
+
level: "info",
|
|
519
|
+
};
|
|
520
|
+
},
|
|
521
|
+
};
|
|
522
|
+
let outcome;
|
|
523
|
+
try {
|
|
524
|
+
outcome = await ctx.ui.custom((tui, theme, keybindings, done) => {
|
|
525
|
+
void tui;
|
|
526
|
+
void keybindings;
|
|
527
|
+
return new SandboxPanel(state, actions, theme, (o) => done(o ?? null));
|
|
528
|
+
});
|
|
529
|
+
}
|
|
530
|
+
catch {
|
|
531
|
+
// custom() unavailable (harness without TUI) — fall through to notify.
|
|
532
|
+
}
|
|
533
|
+
// null = closed without a selection (Esc / cancel): no message, no
|
|
534
|
+
// summary dump. undefined = custom() never ran (RPC): text summary.
|
|
535
|
+
if (outcome) {
|
|
536
|
+
ctx.ui.notify(outcome.text, outcome.level);
|
|
537
|
+
return;
|
|
538
|
+
}
|
|
539
|
+
if (outcome === undefined) {
|
|
540
|
+
ctx.ui.notify(["Sandbox configuration:", ...formatConfigSummary().map((l) => ` ${l}`)].join("\n"), "info");
|
|
541
|
+
}
|
|
542
|
+
};
|
|
543
|
+
pi.registerCommand("sandbox", {
|
|
544
|
+
description: "Show sandbox configuration / manage the session toggle",
|
|
545
|
+
handler: async (args, ctx) => {
|
|
546
|
+
const trimmed = args.trim();
|
|
547
|
+
if (trimmed === "enable")
|
|
548
|
+
return setSessionToggle(true, ctx);
|
|
549
|
+
if (trimmed === "disable")
|
|
550
|
+
return setSessionToggle(false, ctx);
|
|
551
|
+
if (trimmed === "exclude" || trimmed.startsWith("exclude ")) {
|
|
552
|
+
// /sandbox exclude "npm run test:*" — convenience escape valve (the
|
|
553
|
+
// permission gate remains the security boundary).
|
|
554
|
+
const pattern = trimmed.slice("exclude ".length).trim().replace(/^["']|["']$/g, "");
|
|
555
|
+
if (!pattern) {
|
|
556
|
+
ctx.ui.notify("Usage: /sandbox exclude <command-pattern>", "error");
|
|
557
|
+
return;
|
|
558
|
+
}
|
|
559
|
+
currentSettings = { ...currentSettings, excludedCommands: [...(currentSettings.excludedCommands ?? []), pattern] };
|
|
560
|
+
ctx.ui.notify(`Excluded "${pattern}" from sandboxing for this session`, "info");
|
|
561
|
+
return;
|
|
562
|
+
}
|
|
563
|
+
// TUI: the interactive tabbed panel (Claude's /sandbox). RPC/headless:
|
|
564
|
+
// custom() is unavailable there — keep the text summary.
|
|
565
|
+
if (ctx.mode === "tui" && ctx.hasUI) {
|
|
566
|
+
return openSandboxPanel(ctx);
|
|
567
|
+
}
|
|
568
|
+
ctx.ui.notify(["Sandbox configuration:", ...formatConfigSummary().map((l) => ` ${l}`)].join("\n"), "info");
|
|
569
|
+
},
|
|
570
|
+
});
|
|
571
|
+
pi.registerShortcut?.(Key.alt("s"), {
|
|
572
|
+
description: "Toggle the bash sandbox for this session",
|
|
573
|
+
handler: (ctx) => {
|
|
574
|
+
void setSessionToggle((sessionToggle ?? manager.initialized) === false, ctx);
|
|
575
|
+
},
|
|
576
|
+
});
|
|
577
|
+
return handle;
|
|
578
|
+
}
|
|
579
|
+
/**
|
|
580
|
+
* Persist a domain grant to the user or project sandbox config (fail-soft:
|
|
581
|
+
* the session grant already applies even if the write fails).
|
|
582
|
+
*/
|
|
583
|
+
/**
|
|
584
|
+
* Shared safe config mutation for the isolated user/project config.json:
|
|
585
|
+
* - a PARSE FAILURE aborts (never treats a corrupt/half-written file as
|
|
586
|
+
* empty — that would wipe activeProfile/permissions);
|
|
587
|
+
* - the write is ATOMIC (tmp file + rename) so a crash never truncates;
|
|
588
|
+
* - symlinked targets are rejected (a hostile repo's config symlink must
|
|
589
|
+
* not redirect grants to arbitrary paths);
|
|
590
|
+
* - the mutator runs only on a parsed object; a thrown mutator aborts.
|
|
591
|
+
* Throws on any failure — callers decide fail-soft messaging.
|
|
592
|
+
*/
|
|
593
|
+
function mutateConfigJson(target, mutate) {
|
|
594
|
+
let parsed;
|
|
595
|
+
try {
|
|
596
|
+
parsed = JSON.parse(readFileSync(target, "utf-8"));
|
|
597
|
+
}
|
|
598
|
+
catch (err) {
|
|
599
|
+
if (err.code === "ENOENT") {
|
|
600
|
+
parsed = {}; // genuinely fresh file — fine
|
|
601
|
+
}
|
|
602
|
+
else {
|
|
603
|
+
throw new Error(`config.json at ${target} is not valid JSON — refusing to rewrite it`);
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
if (!isPlainRecord(parsed))
|
|
607
|
+
throw new Error(`config.json at ${target} is not an object`);
|
|
608
|
+
const config = { ...parsed };
|
|
609
|
+
mutate(config);
|
|
610
|
+
mkdirSync(dirname(target), { recursive: true });
|
|
611
|
+
// Reject symlinked targets: lstat must show a regular file (or absent).
|
|
612
|
+
try {
|
|
613
|
+
const st = lstatSync(target);
|
|
614
|
+
if (!st.isFile())
|
|
615
|
+
throw new Error(`${target} is not a regular file (symlink?) — refusing`);
|
|
616
|
+
}
|
|
617
|
+
catch (err) {
|
|
618
|
+
if (err.code !== "ENOENT")
|
|
619
|
+
throw err;
|
|
620
|
+
}
|
|
621
|
+
const tmp = `${target}.sandbox-tmp-${process.pid}`;
|
|
622
|
+
writeFileSync(tmp, JSON.stringify(config, null, 2) + "\n", { mode: 0o600 });
|
|
623
|
+
// chmod: writeFile honors mode only on create — guarantee 0600 even when a
|
|
624
|
+
// same-pid retry reuses an existing tmp file (same posture as
|
|
625
|
+
// persistUserRule's config write).
|
|
626
|
+
chmodSync(tmp, 0o600);
|
|
627
|
+
renameSync(tmp, target);
|
|
628
|
+
}
|
|
629
|
+
function isPlainRecord(v) {
|
|
630
|
+
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
631
|
+
}
|
|
632
|
+
/** Persist a domain grant to the user or project sandbox config. */
|
|
633
|
+
function persistDomainGrant(projectRoot, domain, stateHome) {
|
|
634
|
+
const target = projectRoot
|
|
635
|
+
? join(projectRoot, ".yagni-code", "config.json")
|
|
636
|
+
: join(stateHome, "config.json");
|
|
637
|
+
mutateConfigJson(target, (config) => {
|
|
638
|
+
const sandbox = isPlainRecord(config.sandbox) ? { ...config.sandbox } : {};
|
|
639
|
+
const network = isPlainRecord(sandbox.network) ? { ...sandbox.network } : {};
|
|
640
|
+
const domains = Array.isArray(network.allowedDomains) ? [...network.allowedDomains] : [];
|
|
641
|
+
if (!domains.includes(domain))
|
|
642
|
+
domains.push(domain);
|
|
643
|
+
network.allowedDomains = domains;
|
|
644
|
+
sandbox.network = network;
|
|
645
|
+
config.sandbox = sandbox;
|
|
646
|
+
});
|
|
647
|
+
}
|
|
648
|
+
/** Persist sandbox.enabled for the /sandbox enable-from-default-off path. */
|
|
649
|
+
function persistEnabledFlip(enabled, stateHome) {
|
|
650
|
+
const target = join(stateHome, "config.json");
|
|
651
|
+
mutateConfigJson(target, (config) => {
|
|
652
|
+
const sandbox = isPlainRecord(config.sandbox) ? { ...config.sandbox } : {};
|
|
653
|
+
sandbox.enabled = enabled;
|
|
654
|
+
config.sandbox = sandbox;
|
|
655
|
+
});
|
|
656
|
+
}
|
|
657
|
+
/**
|
|
658
|
+
* Persist a panel Mode choice (enabled + autoAllowBashIfSandboxed pair) to
|
|
659
|
+
* the USER config. Claude persists these to settings.local.json (personal
|
|
660
|
+
* per-project); our project-local tier does not exist yet, so the user
|
|
661
|
+
* config is the interim destination — cross-project by nature, which the
|
|
662
|
+
* confirmation message states honestly.
|
|
663
|
+
*/
|
|
664
|
+
function persistSandboxMode(choice, stateHome) {
|
|
665
|
+
const target = join(stateHome, "config.json");
|
|
666
|
+
mutateConfigJson(target, (config) => {
|
|
667
|
+
const sandbox = isPlainRecord(config.sandbox) ? { ...config.sandbox } : {};
|
|
668
|
+
if (choice === "auto-allow") {
|
|
669
|
+
sandbox.enabled = true;
|
|
670
|
+
sandbox.autoAllowBashIfSandboxed = true;
|
|
671
|
+
}
|
|
672
|
+
else if (choice === "regular") {
|
|
673
|
+
sandbox.enabled = true;
|
|
674
|
+
sandbox.autoAllowBashIfSandboxed = false;
|
|
675
|
+
}
|
|
676
|
+
else {
|
|
677
|
+
sandbox.enabled = false;
|
|
678
|
+
sandbox.autoAllowBashIfSandboxed = false;
|
|
679
|
+
}
|
|
680
|
+
config.sandbox = sandbox;
|
|
681
|
+
});
|
|
682
|
+
}
|
|
683
|
+
/** Persist a panel Overrides choice (allowUnsandboxedCommands) — same
|
|
684
|
+
* destination + honesty contract as persistSandboxMode. */
|
|
685
|
+
function persistSandboxOverride(choice, stateHome) {
|
|
686
|
+
const target = join(stateHome, "config.json");
|
|
687
|
+
mutateConfigJson(target, (config) => {
|
|
688
|
+
const sandbox = isPlainRecord(config.sandbox) ? { ...config.sandbox } : {};
|
|
689
|
+
sandbox.allowUnsandboxedCommands = choice === "open";
|
|
690
|
+
config.sandbox = sandbox;
|
|
691
|
+
});
|
|
692
|
+
}
|
|
693
|
+
/**
|
|
694
|
+
* Wait for a spawned child's exit without hanging on inherited stdio —
|
|
695
|
+
* after exit, release the pipes after a short idle grace so a detached
|
|
696
|
+
* descendant holding them open cannot wedge the await. Derived from
|
|
697
|
+
* pi-sandbox (MIT) waitForChildProcess.
|
|
698
|
+
*/
|
|
699
|
+
const EXIT_STDIO_GRACE_MS = 100;
|
|
700
|
+
function waitForChildProcess(child) {
|
|
701
|
+
return new Promise((resolve, reject) => {
|
|
702
|
+
let settled = false;
|
|
703
|
+
let exited = false;
|
|
704
|
+
let exitCode = null;
|
|
705
|
+
let postExitTimer;
|
|
706
|
+
let stdoutEnded = child.stdout === null;
|
|
707
|
+
let stderrEnded = child.stderr === null;
|
|
708
|
+
const cleanup = () => {
|
|
709
|
+
if (postExitTimer)
|
|
710
|
+
clearTimeout(postExitTimer);
|
|
711
|
+
child.removeListener("error", onError);
|
|
712
|
+
child.removeListener("exit", onExit);
|
|
713
|
+
child.removeListener("close", onClose);
|
|
714
|
+
child.stdout?.removeListener("end", onStdoutEnd);
|
|
715
|
+
child.stderr?.removeListener("end", onStderrEnd);
|
|
716
|
+
child.stdout?.removeListener("data", onData);
|
|
717
|
+
child.stderr?.removeListener("data", onData);
|
|
718
|
+
};
|
|
719
|
+
const finalize = (code) => {
|
|
720
|
+
if (settled)
|
|
721
|
+
return;
|
|
722
|
+
settled = true;
|
|
723
|
+
cleanup();
|
|
724
|
+
child.stdout?.destroy();
|
|
725
|
+
child.stderr?.destroy();
|
|
726
|
+
resolve(code);
|
|
727
|
+
};
|
|
728
|
+
const maybeFinalizeAfterExit = () => {
|
|
729
|
+
if (!exited || settled)
|
|
730
|
+
return;
|
|
731
|
+
if (stdoutEnded && stderrEnded)
|
|
732
|
+
finalize(exitCode);
|
|
733
|
+
};
|
|
734
|
+
const armIdleTimer = () => {
|
|
735
|
+
if (postExitTimer)
|
|
736
|
+
clearTimeout(postExitTimer);
|
|
737
|
+
postExitTimer = setTimeout(() => finalize(exitCode), EXIT_STDIO_GRACE_MS);
|
|
738
|
+
};
|
|
739
|
+
const onData = () => {
|
|
740
|
+
if (exited && !settled)
|
|
741
|
+
armIdleTimer();
|
|
742
|
+
};
|
|
743
|
+
const onStdoutEnd = () => {
|
|
744
|
+
stdoutEnded = true;
|
|
745
|
+
maybeFinalizeAfterExit();
|
|
746
|
+
};
|
|
747
|
+
const onStderrEnd = () => {
|
|
748
|
+
stderrEnded = true;
|
|
749
|
+
maybeFinalizeAfterExit();
|
|
750
|
+
};
|
|
751
|
+
const onError = (error) => {
|
|
752
|
+
if (settled)
|
|
753
|
+
return;
|
|
754
|
+
settled = true;
|
|
755
|
+
cleanup();
|
|
756
|
+
reject(error);
|
|
757
|
+
};
|
|
758
|
+
const onExit = (code) => {
|
|
759
|
+
exited = true;
|
|
760
|
+
exitCode = code;
|
|
761
|
+
maybeFinalizeAfterExit();
|
|
762
|
+
if (!settled)
|
|
763
|
+
armIdleTimer();
|
|
764
|
+
};
|
|
765
|
+
const onClose = (code) => finalize(code);
|
|
766
|
+
child.stdout?.once("end", onStdoutEnd);
|
|
767
|
+
child.stderr?.once("end", onStderrEnd);
|
|
768
|
+
child.stdout?.on("data", onData);
|
|
769
|
+
child.stderr?.on("data", onData);
|
|
770
|
+
child.once("error", onError);
|
|
771
|
+
child.once("exit", onExit);
|
|
772
|
+
child.once("close", onClose);
|
|
773
|
+
});
|
|
774
|
+
}
|
|
775
|
+
//# sourceMappingURL=session.js.map
|