@pugi/cli 0.1.0-alpha.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +172 -0
- package/bin/run.js +2 -0
- package/dist/commands/jobs.js +245 -0
- package/dist/core/agents/loader.js +104 -0
- package/dist/core/agents/registry.js +69 -0
- package/dist/core/auto-open-browser.js +128 -0
- package/dist/core/bash-classifier.js +1001 -0
- package/dist/core/clipboard.js +70 -0
- package/dist/core/context/builder.js +114 -0
- package/dist/core/context/compaction-events.js +99 -0
- package/dist/core/context/compaction.js +602 -0
- package/dist/core/context/invariants.js +250 -0
- package/dist/core/context/markdown-loader.js +270 -0
- package/dist/core/credentials.js +355 -0
- package/dist/core/engine/adapter-runner.js +8 -0
- package/dist/core/engine/anvil-client.js +156 -0
- package/dist/core/engine/compaction-hook.js +154 -0
- package/dist/core/engine/index.js +12 -0
- package/dist/core/engine/native-pugi.js +369 -0
- package/dist/core/engine/noop.js +27 -0
- package/dist/core/engine/prompts.js +118 -0
- package/dist/core/engine/tool-bridge.js +313 -0
- package/dist/core/file-cache.js +29 -0
- package/dist/core/hooks.js +415 -0
- package/dist/core/index-store.js +260 -0
- package/dist/core/jobs/registry.js +462 -0
- package/dist/core/mcp/client.js +316 -0
- package/dist/core/mcp/registry.js +171 -0
- package/dist/core/mcp/trust.js +91 -0
- package/dist/core/path-security.js +63 -0
- package/dist/core/permission.js +309 -0
- package/dist/core/repl/cap-warning.js +91 -0
- package/dist/core/repl/clipboard-read.js +174 -0
- package/dist/core/repl/history-search.js +175 -0
- package/dist/core/repl/history.js +172 -0
- package/dist/core/repl/kill-ring.js +138 -0
- package/dist/core/repl/session.js +618 -0
- package/dist/core/repl/slash-commands.js +227 -0
- package/dist/core/repl/workspace-context.js +113 -0
- package/dist/core/session.js +258 -0
- package/dist/core/settings.js +59 -0
- package/dist/core/skills/loader.js +454 -0
- package/dist/core/skills/sources.js +480 -0
- package/dist/core/skills/trust.js +172 -0
- package/dist/core/subagents/dispatcher.js +258 -0
- package/dist/core/subagents/index.js +26 -0
- package/dist/core/subagents/spawn.js +86 -0
- package/dist/core/trust.js +109 -0
- package/dist/index.js +8 -0
- package/dist/runtime/cli.js +3405 -0
- package/dist/runtime/commands/agents.js +385 -0
- package/dist/runtime/commands/budget.js +192 -0
- package/dist/runtime/commands/config.js +231 -0
- package/dist/runtime/commands/privacy.js +107 -0
- package/dist/runtime/commands/skills.js +401 -0
- package/dist/runtime/commands/undo.js +329 -0
- package/dist/runtime/update-check.js +294 -0
- package/dist/tools/bash.js +660 -0
- package/dist/tools/file-tools.js +346 -0
- package/dist/tools/registry.js +25 -0
- package/dist/tools/web-fetch.js +535 -0
- package/dist/tui/agent-tree.js +66 -0
- package/dist/tui/conversation-pane.js +45 -0
- package/dist/tui/device-flow.js +142 -0
- package/dist/tui/input-box.js +474 -0
- package/dist/tui/login-picker.js +69 -0
- package/dist/tui/render.js +125 -0
- package/dist/tui/repl-render.js +240 -0
- package/dist/tui/repl-splash-art.js +64 -0
- package/dist/tui/repl-splash.js +111 -0
- package/dist/tui/repl.js +214 -0
- package/dist/tui/slash-palette.js +106 -0
- package/dist/tui/splash-data.js +61 -0
- package/dist/tui/splash.js +31 -0
- package/dist/tui/status-bar.js +71 -0
- package/dist/tui/update-banner.js +8 -0
- package/dist/tui/workspace-context.js +105 -0
- package/package.json +71 -0
|
@@ -0,0 +1,660 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Class-aware bash tool — Sprint α5.2 (ADR-0056 PR-PUGI-CLI-M1-GAP-B).
|
|
3
|
+
*
|
|
4
|
+
* The agent loop invokes this tool through the registry name `bash`.
|
|
5
|
+
* It supersedes `file-tools.ts::bashTool`, which used the legacy
|
|
6
|
+
* blocklist gate. The tool-bridge wires this new entry point so the
|
|
7
|
+
* registry entry (`registry.ts` `bash`) is not duplicated.
|
|
8
|
+
*
|
|
9
|
+
* Behavioural changes vs the legacy tool:
|
|
10
|
+
* 1. Permission decision routes through `evaluateBashPermission`
|
|
11
|
+
* (7-class taxonomy, mode-aware, destructive override gate).
|
|
12
|
+
* 2. Output cap is 32 KB combined stdout+stderr per call (down
|
|
13
|
+
* from 64 KB). Overflow is persisted to
|
|
14
|
+
* `.pugi/artifacts/<sessionId>/bash-<callId>.out` with the path
|
|
15
|
+
* returned as `artifactRef`.
|
|
16
|
+
* 3. Cwd carry-over: the tool receives `cwd` from the previous
|
|
17
|
+
* turn's session state and writes the new cwd back when the
|
|
18
|
+
* command was a `cd <path>` that landed inside
|
|
19
|
+
* `workspaceRoot ∪ additionalDirectories`. Escapes reset the
|
|
20
|
+
* cwd to workspaceRoot and emit `bash.cwd_escape`.
|
|
21
|
+
* 4. Background jobs: when `background: true`, spawn detached,
|
|
22
|
+
* track in `~/.pugi/jobs.json`, return immediately with
|
|
23
|
+
* `jobId`. `listJobs()` and `killJob(jobId)` are exported.
|
|
24
|
+
* 5. 60s default timeout. SIGTERM at deadline, SIGKILL 5s later.
|
|
25
|
+
* Emit `bash.timeout`.
|
|
26
|
+
* 6. POSIX-only (`/bin/sh`). The non-goal in ADR-0056 explicitly
|
|
27
|
+
* drops Windows shell support for M1.
|
|
28
|
+
*/
|
|
29
|
+
import { randomUUID } from 'node:crypto';
|
|
30
|
+
import { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync, } from 'node:fs';
|
|
31
|
+
import { homedir } from 'node:os';
|
|
32
|
+
import { isAbsolute, join, resolve } from 'node:path';
|
|
33
|
+
import { spawn, spawnSync } from 'node:child_process';
|
|
34
|
+
import { classifyBash } from '../core/bash-classifier.js';
|
|
35
|
+
import { evaluateBashPermission } from '../core/permission.js';
|
|
36
|
+
import { getJobRegistry, } from '../core/jobs/registry.js';
|
|
37
|
+
import { recordToolCall, recordToolResult } from '../core/session.js';
|
|
38
|
+
export const BASH_OUTPUT_CAP_BYTES = 32 * 1024;
|
|
39
|
+
export const BASH_DEFAULT_TIMEOUT_MS = 60_000;
|
|
40
|
+
export const BASH_SIGKILL_GRACE_MS = 5_000;
|
|
41
|
+
/**
|
|
42
|
+
* Mid-stream cap. The 32 KB BASH_OUTPUT_CAP_BYTES is the report cap;
|
|
43
|
+
* this is the in-memory ceiling beyond which we stop buffering and
|
|
44
|
+
* SIGTERM the child to prevent a `yes`-style stream from pinning
|
|
45
|
+
* 60+ MB before the timeout watchdog fires.
|
|
46
|
+
*
|
|
47
|
+
* Code Reviewer P1 retro 2026-05-24: the async path previously
|
|
48
|
+
* accumulated stdout chunks without bound; only spawnSync had a
|
|
49
|
+
* 10 MB maxBuffer ceiling. Aligning the async path closes the gap.
|
|
50
|
+
*/
|
|
51
|
+
export const BASH_LIVE_OUTPUT_CAP_BYTES = 1024 * 1024;
|
|
52
|
+
/**
|
|
53
|
+
* Bash tool entry point. Returns the standard shape the engine loop
|
|
54
|
+
* consumes; throws only on argument-shape errors (e.g. negative
|
|
55
|
+
* timeouts) and otherwise surfaces the failure through
|
|
56
|
+
* `{ exitCode: 126, stderr }`.
|
|
57
|
+
*/
|
|
58
|
+
export async function bashTool(input, ctx) {
|
|
59
|
+
const cmd = input.cmd ?? '';
|
|
60
|
+
const additionalDirectories = ctx.additionalDirectories ?? [];
|
|
61
|
+
const source = ctx.source ?? 'agent';
|
|
62
|
+
const toolCallId = recordToolCall(ctx.session, 'bash', cmd);
|
|
63
|
+
// Permission gate via the new class-aware engine.
|
|
64
|
+
const decision = evaluateBashPermission(cmd, ctx.settings.permissions.mode, {
|
|
65
|
+
workspaceRoot: ctx.root,
|
|
66
|
+
additionalDirectories,
|
|
67
|
+
source,
|
|
68
|
+
});
|
|
69
|
+
if (decision.decision !== 'allow') {
|
|
70
|
+
const reason = `Permission ${decision.decision}: ${decision.reason}`;
|
|
71
|
+
recordToolResult(ctx.session, toolCallId, 'error', reason);
|
|
72
|
+
return {
|
|
73
|
+
stdout: '',
|
|
74
|
+
stderr: `Permission denied: ${decision.reason}`,
|
|
75
|
+
exitCode: 126,
|
|
76
|
+
nextCwd: ctx.lastBashCwd ?? ctx.root,
|
|
77
|
+
truncated: false,
|
|
78
|
+
timedOut: false,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
// Cwd carry-over decision (also re-checked post-run).
|
|
82
|
+
const startCwd = resolveStartCwd(input.cwd ?? ctx.lastBashCwd, ctx.root, additionalDirectories);
|
|
83
|
+
// Background job branch.
|
|
84
|
+
if (input.background === true) {
|
|
85
|
+
return runBackground({ cmd, ctx, toolCallId, startCwd, additionalDirectories });
|
|
86
|
+
}
|
|
87
|
+
// Foreground branch with timeout watchdog.
|
|
88
|
+
const timeoutMs = sanitizeTimeout(input.timeoutMs);
|
|
89
|
+
const childEnv = buildChildEnv();
|
|
90
|
+
// POSIX-only `/bin/sh -c <cmd>`. The ADR-0056 non-goals explicitly
|
|
91
|
+
// exclude Windows for M1.
|
|
92
|
+
const child = spawn('/bin/sh', ['-c', cmd], {
|
|
93
|
+
cwd: startCwd,
|
|
94
|
+
env: childEnv,
|
|
95
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
96
|
+
detached: false,
|
|
97
|
+
});
|
|
98
|
+
const stdoutChunks = [];
|
|
99
|
+
const stderrChunks = [];
|
|
100
|
+
let stdoutBytes = 0;
|
|
101
|
+
let stderrBytes = 0;
|
|
102
|
+
// We keep collecting beyond the report cap (BASH_OUTPUT_CAP_BYTES)
|
|
103
|
+
// for the artifact-overflow file but flag `truncated` so the
|
|
104
|
+
// agent-facing payload is the head. To prevent a runaway producer
|
|
105
|
+
// (`yes`, `cat /dev/urandom`) from pinning hundreds of megabytes
|
|
106
|
+
// before the timeout watchdog fires, we enforce a live ceiling
|
|
107
|
+
// (BASH_LIVE_OUTPUT_CAP_BYTES) and SIGTERM the child when crossed.
|
|
108
|
+
let truncatedMidStream = false;
|
|
109
|
+
const enforceLiveCap = () => {
|
|
110
|
+
if (truncatedMidStream)
|
|
111
|
+
return;
|
|
112
|
+
if (stdoutBytes + stderrBytes <= BASH_LIVE_OUTPUT_CAP_BYTES)
|
|
113
|
+
return;
|
|
114
|
+
truncatedMidStream = true;
|
|
115
|
+
try {
|
|
116
|
+
child.kill('SIGTERM');
|
|
117
|
+
}
|
|
118
|
+
catch {
|
|
119
|
+
// child already exited; the close handler will run
|
|
120
|
+
}
|
|
121
|
+
};
|
|
122
|
+
child.stdout?.on('data', (chunk) => {
|
|
123
|
+
if (truncatedMidStream)
|
|
124
|
+
return;
|
|
125
|
+
stdoutChunks.push(chunk);
|
|
126
|
+
stdoutBytes += chunk.length;
|
|
127
|
+
enforceLiveCap();
|
|
128
|
+
});
|
|
129
|
+
child.stderr?.on('data', (chunk) => {
|
|
130
|
+
if (truncatedMidStream)
|
|
131
|
+
return;
|
|
132
|
+
stderrChunks.push(chunk);
|
|
133
|
+
stderrBytes += chunk.length;
|
|
134
|
+
enforceLiveCap();
|
|
135
|
+
});
|
|
136
|
+
const timeoutOutcome = await waitWithTimeout(child, timeoutMs);
|
|
137
|
+
const stdoutFull = Buffer.concat(stdoutChunks).toString('utf8');
|
|
138
|
+
const stderrFull = Buffer.concat(stderrChunks).toString('utf8');
|
|
139
|
+
const combinedBytes = stdoutBytes + stderrBytes;
|
|
140
|
+
const truncated = combinedBytes > BASH_OUTPUT_CAP_BYTES || truncatedMidStream;
|
|
141
|
+
// Cwd carry-over: detect `cd <path> && <rest>` shapes from the
|
|
142
|
+
// command itself (we cannot observe the child's final cwd without
|
|
143
|
+
// a wrapper script). The classifier already flagged escapes; we
|
|
144
|
+
// re-validate here against allowed roots.
|
|
145
|
+
const nextCwd = computeNextCwd(cmd, startCwd, ctx.root, additionalDirectories, ctx.session);
|
|
146
|
+
// Overflow artifact when needed.
|
|
147
|
+
let artifactRef;
|
|
148
|
+
let stdoutOut = stdoutFull;
|
|
149
|
+
let stderrOut = stderrFull;
|
|
150
|
+
if (truncated) {
|
|
151
|
+
artifactRef = persistOverflow({
|
|
152
|
+
root: ctx.root,
|
|
153
|
+
sessionId: ctx.session.id,
|
|
154
|
+
toolCallId,
|
|
155
|
+
stdout: stdoutFull,
|
|
156
|
+
stderr: stderrFull,
|
|
157
|
+
});
|
|
158
|
+
stdoutOut = capToCombined(stdoutFull, stderrFull).stdout;
|
|
159
|
+
stderrOut = capToCombined(stdoutFull, stderrFull).stderr;
|
|
160
|
+
}
|
|
161
|
+
if (truncatedMidStream) {
|
|
162
|
+
// We killed the child because output cap exceeded mid-stream.
|
|
163
|
+
// Report that as the failure cause rather than as a timeout —
|
|
164
|
+
// the watchdog never fired, only our cap enforcer did.
|
|
165
|
+
const reason = `bash output cap exceeded mid-stream (cap=${BASH_LIVE_OUTPUT_CAP_BYTES} bytes)`;
|
|
166
|
+
emitEvent(ctx.session, 'bash.output_cap_exceeded', {
|
|
167
|
+
cmd,
|
|
168
|
+
capBytes: BASH_LIVE_OUTPUT_CAP_BYTES,
|
|
169
|
+
});
|
|
170
|
+
recordToolResult(ctx.session, toolCallId, 'error', reason);
|
|
171
|
+
return {
|
|
172
|
+
stdout: stdoutOut,
|
|
173
|
+
stderr: stderrOut === '' ? reason : `${stderrOut}\n${reason}`,
|
|
174
|
+
exitCode: 137,
|
|
175
|
+
artifactRef,
|
|
176
|
+
nextCwd,
|
|
177
|
+
truncated: true,
|
|
178
|
+
timedOut: false,
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
if (timeoutOutcome.timedOut) {
|
|
182
|
+
emitEvent(ctx.session, 'bash.timeout', { cmd, timeoutMs });
|
|
183
|
+
recordToolResult(ctx.session, toolCallId, 'error', `bash timed out after ${timeoutMs}ms`);
|
|
184
|
+
return {
|
|
185
|
+
stdout: stdoutOut,
|
|
186
|
+
stderr: stderrOut === '' ? `bash timed out after ${timeoutMs}ms` : `${stderrOut}\nbash timed out after ${timeoutMs}ms`,
|
|
187
|
+
exitCode: 124,
|
|
188
|
+
artifactRef,
|
|
189
|
+
nextCwd,
|
|
190
|
+
truncated,
|
|
191
|
+
timedOut: true,
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
const exitCode = timeoutOutcome.exitCode;
|
|
195
|
+
recordToolResult(ctx.session, toolCallId, 'success', `bash exit=${exitCode} bytes=${combinedBytes}${artifactRef ? ` overflow=${artifactRef}` : ''}`);
|
|
196
|
+
return {
|
|
197
|
+
stdout: stdoutOut,
|
|
198
|
+
stderr: stderrOut,
|
|
199
|
+
exitCode,
|
|
200
|
+
artifactRef,
|
|
201
|
+
nextCwd,
|
|
202
|
+
truncated,
|
|
203
|
+
timedOut: false,
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
function sanitizeTimeout(value) {
|
|
207
|
+
if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) {
|
|
208
|
+
return BASH_DEFAULT_TIMEOUT_MS;
|
|
209
|
+
}
|
|
210
|
+
// Cap user-supplied timeouts at 15 minutes so a runaway tool call
|
|
211
|
+
// cannot wedge the engine loop.
|
|
212
|
+
return Math.min(value, 15 * 60 * 1000);
|
|
213
|
+
}
|
|
214
|
+
function buildChildEnv() {
|
|
215
|
+
const childEnv = {};
|
|
216
|
+
const SAFE_ENV_ALLOW = new Set([
|
|
217
|
+
'PATH',
|
|
218
|
+
'HOME',
|
|
219
|
+
'USER',
|
|
220
|
+
'LOGNAME',
|
|
221
|
+
'SHELL',
|
|
222
|
+
'LANG',
|
|
223
|
+
'TZ',
|
|
224
|
+
'TERM',
|
|
225
|
+
'PWD',
|
|
226
|
+
]);
|
|
227
|
+
for (const [key, value] of Object.entries(process.env)) {
|
|
228
|
+
if (value === undefined)
|
|
229
|
+
continue;
|
|
230
|
+
if (SAFE_ENV_ALLOW.has(key) || key.startsWith('LC_')) {
|
|
231
|
+
childEnv[key] = value;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
return childEnv;
|
|
235
|
+
}
|
|
236
|
+
function resolveStartCwd(requested, root, additionalDirectories) {
|
|
237
|
+
if (!requested)
|
|
238
|
+
return root;
|
|
239
|
+
const absolute = isAbsolute(requested) ? requested : resolve(root, requested);
|
|
240
|
+
const allowedRoots = [root, ...additionalDirectories];
|
|
241
|
+
for (const allowedRaw of allowedRoots) {
|
|
242
|
+
const allowed = allowedRaw.endsWith('/') ? allowedRaw.slice(0, -1) : allowedRaw;
|
|
243
|
+
if (absolute === allowed || absolute.startsWith(`${allowed}/`)) {
|
|
244
|
+
return absolute;
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
return root;
|
|
248
|
+
}
|
|
249
|
+
function computeNextCwd(cmd, startCwd, root, additionalDirectories, session) {
|
|
250
|
+
// We mirror the classifier's view: only `cd <path>` at the head of
|
|
251
|
+
// the command updates cwd for the next turn. We do not attempt to
|
|
252
|
+
// chase `cd` calls that fired inside subshells or compound chains
|
|
253
|
+
// (`(cd foo && ls)` leaves the parent cwd untouched).
|
|
254
|
+
const firstComponent = cmd.trim().split(/\s*(?:&&|\|\||;|\|)\s*/)[0]?.trim() ?? '';
|
|
255
|
+
const match = firstComponent.match(/^cd(?:\s+(\S+))?\s*$/);
|
|
256
|
+
if (!match)
|
|
257
|
+
return startCwd;
|
|
258
|
+
const target = match[1];
|
|
259
|
+
if (target === undefined || target === '-' || target === '~') {
|
|
260
|
+
emitEvent(session, 'bash.cwd_escape', { cmd, reason: 'cd to HOME or last dir' });
|
|
261
|
+
return root;
|
|
262
|
+
}
|
|
263
|
+
const resolved = isAbsolute(target) || target.startsWith('~')
|
|
264
|
+
? resolve(target.startsWith('~') ? target.replace(/^~/, homedir()) : target)
|
|
265
|
+
: resolve(startCwd, target);
|
|
266
|
+
const allowedRoots = [root, ...additionalDirectories];
|
|
267
|
+
for (const allowedRaw of allowedRoots) {
|
|
268
|
+
const allowed = allowedRaw.endsWith('/') ? allowedRaw.slice(0, -1) : allowedRaw;
|
|
269
|
+
if (resolved === allowed || resolved.startsWith(`${allowed}/`)) {
|
|
270
|
+
return resolved;
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
emitEvent(session, 'bash.cwd_escape', { cmd, reason: 'cd target outside workspace' });
|
|
274
|
+
return root;
|
|
275
|
+
}
|
|
276
|
+
function emitEvent(session, name, body) {
|
|
277
|
+
if (!session.enabled)
|
|
278
|
+
return;
|
|
279
|
+
const line = JSON.stringify({
|
|
280
|
+
id: randomUUID(),
|
|
281
|
+
sessionId: session.id,
|
|
282
|
+
timestamp: new Date().toISOString(),
|
|
283
|
+
type: 'bash',
|
|
284
|
+
name,
|
|
285
|
+
...body,
|
|
286
|
+
});
|
|
287
|
+
try {
|
|
288
|
+
appendFileSync(session.eventsPath, `${line}\n`, { encoding: 'utf8', mode: 0o600 });
|
|
289
|
+
}
|
|
290
|
+
catch {
|
|
291
|
+
// Event log is best-effort; never crash the tool because of it.
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
function persistOverflow(input) {
|
|
295
|
+
const dir = join(input.root, '.pugi', 'artifacts', input.sessionId);
|
|
296
|
+
try {
|
|
297
|
+
mkdirSync(dir, { recursive: true });
|
|
298
|
+
const path = join(dir, `bash-${input.toolCallId}.out`);
|
|
299
|
+
const body = `--- stdout ---\n${input.stdout}\n--- stderr ---\n${input.stderr}\n`;
|
|
300
|
+
writeFileSync(path, body, { encoding: 'utf8', mode: 0o600 });
|
|
301
|
+
return path;
|
|
302
|
+
}
|
|
303
|
+
catch {
|
|
304
|
+
return '';
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
function capToCombined(stdout, stderr) {
|
|
308
|
+
// Split the budget proportionally so the head of each stream is
|
|
309
|
+
// preserved. When one stream is empty the other gets the full
|
|
310
|
+
// budget.
|
|
311
|
+
if (stdout.length + stderr.length <= BASH_OUTPUT_CAP_BYTES) {
|
|
312
|
+
return { stdout, stderr };
|
|
313
|
+
}
|
|
314
|
+
if (stdout.length === 0) {
|
|
315
|
+
return { stdout: '', stderr: trimWithMarker(stderr, BASH_OUTPUT_CAP_BYTES) };
|
|
316
|
+
}
|
|
317
|
+
if (stderr.length === 0) {
|
|
318
|
+
return { stdout: trimWithMarker(stdout, BASH_OUTPUT_CAP_BYTES), stderr: '' };
|
|
319
|
+
}
|
|
320
|
+
const total = stdout.length + stderr.length;
|
|
321
|
+
const stdoutBudget = Math.max(1024, Math.floor((stdout.length / total) * BASH_OUTPUT_CAP_BYTES));
|
|
322
|
+
const stderrBudget = BASH_OUTPUT_CAP_BYTES - stdoutBudget;
|
|
323
|
+
return {
|
|
324
|
+
stdout: trimWithMarker(stdout, stdoutBudget),
|
|
325
|
+
stderr: trimWithMarker(stderr, stderrBudget),
|
|
326
|
+
};
|
|
327
|
+
}
|
|
328
|
+
function trimWithMarker(text, budget) {
|
|
329
|
+
if (text.length <= budget)
|
|
330
|
+
return text;
|
|
331
|
+
return `${text.slice(0, budget)}\n(...truncated at ${budget} bytes; full output in artifactRef)`;
|
|
332
|
+
}
|
|
333
|
+
async function waitWithTimeout(child, timeoutMs) {
|
|
334
|
+
return await new Promise((resolvePromise) => {
|
|
335
|
+
let settled = false;
|
|
336
|
+
const sigtermTimer = setTimeout(() => {
|
|
337
|
+
if (settled)
|
|
338
|
+
return;
|
|
339
|
+
try {
|
|
340
|
+
child.kill('SIGTERM');
|
|
341
|
+
}
|
|
342
|
+
catch {
|
|
343
|
+
// child already exited
|
|
344
|
+
}
|
|
345
|
+
const sigkillTimer = setTimeout(() => {
|
|
346
|
+
if (settled)
|
|
347
|
+
return;
|
|
348
|
+
try {
|
|
349
|
+
child.kill('SIGKILL');
|
|
350
|
+
}
|
|
351
|
+
catch {
|
|
352
|
+
// already gone
|
|
353
|
+
}
|
|
354
|
+
}, BASH_SIGKILL_GRACE_MS);
|
|
355
|
+
sigkillTimer.unref();
|
|
356
|
+
}, timeoutMs);
|
|
357
|
+
sigtermTimer.unref();
|
|
358
|
+
const onClose = (code, signal) => {
|
|
359
|
+
if (settled)
|
|
360
|
+
return;
|
|
361
|
+
settled = true;
|
|
362
|
+
clearTimeout(sigtermTimer);
|
|
363
|
+
if (signal === 'SIGTERM' || signal === 'SIGKILL') {
|
|
364
|
+
// Heuristic: if we sent the signal because of the timer,
|
|
365
|
+
// report timeout. If the child raced and exited with the
|
|
366
|
+
// signal anyway (e.g. user pressed ^C through SIGINT trap)
|
|
367
|
+
// we still report timeout when the wall-clock crossed.
|
|
368
|
+
resolvePromise({ timedOut: true, exitCode: 124 });
|
|
369
|
+
return;
|
|
370
|
+
}
|
|
371
|
+
resolvePromise({ timedOut: false, exitCode: code ?? 1 });
|
|
372
|
+
};
|
|
373
|
+
child.on('close', onClose);
|
|
374
|
+
child.on('error', () => {
|
|
375
|
+
if (settled)
|
|
376
|
+
return;
|
|
377
|
+
settled = true;
|
|
378
|
+
clearTimeout(sigtermTimer);
|
|
379
|
+
resolvePromise({ timedOut: false, exitCode: 1 });
|
|
380
|
+
});
|
|
381
|
+
});
|
|
382
|
+
}
|
|
383
|
+
function runBackground(input) {
|
|
384
|
+
const { cmd, ctx, toolCallId, startCwd } = input;
|
|
385
|
+
const childEnv = buildChildEnv();
|
|
386
|
+
const child = spawn('/bin/sh', ['-c', cmd], {
|
|
387
|
+
cwd: startCwd,
|
|
388
|
+
env: childEnv,
|
|
389
|
+
stdio: 'ignore',
|
|
390
|
+
detached: true,
|
|
391
|
+
});
|
|
392
|
+
child.unref();
|
|
393
|
+
const jobId = `pj-${randomUUID()}`;
|
|
394
|
+
const classification = classifyBash(cmd, {
|
|
395
|
+
workspaceRoot: ctx.root,
|
|
396
|
+
additionalDirectories: input.additionalDirectories,
|
|
397
|
+
});
|
|
398
|
+
const registry = getJobRegistry();
|
|
399
|
+
// Persist into the new registry. The promise is intentionally not
|
|
400
|
+
// awaited — `runBackground` is a synchronous control path inside the
|
|
401
|
+
// bash tool's async wrapper. The registry's atomic write is
|
|
402
|
+
// synchronous under the hood so the ledger is consistent before the
|
|
403
|
+
// caller observes the returned jobId, even when we drop the
|
|
404
|
+
// promise. Wire as a `.catch` so an unhandled-rejection never
|
|
405
|
+
// crashes the engine loop.
|
|
406
|
+
void registry
|
|
407
|
+
.add({
|
|
408
|
+
id: jobId,
|
|
409
|
+
pid: child.pid ?? -1,
|
|
410
|
+
command: cmd,
|
|
411
|
+
bashClass: classification.class,
|
|
412
|
+
cwd: startCwd,
|
|
413
|
+
sessionId: ctx.session.id,
|
|
414
|
+
})
|
|
415
|
+
.catch(() => {
|
|
416
|
+
// Best-effort persistence; the in-process tool still returned
|
|
417
|
+
// the jobId so the engine loop knows the spawn succeeded.
|
|
418
|
+
});
|
|
419
|
+
emitEvent(ctx.session, 'bash.background_started', {
|
|
420
|
+
jobId,
|
|
421
|
+
pid: child.pid ?? -1,
|
|
422
|
+
cmd,
|
|
423
|
+
});
|
|
424
|
+
recordToolResult(ctx.session, toolCallId, 'success', `bash background jobId=${jobId} pid=${child.pid ?? -1}`);
|
|
425
|
+
return {
|
|
426
|
+
stdout: `bash started in background as ${jobId}`,
|
|
427
|
+
stderr: '',
|
|
428
|
+
exitCode: 0,
|
|
429
|
+
jobId,
|
|
430
|
+
nextCwd: ctx.lastBashCwd ?? ctx.root,
|
|
431
|
+
truncated: false,
|
|
432
|
+
timedOut: false,
|
|
433
|
+
};
|
|
434
|
+
}
|
|
435
|
+
/**
|
|
436
|
+
* Legacy export preserved for α5.2 callers / tests. Delegates to the
|
|
437
|
+
* new JobRegistry and projects entries back into the historical
|
|
438
|
+
* `PugiJob` shape.
|
|
439
|
+
*/
|
|
440
|
+
export function listJobs() {
|
|
441
|
+
const entries = readRegistryEntriesSync();
|
|
442
|
+
return entries.map(entryToLegacyJob);
|
|
443
|
+
}
|
|
444
|
+
/**
|
|
445
|
+
* Legacy export preserved for α5.2 callers / tests. Delegates to the
|
|
446
|
+
* new JobRegistry. Returns the same `{ killed, reason? }` shape so the
|
|
447
|
+
* existing bash-tool test suite continues to pass without an
|
|
448
|
+
* end-to-end rewrite.
|
|
449
|
+
*/
|
|
450
|
+
export function killJob(jobId) {
|
|
451
|
+
const entries = readRegistryEntriesSync();
|
|
452
|
+
const target = entries.find((entry) => entry.id === jobId);
|
|
453
|
+
if (!target)
|
|
454
|
+
return { killed: false, reason: `unknown jobId: ${jobId}` };
|
|
455
|
+
// Mirror the legacy semantics: synchronous SIGTERM + best-effort
|
|
456
|
+
// SIGKILL escalation + remove the entry from the ledger so the
|
|
457
|
+
// bash-tool test suite's `listJobs().find(...) === undefined`
|
|
458
|
+
// assertion keeps holding. The richer `JobRegistry.kill` (status
|
|
459
|
+
// transitions, async exit polling) is what the new `pugi jobs kill`
|
|
460
|
+
// CLI command uses.
|
|
461
|
+
try {
|
|
462
|
+
process.kill(target.pid, 'SIGTERM');
|
|
463
|
+
}
|
|
464
|
+
catch (error) {
|
|
465
|
+
const code = error.code;
|
|
466
|
+
if (code === 'ESRCH') {
|
|
467
|
+
removeRegistryEntrySync(jobId);
|
|
468
|
+
return { killed: false, reason: 'job already exited' };
|
|
469
|
+
}
|
|
470
|
+
return { killed: false, reason: `kill failed: ${error.message}` };
|
|
471
|
+
}
|
|
472
|
+
setTimeout(() => {
|
|
473
|
+
try {
|
|
474
|
+
process.kill(target.pid, 0);
|
|
475
|
+
try {
|
|
476
|
+
process.kill(target.pid, 'SIGKILL');
|
|
477
|
+
}
|
|
478
|
+
catch {
|
|
479
|
+
// gone between the check and the signal
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
catch {
|
|
483
|
+
// already dead
|
|
484
|
+
}
|
|
485
|
+
}, BASH_SIGKILL_GRACE_MS).unref();
|
|
486
|
+
removeRegistryEntrySync(jobId);
|
|
487
|
+
return { killed: true };
|
|
488
|
+
}
|
|
489
|
+
function entryToLegacyJob(entry) {
|
|
490
|
+
return {
|
|
491
|
+
jobId: entry.id,
|
|
492
|
+
pid: entry.pid,
|
|
493
|
+
cwd: entry.cwd,
|
|
494
|
+
cmd: entry.command,
|
|
495
|
+
class: entry.bashClass,
|
|
496
|
+
startedAt: entry.startedAt,
|
|
497
|
+
sessionId: entry.sessionId,
|
|
498
|
+
};
|
|
499
|
+
}
|
|
500
|
+
/**
|
|
501
|
+
* Synchronous read of the registry file. Used by the legacy
|
|
502
|
+
* `listJobs()` / `killJob()` exports because they cannot return a
|
|
503
|
+
* promise without a breaking signature change. The new `JobRegistry`
|
|
504
|
+
* interface is the async path.
|
|
505
|
+
*/
|
|
506
|
+
function readRegistryEntriesSync() {
|
|
507
|
+
const path = join(homedir(), '.pugi', 'jobs.json');
|
|
508
|
+
// Inline read so the legacy listJobs/killJob entry points do not
|
|
509
|
+
// require an async hop into JobRegistry. The shape parsing mirrors
|
|
510
|
+
// `normalizeEntry` inside `core/jobs/registry.ts`.
|
|
511
|
+
if (!existsSync(path))
|
|
512
|
+
return [];
|
|
513
|
+
try {
|
|
514
|
+
const raw = readFileSync(path, 'utf8');
|
|
515
|
+
if (raw.trim() === '')
|
|
516
|
+
return [];
|
|
517
|
+
const parsed = JSON.parse(raw);
|
|
518
|
+
if (!Array.isArray(parsed))
|
|
519
|
+
return [];
|
|
520
|
+
const out = [];
|
|
521
|
+
for (const candidate of parsed) {
|
|
522
|
+
if (typeof candidate !== 'object' || candidate === null)
|
|
523
|
+
continue;
|
|
524
|
+
const c = candidate;
|
|
525
|
+
const id = typeof c['id'] === 'string'
|
|
526
|
+
? c['id']
|
|
527
|
+
: typeof c['jobId'] === 'string'
|
|
528
|
+
? c['jobId']
|
|
529
|
+
: undefined;
|
|
530
|
+
const pid = typeof c['pid'] === 'number' ? c['pid'] : undefined;
|
|
531
|
+
const command = typeof c['command'] === 'string'
|
|
532
|
+
? c['command']
|
|
533
|
+
: typeof c['cmd'] === 'string'
|
|
534
|
+
? c['cmd']
|
|
535
|
+
: undefined;
|
|
536
|
+
if (!id || pid === undefined || command === undefined)
|
|
537
|
+
continue;
|
|
538
|
+
const bashClassRaw = typeof c['bashClass'] === 'string'
|
|
539
|
+
? c['bashClass']
|
|
540
|
+
: typeof c['class'] === 'string'
|
|
541
|
+
? c['class']
|
|
542
|
+
: 'unknown';
|
|
543
|
+
const status = c['status'] === 'finished' ||
|
|
544
|
+
c['status'] === 'killed' ||
|
|
545
|
+
c['status'] === 'failed' ||
|
|
546
|
+
c['status'] === 'abandoned'
|
|
547
|
+
? c['status']
|
|
548
|
+
: 'running';
|
|
549
|
+
out.push({
|
|
550
|
+
id,
|
|
551
|
+
pid,
|
|
552
|
+
command,
|
|
553
|
+
bashClass: bashClassRaw,
|
|
554
|
+
cwd: typeof c['cwd'] === 'string' ? c['cwd'] : '',
|
|
555
|
+
startedAt: typeof c['startedAt'] === 'string'
|
|
556
|
+
? c['startedAt']
|
|
557
|
+
: new Date().toISOString(),
|
|
558
|
+
status,
|
|
559
|
+
sessionId: typeof c['sessionId'] === 'string' ? c['sessionId'] : 'unknown',
|
|
560
|
+
});
|
|
561
|
+
}
|
|
562
|
+
return out;
|
|
563
|
+
}
|
|
564
|
+
catch {
|
|
565
|
+
return [];
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
function removeRegistryEntrySync(jobId) {
|
|
569
|
+
const path = join(homedir(), '.pugi', 'jobs.json');
|
|
570
|
+
const entries = readRegistryEntriesSync().filter((entry) => entry.id !== jobId);
|
|
571
|
+
try {
|
|
572
|
+
mkdirSync(join(homedir(), '.pugi'), { recursive: true });
|
|
573
|
+
writeFileSync(path, `${JSON.stringify(entries, null, 2)}\n`, {
|
|
574
|
+
encoding: 'utf8',
|
|
575
|
+
mode: 0o600,
|
|
576
|
+
});
|
|
577
|
+
}
|
|
578
|
+
catch {
|
|
579
|
+
// best-effort
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
/**
|
|
583
|
+
* Synchronous helper used by the legacy tool-bridge path. It wraps
|
|
584
|
+
* `spawnSync` for the simplest case (no background, no overflow
|
|
585
|
+
* artifact, default timeout) so callers that cannot await a promise
|
|
586
|
+
* still get the class-aware permission gate. Returns the same shape
|
|
587
|
+
* as the async tool minus the cwd carry-over (since spawnSync
|
|
588
|
+
* cannot stream we approximate the cap by post-truncation).
|
|
589
|
+
*/
|
|
590
|
+
export function bashToolSync(input, ctx) {
|
|
591
|
+
const cmd = input.cmd ?? '';
|
|
592
|
+
const additionalDirectories = ctx.additionalDirectories ?? [];
|
|
593
|
+
const source = ctx.source ?? 'agent';
|
|
594
|
+
const toolCallId = recordToolCall(ctx.session, 'bash', cmd);
|
|
595
|
+
const decision = evaluateBashPermission(cmd, ctx.settings.permissions.mode, {
|
|
596
|
+
workspaceRoot: ctx.root,
|
|
597
|
+
additionalDirectories,
|
|
598
|
+
source,
|
|
599
|
+
});
|
|
600
|
+
if (decision.decision !== 'allow') {
|
|
601
|
+
const reason = `Permission ${decision.decision}: ${decision.reason}`;
|
|
602
|
+
recordToolResult(ctx.session, toolCallId, 'error', reason);
|
|
603
|
+
return {
|
|
604
|
+
stdout: '',
|
|
605
|
+
stderr: `Permission denied: ${decision.reason}`,
|
|
606
|
+
exitCode: 126,
|
|
607
|
+
nextCwd: ctx.lastBashCwd ?? ctx.root,
|
|
608
|
+
truncated: false,
|
|
609
|
+
timedOut: false,
|
|
610
|
+
};
|
|
611
|
+
}
|
|
612
|
+
const startCwd = resolveStartCwd(input.cwd ?? ctx.lastBashCwd, ctx.root, additionalDirectories);
|
|
613
|
+
const timeoutMs = sanitizeTimeout(input.timeoutMs);
|
|
614
|
+
const childEnv = buildChildEnv();
|
|
615
|
+
const result = spawnSync('/bin/sh', ['-c', cmd], {
|
|
616
|
+
cwd: startCwd,
|
|
617
|
+
env: childEnv,
|
|
618
|
+
encoding: 'utf8',
|
|
619
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
620
|
+
timeout: timeoutMs,
|
|
621
|
+
maxBuffer: 10 * 1024 * 1024,
|
|
622
|
+
});
|
|
623
|
+
const stdoutFull = (result.stdout ?? '').toString();
|
|
624
|
+
const stderrFull = (result.stderr ?? '').toString();
|
|
625
|
+
const truncated = stdoutFull.length + stderrFull.length > BASH_OUTPUT_CAP_BYTES;
|
|
626
|
+
let artifactRef;
|
|
627
|
+
let stdoutOut = stdoutFull;
|
|
628
|
+
let stderrOut = stderrFull;
|
|
629
|
+
if (truncated) {
|
|
630
|
+
artifactRef = persistOverflow({
|
|
631
|
+
root: ctx.root,
|
|
632
|
+
sessionId: ctx.session.id,
|
|
633
|
+
toolCallId,
|
|
634
|
+
stdout: stdoutFull,
|
|
635
|
+
stderr: stderrFull,
|
|
636
|
+
});
|
|
637
|
+
({ stdout: stdoutOut, stderr: stderrOut } = capToCombined(stdoutFull, stderrFull));
|
|
638
|
+
}
|
|
639
|
+
const timedOut = result.error?.code === 'ETIMEDOUT' ||
|
|
640
|
+
result.signal === 'SIGTERM';
|
|
641
|
+
const exitCode = timedOut ? 124 : result.status ?? 1;
|
|
642
|
+
const nextCwd = computeNextCwd(cmd, startCwd, ctx.root, additionalDirectories, ctx.session);
|
|
643
|
+
if (timedOut) {
|
|
644
|
+
emitEvent(ctx.session, 'bash.timeout', { cmd, timeoutMs });
|
|
645
|
+
recordToolResult(ctx.session, toolCallId, 'error', `bash timed out after ${timeoutMs}ms`);
|
|
646
|
+
}
|
|
647
|
+
else {
|
|
648
|
+
recordToolResult(ctx.session, toolCallId, 'success', `bash exit=${exitCode} bytes=${stdoutFull.length + stderrFull.length}${artifactRef ? ` overflow=${artifactRef}` : ''}`);
|
|
649
|
+
}
|
|
650
|
+
return {
|
|
651
|
+
stdout: stdoutOut,
|
|
652
|
+
stderr: stderrOut,
|
|
653
|
+
exitCode,
|
|
654
|
+
artifactRef,
|
|
655
|
+
nextCwd,
|
|
656
|
+
truncated,
|
|
657
|
+
timedOut,
|
|
658
|
+
};
|
|
659
|
+
}
|
|
660
|
+
//# sourceMappingURL=bash.js.map
|