aegis-desktop 1.0.13 → 1.0.15
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/SOURCE-OFFER.txt +9 -0
- package/bin/aegis.js +1 -32
- package/lib/account-key.js +1 -56
- package/lib/auto.js +1 -430
- package/lib/avatar/companion.js +1 -698
- package/lib/avatar/cosmetics.js +1 -532
- package/lib/avatar/events.js +1 -344
- package/lib/avatar/holder-turn.js +1 -350
- package/lib/avatar/holders.js +1 -612
- package/lib/avatar/identity.js +1 -641
- package/lib/avatar/level.js +1 -368
- package/lib/avatar/mirror.js +1 -524
- package/lib/avatar/persona.js +1 -388
- package/lib/avatar/profile.js +1 -977
- package/lib/avatar/register.js +1 -307
- package/lib/avatar/store.js +1 -472
- package/lib/avatar/tiers.js +1 -38
- package/lib/avatar/transfer.js +1 -415
- package/lib/avatar/turn.js +1 -216
- package/lib/avatar/voice.js +1 -328
- package/lib/avatar/xp.js +1 -404
- package/lib/deep-link.js +1 -120
- package/lib/fingerprint.js +2 -347
- package/lib/google-connect.js +1 -169
- package/lib/local/agents.js +1 -105
- package/lib/local/autonomous.js +1 -1045
- package/lib/local/claude-pro.js +1 -993
- package/lib/local/context.js +1 -95
- package/lib/local/engine.js +1 -3228
- package/lib/local/entitlement.js +1 -654
- package/lib/local/git-scope.js +1 -228
- package/lib/local/local.js +1 -2135
- package/lib/local/prompt.js +1 -275
- package/lib/local/queue.js +1 -560
- package/lib/local/session-rounds.js +1 -208
- package/lib/local/shell.js +1 -680
- package/lib/local/telemetry.js +1 -470
- package/lib/local/tools.js +1 -1238
- package/lib/local/turn-guard.js +1 -300
- package/lib/local/worktree-lock.js +1 -235
- package/lib/provider-key-mirror.js +1 -252
- package/lib/quick-launcher.js +1 -84
- package/lib/settings.js +1 -890
- package/lib/sync/memory-queue.js +1 -60
- package/lib/sync/persist-gate.js +1 -148
- package/lib/sync/sessions.js +1 -106
- package/lib/window-state.js +1 -66
- package/main.js +3 -4390
- package/package.json +3 -5
- package/preload.js +3 -628
- package/renderer/app.js +1 -6560
- package/renderer/avatar/assemble.js +1 -335
- package/renderer/avatar/avatar.js +1 -380
- package/renderer/avatar/cards.js +1 -141
- package/renderer/avatar/hud.js +1 -206
- package/renderer/avatar/machine.js +1 -213
- package/renderer/avatar/pane.js +1 -439
- package/renderer/avatar/parts.js +1 -437
- package/renderer/budget.js +1 -168
- package/renderer/companion.js +1 -223
- package/renderer/diff.js +1 -359
- package/renderer/diffview.js +1 -336
- package/renderer/fingerprint.js +3 -720
- package/renderer/markdown.js +1 -168
- package/renderer/provider-toolbar.js +1 -134
- package/renderer/providers.js +1 -259
- package/renderer/quick.js +1 -191
- package/renderer/stream-policy.js +1 -306
- package/renderer/transcript-view.js +1 -323
- package/renderer/usage.js +1 -1239
- package/vendor/aegis.js +1 -2054
- package/vendor/attest.js +1 -442
- package/vendor/authorship-key.js +1 -410
- package/vendor/auto-provision.js +1 -134
- package/vendor/brain-catalog.js +1 -268
- package/vendor/byok-catalog.js +1 -230
- package/vendor/credentials.js +1 -494
- package/vendor/device-fingerprint.js +1 -141
- package/vendor/drive-connect.js +1 -85
- package/vendor/drive-upload.js +1 -174
- package/vendor/env-file.js +1 -1273
- package/vendor/fingerprint.js +1 -1064
- package/vendor/foreign-memory.js +1 -669
- package/vendor/google-signin.js +1 -125
- package/vendor/hooks.js +1 -430
- package/vendor/model-discovery.js +1 -330
- package/vendor/platform.js +1 -937
- package/vendor/prefs.js +1 -351
- package/vendor/presence-constants.js +1 -107
- package/vendor/presence.js +1 -438
- package/vendor/record-vault.js +3 -1055
- package/vendor/session-store.js +1 -548
- package/vendor/topup.js +1 -297
- package/vendor/touch.js +1 -458
- package/vendor/update.js +1 -704
- package/vendor/user-models.js +1 -381
package/lib/local/shell.js
CHANGED
|
@@ -1,682 +1,3 @@
|
|
|
1
1
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
2
|
// Copyright (C) 2026 Niklas Borneklint — AEGIS Code (aegiscode) / Aegiscloud.
|
|
3
|
-
// AEGIS Code (aegiscode) — source: https://github.com/aegisinfo/aegiscode-plugin
|
|
4
|
-
// Conveying a modified copy, or running one as a network service, owes the
|
|
5
|
-
// corresponding source under this licence (§5/§13) — see LICENSING.md.
|
|
6
|
-
'use strict';
|
|
7
|
-
|
|
8
|
-
/**
|
|
9
|
-
* shell.js — a persistent shell session for the `exec` tool, ported from
|
|
10
|
-
* the upstream engine reference (client half of the same design).
|
|
11
|
-
*
|
|
12
|
-
* The desktop `exec` tool used to spawn ONE process per call: `cd /foo` in
|
|
13
|
-
* one turn had no effect on the next call, so a model that wanted to work
|
|
14
|
-
* inside a subdirectory had to prefix every single command with `cd X &&`.
|
|
15
|
-
* This keeps ONE long-lived shell per chat turn — a probed POSIX sh/bash on
|
|
16
|
-
* macOS/Linux, PowerShell on Windows (no bash there by default) — feeding it
|
|
17
|
-
* commands over stdin and framing each command's output with a per-session
|
|
18
|
-
* random sentinel printed alongside the exit code. On POSIX, stderr is merged
|
|
19
|
-
* into stdout (`exec 2>&1`) so ordering is preserved; PowerShell's stderr pipe
|
|
20
|
-
* is merged the same way by listening on both streams.
|
|
21
|
-
*
|
|
22
|
-
* Phase 30.3 — the shell is PROBED, never assumed. Until this step the session
|
|
23
|
-
* spawned `process.env.SHELL || '/bin/bash'` and then fed it brace groups, `exec
|
|
24
|
-
* 2>&1` and `$?` — all POSIX-only. Windows was handled; a POSIX host whose
|
|
25
|
-
* `$SHELL` is fish, csh or nushell was not, and those three reject that syntax
|
|
26
|
-
* outright (a brace group to fish is not a syntax error you can ignore: it
|
|
27
|
-
* silently runs the wrong thing and eats the sentinel, hanging the session to
|
|
28
|
-
* its timeout). So `resolveSessionShell()` below only ever returns a shell it
|
|
29
|
-
* has actually EXECUTED once with a POSIX-only probe script, and the dialect the
|
|
30
|
-
* script builder uses follows the verified dialect — `this.shell.kind` — rather
|
|
31
|
-
* than the OS. A `$SHELL` naming a known-non-POSIX shell (fish/csh/tcsh/nu/
|
|
32
|
-
* xonsh) is never adopted and never probed; it is named in the evidence trail
|
|
33
|
-
* and in the caveat, and if no sh/bash can be verified at all the session
|
|
34
|
-
* REFUSES with a message naming it, instead of quietly feeding it POSIX syntax.
|
|
35
|
-
*
|
|
36
|
-
* Self-contained (node:child_process + node:crypto + the shared
|
|
37
|
-
* client/platform.js) and never throws: run() always resolves
|
|
38
|
-
* { content, isError }. If the session can't start or dies, run() falls back to
|
|
39
|
-
* a one-shot spawn so `exec` keeps working.
|
|
40
|
-
*/
|
|
41
|
-
|
|
42
|
-
const { spawn, spawnSync } = require('node:child_process');
|
|
43
|
-
const crypto = require('node:crypto');
|
|
44
|
-
|
|
45
|
-
// The ONE platform module (client/platform.js, Phase 30.1) — resolved the same
|
|
46
|
-
// two ways this repo resolves every shared module (see tools.js/engine.js):
|
|
47
|
-
// repo-relative in a checkout and in the CLI's mirrored vendor tree, then the
|
|
48
|
-
// staged app-dir copy a packaged desktop build carries. Executable lookup goes
|
|
49
|
-
// through it (`resolveExecutable`) rather than through a path literal, so the
|
|
50
|
-
// Windows half of that search is exercised by tests on this Linux host.
|
|
51
|
-
let platform;
|
|
52
|
-
try {
|
|
53
|
-
platform = require('../../../client/platform.js');
|
|
54
|
-
} catch (err) {
|
|
55
|
-
try {
|
|
56
|
-
platform = require('../../vendor/platform.js');
|
|
57
|
-
} catch {
|
|
58
|
-
throw new Error(
|
|
59
|
-
`shell.js: cannot load the shared platform module (client/platform.js, ` +
|
|
60
|
-
`or its staged copy): ${err && err.message ? err.message : err}`
|
|
61
|
-
);
|
|
62
|
-
}
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
const IS_WIN32 = platform.IS_WIN32;
|
|
66
|
-
const OUTPUT_CAP = 30_000;
|
|
67
|
-
const MAX_TIMEOUT = 600_000;
|
|
68
|
-
const DEFAULT_TIMEOUT = 120_000;
|
|
69
|
-
|
|
70
|
-
// ---------------------------------------------------------------------------
|
|
71
|
-
// Dialect resolution (Phase 30.3)
|
|
72
|
-
// ---------------------------------------------------------------------------
|
|
73
|
-
|
|
74
|
-
/**
|
|
75
|
-
* The shells whose `-c` we are allowed to treat as POSIX sh. Anything else —
|
|
76
|
-
* fish, csh/tcsh, nushell, xonsh, elvish, pwsh — is a different LANGUAGE, not a
|
|
77
|
-
* differently-named sh: it is never spawned for a session and never handed the
|
|
78
|
-
* brace-group framing below.
|
|
79
|
-
*/
|
|
80
|
-
const POSIX_SHELL_NAMES = ['bash', 'sh', 'dash', 'ksh', 'zsh'];
|
|
81
|
-
|
|
82
|
-
/** Substitute candidates, in order, when `$SHELL` is unusable. */
|
|
83
|
-
const POSIX_FALLBACK_NAMES = ['bash', 'sh'];
|
|
84
|
-
|
|
85
|
-
/**
|
|
86
|
-
* The probe script: builtins only (no PATH, no filesystem, no output), and
|
|
87
|
-
* POSIX-only syntax — fish, csh and nu all reject this construct, so a clean
|
|
88
|
-
* exit 0 is evidence that `-c` really runs POSIX sh here, rather than evidence
|
|
89
|
-
* that a binary with the right name exists. Same probe the engine's Phase 21b
|
|
90
|
-
* resolution uses.
|
|
91
|
-
*/
|
|
92
|
-
const POSIX_PROBE = 'if [ 1 -eq 1 ]; then exit 0; fi';
|
|
93
|
-
const SHELL_PROBE_TIMEOUT = 4000;
|
|
94
|
-
|
|
95
|
-
/** Last path segment of a shell reference, minus a `.exe`, lower-cased. */
|
|
96
|
-
function shellName(cmd) {
|
|
97
|
-
return String(cmd || '')
|
|
98
|
-
.trim()
|
|
99
|
-
.split(/[\\/]/)
|
|
100
|
-
.pop()
|
|
101
|
-
.toLowerCase()
|
|
102
|
-
.replace(/\.exe$/, '');
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
/** True for the shells whose `-c` may be treated as POSIX sh. */
|
|
106
|
-
function isPosixShellName(cmd) {
|
|
107
|
-
return POSIX_SHELL_NAMES.includes(shellName(cmd));
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
/** The real probe: spawn `cmd -c <POSIX_PROBE>`, true only on a clean exit 0. */
|
|
111
|
-
function probePosixShell(cmd, { env, timeout = SHELL_PROBE_TIMEOUT } = {}) {
|
|
112
|
-
try {
|
|
113
|
-
const res = spawnSync(cmd, ['-c', POSIX_PROBE], {
|
|
114
|
-
env,
|
|
115
|
-
encoding: 'utf8',
|
|
116
|
-
timeout,
|
|
117
|
-
windowsHide: true,
|
|
118
|
-
stdio: 'ignore',
|
|
119
|
-
});
|
|
120
|
-
return !res.error && res.status === 0;
|
|
121
|
-
} catch {
|
|
122
|
-
return false;
|
|
123
|
-
}
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
/** Normalize the injected seams, defaulting each to the real thing. */
|
|
127
|
-
function shellSeams(options) {
|
|
128
|
-
const o = options && typeof options === 'object' ? options : {};
|
|
129
|
-
return {
|
|
130
|
-
platform: typeof o.platform === 'string' && o.platform ? o.platform : process.platform,
|
|
131
|
-
env: o.env && typeof o.env === 'object' ? o.env : process.env,
|
|
132
|
-
exists: typeof o.exists === 'function' ? o.exists : undefined,
|
|
133
|
-
probe: typeof o.probe === 'function' ? o.probe : undefined,
|
|
134
|
-
injected: typeof o.probe === 'function' || typeof o.exists === 'function',
|
|
135
|
-
};
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
/**
|
|
139
|
-
* Try one candidate. Returns a verified spec or null, recording why in `tried`
|
|
140
|
-
* and every shell it actually executed in `executed`.
|
|
141
|
-
*
|
|
142
|
-
* The name gate comes FIRST and applies to every candidate, including `$SHELL`:
|
|
143
|
-
* fish is refused on its name, before a probe could ever adopt it, so there is
|
|
144
|
-
* no ordering in which a fish gets spawned and then handed POSIX syntax.
|
|
145
|
-
*/
|
|
146
|
-
function considerPosixCandidate(o, candidate, tried, executed) {
|
|
147
|
-
const raw = String(candidate.cmd || '').trim();
|
|
148
|
-
if (!raw) {
|
|
149
|
-
tried.push({ candidate: candidate.source || '(unset)', ok: false, reason: 'unset' });
|
|
150
|
-
return null;
|
|
151
|
-
}
|
|
152
|
-
if (!isPosixShellName(raw)) {
|
|
153
|
-
tried.push({
|
|
154
|
-
candidate: raw,
|
|
155
|
-
ok: false,
|
|
156
|
-
reason:
|
|
157
|
-
`${candidate.source || 'candidate'} is not a POSIX shell — ${shellName(raw) || raw} is a ` +
|
|
158
|
-
'different language (fish/csh/nu), so it was never executed and never sent POSIX syntax',
|
|
159
|
-
});
|
|
160
|
-
return null;
|
|
161
|
-
}
|
|
162
|
-
const resolved = platform.resolveExecutable(raw, {
|
|
163
|
-
platform: o.platform,
|
|
164
|
-
env: o.env,
|
|
165
|
-
exists: o.exists,
|
|
166
|
-
});
|
|
167
|
-
if (!resolved) {
|
|
168
|
-
tried.push({
|
|
169
|
-
candidate: raw,
|
|
170
|
-
ok: false,
|
|
171
|
-
reason: /[\\/]/.test(raw) ? 'not present' : 'not found on PATH',
|
|
172
|
-
});
|
|
173
|
-
return null;
|
|
174
|
-
}
|
|
175
|
-
const ok = o.probe
|
|
176
|
-
? Boolean(o.probe(resolved, ['-c', POSIX_PROBE]))
|
|
177
|
-
: probePosixShell(resolved, { env: o.env });
|
|
178
|
-
executed.push(resolved);
|
|
179
|
-
if (!ok) {
|
|
180
|
-
tried.push({
|
|
181
|
-
candidate: resolved,
|
|
182
|
-
ok: false,
|
|
183
|
-
reason: `failed the POSIX probe (\`-c\` with \`${POSIX_PROBE}\` did not exit 0) — refused`,
|
|
184
|
-
});
|
|
185
|
-
return null;
|
|
186
|
-
}
|
|
187
|
-
tried.push({
|
|
188
|
-
candidate: resolved,
|
|
189
|
-
ok: true,
|
|
190
|
-
reason: 'verified: one `-c` POSIX script exited 0',
|
|
191
|
-
});
|
|
192
|
-
return { ok: true, cmd: resolved, arg: '-c', kind: 'posix-sh', source: candidate.source, verified: true };
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
/**
|
|
196
|
-
* Resolve the session shell on a POSIX host, with the evidence for the answer.
|
|
197
|
-
*
|
|
198
|
-
* Returns { ok, cmd, arg, kind, source, verified, unsupported, caveat, error,
|
|
199
|
-
* tried[] }:
|
|
200
|
-
*
|
|
201
|
-
* - `$SHELL` wins when it NAMES a POSIX shell and passes the probe;
|
|
202
|
-
* - a `$SHELL` naming fish/csh/nu/xonsh (or anything else non-POSIX, including
|
|
203
|
-
* garbage) is refused by name, recorded in `tried`, and named in `caveat`
|
|
204
|
-
* when a substitute is found and in `error` when none can be;
|
|
205
|
-
* - when `$SHELL` is unusable the POSIX dialect runs through an explicit
|
|
206
|
-
* `bash`/`sh` found on PATH — resolved by client/platform.js, not by a path
|
|
207
|
-
* literal — and only after that substitute passed the probe itself;
|
|
208
|
-
* - `ok:false` means NO shell was verified: the caller must report `error`
|
|
209
|
-
* (which names the unsupported `$SHELL`) instead of spawning anything.
|
|
210
|
-
*/
|
|
211
|
-
function resolvePosixSessionShell(o) {
|
|
212
|
-
const tried = [];
|
|
213
|
-
const executed = [];
|
|
214
|
-
const envShell = String((o.env && o.env.SHELL) || '').trim();
|
|
215
|
-
const candidates = [];
|
|
216
|
-
|
|
217
|
-
if (!envShell) {
|
|
218
|
-
tried.push({ candidate: '$SHELL', ok: false, reason: '$SHELL is unset' });
|
|
219
|
-
} else {
|
|
220
|
-
candidates.push({ cmd: envShell, source: '$SHELL' });
|
|
221
|
-
}
|
|
222
|
-
for (const name of POSIX_FALLBACK_NAMES) {
|
|
223
|
-
candidates.push({ cmd: name, source: `PATH:${name}` });
|
|
224
|
-
}
|
|
225
|
-
|
|
226
|
-
let hit = null;
|
|
227
|
-
for (const candidate of candidates) {
|
|
228
|
-
hit = considerPosixCandidate(o, candidate, tried, executed);
|
|
229
|
-
if (hit) break;
|
|
230
|
-
}
|
|
231
|
-
|
|
232
|
-
const unsupported = envShell && !isPosixShellName(envShell) ? envShell : null;
|
|
233
|
-
if (hit) {
|
|
234
|
-
return {
|
|
235
|
-
...hit,
|
|
236
|
-
unsupported,
|
|
237
|
-
caveat: unsupported
|
|
238
|
-
? `$SHELL is ${unsupported}, which is not a POSIX shell — the session runs the POSIX ` +
|
|
239
|
-
`dialect through ${hit.cmd} instead, and ${unsupported} is never sent brace groups, ` +
|
|
240
|
-
'`exec 2>&1` or `$?`'
|
|
241
|
-
: null,
|
|
242
|
-
error: null,
|
|
243
|
-
executed,
|
|
244
|
-
tried,
|
|
245
|
-
};
|
|
246
|
-
}
|
|
247
|
-
|
|
248
|
-
const error = unsupported
|
|
249
|
-
? `exec: refusing to run — $SHELL is "${unsupported}", which is not a POSIX shell ` +
|
|
250
|
-
'(fish/csh/nushell are a different language), and no sh/bash could be verified on PATH ' +
|
|
251
|
-
'to run the POSIX dialect instead. Set SHELL to bash or sh, or put one on PATH.'
|
|
252
|
-
: `exec: no POSIX shell available — $SHELL is ${envShell ? `"${envShell}"` : 'unset'} and no ` +
|
|
253
|
-
'sh/bash passed the POSIX probe; set SHELL to bash or sh, or put one on PATH.';
|
|
254
|
-
return {
|
|
255
|
-
ok: false,
|
|
256
|
-
cmd: null,
|
|
257
|
-
arg: null,
|
|
258
|
-
kind: null,
|
|
259
|
-
source: 'refused',
|
|
260
|
-
verified: false,
|
|
261
|
-
unsupported,
|
|
262
|
-
caveat: null,
|
|
263
|
-
error,
|
|
264
|
-
executed,
|
|
265
|
-
tried,
|
|
266
|
-
};
|
|
267
|
-
}
|
|
268
|
-
|
|
269
|
-
// Verified verdicts are cached per (platform, $SHELL, PATH) so the probe runs
|
|
270
|
-
// once per process — but never when a seam was injected: a test double must not
|
|
271
|
-
// be able to poison the real verdicts.
|
|
272
|
-
const SHELL_CACHE = new Map();
|
|
273
|
-
|
|
274
|
-
/**
|
|
275
|
-
* Which shell runs the session here, and the evidence for that answer.
|
|
276
|
-
*
|
|
277
|
-
* Windows keeps PowerShell (there is no bash there by default), exactly as
|
|
278
|
-
* before; everywhere else the answer comes from resolvePosixSessionShell above
|
|
279
|
-
* and is only ever a shell that passed the POSIX probe. `options` is the same
|
|
280
|
-
* injected seam the shared platform module takes —
|
|
281
|
-
* `{ platform, env, exists, probe }` — so every branch is exercised on this
|
|
282
|
-
* Linux host (test/shell-dialect.test.mjs) rather than on a runner this project
|
|
283
|
-
* does not have.
|
|
284
|
-
*/
|
|
285
|
-
function resolveSessionShell(options = {}) {
|
|
286
|
-
const o = shellSeams(options);
|
|
287
|
-
if (o.platform === 'win32') {
|
|
288
|
-
const cmdSpec = platform.shellSpec({ platform: 'win32', env: o.env });
|
|
289
|
-
const powershell =
|
|
290
|
-
platform.resolveExecutable('powershell', { platform: 'win32', env: o.env, exists: o.exists }) ||
|
|
291
|
-
'powershell.exe';
|
|
292
|
-
// ONE-SHOT NOW SPEAKS THE SESSION'S DIALECT TOO (2026-02-14 report, item 1).
|
|
293
|
-
//
|
|
294
|
-
// This used to be `oneShot: cmdSpec` — PowerShell for the session, cmd.exe
|
|
295
|
-
// for every one-shot — and that mismatch is what made `exec` silently wrong
|
|
296
|
-
// mid-turn. When the live session died (sentinel timeout, killed child, a
|
|
297
|
-
// rejected stdin write) the degrade paths below run one-shot, so from that
|
|
298
|
-
// moment on every remaining command in the turn was parsed by cmd.exe while
|
|
299
|
-
// the model kept emitting PowerShell quoting. cmd.exe answers
|
|
300
|
-
// `'"Write-Output \"' is not recognized as an internal or external command`
|
|
301
|
-
// — localized, so on sv-SE it reads `Felaktig syntax för filnamn, katalognamn
|
|
302
|
-
// eller volymetikett`, which looks like a filesystem fault and not a shell
|
|
303
|
-
// change — and nothing in the tool result said the shell had switched.
|
|
304
|
-
//
|
|
305
|
-
// `-Command` accepts exactly the quoting the session accepts, so a
|
|
306
|
-
// degradation is now a transport change and not a language change. cmd.exe
|
|
307
|
-
// is no longer reachable from this path at all: if PowerShell cannot be
|
|
308
|
-
// resolved the spawn fails loudly (oneShotShell reports `error: …`) instead
|
|
309
|
-
// of quietly running the command in a shell that cannot read it.
|
|
310
|
-
const oneShot = {
|
|
311
|
-
cmd: powershell,
|
|
312
|
-
arg: '-Command',
|
|
313
|
-
kind: 'powershell',
|
|
314
|
-
source: 'powershell.exe',
|
|
315
|
-
};
|
|
316
|
-
return {
|
|
317
|
-
ok: true,
|
|
318
|
-
cmd: powershell,
|
|
319
|
-
arg: '-',
|
|
320
|
-
kind: 'powershell',
|
|
321
|
-
source: 'powershell.exe',
|
|
322
|
-
verified: true,
|
|
323
|
-
unsupported: null,
|
|
324
|
-
caveat: null,
|
|
325
|
-
error: null,
|
|
326
|
-
oneShot,
|
|
327
|
-
executed: [],
|
|
328
|
-
tried: [
|
|
329
|
-
{ candidate: powershell, ok: true, reason: 'the Windows default (PowerShell session AND one-shot)' },
|
|
330
|
-
{
|
|
331
|
-
candidate: cmdSpec.cmd,
|
|
332
|
-
ok: false,
|
|
333
|
-
reason: 'cmd.exe is never used for one-shot: it cannot parse the PowerShell quoting the session teaches',
|
|
334
|
-
},
|
|
335
|
-
],
|
|
336
|
-
};
|
|
337
|
-
}
|
|
338
|
-
|
|
339
|
-
const key = [o.platform, o.env && o.env.SHELL, o.env && o.env.PATH].join('\u0000');
|
|
340
|
-
if (!o.injected && SHELL_CACHE.has(key)) return SHELL_CACHE.get(key);
|
|
341
|
-
const spec = resolvePosixSessionShell(o);
|
|
342
|
-
if (!o.injected) SHELL_CACHE.set(key, spec);
|
|
343
|
-
return spec;
|
|
344
|
-
}
|
|
345
|
-
|
|
346
|
-
function cap(s) {
|
|
347
|
-
return s.length > OUTPUT_CAP ? `${s.slice(0, OUTPUT_CAP)}\n… (truncated)` : s;
|
|
348
|
-
}
|
|
349
|
-
|
|
350
|
-
// The buffer is BOUNDED WHILE IT FILLS, not just when it is reported.
|
|
351
|
-
//
|
|
352
|
-
// `cap()` above runs on close, which is fine for the text the user sees but
|
|
353
|
-
// useless as a memory bound: between the spawn and the exit the raw stream is
|
|
354
|
-
// accumulated in this process with no ceiling at all, so a command that never
|
|
355
|
-
// stops printing (`yes`, `cat /dev/zero`, a runaway build log) grows the MAIN
|
|
356
|
-
// process's heap for as long as the timeout allows — up to 10 minutes — before
|
|
357
|
-
// the truncation it was always going to get. A tool call must not be able to
|
|
358
|
-
// OOM the app it runs in.
|
|
359
|
-
//
|
|
360
|
-
// The window kept is head + tail, because neither end may be dropped:
|
|
361
|
-
// - the HEAD is what the user and the model are shown (OUTPUT_CAP already
|
|
362
|
-
// says only that much is reported);
|
|
363
|
-
// - the TAIL is where the exit sentinel lives for a session (`printf
|
|
364
|
-
// '__AEGIS_SH_…EXIT:%d'`) — dropping it would not merely lose output, it
|
|
365
|
-
// would hang the session until the timeout, because the resolver scans the
|
|
366
|
-
// buffer for a marker that can never arrive.
|
|
367
|
-
// 64 KiB of tail is ~1500× the sentinel line, and surviving a chunk boundary
|
|
368
|
-
// needs only the sentinel's own length.
|
|
369
|
-
const TAIL_KEEP = 64 * 1024;
|
|
370
|
-
const BUFFER_CEILING = OUTPUT_CAP + TAIL_KEEP;
|
|
371
|
-
const ELISION = '\n… (earlier output elided — command produced more than the buffer ceiling)\n';
|
|
372
|
-
|
|
373
|
-
/** Append one stream chunk to `prev`, keeping the bounded head+tail window.
|
|
374
|
-
* Pure, so the streaming paths and their tests agree on the policy. */
|
|
375
|
-
function appendBounded(prev, chunk) {
|
|
376
|
-
const next = prev + chunk;
|
|
377
|
-
if (next.length <= BUFFER_CEILING) return next;
|
|
378
|
-
// Elide the MIDDLE: head stays visible, tail keeps the sentinel reachable.
|
|
379
|
-
// The marker makes the missing bytes explicit rather than letting the two
|
|
380
|
-
// halves read as contiguous output.
|
|
381
|
-
return next.slice(0, OUTPUT_CAP) + ELISION + next.slice(-TAIL_KEEP);
|
|
382
|
-
}
|
|
383
|
-
|
|
384
|
-
/**
|
|
385
|
-
* The spec for a ONE-SHOT command (no live session) — the same resolution, so
|
|
386
|
-
* the `exec` tool's no-session path and this module's fallback cannot disagree
|
|
387
|
-
* about which shell speaks the dialect the command was written in.
|
|
388
|
-
*
|
|
389
|
-
* Windows keeps cmd.exe (`/d /s /c`), exactly what it used before; everywhere
|
|
390
|
-
* else the answer is a PROBED POSIX sh/bash, and `{ ok:false, error }` when
|
|
391
|
-
* nothing could be verified — the caller reports `error` (which names the
|
|
392
|
-
* unsupported `$SHELL`) instead of shelling out in a language it does not speak.
|
|
393
|
-
*/
|
|
394
|
-
function oneShotShellSpec(options = {}) {
|
|
395
|
-
const spec = resolveSessionShell(options);
|
|
396
|
-
if (!spec.ok) {
|
|
397
|
-
return { ok: false, cmd: null, arg: null, kind: null, error: spec.error, spec };
|
|
398
|
-
}
|
|
399
|
-
if (spec.kind === 'powershell') {
|
|
400
|
-
// The session's own one-shot spec (PowerShell `-Command`), so the `exec`
|
|
401
|
-
// tool's no-session path and this module's fallback cannot disagree about
|
|
402
|
-
// the dialect — which is exactly how cmd.exe used to sneak back in.
|
|
403
|
-
const shot = spec.oneShot || platform.shellSpec({ platform: 'win32', env: shellSeams(options).env });
|
|
404
|
-
return { ok: true, cmd: shot.cmd, arg: shot.arg, kind: shot.kind || 'powershell', error: null, spec };
|
|
405
|
-
}
|
|
406
|
-
return { ok: true, cmd: spec.cmd, arg: spec.arg, kind: 'posix-sh', error: null, spec };
|
|
407
|
-
}
|
|
408
|
-
|
|
409
|
-
/**
|
|
410
|
-
* One-shot fallback — the pre-session behavior, used when no live session.
|
|
411
|
-
*
|
|
412
|
-
* The dialect is resolved the same way the session's is: a POSIX `-c` runs in a
|
|
413
|
-
* verified sh/bash (never in an unprobed `$SHELL`), and when nothing can be
|
|
414
|
-
* verified the refusal names the shell that was refused rather than shelling out
|
|
415
|
-
* to it in a language it does not speak.
|
|
416
|
-
*/
|
|
417
|
-
function oneShotShell({ command, timeout = DEFAULT_TIMEOUT, working_directory, shellOptions } = {}) {
|
|
418
|
-
return new Promise((resolve) => {
|
|
419
|
-
const o = shellSeams(shellOptions);
|
|
420
|
-
const spec = resolveSessionShell(shellOptions);
|
|
421
|
-
if (!spec || !spec.ok) {
|
|
422
|
-
resolve({ content: (spec && spec.error) || 'exec: no usable shell', isError: true });
|
|
423
|
-
return;
|
|
424
|
-
}
|
|
425
|
-
const call = spec.kind === 'powershell' ? spec.oneShot : spec;
|
|
426
|
-
const child = spawn(call.cmd, [call.arg, String(command || '')], {
|
|
427
|
-
cwd: working_directory || process.cwd(),
|
|
428
|
-
timeout: Math.min(Number(timeout) || DEFAULT_TIMEOUT, MAX_TIMEOUT),
|
|
429
|
-
env: o.env,
|
|
430
|
-
});
|
|
431
|
-
let out = '';
|
|
432
|
-
let err = '';
|
|
433
|
-
// Bounded both ways: a one-shot command with no live session is just as
|
|
434
|
-
// capable of printing forever as a session command (appendBounded's header).
|
|
435
|
-
child.stdout.on('data', (d) => { out = appendBounded(out, d.toString()); });
|
|
436
|
-
child.stderr.on('data', (d) => { err = appendBounded(err, d.toString()); });
|
|
437
|
-
child.on('error', (e) => resolve({ content: `error: ${e.message}`, isError: true }));
|
|
438
|
-
child.on('close', (code) => {
|
|
439
|
-
const body = `${out}${err ? `\n${err}` : ''}`.trim() || `(exit ${code})`;
|
|
440
|
-
resolve({ content: cap(body), isError: code !== 0 });
|
|
441
|
-
});
|
|
442
|
-
});
|
|
443
|
-
}
|
|
444
|
-
|
|
445
|
-
class ShellSession {
|
|
446
|
-
constructor({ cwd, shellOptions } = {}) {
|
|
447
|
-
this.sentinel = `__AEGIS_SH_${crypto.randomBytes(8).toString('hex')}__`;
|
|
448
|
-
this.marker = `${this.sentinel}EXIT:`;
|
|
449
|
-
this.buf = '';
|
|
450
|
-
this.alive = false;
|
|
451
|
-
this._pending = null; // resolver-scan for the in-flight command
|
|
452
|
-
this.cwd = cwd || process.cwd();
|
|
453
|
-
this.disposed = false; // an explicit dispose() must NOT be self-healed
|
|
454
|
-
this._respawns = 0; // one self-heal attempt per session, never a loop
|
|
455
|
-
this._degradedNoted = false; // the degrade marker is emitted once per session
|
|
456
|
-
this.shellOptions = shellOptions || {};
|
|
457
|
-
// Resolved BEFORE anything is spawned or written: `shell.kind` is what the
|
|
458
|
-
// command framing below branches on, so an unverified `$SHELL` can never
|
|
459
|
-
// receive POSIX syntax — it is never spawned at all.
|
|
460
|
-
this.shell = resolveSessionShell(this.shellOptions);
|
|
461
|
-
this.shellError = this.shell.ok ? null : this.shell.error;
|
|
462
|
-
this._start(cwd);
|
|
463
|
-
}
|
|
464
|
-
|
|
465
|
-
_start(cwd) {
|
|
466
|
-
if (!this.shell || !this.shell.ok) {
|
|
467
|
-
// Refused: no shell was verified, so nothing is spawned. run() reports
|
|
468
|
-
// this.shellError (which names the unsupported $SHELL).
|
|
469
|
-
this.alive = false;
|
|
470
|
-
return;
|
|
471
|
-
}
|
|
472
|
-
const env = shellSeams(this.shellOptions).env;
|
|
473
|
-
try {
|
|
474
|
-
this.child = this.shell.kind === 'powershell'
|
|
475
|
-
// -Command - reads the script from stdin as a non-interactive session,
|
|
476
|
-
// so there are no prompts to pollute output, but state still persists.
|
|
477
|
-
? spawn(this.shell.cmd, ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', '-'], {
|
|
478
|
-
cwd: cwd || process.cwd(),
|
|
479
|
-
env,
|
|
480
|
-
stdio: ['pipe', 'pipe', 'pipe'],
|
|
481
|
-
})
|
|
482
|
-
// No args → the shell reads commands from stdin as a non-interactive
|
|
483
|
-
// script, so there are no prompts to pollute output, but state persists.
|
|
484
|
-
// This is a shell that PASSED the POSIX probe (see resolveSessionShell).
|
|
485
|
-
: spawn(this.shell.cmd, [], {
|
|
486
|
-
cwd: cwd || process.cwd(),
|
|
487
|
-
env,
|
|
488
|
-
stdio: ['pipe', 'pipe', 'pipe'],
|
|
489
|
-
});
|
|
490
|
-
} catch {
|
|
491
|
-
this.alive = false;
|
|
492
|
-
return;
|
|
493
|
-
}
|
|
494
|
-
this.alive = true;
|
|
495
|
-
const onData = (d) => {
|
|
496
|
-
// Bounded — see appendBounded. The tail is kept precisely so the sentinel
|
|
497
|
-
// this session's resolver scans for survives a command that floods stdout.
|
|
498
|
-
this.buf = appendBounded(this.buf, d.toString());
|
|
499
|
-
if (this._pending) this._pending();
|
|
500
|
-
};
|
|
501
|
-
this.child.stdout.on('data', onData);
|
|
502
|
-
this.child.stderr.on('data', onData);
|
|
503
|
-
this.child.on('exit', () => { this.alive = false; if (this._pending) this._pending(true); });
|
|
504
|
-
this.child.on('error', () => { this.alive = false; if (this._pending) this._pending(true); });
|
|
505
|
-
// Merge stderr into stdout for the whole session so output ordering is
|
|
506
|
-
// faithful and the sentinel (printed to fd1) always lands after it.
|
|
507
|
-
// PowerShell errors already arrive on the stderr pipe onData folds in
|
|
508
|
-
// above, so only a POSIX session needs the explicit redirect — and `exec`
|
|
509
|
-
// redirection is POSIX, which is why this is gated on the VERIFIED dialect
|
|
510
|
-
// rather than on `!IS_WIN32`.
|
|
511
|
-
if (this.shell.kind === 'posix-sh') {
|
|
512
|
-
try { this.child.stdin.write('exec 2>&1\n'); } catch { this.alive = false; }
|
|
513
|
-
}
|
|
514
|
-
}
|
|
515
|
-
|
|
516
|
-
/**
|
|
517
|
-
* Run one command in the session. Resolves { content, isError }. A dead or
|
|
518
|
-
* unstartable session (or a working_directory that must not persist)
|
|
519
|
-
* degrades gracefully. `working_directory` is scoped to this one command (a
|
|
520
|
-
* subshell on POSIX, Push-Location/Pop-Location on PowerShell) so it doesn't
|
|
521
|
-
* move the session's cwd.
|
|
522
|
-
*/
|
|
523
|
-
run(command, { timeout = DEFAULT_TIMEOUT, working_directory } = {}) {
|
|
524
|
-
const posixSession = this.shell && this.shell.kind === 'posix-sh' && this.shell.verified;
|
|
525
|
-
if (!this.alive || !this.child || ((this.shell && this.shell.kind === 'posix-sh') && !posixSession)) {
|
|
526
|
-
if (!this.alive && this.shellError) {
|
|
527
|
-
return Promise.resolve({ content: this.shellError, isError: true });
|
|
528
|
-
}
|
|
529
|
-
// SELF-HEAL BEFORE DEGRADING. The old path latched: once the live session
|
|
530
|
-
// died (a timeout kill, a child exit, a spawn error) every remaining
|
|
531
|
-
// command in the turn ran one-shot, and on Windows that one-shot used to
|
|
532
|
-
// be cmd.exe — so the agent stayed silently wrong for the rest of the
|
|
533
|
-
// turn. Re-probe and re-spawn ONCE instead, and only then fall through.
|
|
534
|
-
if (!this.alive && !this.disposed && this._respawns < 1) {
|
|
535
|
-
this._respawns += 1;
|
|
536
|
-
this.buf = '';
|
|
537
|
-
this.shell = resolveSessionShell(this.shellOptions);
|
|
538
|
-
this.shellError = this.shell.ok ? null : this.shell.error;
|
|
539
|
-
this._start(this.cwd);
|
|
540
|
-
if (this.alive) return this.run(command, { timeout, working_directory });
|
|
541
|
-
}
|
|
542
|
-
return oneShotShell({ command, timeout, working_directory, shellOptions: this.shellOptions })
|
|
543
|
-
.then((result) => this._noteDegraded(result, !this.alive ? 'the persistent session is not running' : 'no usable session dialect'));
|
|
544
|
-
}
|
|
545
|
-
let cmd = String(command || '');
|
|
546
|
-
|
|
547
|
-
return new Promise((resolve) => {
|
|
548
|
-
let settled = false;
|
|
549
|
-
const finish = (result) => {
|
|
550
|
-
if (settled) return;
|
|
551
|
-
settled = true;
|
|
552
|
-
clearTimeout(timer);
|
|
553
|
-
this._pending = null;
|
|
554
|
-
resolve(result);
|
|
555
|
-
};
|
|
556
|
-
|
|
557
|
-
const timer = setTimeout(() => {
|
|
558
|
-
// A hung command (e.g. one that reads stdin) can never reach the
|
|
559
|
-
// sentinel — kill the poisoned session and report what we have.
|
|
560
|
-
const partial = cap(this.buf.trim());
|
|
561
|
-
this.dispose();
|
|
562
|
-
finish({ content: `${partial}${partial ? '\n' : ''}(timed out after ${timeout}ms)`, isError: true });
|
|
563
|
-
}, Math.min(Number(timeout) || DEFAULT_TIMEOUT, MAX_TIMEOUT));
|
|
564
|
-
|
|
565
|
-
this._pending = (died) => {
|
|
566
|
-
if (died && !this.buf.includes(this.marker)) {
|
|
567
|
-
finish({ content: cap(this.buf.trim()) || '(shell session ended)', isError: true });
|
|
568
|
-
return;
|
|
569
|
-
}
|
|
570
|
-
const idx = this.buf.indexOf(this.marker);
|
|
571
|
-
if (idx === -1) return; // sentinel not here yet — wait for more data
|
|
572
|
-
const after = this.buf.slice(idx + this.marker.length);
|
|
573
|
-
const nl = after.indexOf('\n');
|
|
574
|
-
if (nl === -1) return; // exit-code line still arriving
|
|
575
|
-
const code = parseInt(after.slice(0, nl), 10);
|
|
576
|
-
const output = this.buf.slice(0, idx).replace(/\n+$/, '');
|
|
577
|
-
this.buf = after.slice(nl + 1); // leftover for the next command (usually '')
|
|
578
|
-
finish({ content: cap(output || `(exit ${code})`), isError: code !== 0 });
|
|
579
|
-
};
|
|
580
|
-
|
|
581
|
-
// Send the command, then print the sentinel + exit code on its own line.
|
|
582
|
-
let script;
|
|
583
|
-
if (!posixSession) {
|
|
584
|
-
// Push-Location/Pop-Location scope working_directory to this one
|
|
585
|
-
// command without moving the session's persistent cwd. $__c captures
|
|
586
|
-
// the real exit status before Pop-Location's own success would
|
|
587
|
-
// otherwise overwrite $?/$LASTEXITCODE.
|
|
588
|
-
const body = cmd.trim() ? cmd : 'Write-Output $null';
|
|
589
|
-
const exitCapture =
|
|
590
|
-
'$__c = if ($LASTEXITCODE -ne $null) { $LASTEXITCODE } elseif ($?) { 0 } else { 1 }';
|
|
591
|
-
script = working_directory
|
|
592
|
-
? `Push-Location -LiteralPath ${pshq(working_directory)}\ntry {\n${body}\n${exitCapture}\n} finally {\nPop-Location\n}\n`
|
|
593
|
-
: `${body}\n${exitCapture}\n`;
|
|
594
|
-
script += `Write-Output ("${this.marker}" + $__c)\n`;
|
|
595
|
-
} else {
|
|
596
|
-
// POSIX dialect, sent ONLY to a shell that passed the POSIX probe
|
|
597
|
-
// (posixSession above) — never to fish/csh/nu. The command runs in a
|
|
598
|
-
// brace group with its stdin redirected from /dev/null: a group (not a
|
|
599
|
-
// subshell) keeps cd/export state persisting, and `</dev/null` stops the
|
|
600
|
-
// command from consuming the control channel. Without this, any
|
|
601
|
-
// stdin-reading command (cat, read, a REPL, ssh, a y/n prompt) swallows
|
|
602
|
-
// the sentinel line that follows it — hanging the whole session until
|
|
603
|
-
// the timeout, and echoing commands (cat) even splice the sentinel into
|
|
604
|
-
// their output and mis-resolve with garbage. working_directory runs in a
|
|
605
|
-
// subshell (parens, not the brace group) so it doesn't move the
|
|
606
|
-
// session's cwd.
|
|
607
|
-
if (working_directory) cmd = `( cd ${shq(working_directory)} && ${cmd} )`;
|
|
608
|
-
const body = cmd.trim() ? cmd : ':';
|
|
609
|
-
script = `{ ${body}\n} </dev/null\nprintf '\\n%s%d\\n' '${this.sentinel}EXIT:' "$?"\n`;
|
|
610
|
-
}
|
|
611
|
-
try {
|
|
612
|
-
this.child.stdin.write(script);
|
|
613
|
-
} catch {
|
|
614
|
-
this.alive = false;
|
|
615
|
-
oneShotShell({ command, timeout, working_directory, shellOptions: this.shellOptions })
|
|
616
|
-
.then((r) => finish(this._noteDegraded(r, 'the session stdin rejected the command')));
|
|
617
|
-
return;
|
|
618
|
-
}
|
|
619
|
-
// Data may already be buffered (fast commands) — scan once now.
|
|
620
|
-
if (this._pending) this._pending();
|
|
621
|
-
});
|
|
622
|
-
}
|
|
623
|
-
|
|
624
|
-
/**
|
|
625
|
-
* Mark a result that came from the one-shot fallback rather than the live
|
|
626
|
-
* session, so the model is TOLD the shell changed and can stop trusting its
|
|
627
|
-
* quoting contract.
|
|
628
|
-
*
|
|
629
|
-
* The 2026-02-14 report's worst finding was precisely that this happened
|
|
630
|
-
* silently: "nothing in the result tells the agent the shell changed" while
|
|
631
|
-
* the agent "degrades to a one-shot cmd.exe — and stays there for the
|
|
632
|
-
* remainder of the turn", so it kept emitting PowerShell quoting, got
|
|
633
|
-
* localized cmd.exe syntax errors, and finally blamed the user's machine.
|
|
634
|
-
* Emitted ONCE per session so a degraded turn stays readable.
|
|
635
|
-
*/
|
|
636
|
-
_noteDegraded(result, reason) {
|
|
637
|
-
const r = result && typeof result === 'object' ? result : { content: String(result == null ? '' : result), isError: true };
|
|
638
|
-
if (this._degradedNoted) return r;
|
|
639
|
-
this._degradedNoted = true;
|
|
640
|
-
const via = this.shell && this.shell.cmd ? this.shell.cmd : 'unknown shell';
|
|
641
|
-
const was = this.shell && this.shell.kind ? this.shell.kind : 'none';
|
|
642
|
-
const marker = `[shell_degraded] ran one-shot via ${via} (${reason}); session dialect was ${was} — quoting contracts may differ`;
|
|
643
|
-
return { ...r, content: `${marker}\n${r.content || ''}` };
|
|
644
|
-
}
|
|
645
|
-
|
|
646
|
-
dispose() {
|
|
647
|
-
this.disposed = true;
|
|
648
|
-
this.alive = false;
|
|
649
|
-
if (this.child) {
|
|
650
|
-
try { this.child.stdin.end(); } catch { /* already gone */ }
|
|
651
|
-
try { this.child.kill('SIGKILL'); } catch { /* already gone */ }
|
|
652
|
-
this.child = null;
|
|
653
|
-
}
|
|
654
|
-
}
|
|
655
|
-
}
|
|
656
|
-
|
|
657
|
-
// Minimal single-quote shell-escape for a path.
|
|
658
|
-
function shq(s) {
|
|
659
|
-
return `'${String(s).replace(/'/g, `'\\''`)}'`;
|
|
660
|
-
}
|
|
661
|
-
|
|
662
|
-
// Minimal single-quote escape for a PowerShell -LiteralPath (double up ').
|
|
663
|
-
function pshq(s) {
|
|
664
|
-
return `'${String(s).replace(/'/g, "''")}'`;
|
|
665
|
-
}
|
|
666
|
-
|
|
667
|
-
module.exports = {
|
|
668
|
-
ShellSession,
|
|
669
|
-
oneShotShell,
|
|
670
|
-
IS_WIN32,
|
|
671
|
-
// Phase 30.3: the dialect probe and its verdict, exported so a test can
|
|
672
|
-
// assert on the resolution without spawning a session.
|
|
673
|
-
resolveSessionShell,
|
|
674
|
-
oneShotShellSpec,
|
|
675
|
-
isPosixShellName,
|
|
676
|
-
POSIX_SHELL_NAMES,
|
|
677
|
-
POSIX_PROBE,
|
|
678
|
-
appendBounded,
|
|
679
|
-
OUTPUT_CAP,
|
|
680
|
-
BUFFER_CEILING,
|
|
681
|
-
TAIL_KEEP,
|
|
682
|
-
};
|
|
3
|
+
"use strict";const{spawn:spawn,spawnSync:spawnSync}=require("node:child_process"),crypto=require("node:crypto");let platform;try{platform=require("../../../client/platform.js")}catch(e){try{platform=require("../../vendor/platform.js")}catch{throw new Error(`shell.js: cannot load the shared platform module (client/platform.js, or its staged copy): ${e&&e.message?e.message:e}`)}}const IS_WIN32=platform.IS_WIN32,OUTPUT_CAP=3e4,MAX_TIMEOUT=6e5,DEFAULT_TIMEOUT=12e4,POSIX_SHELL_NAMES=["bash","sh","dash","ksh","zsh"],POSIX_FALLBACK_NAMES=["bash","sh"],POSIX_PROBE="if [ 1 -eq 1 ]; then exit 0; fi",SHELL_PROBE_TIMEOUT=4e3;function shellName(e){return String(e||"").trim().split(/[\\/]/).pop().toLowerCase().replace(/\.exe$/,"")}function isPosixShellName(e){return POSIX_SHELL_NAMES.includes(shellName(e))}function probePosixShell(e,{env:s,timeout:t=SHELL_PROBE_TIMEOUT}={}){try{const n=spawnSync(e,["-c",POSIX_PROBE],{env:s,encoding:"utf8",timeout:t,windowsHide:!0,stdio:"ignore"});return!n.error&&0===n.status}catch{return!1}}function shellSeams(e){const s=e&&"object"==typeof e?e:{};return{platform:"string"==typeof s.platform&&s.platform?s.platform:process.platform,env:s.env&&"object"==typeof s.env?s.env:process.env,exists:"function"==typeof s.exists?s.exists:void 0,probe:"function"==typeof s.probe?s.probe:void 0,injected:"function"==typeof s.probe||"function"==typeof s.exists}}function considerPosixCandidate(e,s,t,n){const i=String(s.cmd||"").trim();if(!i)return t.push({candidate:s.source||"(unset)",ok:!1,reason:"unset"}),null;if(!isPosixShellName(i))return t.push({candidate:i,ok:!1,reason:`${s.source||"candidate"} is not a POSIX shell — ${shellName(i)||i} is a different language (fish/csh/nu), so it was never executed and never sent POSIX syntax`}),null;const o=platform.resolveExecutable(i,{platform:e.platform,env:e.env,exists:e.exists});if(!o)return t.push({candidate:i,ok:!1,reason:/[\\/]/.test(i)?"not present":"not found on PATH"}),null;const r=e.probe?Boolean(e.probe(o,["-c",POSIX_PROBE])):probePosixShell(o,{env:e.env});return n.push(o),r?(t.push({candidate:o,ok:!0,reason:"verified: one `-c` POSIX script exited 0"}),{ok:!0,cmd:o,arg:"-c",kind:"posix-sh",source:s.source,verified:!0}):(t.push({candidate:o,ok:!1,reason:`failed the POSIX probe (\`-c\` with \`${POSIX_PROBE}\` did not exit 0) — refused`}),null)}function resolvePosixSessionShell(e){const s=[],t=[],n=String(e.env&&e.env.SHELL||"").trim(),i=[];n?i.push({cmd:n,source:"$SHELL"}):s.push({candidate:"$SHELL",ok:!1,reason:"$SHELL is unset"});for(const e of POSIX_FALLBACK_NAMES)i.push({cmd:e,source:`PATH:${e}`});let o=null;for(const n of i)if(o=considerPosixCandidate(e,n,s,t),o)break;const r=n&&!isPosixShellName(n)?n:null;return o?{...o,unsupported:r,caveat:r?`$SHELL is ${r}, which is not a POSIX shell — the session runs the POSIX dialect through ${o.cmd} instead, and ${r} is never sent brace groups, \`exec 2>&1\` or \`$?\``:null,error:null,executed:t,tried:s}:{ok:!1,cmd:null,arg:null,kind:null,source:"refused",verified:!1,unsupported:r,caveat:null,error:r?`exec: refusing to run — $SHELL is "${r}", which is not a POSIX shell (fish/csh/nushell are a different language), and no sh/bash could be verified on PATH to run the POSIX dialect instead. Set SHELL to bash or sh, or put one on PATH.`:`exec: no POSIX shell available — $SHELL is ${n?`"${n}"`:"unset"} and no sh/bash passed the POSIX probe; set SHELL to bash or sh, or put one on PATH.`,executed:t,tried:s}}const SHELL_CACHE=new Map;function resolveSessionShell(e={}){const s=shellSeams(e);if("win32"===s.platform){const e=platform.shellSpec({platform:"win32",env:s.env}),t=platform.resolveExecutable("powershell",{platform:"win32",env:s.env,exists:s.exists})||"powershell.exe";return{ok:!0,cmd:t,arg:"-",kind:"powershell",source:"powershell.exe",verified:!0,unsupported:null,caveat:null,error:null,oneShot:{cmd:t,arg:"-Command",kind:"powershell",source:"powershell.exe"},executed:[],tried:[{candidate:t,ok:!0,reason:"the Windows default (PowerShell session AND one-shot)"},{candidate:e.cmd,ok:!1,reason:"cmd.exe is never used for one-shot: it cannot parse the PowerShell quoting the session teaches"}]}}const t=[s.platform,s.env&&s.env.SHELL,s.env&&s.env.PATH].join("\0");if(!s.injected&&SHELL_CACHE.has(t))return SHELL_CACHE.get(t);const n=resolvePosixSessionShell(s);return s.injected||SHELL_CACHE.set(t,n),n}function cap(e){return e.length>OUTPUT_CAP?`${e.slice(0,OUTPUT_CAP)}\n… (truncated)`:e}const TAIL_KEEP=65536,BUFFER_CEILING=OUTPUT_CAP+TAIL_KEEP,ELISION="\n… (earlier output elided — command produced more than the buffer ceiling)\n";function appendBounded(e,s){const t=e+s;return t.length<=BUFFER_CEILING?t:t.slice(0,OUTPUT_CAP)+ELISION+t.slice(-TAIL_KEEP)}function oneShotShellSpec(e={}){const s=resolveSessionShell(e);if(!s.ok)return{ok:!1,cmd:null,arg:null,kind:null,error:s.error,spec:s};if("powershell"===s.kind){const t=s.oneShot||platform.shellSpec({platform:"win32",env:shellSeams(e).env});return{ok:!0,cmd:t.cmd,arg:t.arg,kind:t.kind||"powershell",error:null,spec:s}}return{ok:!0,cmd:s.cmd,arg:s.arg,kind:"posix-sh",error:null,spec:s}}function oneShotShell({command:e,timeout:s=12e4,working_directory:t,shellOptions:n}={}){return new Promise((i=>{const o=shellSeams(n),r=resolveSessionShell(n);if(!r||!r.ok)return void i({content:r&&r.error||"exec: no usable shell",isError:!0});const l="powershell"===r.kind?r.oneShot:r,h=spawn(l.cmd,[l.arg,String(e||"")],{cwd:t||process.cwd(),timeout:Math.min(Number(s)||12e4,6e5),env:o.env});let c="",d="";h.stdout.on("data",(e=>{c=appendBounded(c,e.toString())})),h.stderr.on("data",(e=>{d=appendBounded(d,e.toString())})),h.on("error",(e=>i({content:`error: ${e.message}`,isError:!0}))),h.on("close",(e=>{const s=`${c}${d?`\n${d}`:""}`.trim()||`(exit ${e})`;i({content:cap(s),isError:0!==e})}))}))}class ShellSession{constructor({cwd:e,shellOptions:s}={}){this.sentinel=`__AEGIS_SH_${crypto.randomBytes(8).toString("hex")}__`,this.marker=`${this.sentinel}EXIT:`,this.buf="",this.alive=!1,this._pending=null,this.cwd=e||process.cwd(),this.disposed=!1,this._respawns=0,this._degradedNoted=!1,this.shellOptions=s||{},this.shell=resolveSessionShell(this.shellOptions),this.shellError=this.shell.ok?null:this.shell.error,this._start(e)}_start(e){if(!this.shell||!this.shell.ok)return void(this.alive=!1);const s=shellSeams(this.shellOptions).env;try{this.child="powershell"===this.shell.kind?spawn(this.shell.cmd,["-NoLogo","-NoProfile","-NonInteractive","-Command","-"],{cwd:e||process.cwd(),env:s,stdio:["pipe","pipe","pipe"]}):spawn(this.shell.cmd,[],{cwd:e||process.cwd(),env:s,stdio:["pipe","pipe","pipe"]})}catch{return void(this.alive=!1)}this.alive=!0;const t=e=>{this.buf=appendBounded(this.buf,e.toString()),this._pending&&this._pending()};if(this.child.stdout.on("data",t),this.child.stderr.on("data",t),this.child.on("exit",(()=>{this.alive=!1,this._pending&&this._pending(!0)})),this.child.on("error",(()=>{this.alive=!1,this._pending&&this._pending(!0)})),"posix-sh"===this.shell.kind)try{this.child.stdin.write("exec 2>&1\n")}catch{this.alive=!1}}run(e,{timeout:s=12e4,working_directory:t}={}){const n=this.shell&&"posix-sh"===this.shell.kind&&this.shell.verified;if(!this.alive||!this.child||this.shell&&"posix-sh"===this.shell.kind&&!n)return!this.alive&&this.shellError?Promise.resolve({content:this.shellError,isError:!0}):!this.alive&&!this.disposed&&this._respawns<1&&(this._respawns+=1,this.buf="",this.shell=resolveSessionShell(this.shellOptions),this.shellError=this.shell.ok?null:this.shell.error,this._start(this.cwd),this.alive)?this.run(e,{timeout:s,working_directory:t}):oneShotShell({command:e,timeout:s,working_directory:t,shellOptions:this.shellOptions}).then((e=>this._noteDegraded(e,this.alive?"no usable session dialect":"the persistent session is not running")));let i=String(e||"");return new Promise((o=>{let r=!1;const l=e=>{r||(r=!0,clearTimeout(h),this._pending=null,o(e))},h=setTimeout((()=>{const e=cap(this.buf.trim());this.dispose(),l({content:`${e}${e?"\n":""}(timed out after ${s}ms)`,isError:!0})}),Math.min(Number(s)||12e4,6e5));let c;if(this._pending=e=>{if(e&&!this.buf.includes(this.marker))return void l({content:cap(this.buf.trim())||"(shell session ended)",isError:!0});const s=this.buf.indexOf(this.marker);if(-1===s)return;const t=this.buf.slice(s+this.marker.length),n=t.indexOf("\n");if(-1===n)return;const i=parseInt(t.slice(0,n),10),o=this.buf.slice(0,s).replace(/\n+$/,"");this.buf=t.slice(n+1),l({content:cap(o||`(exit ${i})`),isError:0!==i})},n)t&&(i=`( cd ${shq(t)} && ${i} )`),c=`{ ${i.trim()?i:":"}\n} </dev/null\nprintf '\\n%s%d\\n' '${this.sentinel}EXIT:' "$?"\n`;else{const e=i.trim()?i:"Write-Output $null",s="$__c = if ($LASTEXITCODE -ne $null) { $LASTEXITCODE } elseif ($?) { 0 } else { 1 }";c=t?`Push-Location -LiteralPath ${pshq(t)}\ntry {\n${e}\n${s}\n} finally {\nPop-Location\n}\n`:`${e}\n${s}\n`,c+=`Write-Output ("${this.marker}" + $__c)\n`}try{this.child.stdin.write(c)}catch{return this.alive=!1,void oneShotShell({command:e,timeout:s,working_directory:t,shellOptions:this.shellOptions}).then((e=>l(this._noteDegraded(e,"the session stdin rejected the command"))))}this._pending&&this._pending()}))}_noteDegraded(e,s){const t=e&&"object"==typeof e?e:{content:String(null==e?"":e),isError:!0};if(this._degradedNoted)return t;this._degradedNoted=!0;const n=`[shell_degraded] ran one-shot via ${this.shell&&this.shell.cmd?this.shell.cmd:"unknown shell"} (${s}); session dialect was ${this.shell&&this.shell.kind?this.shell.kind:"none"} — quoting contracts may differ`;return{...t,content:`${n}\n${t.content||""}`}}dispose(){if(this.disposed=!0,this.alive=!1,this.child){try{this.child.stdin.end()}catch{}try{this.child.kill("SIGKILL")}catch{}this.child=null}}}function shq(e){return`'${String(e).replace(/'/g,"'\\''")}'`}function pshq(e){return`'${String(e).replace(/'/g,"''")}'`}module.exports={ShellSession:ShellSession,oneShotShell:oneShotShell,IS_WIN32:IS_WIN32,resolveSessionShell:resolveSessionShell,oneShotShellSpec:oneShotShellSpec,isPosixShellName:isPosixShellName,POSIX_SHELL_NAMES:POSIX_SHELL_NAMES,POSIX_PROBE:POSIX_PROBE,appendBounded:appendBounded,OUTPUT_CAP:OUTPUT_CAP,BUFFER_CEILING:BUFFER_CEILING,TAIL_KEEP:TAIL_KEEP};
|