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.
@@ -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.path);
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.path);
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 };
@@ -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 macOS/Linux,
11
- * PowerShell on Windows (no bash there by default) — feeding it commands over
12
- * stdin and framing each command's output with a per-session random sentinel
13
- * printed alongside the exit code. On bash, stderr is merged into stdout
14
- * (`exec 2>&1`) so ordering is preserved; PowerShell's stderr pipe is merged
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
- * Self-contained (node:child_process + node:crypto only) and never throws:
18
- * run() always resolves { content, isError }. If the session can't start or
19
- * dies, run() falls back to a one-shot spawn so `exec` keeps working.
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
- const IS_WIN32 = process.platform === 'win32';
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
- /** One-shot fallback — the pre-session behavior, used when no live session. */
69
- function oneShotShell({ command, timeout = DEFAULT_TIMEOUT, working_directory } = {}) {
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 cmd = IS_WIN32 ? (process.env.ComSpec || 'cmd.exe') : (process.env.SHELL || '/bin/sh');
72
- const arg = IS_WIN32 ? '/d /s /c' : '-c';
73
- const child = spawn(cmd, [arg, String(command || '')], {
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 = IS_WIN32
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('powershell.exe', ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', '-'], {
436
+ ? spawn(this.shell.cmd, ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', '-'], {
107
437
  cwd: cwd || process.cwd(),
108
- env: process.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
- : spawn(process.env.SHELL || '/bin/bash', [], {
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: process.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 bash needs the explicit redirect.
137
- if (!IS_WIN32) {
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 bash, Push-Location/Pop-Location on PowerShell) so it doesn't
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
- if (!this.alive || !this.child) {
151
- return oneShotShell({ command, timeout, working_directory });
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 (IS_WIN32) {
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
- // The command runs in a brace group with its stdin redirected from
205
- // /dev/null: a group (not a subshell) keeps cd/export state
206
- // persisting, and `</dev/null` stops the command from consuming the
207
- // control channel. Without this, any stdin-reading command (cat,
208
- // read, a REPL, ssh, a y/n prompt) swallows the sentinel line that
209
- // follows it — hanging the whole session until the timeout, and
210
- // echoing commands (cat) even splice the sentinel into their output
211
- // and mis-resolve with garbage. working_directory runs in a subshell
212
- // (parens, not the brace group) so it doesn't move the session's cwd.
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 = { ShellSession, oneShotShell, IS_WIN32, appendBounded, OUTPUT_CAP, BUFFER_CEILING, TAIL_KEEP };
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
+ };