aegis-desktop 0.8.18 → 0.8.20
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/lib/local/engine.js +85 -3
- package/lib/local/shell.js +393 -39
- package/lib/local/tools.js +156 -13
- package/lib/settings.js +36 -1
- package/package.json +1 -1
- package/renderer/budget.js +13 -0
- package/renderer/usage.js +30 -0
- package/vendor/aegis.js +13 -0
- package/vendor/agent-gate.mjs +99 -14
- package/vendor/authorship-key.js +45 -4
- package/vendor/byok-catalog.js +49 -1
- package/vendor/hooks.js +427 -0
- package/vendor/platform.js +410 -0
package/lib/local/engine.js
CHANGED
|
@@ -240,6 +240,57 @@ function providerKeyFromEnv(provider) {
|
|
|
240
240
|
const DEEPSEEK_REASONING_MODEL_RE = /^deepseek-(v4(\.\d+)?-(flash|pro)|flash|pro|reasoner)$/;
|
|
241
241
|
const EFFORT_TOKEN_BUDGET = { low: 8192, medium: 16384, high: 32768 };
|
|
242
242
|
|
|
243
|
+
/**
|
|
244
|
+
* DeepSeek native reasoning level per config rung.
|
|
245
|
+
*
|
|
246
|
+
* The field is `reasoning_effort`, NOT `effort`. Verified live 2026-10-02: a
|
|
247
|
+
* DeepSeek body carrying `effort` returns HTTP 200 and is ignored, while
|
|
248
|
+
* `reasoning_effort` binds and rejects an unknown value with a 422 naming its
|
|
249
|
+
* enum (none|minimal|low|medium|high|xhigh|ultra|max). Behaviourally,
|
|
250
|
+
* `reasoning_effort:"none"` produced 0 reasoning_content chars and `"max"`
|
|
251
|
+
* produced 138, whereas `effort:"none"` still produced 112 — thinking stayed
|
|
252
|
+
* on. So the old field was inert on every turn, and inertness is the worst
|
|
253
|
+
* failure here: it 200s, nothing surfaces, and the model merely thinks at the
|
|
254
|
+
* wrong depth.
|
|
255
|
+
*
|
|
256
|
+
* Every rung of the knob has a namesake in that enum, so the map is 1:1 rather
|
|
257
|
+
* than the earlier (low|max, no medium) fold, which rested on the mistaken
|
|
258
|
+
* belief that DeepSeek had no `medium`. `max_tokens` (EFFORT_TOKEN_BUDGET)
|
|
259
|
+
* still scales as the output ceiling, since hidden chain-of-thought shares that
|
|
260
|
+
* budget on DeepSeek.
|
|
261
|
+
*
|
|
262
|
+
* Mirrors the upstream engine's src/backend.js DEEPSEEK_EFFORT verbatim;
|
|
263
|
+
* desktop/renderer/budget.js carries the renderer's copy for the same
|
|
264
|
+
* no-drift guarantee as DEEPSEEK_REASONING_MODEL_RE above.
|
|
265
|
+
*/
|
|
266
|
+
const DEEPSEEK_EFFORT = { low: 'low', medium: 'medium', high: 'high' };
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Per-family effort translation, mirroring the upstream engine's
|
|
270
|
+
* EFFORT_DIALECTS. The knob is one rung here (low|medium|high) but it is NOT
|
|
271
|
+
* portable across vendors, and passing it through untranslated is a correctness
|
|
272
|
+
* bug rather than a no-op: Groq 400s any level outside a model's supported set,
|
|
273
|
+
* and Gemini 3 fails on `medium` outright while low/high succeed. So each
|
|
274
|
+
* family states its own wire field and level map, and a family with no such
|
|
275
|
+
* field gets NOTHING — an unknown body field either 400s or is silently
|
|
276
|
+
* dropped, and both are worse than stating no opinion.
|
|
277
|
+
*
|
|
278
|
+
* xAI's `*-non-reasoning` row is left alone entirely: the model exists to not
|
|
279
|
+
* reason, and sending it an effort field is precisely the request the user
|
|
280
|
+
* picked that row to avoid. grok-4.20 and gemini-2.0 stay bare too — no
|
|
281
|
+
* documented level field exists for them, and guessing on a strict validator
|
|
282
|
+
* is a 400 rather than an upgrade.
|
|
283
|
+
*/
|
|
284
|
+
const EFFORT_IDENTITY = { low: 'low', medium: 'medium', high: 'high' };
|
|
285
|
+
const EFFORT_INERT_RE = /non-reasoning/i;
|
|
286
|
+
const EFFORT_DIALECTS = [
|
|
287
|
+
{ provider: 'deepseek', re: DEEPSEEK_REASONING_MODEL_RE, field: 'reasoning_effort', map: DEEPSEEK_EFFORT },
|
|
288
|
+
{ provider: 'openai', re: /^(gpt-5|o[0-9])/i, field: 'reasoning_effort', map: EFFORT_IDENTITY },
|
|
289
|
+
{ provider: 'groq', re: /^(openai\/gpt-oss|qwen\/qwen3)/i, field: 'reasoning_effort', map: EFFORT_IDENTITY },
|
|
290
|
+
{ provider: 'xai', re: /^grok-(4\.[5-9]|[5-9])/, field: 'reasoning_effort', map: EFFORT_IDENTITY },
|
|
291
|
+
{ provider: 'google', re: /^gemini-(2\.5|3|[4-9])/, field: 'reasoning_effort', map: { ...EFFORT_IDENTITY, medium: 'high' } },
|
|
292
|
+
];
|
|
293
|
+
|
|
243
294
|
/**
|
|
244
295
|
* Idle-stream budget for a pooled brain call ("work autonomously"). The
|
|
245
296
|
* generic watchdog in vendor/aegis.js kills a stream that goes 60s without a
|
|
@@ -296,6 +347,24 @@ function reasoningBudget(model, maxTokens, effort) {
|
|
|
296
347
|
return undefined;
|
|
297
348
|
}
|
|
298
349
|
|
|
350
|
+
/**
|
|
351
|
+
* The native effort verdict for this provider+model: `{ field, level }`, or
|
|
352
|
+
* `undefined` when the family has no effort knob. `undefined` means "say
|
|
353
|
+
* nothing", never "send a default" — inventing a level is how the knob silently
|
|
354
|
+
* overrides the vendor's own default. Supersedes the DeepSeek-only
|
|
355
|
+
* deepseekEffort() it replaces: same DeepSeek mapping, now stated for every
|
|
356
|
+
* family that has one instead of only the one that used to be wired.
|
|
357
|
+
*/
|
|
358
|
+
function nativeEffort(provider, model, effort) {
|
|
359
|
+
const m = String(model || '');
|
|
360
|
+
if (EFFORT_INERT_RE.test(m)) return undefined;
|
|
361
|
+
const eff = effort === 'low' || effort === 'medium' ? effort : 'high';
|
|
362
|
+
for (const d of EFFORT_DIALECTS) {
|
|
363
|
+
if (d.provider === provider && d.re.test(m)) return { field: d.field, level: d.map[eff] };
|
|
364
|
+
}
|
|
365
|
+
return undefined;
|
|
366
|
+
}
|
|
367
|
+
|
|
299
368
|
/** Relay model entries arrive as ids or objects; keep only real model ids. */
|
|
300
369
|
function normalizeCatalog(models) {
|
|
301
370
|
if (!Array.isArray(models)) return [];
|
|
@@ -741,7 +810,7 @@ function createLocalEngine({
|
|
|
741
810
|
// direction too: a path wrongly claimed stops being reported as foreign.
|
|
742
811
|
const result = await T.executeTool(name, args, toolCtx);
|
|
743
812
|
if (guard && (name === 'writeFile' || name === 'editFile') && result && result.ok !== false) {
|
|
744
|
-
recordWrite(guard, args && args.
|
|
813
|
+
recordWrite(guard, args && args.file_path);
|
|
745
814
|
}
|
|
746
815
|
return result;
|
|
747
816
|
}
|
|
@@ -790,7 +859,7 @@ function createLocalEngine({
|
|
|
790
859
|
// unconditionally, making every line below it unreachable — the
|
|
791
860
|
// approved-write path recorded nothing at all.)
|
|
792
861
|
if (res && res.ok !== false && toolCtx && toolCtx.guard) {
|
|
793
|
-
recordWrite(toolCtx.guard, args && args.
|
|
862
|
+
recordWrite(toolCtx.guard, args && args.file_path);
|
|
794
863
|
}
|
|
795
864
|
return res;
|
|
796
865
|
}
|
|
@@ -1146,6 +1215,12 @@ function createLocalEngine({
|
|
|
1146
1215
|
// automatically by byokChatCompletion() as X-AEGIS-Key so the account
|
|
1147
1216
|
// gets billed the handling fee; see client/aegis.js.
|
|
1148
1217
|
const { provider, model: bareModel } = splitByokModel(opts.model);
|
|
1218
|
+
// Translate the rung for THIS vendor once, and carry the verdict rather
|
|
1219
|
+
// than a bare level: the relay validates strictly upstream and the field
|
|
1220
|
+
// name is not portable (DeepSeek's OpenAI-compatible surface takes
|
|
1221
|
+
// `reasoning_effort`, same spelling as OpenAI's). Undefined for a family
|
|
1222
|
+
// with no dialect, which the transport then omits entirely.
|
|
1223
|
+
const effort = nativeEffort(provider, bareModel, opts.effort);
|
|
1149
1224
|
try {
|
|
1150
1225
|
return await aegis.byokChatCompletion({
|
|
1151
1226
|
provider,
|
|
@@ -1155,6 +1230,13 @@ function createLocalEngine({
|
|
|
1155
1230
|
system: opts.system,
|
|
1156
1231
|
messages: opts.messages,
|
|
1157
1232
|
maxTokens: opts.maxTokens,
|
|
1233
|
+
// Native effort knob, field and level both from the dialect table.
|
|
1234
|
+
// `bareModel` is already provider-less, so it matches the dialect
|
|
1235
|
+
// regexes without further stripping. Only a dialect whose field is
|
|
1236
|
+
// `reasoning_effort` is forwarded: DeepSeek used to ride a separate
|
|
1237
|
+
// `effort` body key, which the API 200s and ignores, so that whole
|
|
1238
|
+
// second channel is gone rather than left as a way to reintroduce it.
|
|
1239
|
+
reasoningEffort: effort && effort.field === 'reasoning_effort' ? effort.level : undefined,
|
|
1158
1240
|
stream: true,
|
|
1159
1241
|
onStream: opts.onDelta,
|
|
1160
1242
|
signal: opts.signal,
|
|
@@ -1856,4 +1938,4 @@ function createLocalEngine({
|
|
|
1856
1938
|
};
|
|
1857
1939
|
}
|
|
1858
1940
|
|
|
1859
|
-
module.exports = { CLASSES, createLocalEngine, extractToolCalls, parseArgs, reasoningBudget };
|
|
1941
|
+
module.exports = { CLASSES, createLocalEngine, extractToolCalls, parseArgs, reasoningBudget, nativeEffort };
|
package/lib/local/shell.js
CHANGED
|
@@ -7,26 +7,308 @@
|
|
|
7
7
|
* The desktop `exec` tool used to spawn ONE process per call: `cd /foo` in
|
|
8
8
|
* one turn had no effect on the next call, so a model that wanted to work
|
|
9
9
|
* inside a subdirectory had to prefix every single command with `cd X &&`.
|
|
10
|
-
* This keeps ONE long-lived shell per chat turn — bash on
|
|
11
|
-
* PowerShell on Windows (no bash there by default) — feeding it
|
|
12
|
-
* stdin and framing each command's output with a per-session
|
|
13
|
-
* printed alongside the exit code. On
|
|
14
|
-
* (`exec 2>&1`) so ordering is preserved; PowerShell's stderr pipe
|
|
15
|
-
* the same way by listening on both streams.
|
|
10
|
+
* This keeps ONE long-lived shell per chat turn — a probed POSIX sh/bash on
|
|
11
|
+
* macOS/Linux, PowerShell on Windows (no bash there by default) — feeding it
|
|
12
|
+
* commands over stdin and framing each command's output with a per-session
|
|
13
|
+
* random sentinel printed alongside the exit code. On POSIX, stderr is merged
|
|
14
|
+
* into stdout (`exec 2>&1`) so ordering is preserved; PowerShell's stderr pipe
|
|
15
|
+
* is merged the same way by listening on both streams.
|
|
16
16
|
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
17
|
+
* Phase 30.3 — the shell is PROBED, never assumed. Until this step the session
|
|
18
|
+
* spawned `process.env.SHELL || '/bin/bash'` and then fed it brace groups, `exec
|
|
19
|
+
* 2>&1` and `$?` — all POSIX-only. Windows was handled; a POSIX host whose
|
|
20
|
+
* `$SHELL` is fish, csh or nushell was not, and those three reject that syntax
|
|
21
|
+
* outright (a brace group to fish is not a syntax error you can ignore: it
|
|
22
|
+
* silently runs the wrong thing and eats the sentinel, hanging the session to
|
|
23
|
+
* its timeout). So `resolveSessionShell()` below only ever returns a shell it
|
|
24
|
+
* has actually EXECUTED once with a POSIX-only probe script, and the dialect the
|
|
25
|
+
* script builder uses follows the verified dialect — `this.shell.kind` — rather
|
|
26
|
+
* than the OS. A `$SHELL` naming a known-non-POSIX shell (fish/csh/tcsh/nu/
|
|
27
|
+
* xonsh) is never adopted and never probed; it is named in the evidence trail
|
|
28
|
+
* and in the caveat, and if no sh/bash can be verified at all the session
|
|
29
|
+
* REFUSES with a message naming it, instead of quietly feeding it POSIX syntax.
|
|
30
|
+
*
|
|
31
|
+
* Self-contained (node:child_process + node:crypto + the shared
|
|
32
|
+
* client/platform.js) and never throws: run() always resolves
|
|
33
|
+
* { content, isError }. If the session can't start or dies, run() falls back to
|
|
34
|
+
* a one-shot spawn so `exec` keeps working.
|
|
20
35
|
*/
|
|
21
36
|
|
|
22
|
-
const { spawn } = require('node:child_process');
|
|
37
|
+
const { spawn, spawnSync } = require('node:child_process');
|
|
23
38
|
const crypto = require('node:crypto');
|
|
24
39
|
|
|
25
|
-
|
|
40
|
+
// The ONE platform module (client/platform.js, Phase 30.1) — resolved the same
|
|
41
|
+
// two ways this repo resolves every shared module (see tools.js/engine.js):
|
|
42
|
+
// repo-relative in a checkout and in the CLI's mirrored vendor tree, then the
|
|
43
|
+
// staged app-dir copy a packaged desktop build carries. Executable lookup goes
|
|
44
|
+
// through it (`resolveExecutable`) rather than through a path literal, so the
|
|
45
|
+
// Windows half of that search is exercised by tests on this Linux host.
|
|
46
|
+
let platform;
|
|
47
|
+
try {
|
|
48
|
+
platform = require('../../../client/platform.js');
|
|
49
|
+
} catch (err) {
|
|
50
|
+
try {
|
|
51
|
+
platform = require('../../vendor/platform.js');
|
|
52
|
+
} catch {
|
|
53
|
+
throw new Error(
|
|
54
|
+
`shell.js: cannot load the shared platform module (client/platform.js, ` +
|
|
55
|
+
`or its staged copy): ${err && err.message ? err.message : err}`
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const IS_WIN32 = platform.IS_WIN32;
|
|
26
61
|
const OUTPUT_CAP = 30_000;
|
|
27
62
|
const MAX_TIMEOUT = 600_000;
|
|
28
63
|
const DEFAULT_TIMEOUT = 120_000;
|
|
29
64
|
|
|
65
|
+
// ---------------------------------------------------------------------------
|
|
66
|
+
// Dialect resolution (Phase 30.3)
|
|
67
|
+
// ---------------------------------------------------------------------------
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* The shells whose `-c` we are allowed to treat as POSIX sh. Anything else —
|
|
71
|
+
* fish, csh/tcsh, nushell, xonsh, elvish, pwsh — is a different LANGUAGE, not a
|
|
72
|
+
* differently-named sh: it is never spawned for a session and never handed the
|
|
73
|
+
* brace-group framing below.
|
|
74
|
+
*/
|
|
75
|
+
const POSIX_SHELL_NAMES = ['bash', 'sh', 'dash', 'ksh', 'zsh'];
|
|
76
|
+
|
|
77
|
+
/** Substitute candidates, in order, when `$SHELL` is unusable. */
|
|
78
|
+
const POSIX_FALLBACK_NAMES = ['bash', 'sh'];
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* The probe script: builtins only (no PATH, no filesystem, no output), and
|
|
82
|
+
* POSIX-only syntax — fish, csh and nu all reject this construct, so a clean
|
|
83
|
+
* exit 0 is evidence that `-c` really runs POSIX sh here, rather than evidence
|
|
84
|
+
* that a binary with the right name exists. Same probe the engine's Phase 21b
|
|
85
|
+
* resolution uses.
|
|
86
|
+
*/
|
|
87
|
+
const POSIX_PROBE = 'if [ 1 -eq 1 ]; then exit 0; fi';
|
|
88
|
+
const SHELL_PROBE_TIMEOUT = 4000;
|
|
89
|
+
|
|
90
|
+
/** Last path segment of a shell reference, minus a `.exe`, lower-cased. */
|
|
91
|
+
function shellName(cmd) {
|
|
92
|
+
return String(cmd || '')
|
|
93
|
+
.trim()
|
|
94
|
+
.split(/[\\/]/)
|
|
95
|
+
.pop()
|
|
96
|
+
.toLowerCase()
|
|
97
|
+
.replace(/\.exe$/, '');
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** True for the shells whose `-c` may be treated as POSIX sh. */
|
|
101
|
+
function isPosixShellName(cmd) {
|
|
102
|
+
return POSIX_SHELL_NAMES.includes(shellName(cmd));
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** The real probe: spawn `cmd -c <POSIX_PROBE>`, true only on a clean exit 0. */
|
|
106
|
+
function probePosixShell(cmd, { env, timeout = SHELL_PROBE_TIMEOUT } = {}) {
|
|
107
|
+
try {
|
|
108
|
+
const res = spawnSync(cmd, ['-c', POSIX_PROBE], {
|
|
109
|
+
env,
|
|
110
|
+
encoding: 'utf8',
|
|
111
|
+
timeout,
|
|
112
|
+
windowsHide: true,
|
|
113
|
+
stdio: 'ignore',
|
|
114
|
+
});
|
|
115
|
+
return !res.error && res.status === 0;
|
|
116
|
+
} catch {
|
|
117
|
+
return false;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Normalize the injected seams, defaulting each to the real thing. */
|
|
122
|
+
function shellSeams(options) {
|
|
123
|
+
const o = options && typeof options === 'object' ? options : {};
|
|
124
|
+
return {
|
|
125
|
+
platform: typeof o.platform === 'string' && o.platform ? o.platform : process.platform,
|
|
126
|
+
env: o.env && typeof o.env === 'object' ? o.env : process.env,
|
|
127
|
+
exists: typeof o.exists === 'function' ? o.exists : undefined,
|
|
128
|
+
probe: typeof o.probe === 'function' ? o.probe : undefined,
|
|
129
|
+
injected: typeof o.probe === 'function' || typeof o.exists === 'function',
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Try one candidate. Returns a verified spec or null, recording why in `tried`
|
|
135
|
+
* and every shell it actually executed in `executed`.
|
|
136
|
+
*
|
|
137
|
+
* The name gate comes FIRST and applies to every candidate, including `$SHELL`:
|
|
138
|
+
* fish is refused on its name, before a probe could ever adopt it, so there is
|
|
139
|
+
* no ordering in which a fish gets spawned and then handed POSIX syntax.
|
|
140
|
+
*/
|
|
141
|
+
function considerPosixCandidate(o, candidate, tried, executed) {
|
|
142
|
+
const raw = String(candidate.cmd || '').trim();
|
|
143
|
+
if (!raw) {
|
|
144
|
+
tried.push({ candidate: candidate.source || '(unset)', ok: false, reason: 'unset' });
|
|
145
|
+
return null;
|
|
146
|
+
}
|
|
147
|
+
if (!isPosixShellName(raw)) {
|
|
148
|
+
tried.push({
|
|
149
|
+
candidate: raw,
|
|
150
|
+
ok: false,
|
|
151
|
+
reason:
|
|
152
|
+
`${candidate.source || 'candidate'} is not a POSIX shell — ${shellName(raw) || raw} is a ` +
|
|
153
|
+
'different language (fish/csh/nu), so it was never executed and never sent POSIX syntax',
|
|
154
|
+
});
|
|
155
|
+
return null;
|
|
156
|
+
}
|
|
157
|
+
const resolved = platform.resolveExecutable(raw, {
|
|
158
|
+
platform: o.platform,
|
|
159
|
+
env: o.env,
|
|
160
|
+
exists: o.exists,
|
|
161
|
+
});
|
|
162
|
+
if (!resolved) {
|
|
163
|
+
tried.push({
|
|
164
|
+
candidate: raw,
|
|
165
|
+
ok: false,
|
|
166
|
+
reason: /[\\/]/.test(raw) ? 'not present' : 'not found on PATH',
|
|
167
|
+
});
|
|
168
|
+
return null;
|
|
169
|
+
}
|
|
170
|
+
const ok = o.probe
|
|
171
|
+
? Boolean(o.probe(resolved, ['-c', POSIX_PROBE]))
|
|
172
|
+
: probePosixShell(resolved, { env: o.env });
|
|
173
|
+
executed.push(resolved);
|
|
174
|
+
if (!ok) {
|
|
175
|
+
tried.push({
|
|
176
|
+
candidate: resolved,
|
|
177
|
+
ok: false,
|
|
178
|
+
reason: `failed the POSIX probe (\`-c\` with \`${POSIX_PROBE}\` did not exit 0) — refused`,
|
|
179
|
+
});
|
|
180
|
+
return null;
|
|
181
|
+
}
|
|
182
|
+
tried.push({
|
|
183
|
+
candidate: resolved,
|
|
184
|
+
ok: true,
|
|
185
|
+
reason: 'verified: one `-c` POSIX script exited 0',
|
|
186
|
+
});
|
|
187
|
+
return { ok: true, cmd: resolved, arg: '-c', kind: 'posix-sh', source: candidate.source, verified: true };
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Resolve the session shell on a POSIX host, with the evidence for the answer.
|
|
192
|
+
*
|
|
193
|
+
* Returns { ok, cmd, arg, kind, source, verified, unsupported, caveat, error,
|
|
194
|
+
* tried[] }:
|
|
195
|
+
*
|
|
196
|
+
* - `$SHELL` wins when it NAMES a POSIX shell and passes the probe;
|
|
197
|
+
* - a `$SHELL` naming fish/csh/nu/xonsh (or anything else non-POSIX, including
|
|
198
|
+
* garbage) is refused by name, recorded in `tried`, and named in `caveat`
|
|
199
|
+
* when a substitute is found and in `error` when none can be;
|
|
200
|
+
* - when `$SHELL` is unusable the POSIX dialect runs through an explicit
|
|
201
|
+
* `bash`/`sh` found on PATH — resolved by client/platform.js, not by a path
|
|
202
|
+
* literal — and only after that substitute passed the probe itself;
|
|
203
|
+
* - `ok:false` means NO shell was verified: the caller must report `error`
|
|
204
|
+
* (which names the unsupported `$SHELL`) instead of spawning anything.
|
|
205
|
+
*/
|
|
206
|
+
function resolvePosixSessionShell(o) {
|
|
207
|
+
const tried = [];
|
|
208
|
+
const executed = [];
|
|
209
|
+
const envShell = String((o.env && o.env.SHELL) || '').trim();
|
|
210
|
+
const candidates = [];
|
|
211
|
+
|
|
212
|
+
if (!envShell) {
|
|
213
|
+
tried.push({ candidate: '$SHELL', ok: false, reason: '$SHELL is unset' });
|
|
214
|
+
} else {
|
|
215
|
+
candidates.push({ cmd: envShell, source: '$SHELL' });
|
|
216
|
+
}
|
|
217
|
+
for (const name of POSIX_FALLBACK_NAMES) {
|
|
218
|
+
candidates.push({ cmd: name, source: `PATH:${name}` });
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
let hit = null;
|
|
222
|
+
for (const candidate of candidates) {
|
|
223
|
+
hit = considerPosixCandidate(o, candidate, tried, executed);
|
|
224
|
+
if (hit) break;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const unsupported = envShell && !isPosixShellName(envShell) ? envShell : null;
|
|
228
|
+
if (hit) {
|
|
229
|
+
return {
|
|
230
|
+
...hit,
|
|
231
|
+
unsupported,
|
|
232
|
+
caveat: unsupported
|
|
233
|
+
? `$SHELL is ${unsupported}, which is not a POSIX shell — the session runs the POSIX ` +
|
|
234
|
+
`dialect through ${hit.cmd} instead, and ${unsupported} is never sent brace groups, ` +
|
|
235
|
+
'`exec 2>&1` or `$?`'
|
|
236
|
+
: null,
|
|
237
|
+
error: null,
|
|
238
|
+
executed,
|
|
239
|
+
tried,
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
const error = unsupported
|
|
244
|
+
? `exec: refusing to run — $SHELL is "${unsupported}", which is not a POSIX shell ` +
|
|
245
|
+
'(fish/csh/nushell are a different language), and no sh/bash could be verified on PATH ' +
|
|
246
|
+
'to run the POSIX dialect instead. Set SHELL to bash or sh, or put one on PATH.'
|
|
247
|
+
: `exec: no POSIX shell available — $SHELL is ${envShell ? `"${envShell}"` : 'unset'} and no ` +
|
|
248
|
+
'sh/bash passed the POSIX probe; set SHELL to bash or sh, or put one on PATH.';
|
|
249
|
+
return {
|
|
250
|
+
ok: false,
|
|
251
|
+
cmd: null,
|
|
252
|
+
arg: null,
|
|
253
|
+
kind: null,
|
|
254
|
+
source: 'refused',
|
|
255
|
+
verified: false,
|
|
256
|
+
unsupported,
|
|
257
|
+
caveat: null,
|
|
258
|
+
error,
|
|
259
|
+
executed,
|
|
260
|
+
tried,
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// Verified verdicts are cached per (platform, $SHELL, PATH) so the probe runs
|
|
265
|
+
// once per process — but never when a seam was injected: a test double must not
|
|
266
|
+
// be able to poison the real verdicts.
|
|
267
|
+
const SHELL_CACHE = new Map();
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Which shell runs the session here, and the evidence for that answer.
|
|
271
|
+
*
|
|
272
|
+
* Windows keeps PowerShell (there is no bash there by default), exactly as
|
|
273
|
+
* before; everywhere else the answer comes from resolvePosixSessionShell above
|
|
274
|
+
* and is only ever a shell that passed the POSIX probe. `options` is the same
|
|
275
|
+
* injected seam the shared platform module takes —
|
|
276
|
+
* `{ platform, env, exists, probe }` — so every branch is exercised on this
|
|
277
|
+
* Linux host (test/shell-dialect.test.mjs) rather than on a runner this project
|
|
278
|
+
* does not have.
|
|
279
|
+
*/
|
|
280
|
+
function resolveSessionShell(options = {}) {
|
|
281
|
+
const o = shellSeams(options);
|
|
282
|
+
if (o.platform === 'win32') {
|
|
283
|
+
const spec = platform.shellSpec({ platform: 'win32', env: o.env });
|
|
284
|
+
const powershell =
|
|
285
|
+
platform.resolveExecutable('powershell', { platform: 'win32', env: o.env, exists: o.exists }) ||
|
|
286
|
+
'powershell.exe';
|
|
287
|
+
return {
|
|
288
|
+
ok: true,
|
|
289
|
+
cmd: powershell,
|
|
290
|
+
arg: '-',
|
|
291
|
+
kind: 'powershell',
|
|
292
|
+
source: 'powershell.exe',
|
|
293
|
+
verified: true,
|
|
294
|
+
unsupported: null,
|
|
295
|
+
caveat: null,
|
|
296
|
+
error: null,
|
|
297
|
+
// One-shot commands on Windows stay cmd.exe: that is what the pre-session
|
|
298
|
+
// `exec` always used, and PowerShell is only the session's dialect.
|
|
299
|
+
oneShot: spec,
|
|
300
|
+
executed: [],
|
|
301
|
+
tried: [{ candidate: powershell, ok: true, reason: 'the Windows default (PowerShell session, cmd.exe one-shot)' }],
|
|
302
|
+
};
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
const key = [o.platform, o.env && o.env.SHELL, o.env && o.env.PATH].join('\u0000');
|
|
306
|
+
if (!o.injected && SHELL_CACHE.has(key)) return SHELL_CACHE.get(key);
|
|
307
|
+
const spec = resolvePosixSessionShell(o);
|
|
308
|
+
if (!o.injected) SHELL_CACHE.set(key, spec);
|
|
309
|
+
return spec;
|
|
310
|
+
}
|
|
311
|
+
|
|
30
312
|
function cap(s) {
|
|
31
313
|
return s.length > OUTPUT_CAP ? `${s.slice(0, OUTPUT_CAP)}\n… (truncated)` : s;
|
|
32
314
|
}
|
|
@@ -65,14 +347,49 @@ function appendBounded(prev, chunk) {
|
|
|
65
347
|
return next.slice(0, OUTPUT_CAP) + ELISION + next.slice(-TAIL_KEEP);
|
|
66
348
|
}
|
|
67
349
|
|
|
68
|
-
/**
|
|
69
|
-
|
|
350
|
+
/**
|
|
351
|
+
* The spec for a ONE-SHOT command (no live session) — the same resolution, so
|
|
352
|
+
* the `exec` tool's no-session path and this module's fallback cannot disagree
|
|
353
|
+
* about which shell speaks the dialect the command was written in.
|
|
354
|
+
*
|
|
355
|
+
* Windows keeps cmd.exe (`/d /s /c`), exactly what it used before; everywhere
|
|
356
|
+
* else the answer is a PROBED POSIX sh/bash, and `{ ok:false, error }` when
|
|
357
|
+
* nothing could be verified — the caller reports `error` (which names the
|
|
358
|
+
* unsupported `$SHELL`) instead of shelling out in a language it does not speak.
|
|
359
|
+
*/
|
|
360
|
+
function oneShotShellSpec(options = {}) {
|
|
361
|
+
const spec = resolveSessionShell(options);
|
|
362
|
+
if (!spec.ok) {
|
|
363
|
+
return { ok: false, cmd: null, arg: null, kind: null, error: spec.error, spec };
|
|
364
|
+
}
|
|
365
|
+
if (spec.kind === 'powershell') {
|
|
366
|
+
const cmdSpec = platform.shellSpec({ platform: 'win32', env: shellSeams(options).env });
|
|
367
|
+
return { ok: true, cmd: cmdSpec.cmd, arg: cmdSpec.arg, kind: 'cmd', error: null, spec };
|
|
368
|
+
}
|
|
369
|
+
return { ok: true, cmd: spec.cmd, arg: spec.arg, kind: 'posix-sh', error: null, spec };
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/**
|
|
373
|
+
* One-shot fallback — the pre-session behavior, used when no live session.
|
|
374
|
+
*
|
|
375
|
+
* The dialect is resolved the same way the session's is: a POSIX `-c` runs in a
|
|
376
|
+
* verified sh/bash (never in an unprobed `$SHELL`), and when nothing can be
|
|
377
|
+
* verified the refusal names the shell that was refused rather than shelling out
|
|
378
|
+
* to it in a language it does not speak.
|
|
379
|
+
*/
|
|
380
|
+
function oneShotShell({ command, timeout = DEFAULT_TIMEOUT, working_directory, shellOptions } = {}) {
|
|
70
381
|
return new Promise((resolve) => {
|
|
71
|
-
const
|
|
72
|
-
const
|
|
73
|
-
|
|
382
|
+
const o = shellSeams(shellOptions);
|
|
383
|
+
const spec = resolveSessionShell(shellOptions);
|
|
384
|
+
if (!spec || !spec.ok) {
|
|
385
|
+
resolve({ content: (spec && spec.error) || 'exec: no usable shell', isError: true });
|
|
386
|
+
return;
|
|
387
|
+
}
|
|
388
|
+
const call = spec.kind === 'powershell' ? spec.oneShot : spec;
|
|
389
|
+
const child = spawn(call.cmd, [call.arg, String(command || '')], {
|
|
74
390
|
cwd: working_directory || process.cwd(),
|
|
75
391
|
timeout: Math.min(Number(timeout) || DEFAULT_TIMEOUT, MAX_TIMEOUT),
|
|
392
|
+
env: o.env,
|
|
76
393
|
});
|
|
77
394
|
let out = '';
|
|
78
395
|
let err = '';
|
|
@@ -89,30 +406,44 @@ function oneShotShell({ command, timeout = DEFAULT_TIMEOUT, working_directory }
|
|
|
89
406
|
}
|
|
90
407
|
|
|
91
408
|
class ShellSession {
|
|
92
|
-
constructor({ cwd } = {}) {
|
|
409
|
+
constructor({ cwd, shellOptions } = {}) {
|
|
93
410
|
this.sentinel = `__AEGIS_SH_${crypto.randomBytes(8).toString('hex')}__`;
|
|
94
411
|
this.marker = `${this.sentinel}EXIT:`;
|
|
95
412
|
this.buf = '';
|
|
96
413
|
this.alive = false;
|
|
97
414
|
this._pending = null; // resolver-scan for the in-flight command
|
|
415
|
+
this.shellOptions = shellOptions || {};
|
|
416
|
+
// Resolved BEFORE anything is spawned or written: `shell.kind` is what the
|
|
417
|
+
// command framing below branches on, so an unverified `$SHELL` can never
|
|
418
|
+
// receive POSIX syntax — it is never spawned at all.
|
|
419
|
+
this.shell = resolveSessionShell(this.shellOptions);
|
|
420
|
+
this.shellError = this.shell.ok ? null : this.shell.error;
|
|
98
421
|
this._start(cwd);
|
|
99
422
|
}
|
|
100
423
|
|
|
101
424
|
_start(cwd) {
|
|
425
|
+
if (!this.shell || !this.shell.ok) {
|
|
426
|
+
// Refused: no shell was verified, so nothing is spawned. run() reports
|
|
427
|
+
// this.shellError (which names the unsupported $SHELL).
|
|
428
|
+
this.alive = false;
|
|
429
|
+
return;
|
|
430
|
+
}
|
|
431
|
+
const env = shellSeams(this.shellOptions).env;
|
|
102
432
|
try {
|
|
103
|
-
this.child =
|
|
433
|
+
this.child = this.shell.kind === 'powershell'
|
|
104
434
|
// -Command - reads the script from stdin as a non-interactive session,
|
|
105
435
|
// so there are no prompts to pollute output, but state still persists.
|
|
106
|
-
? spawn(
|
|
436
|
+
? spawn(this.shell.cmd, ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', '-'], {
|
|
107
437
|
cwd: cwd || process.cwd(),
|
|
108
|
-
env
|
|
438
|
+
env,
|
|
109
439
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
110
440
|
})
|
|
111
441
|
// No args → the shell reads commands from stdin as a non-interactive
|
|
112
442
|
// script, so there are no prompts to pollute output, but state persists.
|
|
113
|
-
|
|
443
|
+
// This is a shell that PASSED the POSIX probe (see resolveSessionShell).
|
|
444
|
+
: spawn(this.shell.cmd, [], {
|
|
114
445
|
cwd: cwd || process.cwd(),
|
|
115
|
-
env
|
|
446
|
+
env,
|
|
116
447
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
117
448
|
});
|
|
118
449
|
} catch {
|
|
@@ -133,8 +464,10 @@ class ShellSession {
|
|
|
133
464
|
// Merge stderr into stdout for the whole session so output ordering is
|
|
134
465
|
// faithful and the sentinel (printed to fd1) always lands after it.
|
|
135
466
|
// PowerShell errors already arrive on the stderr pipe onData folds in
|
|
136
|
-
// above, so only
|
|
137
|
-
|
|
467
|
+
// above, so only a POSIX session needs the explicit redirect — and `exec`
|
|
468
|
+
// redirection is POSIX, which is why this is gated on the VERIFIED dialect
|
|
469
|
+
// rather than on `!IS_WIN32`.
|
|
470
|
+
if (this.shell.kind === 'posix-sh') {
|
|
138
471
|
try { this.child.stdin.write('exec 2>&1\n'); } catch { this.alive = false; }
|
|
139
472
|
}
|
|
140
473
|
}
|
|
@@ -143,12 +476,16 @@ class ShellSession {
|
|
|
143
476
|
* Run one command in the session. Resolves { content, isError }. A dead or
|
|
144
477
|
* unstartable session (or a working_directory that must not persist)
|
|
145
478
|
* degrades gracefully. `working_directory` is scoped to this one command (a
|
|
146
|
-
* subshell on
|
|
479
|
+
* subshell on POSIX, Push-Location/Pop-Location on PowerShell) so it doesn't
|
|
147
480
|
* move the session's cwd.
|
|
148
481
|
*/
|
|
149
482
|
run(command, { timeout = DEFAULT_TIMEOUT, working_directory } = {}) {
|
|
150
|
-
|
|
151
|
-
|
|
483
|
+
const posixSession = this.shell && this.shell.kind === 'posix-sh' && this.shell.verified;
|
|
484
|
+
if (!this.alive || !this.child || ((this.shell && this.shell.kind === 'posix-sh') && !posixSession)) {
|
|
485
|
+
if (!this.alive && this.shellError) {
|
|
486
|
+
return Promise.resolve({ content: this.shellError, isError: true });
|
|
487
|
+
}
|
|
488
|
+
return oneShotShell({ command, timeout, working_directory, shellOptions: this.shellOptions });
|
|
152
489
|
}
|
|
153
490
|
let cmd = String(command || '');
|
|
154
491
|
|
|
@@ -188,7 +525,7 @@ class ShellSession {
|
|
|
188
525
|
|
|
189
526
|
// Send the command, then print the sentinel + exit code on its own line.
|
|
190
527
|
let script;
|
|
191
|
-
if (
|
|
528
|
+
if (!posixSession) {
|
|
192
529
|
// Push-Location/Pop-Location scope working_directory to this one
|
|
193
530
|
// command without moving the session's persistent cwd. $__c captures
|
|
194
531
|
// the real exit status before Pop-Location's own success would
|
|
@@ -201,15 +538,17 @@ class ShellSession {
|
|
|
201
538
|
: `${body}\n${exitCapture}\n`;
|
|
202
539
|
script += `Write-Output ("${this.marker}" + $__c)\n`;
|
|
203
540
|
} else {
|
|
204
|
-
//
|
|
205
|
-
//
|
|
206
|
-
//
|
|
207
|
-
//
|
|
208
|
-
//
|
|
209
|
-
//
|
|
210
|
-
//
|
|
211
|
-
// and
|
|
212
|
-
//
|
|
541
|
+
// POSIX dialect, sent ONLY to a shell that passed the POSIX probe
|
|
542
|
+
// (posixSession above) — never to fish/csh/nu. The command runs in a
|
|
543
|
+
// brace group with its stdin redirected from /dev/null: a group (not a
|
|
544
|
+
// subshell) keeps cd/export state persisting, and `</dev/null` stops the
|
|
545
|
+
// command from consuming the control channel. Without this, any
|
|
546
|
+
// stdin-reading command (cat, read, a REPL, ssh, a y/n prompt) swallows
|
|
547
|
+
// the sentinel line that follows it — hanging the whole session until
|
|
548
|
+
// the timeout, and echoing commands (cat) even splice the sentinel into
|
|
549
|
+
// their output and mis-resolve with garbage. working_directory runs in a
|
|
550
|
+
// subshell (parens, not the brace group) so it doesn't move the
|
|
551
|
+
// session's cwd.
|
|
213
552
|
if (working_directory) cmd = `( cd ${shq(working_directory)} && ${cmd} )`;
|
|
214
553
|
const body = cmd.trim() ? cmd : ':';
|
|
215
554
|
script = `{ ${body}\n} </dev/null\nprintf '\\n%s%d\\n' '${this.sentinel}EXIT:' "$?"\n`;
|
|
@@ -218,7 +557,7 @@ class ShellSession {
|
|
|
218
557
|
this.child.stdin.write(script);
|
|
219
558
|
} catch {
|
|
220
559
|
this.alive = false;
|
|
221
|
-
oneShotShell({ command, timeout, working_directory }).then(finish);
|
|
560
|
+
oneShotShell({ command, timeout, working_directory, shellOptions: this.shellOptions }).then(finish);
|
|
222
561
|
return;
|
|
223
562
|
}
|
|
224
563
|
// Data may already be buffered (fast commands) — scan once now.
|
|
@@ -246,4 +585,19 @@ function pshq(s) {
|
|
|
246
585
|
return `'${String(s).replace(/'/g, "''")}'`;
|
|
247
586
|
}
|
|
248
587
|
|
|
249
|
-
module.exports = {
|
|
588
|
+
module.exports = {
|
|
589
|
+
ShellSession,
|
|
590
|
+
oneShotShell,
|
|
591
|
+
IS_WIN32,
|
|
592
|
+
// Phase 30.3: the dialect probe and its verdict, exported so a test can
|
|
593
|
+
// assert on the resolution without spawning a session.
|
|
594
|
+
resolveSessionShell,
|
|
595
|
+
oneShotShellSpec,
|
|
596
|
+
isPosixShellName,
|
|
597
|
+
POSIX_SHELL_NAMES,
|
|
598
|
+
POSIX_PROBE,
|
|
599
|
+
appendBounded,
|
|
600
|
+
OUTPUT_CAP,
|
|
601
|
+
BUFFER_CEILING,
|
|
602
|
+
TAIL_KEEP,
|
|
603
|
+
};
|