mixdog 0.9.22 → 0.9.24
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -4
- package/package.json +1 -1
- package/scripts/boot-smoke.mjs +1 -1
- package/scripts/channel-daemon-smoke.mjs +483 -0
- package/scripts/channel-daemon-stub.mjs +80 -0
- package/scripts/debounced-skills-async-save-test.mjs +57 -0
- package/scripts/explore-bench-tmp.mjs +17 -0
- package/scripts/find-fuzzy-hidden-test.mjs +145 -0
- package/scripts/mcp-grace-deferred-test.mjs +149 -0
- package/scripts/statusline-quota-hysteresis-test.mjs +37 -0
- package/scripts/tool-smoke.mjs +68 -47
- package/src/rules/agent/30-explorer.md +6 -0
- package/src/rules/shared/01-tool.md +11 -4
- package/src/runtime/agent/orchestrator/agent-runtime/agent-progress-watchdog.mjs +76 -1
- package/src/runtime/agent/orchestrator/config.mjs +33 -7
- package/src/runtime/agent/orchestrator/context/collect.mjs +43 -8
- package/src/runtime/agent/orchestrator/mcp/child-tree.mjs +226 -0
- package/src/runtime/agent/orchestrator/mcp/client.mjs +184 -33
- package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +38 -1
- package/src/runtime/agent/orchestrator/providers/anthropic.mjs +39 -1
- package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +1 -1
- package/src/runtime/agent/orchestrator/providers/openai-compat-wire.mjs +5 -3
- package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +2 -2
- package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +2 -2
- package/src/runtime/agent/orchestrator/providers/openai-ws-stream.mjs +1 -1
- package/src/runtime/agent/orchestrator/session/agent-loop.mjs +24 -7
- package/src/runtime/agent/orchestrator/session/loop/completion-guards.mjs +3 -2
- package/src/runtime/agent/orchestrator/session/loop/deferred-call-through.mjs +6 -3
- package/src/runtime/agent/orchestrator/session/loop/tool-helpers.mjs +1 -1
- package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +37 -4
- package/src/runtime/agent/orchestrator/session/manager/runtime-liveness.mjs +11 -1
- package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +14 -3
- package/src/runtime/agent/orchestrator/session/manager/tool-resolution.mjs +40 -6
- package/src/runtime/agent/orchestrator/session/store.mjs +30 -14
- package/src/runtime/agent/orchestrator/session/tool-batch.mjs +2 -1
- package/src/runtime/agent/orchestrator/tools/bash-session.mjs +13 -5
- package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +62 -24
- package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +7 -6
- package/src/runtime/agent/orchestrator/tools/builtin/cache-layers.mjs +20 -0
- package/src/runtime/agent/orchestrator/tools/builtin/fuzzy-match.mjs +34 -3
- package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +220 -27
- package/src/runtime/agent/orchestrator/tools/builtin/shell-analysis.mjs +61 -0
- package/src/runtime/agent/orchestrator/tools/builtin/shell-job-paths.mjs +1 -1
- package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +67 -27
- package/src/runtime/agent/orchestrator/tools/builtin/shell-runtime.mjs +6 -5
- package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +54 -5
- package/src/runtime/agent/orchestrator/tools/patch/orchestrator.mjs +97 -54
- package/src/runtime/agent/orchestrator/tools/patch/paths.mjs +49 -31
- package/src/runtime/agent/orchestrator/tools/patch/v4a-convert.mjs +35 -2
- package/src/runtime/agent/orchestrator/tools/shell-command.mjs +126 -22
- package/src/runtime/channels/lib/crash-log.mjs +4 -2
- package/src/runtime/channels/lib/output-forwarder.mjs +2 -1
- package/src/runtime/channels/lib/owned-runtime.mjs +67 -223
- package/src/runtime/channels/lib/owner-heartbeat.mjs +9 -62
- package/src/runtime/channels/lib/parent-bridge.mjs +16 -1
- package/src/runtime/channels/lib/runtime-paths.mjs +32 -113
- package/src/runtime/channels/lib/session-discovery.mjs +2 -1
- package/src/runtime/channels/lib/tool-dispatch.mjs +22 -14
- package/src/runtime/channels/lib/tool-format.mjs +7 -2
- package/src/runtime/channels/lib/worker-main.mjs +42 -30
- package/src/runtime/memory/index.mjs +54 -7
- package/src/runtime/memory/lib/embedding-warmup.mjs +7 -0
- package/src/runtime/memory/lib/pg/process.mjs +85 -40
- package/src/runtime/memory/lib/pg/supervisor.mjs +82 -17
- package/src/runtime/memory/lib/query-handlers.mjs +4 -1
- package/src/runtime/memory/lib/recall-format.mjs +7 -3
- package/src/runtime/memory/tool-defs.mjs +1 -1
- package/src/runtime/shared/atomic-file.mjs +150 -6
- package/src/runtime/shared/background-tasks.mjs +1 -1
- package/src/runtime/shared/config.mjs +53 -1
- package/src/runtime/shared/tool-execution-contract.mjs +1 -1
- package/src/runtime/shared/tool-primitives.mjs +31 -1
- package/src/runtime/shared/tool-surface.mjs +42 -13
- package/src/runtime/shared/update-checker.mjs +3 -0
- package/src/runtime/shared/user-data-guard.mjs +66 -0
- package/src/session-runtime/config-lifecycle.mjs +221 -20
- package/src/session-runtime/lifecycle-api.mjs +9 -0
- package/src/session-runtime/mcp-glue.mjs +93 -1
- package/src/session-runtime/resource-api.mjs +62 -8
- package/src/session-runtime/runtime-core.mjs +118 -4
- package/src/session-runtime/session-text.mjs +41 -0
- package/src/session-runtime/session-turn-api.mjs +50 -0
- package/src/session-runtime/settings-api.mjs +8 -1
- package/src/session-runtime/tool-catalog.mjs +350 -38
- package/src/session-runtime/tool-defs.mjs +7 -7
- package/src/session-runtime/workflow.mjs +2 -1
- package/src/standalone/channel-admin.mjs +32 -3
- package/src/standalone/channel-daemon-client.mjs +226 -0
- package/src/standalone/channel-daemon-transport.mjs +545 -0
- package/src/standalone/channel-daemon.mjs +176 -0
- package/src/standalone/channel-worker.mjs +224 -4
- package/src/standalone/explore-tool.mjs +87 -15
- package/src/standalone/hook-bus.mjs +71 -3
- package/src/tui/App.jsx +107 -19
- package/src/tui/app/clipboard.mjs +39 -19
- package/src/tui/app/doctor.mjs +57 -0
- package/src/tui/app/extension-pickers.mjs +53 -9
- package/src/tui/app/maintenance-pickers.mjs +0 -5
- package/src/tui/app/slash-dispatch.mjs +4 -4
- package/src/tui/app/text-layout.mjs +11 -0
- package/src/tui/app/use-mouse-input.mjs +235 -51
- package/src/tui/app/use-prompt-handlers.mjs +49 -30
- package/src/tui/app/use-transcript-scroll.mjs +124 -27
- package/src/tui/app/use-transcript-window.mjs +55 -1
- package/src/tui/components/Message.jsx +1 -1
- package/src/tui/components/PromptInput.jsx +3 -1
- package/src/tui/components/QueuedCommands.jsx +21 -10
- package/src/tui/components/StatusLine.jsx +3 -3
- package/src/tui/components/ToolExecution.jsx +16 -4
- package/src/tui/components/TranscriptItem.jsx +1 -1
- package/src/tui/dist/index.mjs +820 -326
- package/src/tui/engine/agent-job-feed.mjs +5 -0
- package/src/tui/engine/notification-plan.mjs +5 -0
- package/src/tui/engine/session-api.mjs +23 -8
- package/src/tui/engine/session-flow.mjs +6 -0
- package/src/tui/engine/tool-card-results.mjs +14 -5
- package/src/tui/engine/turn.mjs +9 -2
- package/src/tui/engine.mjs +32 -5
- package/src/tui/index.jsx +62 -18
- package/src/tui/paste-attachments.mjs +26 -0
- package/src/ui/statusline-agents.mjs +36 -0
- package/src/ui/statusline.mjs +60 -10
- package/src/ui/tool-card.mjs +8 -1
- package/src/vendor/statusline/bin/statusline-route.mjs +23 -7
- package/src/workflows/bench/WORKFLOW.md +46 -0
- package/src/workflows/default/WORKFLOW.md +6 -0
- package/src/workflows/solo/WORKFLOW.md +5 -0
- package/vendor/ink/build/ink.js +23 -1
- package/vendor/ink/build/output.js +154 -71
- package/vendor/ink/build/render-node-to-output.js +44 -2
- package/vendor/ink/build/render.js +4 -0
- package/vendor/ink/build/renderer.js +4 -1
|
@@ -83,6 +83,20 @@ export function fuzzyScore(query, str) {
|
|
|
83
83
|
return score;
|
|
84
84
|
}
|
|
85
85
|
|
|
86
|
+
// Below this per-query-char score, a subsequence-only match (query chars in
|
|
87
|
+
// order but scattered mid-word, no contiguity/boundary structure) is treated
|
|
88
|
+
// as noise rather than a real hit. Contiguous substring / basename matches
|
|
89
|
+
// bypass the floor entirely — an exact hit must never be filtered.
|
|
90
|
+
const SUBSEQUENCE_MIN_PER_CHAR = 4;
|
|
91
|
+
|
|
92
|
+
// Separator/case-insensitive normalization used for the "contiguous substring"
|
|
93
|
+
// strong-match test. Mirrors the query normalization in fuzzyScore so
|
|
94
|
+
// "tool-events.log" matches a "tool-events.log" basename or a ".../tool-events.log"
|
|
95
|
+
// path regardless of separators.
|
|
96
|
+
function normalizeForContains(s) {
|
|
97
|
+
return String(s || '').toLowerCase().replace(/[\/\\_.\-\s]+/g, '');
|
|
98
|
+
}
|
|
99
|
+
|
|
86
100
|
/**
|
|
87
101
|
* Rank candidates by fuzzy score against `query`, dropping non-matches.
|
|
88
102
|
* @param {string} query
|
|
@@ -91,13 +105,30 @@ export function fuzzyScore(query, str) {
|
|
|
91
105
|
* @returns {Array<{item:object, score:number}>} sorted desc, then path asc
|
|
92
106
|
*/
|
|
93
107
|
export function fuzzyRank(query, items, limit = 0) {
|
|
108
|
+
const normQuery = normalizeForContains(query);
|
|
109
|
+
// Floor scales with query length: a scattered subsequence earns ~1 point
|
|
110
|
+
// per char, while any contiguous run (+5/char) or word-boundary hit
|
|
111
|
+
// (+8) pushes a genuine match well past 4/char.
|
|
112
|
+
const floor = normQuery.length * SUBSEQUENCE_MIN_PER_CHAR;
|
|
94
113
|
const scored = [];
|
|
95
114
|
for (const item of items) {
|
|
96
|
-
const
|
|
97
|
-
const
|
|
115
|
+
const p = String(item.path || '');
|
|
116
|
+
const pathScore = fuzzyScore(query, p);
|
|
117
|
+
const base = p.split(/[\\/]/).pop() || '';
|
|
98
118
|
const baseScore = fuzzyScore(query, base);
|
|
99
119
|
const sc = Math.max(pathScore ?? -Infinity, baseScore === null ? -Infinity : baseScore + 40);
|
|
100
|
-
if (Number.isFinite(sc))
|
|
120
|
+
if (!Number.isFinite(sc)) continue;
|
|
121
|
+
// Strong match: the query (separators stripped) is a contiguous
|
|
122
|
+
// substring of the basename or the full path. These ALWAYS pass so an
|
|
123
|
+
// exact substring/basename hit can never be starved out as noise.
|
|
124
|
+
const strong = normQuery.length > 0
|
|
125
|
+
&& (normalizeForContains(base).includes(normQuery)
|
|
126
|
+
|| normalizeForContains(p).includes(normQuery));
|
|
127
|
+
// Otherwise it is subsequence-only: keep it only if it clears the
|
|
128
|
+
// per-char floor. Weak scattered matches (the pgAdmin-style junk that
|
|
129
|
+
// merely contains the query chars in order) fall below it and drop out.
|
|
130
|
+
if (!strong && sc < floor) continue;
|
|
131
|
+
scored.push({ item, score: sc });
|
|
101
132
|
}
|
|
102
133
|
scored.sort((a, b) => (b.score - a.score) || (a.item.path < b.item.path ? -1 : a.item.path > b.item.path ? 1 : 0));
|
|
103
134
|
return limit > 0 ? scored.slice(0, limit) : scored;
|
|
@@ -22,6 +22,7 @@ import {
|
|
|
22
22
|
getCachedReadOnlyStat,
|
|
23
23
|
statPathsForMtime,
|
|
24
24
|
lstatPathsForMtime,
|
|
25
|
+
registerCacheInvalidationListener,
|
|
25
26
|
} from './cache-layers.mjs';
|
|
26
27
|
import {
|
|
27
28
|
compileSimpleGlob,
|
|
@@ -339,6 +340,106 @@ export async function executeTreeTool(args, workDir, options = {}) {
|
|
|
339
340
|
return out;
|
|
340
341
|
}
|
|
341
342
|
|
|
343
|
+
// ── Broad-enumeration cache (shared `rg --files` sweep) ──────────────────
|
|
344
|
+
// A `rg --files` sweep of a root depends ONLY on (root, hidden, depth,
|
|
345
|
+
// includeNoise) — NOT on the per-query narrowing. Yet both the fuzzy-find
|
|
346
|
+
// broad pass and the find_files broad fast path re-run that full sweep for
|
|
347
|
+
// every query item AND for every concurrent caller (measured 1-4s each when
|
|
348
|
+
// 8 explorer sub-sessions hit the same root). Cache the PARSED file list per
|
|
349
|
+
// key with in-flight promise dedup (N concurrent callers share ONE sweep)
|
|
350
|
+
// plus a short TTL for serial reuse. Truncated/partial sweeps are
|
|
351
|
+
// known-incomplete and are NEVER cached.
|
|
352
|
+
const FIND_ENUM_CACHE = new Map(); // key -> { files, expiresAt }
|
|
353
|
+
const FIND_ENUM_INFLIGHT = new Map(); // key -> Promise<{files,truncated,partial}>
|
|
354
|
+
|
|
355
|
+
// The broad enumeration is a DERIVED cache the scope/path invalidation layer
|
|
356
|
+
// does not otherwise know about — a file created/renamed after a sweep would
|
|
357
|
+
// stay invisible to broad find reuse for the whole TTL. Drop all entries on any
|
|
358
|
+
// write-invalidation event (TTL remains the secondary bound). Full clear is
|
|
359
|
+
// fine: entries are cheap to rebuild.
|
|
360
|
+
registerCacheInvalidationListener(() => {
|
|
361
|
+
FIND_ENUM_CACHE.clear();
|
|
362
|
+
FIND_ENUM_INFLIGHT.clear();
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
function findEnumTtlMs() {
|
|
366
|
+
const raw = process.env.MIXDOG_FIND_ENUM_CACHE_TTL_MS;
|
|
367
|
+
if (raw == null || raw === '') return 30000;
|
|
368
|
+
const n = Number(raw);
|
|
369
|
+
if (!Number.isFinite(n) || n < 0) return 30000; // malformed → default
|
|
370
|
+
return Math.floor(n); // 0 = disabled
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
function findEnumKey({ root, hidden, depth, includeNoise }) {
|
|
374
|
+
return `${root}\u0000${hidden ? 1 : 0}\u0000${depth ?? ''}\u0000${includeNoise ? 1 : 0}`;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
// Parse `rg --files` stdout into the same normalized relative-path list both
|
|
378
|
+
// broad passes build (strip trailing CR, drop empties, strip leading `./`,
|
|
379
|
+
// forward-slash). Module-level so the cache and both call sites agree.
|
|
380
|
+
function parseRgFileList(stdout) {
|
|
381
|
+
return String(stdout)
|
|
382
|
+
.split('\n')
|
|
383
|
+
.map((p) => (p.endsWith('\r') ? p.slice(0, -1) : p))
|
|
384
|
+
.filter((p) => p.length > 0)
|
|
385
|
+
.map((p) => normalizeOutputPath(p.replace(/^\.[/\\]/, '')));
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
// Run (or reuse) the broad `rg --files` sweep for a root. Returns
|
|
389
|
+
// { files, truncated, partial }. The returned `files` array is SHARED — callers
|
|
390
|
+
// must treat it as read-only. `rgArgs` must be the broad-pass args (no per-query
|
|
391
|
+
// narrowing); the cache key is the 4 dims only, so any caller producing an
|
|
392
|
+
// equivalent sweep for the same dims reuses the result.
|
|
393
|
+
async function getBroadEnumeration({ root, hidden, depth, includeNoise, rgArgs, cwd, runRgImpl = runRg }) {
|
|
394
|
+
const ttl = findEnumTtlMs();
|
|
395
|
+
const key = findEnumKey({ root, hidden, depth, includeNoise });
|
|
396
|
+
if (ttl > 0) {
|
|
397
|
+
const hit = FIND_ENUM_CACHE.get(key);
|
|
398
|
+
if (hit && hit.expiresAt > Date.now()) {
|
|
399
|
+
return { files: hit.files, truncated: false, partial: false };
|
|
400
|
+
}
|
|
401
|
+
if (hit) FIND_ENUM_CACHE.delete(key); // expired
|
|
402
|
+
const inflight = FIND_ENUM_INFLIGHT.get(key);
|
|
403
|
+
if (inflight) return inflight;
|
|
404
|
+
}
|
|
405
|
+
const run = (async () => {
|
|
406
|
+
const stdout = await runRgImpl(rgArgs, { cwd });
|
|
407
|
+
const truncated = Boolean(stdout && typeof stdout === 'object' && stdout.truncated);
|
|
408
|
+
const partial = Boolean(stdout && typeof stdout === 'object' && stdout.partial);
|
|
409
|
+
const files = parseRgFileList(stdout);
|
|
410
|
+
// Never cache a truncated/partial sweep — it is known-incomplete, so a
|
|
411
|
+
// later query with a larger head_limit must re-run the enumeration.
|
|
412
|
+
if (ttl > 0 && !truncated && !partial) {
|
|
413
|
+
FIND_ENUM_CACHE.set(key, { files, expiresAt: Date.now() + ttl });
|
|
414
|
+
}
|
|
415
|
+
return { files, truncated, partial };
|
|
416
|
+
})();
|
|
417
|
+
if (ttl > 0) {
|
|
418
|
+
FIND_ENUM_INFLIGHT.set(key, run);
|
|
419
|
+
try { return await run; }
|
|
420
|
+
finally { FIND_ENUM_INFLIGHT.delete(key); }
|
|
421
|
+
}
|
|
422
|
+
return run;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
// Best-effort warm of the broad enumeration for a root using the `find` tool's
|
|
426
|
+
// DEFAULT flags (hidden:true, includeNoise:false, depth:unbounded). Swallows
|
|
427
|
+
// all errors — a failed prewarm must never surface or block the caller.
|
|
428
|
+
export async function prewarmFindEnumeration(root) {
|
|
429
|
+
try {
|
|
430
|
+
if (!root || typeof root !== 'string') return;
|
|
431
|
+
const hidden = true, includeNoise = false, depth = null;
|
|
432
|
+
const rgArgs = ['--files', '--no-ignore', '--hidden'];
|
|
433
|
+
for (const ex of DEFAULT_IGNORE_GLOBS) rgArgs.push('--glob', ex);
|
|
434
|
+
rgArgs.push('.');
|
|
435
|
+
await getBroadEnumeration({
|
|
436
|
+
root: normalizeOutputPath(root),
|
|
437
|
+
hidden, depth, includeNoise,
|
|
438
|
+
rgArgs, cwd: root,
|
|
439
|
+
});
|
|
440
|
+
} catch { /* best-effort warm; never surface */ }
|
|
441
|
+
}
|
|
442
|
+
|
|
342
443
|
// Fuzzy filename search (nucleo-style): collect the file
|
|
343
444
|
// list via `rg --files`, then rank by subsequence score. `list.fuzzy` still
|
|
344
445
|
// routes here for hidden backward compatibility, but the model-facing tool is
|
|
@@ -372,7 +473,11 @@ export async function executeFuzzyFindTool(args, workDir, options = {}) {
|
|
|
372
473
|
const fullPath = resolveAgainstCwd(inputPath, workDir);
|
|
373
474
|
const guardFull = listGuardPath(fullPath);
|
|
374
475
|
if (guardFull) return guardFull;
|
|
375
|
-
|
|
476
|
+
// Fuzzy find defaults to searching dot-directories (hidden:true) so
|
|
477
|
+
// machine-wide discovery reaches paths like ~/.mixdog/data/…; callers
|
|
478
|
+
// opt out with hidden:false. .git and other noise dirs are still pruned
|
|
479
|
+
// via DEFAULT_IGNORE_GLOBS below (unless include_noise).
|
|
480
|
+
const hidden = args.hidden === false ? false : true;
|
|
376
481
|
const includeNoise = Boolean(args.include_noise);
|
|
377
482
|
// head_limit:0 means "no cap" per list semantics; default is intentionally
|
|
378
483
|
// compact so ambiguous discovery does not dump a huge candidate list.
|
|
@@ -392,36 +497,108 @@ export async function executeFuzzyFindTool(args, workDir, options = {}) {
|
|
|
392
497
|
});
|
|
393
498
|
const cached = cacheGet(cacheKey);
|
|
394
499
|
if (cached !== null) return cached;
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
500
|
+
// --no-ignore: match the find_files fast path contract — do not consult
|
|
501
|
+
// .gitignore, so a .gitignored-but-present file is still discoverable.
|
|
502
|
+
// Noise dirs stay excluded via DEFAULT_IGNORE_GLOBS below.
|
|
503
|
+
// Shared rg flags for both enumeration passes below.
|
|
504
|
+
const baseRgArgs = ['--files', '--no-ignore'];
|
|
505
|
+
if (hidden) baseRgArgs.push('--hidden');
|
|
506
|
+
if (depth != null) baseRgArgs.push('--max-depth', String(depth));
|
|
507
|
+
// Noise-exclusion globs are kept SEPARATE and always appended LAST (after
|
|
508
|
+
// any positive --iglob). ripgrep's "last matching glob wins" rule means a
|
|
509
|
+
// positive include placed after these negations would re-admit e.g.
|
|
510
|
+
// `.git/<query>` — so the exclusions must trail the narrowed include.
|
|
511
|
+
const ignoreGlobs = [];
|
|
398
512
|
if (!includeNoise) {
|
|
399
|
-
for (const ex of DEFAULT_IGNORE_GLOBS)
|
|
513
|
+
for (const ex of DEFAULT_IGNORE_GLOBS) ignoreGlobs.push('--glob', ex);
|
|
400
514
|
}
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
const
|
|
515
|
+
// The narrowed pass must treat `query` as a LITERAL filename substring, not
|
|
516
|
+
// a glob. Wrap every globset metacharacter in a single-char character class
|
|
517
|
+
// (`[` → `[[]`, `*` → `[*]`, …): character-class quoting is the only form
|
|
518
|
+
// globset honors on Windows, where a backslash-escape (`\*`) is read as a
|
|
519
|
+
// literal path separator and does NOT escape. So a query like "[slug].tsx"
|
|
520
|
+
// still produces the intended `*[[]slug[]].tsx*` include instead of a
|
|
521
|
+
// character-class that matches one of s/l/u/g.
|
|
522
|
+
const escapeGlobLiteral = (s) => s.replace(/[*?[\]{}]/g, (c) => `[${c}]`);
|
|
523
|
+
// Test-only seam: allow a caller to inject a runRg stand-in (e.g. to
|
|
524
|
+
// simulate a truncated broad pass) without touching the production path.
|
|
525
|
+
// Never set on the real tool-execution options object.
|
|
526
|
+
const runRgImpl = (options && typeof options.__runRg === 'function') ? options.__runRg : runRg;
|
|
527
|
+
const parseRgFiles = (stdout) => String(stdout)
|
|
409
528
|
.split('\n')
|
|
410
529
|
// Strip only the trailing CR from rg's line split — do NOT trim, or a
|
|
411
530
|
// filename with leading/trailing spaces would be corrupted.
|
|
412
531
|
.map((p) => (p.endsWith('\r') ? p.slice(0, -1) : p))
|
|
413
532
|
.filter((p) => p.length > 0)
|
|
414
|
-
.map((p) =>
|
|
533
|
+
.map((p) => normalizeOutputPath(p.replace(/^\.[/\\]/, '')));
|
|
534
|
+
// Broad enumeration: every file under the scope, ranked by fuzzy score.
|
|
535
|
+
// Subject to rg's 20MB/20s cap — an exact-name hit deep in a huge tree can
|
|
536
|
+
// be dropped by cap lottery, so it is backstopped by the narrowed pass.
|
|
537
|
+
// Shared across queries/concurrent callers via the broad-enumeration cache
|
|
538
|
+
// (keyed on root+hidden+depth+includeNoise, i.e. exactly this pass's args).
|
|
539
|
+
let broadEnum;
|
|
540
|
+
try {
|
|
541
|
+
broadEnum = await getBroadEnumeration({
|
|
542
|
+
root: normalizeOutputPath(fullPath),
|
|
543
|
+
hidden, depth, includeNoise,
|
|
544
|
+
rgArgs: [...baseRgArgs, ...ignoreGlobs, '.'],
|
|
545
|
+
cwd: fullPath,
|
|
546
|
+
runRgImpl,
|
|
547
|
+
});
|
|
548
|
+
} catch (err) {
|
|
549
|
+
return `Error: ${normalizeErrorMessage(err instanceof Error ? err.message : String(err))}`;
|
|
550
|
+
}
|
|
551
|
+
const rgTruncated = broadEnum.truncated;
|
|
552
|
+
const rgPartial = broadEnum.partial;
|
|
553
|
+
// Narrowed enumeration: only files whose NAME contains the query
|
|
554
|
+
// (case-insensitive substring glob). This output is tiny and effectively
|
|
555
|
+
// never truncated, so exact/substring hits are guaranteed to reach ranking
|
|
556
|
+
// regardless of whether the broad pass was cut at the cap. Best-effort:
|
|
557
|
+
// failures here never fail the tool — the broad pass still stands.
|
|
558
|
+
let narrowPaths = [];
|
|
559
|
+
try {
|
|
560
|
+
// A positive --iglob whitelist makes ripgrep re-admit paths its own
|
|
561
|
+
// `!**/<noise>/**` negations would otherwise exclude (the whitelist
|
|
562
|
+
// wins regardless of glob order), so noise dirs are pruned in JS here
|
|
563
|
+
// instead — matching the broad pass's effective exclusion set.
|
|
564
|
+
const narrowStdout = await runRgImpl([...baseRgArgs, '--iglob', `*${escapeGlobLiteral(query)}*`, '.'], { cwd: fullPath });
|
|
565
|
+
narrowPaths = parseRgFiles(narrowStdout).filter((p) =>
|
|
566
|
+
includeNoise || !p.split('/').some((seg) => NOISE_DIR_NAMES.has(seg)));
|
|
567
|
+
} catch { /* best-effort backstop; broad pass already collected */ }
|
|
568
|
+
// Merge broad + narrowed, deduplicating by path (broad order preserved,
|
|
569
|
+
// narrowed-only exact-name candidates appended).
|
|
570
|
+
const seen = new Set();
|
|
571
|
+
const items = [];
|
|
572
|
+
for (const p of broadEnum.files) {
|
|
573
|
+
if (seen.has(p)) continue;
|
|
574
|
+
seen.add(p);
|
|
575
|
+
items.push({ path: p });
|
|
576
|
+
}
|
|
577
|
+
for (const p of narrowPaths) {
|
|
578
|
+
if (seen.has(p)) continue;
|
|
579
|
+
seen.add(p);
|
|
580
|
+
items.push({ path: p });
|
|
581
|
+
}
|
|
415
582
|
const rankLimit = headLimit > 0 ? headLimit + 1 : headLimit;
|
|
416
583
|
const rankedRaw = fuzzyRank(query, items, rankLimit);
|
|
417
584
|
const hasMore = headLimit > 0 && rankedRaw.length > headLimit;
|
|
418
585
|
const ranked = hasMore ? rankedRaw.slice(0, headLimit) : rankedRaw;
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
586
|
+
// Build output lines uniformly for the hit and no-match cases so a
|
|
587
|
+
// truncated/partial broad pass ALWAYS surfaces its warning — otherwise a
|
|
588
|
+
// cut-off enumeration that happened to drop the sole match would silently
|
|
589
|
+
// report "(no fuzzy match …)" as if the tree were exhaustively searched.
|
|
590
|
+
const noMatch = ranked.length === 0;
|
|
591
|
+
const lines = noMatch ? [`(no fuzzy match for "${query}")`] : ranked.map((r) => r.item.path);
|
|
592
|
+
if (!noMatch && hasMore) lines.push(`... (top ${headLimit}; raise head_limit for more)`);
|
|
593
|
+
if (rgTruncated) lines.push('... [warning] rg stdout truncated at 20MB cap; broad ranking incomplete (exact-name hits still merged)');
|
|
594
|
+
if (rgPartial && !rgTruncated) lines.push('... [warning] rg exit 2 (partial results); broad ranking may be incomplete');
|
|
595
|
+
const result = lines.join('\n');
|
|
596
|
+
// Do not cache a truncated/partial enumeration — the broad ranking is
|
|
597
|
+
// known-incomplete, so a later call with a larger head_limit must re-run.
|
|
598
|
+
// A no-match result is also left uncached (mirrors the prior early return).
|
|
599
|
+
if (!noMatch && !rgTruncated && !rgPartial) {
|
|
600
|
+
cacheSet(cacheKey, result, { scopes: [fullPath] });
|
|
601
|
+
}
|
|
425
602
|
if (typeof options?.onProgress === 'function') {
|
|
426
603
|
try { options.onProgress(`${ranked.length} candidates`); } catch { /* best-effort */ }
|
|
427
604
|
}
|
|
@@ -563,14 +740,30 @@ export async function executeFindFilesTool(args, workDir, options = {}) {
|
|
|
563
740
|
// pre-filter matches the JS matcher; explicit globs pass through.
|
|
564
741
|
if (namePattern) rgArgs.push('--iglob', nameIsGlob ? namePattern : `*${namePattern}*`);
|
|
565
742
|
rgArgs.push('.');
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
743
|
+
// No `name` filter → this is the pure broad sweep (rgArgs match the
|
|
744
|
+
// fuzzy-find broad pass), so share it via the enumeration cache.
|
|
745
|
+
// With a `name` filter the sweep is narrowed by --iglob and must
|
|
746
|
+
// NOT hit the broad cache — run it directly.
|
|
747
|
+
let relPaths;
|
|
748
|
+
if (!namePattern) {
|
|
749
|
+
const enumRes = await getBroadEnumeration({
|
|
750
|
+
root: normalizeOutputPath(fullPath),
|
|
751
|
+
hidden, depth, includeNoise,
|
|
752
|
+
rgArgs, cwd: fullPath,
|
|
753
|
+
});
|
|
754
|
+
rgStdoutTruncated = enumRes.truncated;
|
|
755
|
+
rgStdoutPartial = enumRes.partial;
|
|
756
|
+
relPaths = enumRes.files;
|
|
757
|
+
} else {
|
|
758
|
+
const stdout = await runRg(rgArgs, { cwd: fullPath });
|
|
759
|
+
rgStdoutTruncated = Boolean(stdout && typeof stdout === 'object' && stdout.truncated);
|
|
760
|
+
rgStdoutPartial = Boolean(stdout && typeof stdout === 'object' && stdout.partial);
|
|
761
|
+
relPaths = parseRgFileList(stdout);
|
|
762
|
+
}
|
|
569
763
|
const candidates = [];
|
|
570
|
-
for (const
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
const candidate = resolveAgainstCwd(normalizeInputPath(trimmed), fullPath);
|
|
764
|
+
for (const rel of relPaths) {
|
|
765
|
+
if (!rel) continue;
|
|
766
|
+
const candidate = resolveAgainstCwd(normalizeInputPath(rel), fullPath);
|
|
574
767
|
if (!matchesFindNamePattern(basename(candidate), candidate)) continue;
|
|
575
768
|
candidates.push(candidate);
|
|
576
769
|
if (candidates.length >= FIND_ABSOLUTE_CAP) {
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { statSync } from 'fs';
|
|
2
2
|
import { assertPathReachable, assertPathsReachable } from './fs-reachability.mjs';
|
|
3
3
|
import { isAbsolute, resolve } from 'path';
|
|
4
|
+
import { WRAPPER_NAMES } from '../shell-policy.mjs';
|
|
4
5
|
import {
|
|
5
6
|
cwdRelativePath,
|
|
6
7
|
normalizeInputPath,
|
|
@@ -651,3 +652,63 @@ export function foregroundLongCommandHint(command, timeoutMs, args = {}) {
|
|
|
651
652
|
if (!longTimeout && !watchLike && !longSleep) return '';
|
|
652
653
|
return 'Error: long foreground command detected.';
|
|
653
654
|
}
|
|
655
|
+
|
|
656
|
+
// Commands that must NOT be promoted to background on a foreground timeout —
|
|
657
|
+
// they run in the foreground and are killed at their deadline instead. Mirrors
|
|
658
|
+
// the reference CLI DISALLOWED_AUTO_BACKGROUND_COMMANDS: `sleep` on posix,
|
|
659
|
+
// `start-sleep`/`sleep` (the PS built-in alias) on PowerShell. A `sleep`/
|
|
660
|
+
// `Start-Sleep` the caller wants to survive must be launched with
|
|
661
|
+
// run_in_background/async explicitly.
|
|
662
|
+
const _DISALLOWED_AUTO_BACKGROUND_POSIX = ['sleep'];
|
|
663
|
+
const _DISALLOWED_AUTO_BACKGROUND_PS = ['start-sleep', 'sleep'];
|
|
664
|
+
|
|
665
|
+
// Whether one shell segment reaches a disallowed sleep-like command. Strips a
|
|
666
|
+
// leading subshell/paren/brace/`&`, then walks tokens skipping VAR=val and
|
|
667
|
+
// option flags (quotes stripped so `'Start-Sleep'` resolves). A disallowed base
|
|
668
|
+
// name found anywhere reachable blocks promotion. Once a command-runner wrapper
|
|
669
|
+
// (env/sudo/timeout/nice/…, shell-policy.mjs's WRAPPER_NAMES) has been seen we
|
|
670
|
+
// do NOT model that wrapper's flag arity — we keep scanning EVERY later bare
|
|
671
|
+
// token for a hidden `sleep`, so `sudo -u nobody sleep 5` / `setpriv --reuid
|
|
672
|
+
// user sleep` are caught (erring toward NOT promoting). Without a wrapper the
|
|
673
|
+
// first real command token decides the segment.
|
|
674
|
+
function _segmentDisallowed(segment, disallowed) {
|
|
675
|
+
const stripped = String(segment || '').replace(/^[\s(){}&]+/, '');
|
|
676
|
+
const tokens = stripped.split(/\s+/).filter(Boolean);
|
|
677
|
+
let sawWrapper = false;
|
|
678
|
+
for (let tok of tokens) {
|
|
679
|
+
tok = tok.replace(/^['"]+|['"]+$/g, '');
|
|
680
|
+
if (!tok) continue;
|
|
681
|
+
if (/^[A-Za-z_]\w*=/.test(tok)) continue; // VAR=val assignment
|
|
682
|
+
if (tok.startsWith('-')) continue; // option flag
|
|
683
|
+
const stem = tok.toLowerCase().replace(/^.*[\\/]/, '').replace(/\.exe$/, '');
|
|
684
|
+
if (disallowed.includes(stem)) return true; // sleep reachable here
|
|
685
|
+
if (WRAPPER_NAMES.has(stem)) { sawWrapper = true; continue; }
|
|
686
|
+
if (/^\d+(?:\.\d+)?[smhd]?$/i.test(tok)) continue; // numeric/duration arg
|
|
687
|
+
// First real command token. Without a preceding wrapper it IS the base
|
|
688
|
+
// command and it wasn't disallowed → segment is safe. With a wrapper,
|
|
689
|
+
// this may be a wrapper value arg (`sudo -u nobody …`); keep scanning
|
|
690
|
+
// for a hidden sleep instead of stopping here.
|
|
691
|
+
if (!sawWrapper) return false;
|
|
692
|
+
}
|
|
693
|
+
return false;
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
// Whether a still-running foreground one-shot may be promoted to a background
|
|
697
|
+
// job when it hits its timeout (CC isAutobackgroundingAllowed analogue).
|
|
698
|
+
// Scans EVERY segment of a &&/||/;/|/& chain and, per segment, peels wrappers
|
|
699
|
+
// before reading the base command — so a sleep-like hidden behind a chain
|
|
700
|
+
// (`cd x && sleep 5`) or a runner wrapper (`timeout 5 sleep`, `env sleep`) is
|
|
701
|
+
// still caught. Errs toward NOT promoting: any disallowed base command in any
|
|
702
|
+
// segment blocks promotion. shellType 'powershell' uses the PS disallow list.
|
|
703
|
+
export function isAutobackgroundingAllowed(command, shellType) {
|
|
704
|
+
const cmd = String(command || '').trim();
|
|
705
|
+
if (!cmd) return true;
|
|
706
|
+
const disallowed = shellType === 'powershell'
|
|
707
|
+
? _DISALLOWED_AUTO_BACKGROUND_PS
|
|
708
|
+
: _DISALLOWED_AUTO_BACKGROUND_POSIX;
|
|
709
|
+
const segments = cmd.split(/(?:&&|\|\||[;\n|&])/);
|
|
710
|
+
for (const seg of segments) {
|
|
711
|
+
if (_segmentDisallowed(seg, disallowed)) return false;
|
|
712
|
+
}
|
|
713
|
+
return true;
|
|
714
|
+
}
|
|
@@ -109,7 +109,7 @@ async function sweepStaleShellJobs(dir) {
|
|
|
109
109
|
});
|
|
110
110
|
await Promise.all([
|
|
111
111
|
...expired.flatMap((jobId) =>
|
|
112
|
-
['.json', '.done', '.exit', '.enforced', '.exit.cmd.sh', '.exit.cmd.ps1', '.stdout.log', '.stderr.log'].map((ext) =>
|
|
112
|
+
['.json', '.done', '.exit', '.enforced', '.exit.cmd.sh', '.exit.cmd.ps1', '.exit.user.ps1', '.stdout.log', '.stderr.log'].map((ext) =>
|
|
113
113
|
fsPromises.unlink(join(dir, jobId + ext)).catch(() => {}),
|
|
114
114
|
),
|
|
115
115
|
),
|
|
@@ -86,10 +86,6 @@ function psSingleQuote(s) {
|
|
|
86
86
|
return `'${String(s).replace(/'/g, "''")}'`;
|
|
87
87
|
}
|
|
88
88
|
|
|
89
|
-
function powerShellEncodedCommand(command) {
|
|
90
|
-
return Buffer.from(String(command || ''), 'utf16le').toString('base64');
|
|
91
|
-
}
|
|
92
|
-
|
|
93
89
|
function isPowerShellShell(shell, shellType) {
|
|
94
90
|
if (shellType === 'powershell') return true;
|
|
95
91
|
const stem = basename(String(shell || '')).toLowerCase().replace(/\.exe$/, '');
|
|
@@ -406,6 +402,11 @@ export function startBackgroundShellJob({ command, timeoutMs, workDir, mergeStde
|
|
|
406
402
|
// watcher never keeps the host process alive.
|
|
407
403
|
const SHELL_JOB_WATCH_POLL_MS = 2000;
|
|
408
404
|
const SHELL_JOB_WATCH_GRACE_MS = 5000;
|
|
405
|
+
// 32-bit timer ceiling: setTimeout delays above 2^31-1 wrap to a tiny value
|
|
406
|
+
// and fire immediately. The resolved timeout is already clamped at parse time
|
|
407
|
+
// (bash-tool.mjs TIMER_MAX_MS), but sites that add grace to it can still exceed
|
|
408
|
+
// the ceiling, so clamp the summed delay here too.
|
|
409
|
+
const TIMER_MAX_MS = 2_147_483_647;
|
|
409
410
|
// Registry of armed background-job watchers keyed by jobId. task wait
|
|
410
411
|
// and `kill` actions already hold the completed outcome, so they cancel the
|
|
411
412
|
// armed watcher here to prevent a double-notify when its next poll fires.
|
|
@@ -657,11 +658,16 @@ export function watchBackgroundShellJob(jobId, notifyCtx) {
|
|
|
657
658
|
} catch { watcher = null; }
|
|
658
659
|
pollTimer = setInterval(() => checkDone('poll'), SHELL_JOB_WATCH_POLL_MS);
|
|
659
660
|
if (typeof pollTimer.unref === 'function') pollTimer.unref();
|
|
660
|
-
const startedAtMs = Date.parse(readShellJobDetail(jobId)?.startedAt || '') || Date.now();
|
|
661
661
|
const timeoutMs = Number(readShellJobDetail(jobId)?.timeoutMs || 0);
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
662
|
+
// Only arm the hard-stop when a timeout is enforced. timeoutMs<=0 means
|
|
663
|
+
// unlimited: arming it would fire ~grace ms after arm and mark the job
|
|
664
|
+
// failed. fs.watch + poll remain the completion paths for such jobs.
|
|
665
|
+
if (timeoutMs > 0) {
|
|
666
|
+
const startedAtMs = Date.parse(readShellJobDetail(jobId)?.startedAt || '') || Date.now();
|
|
667
|
+
const hardStopMs = Math.min(TIMER_MAX_MS, Math.max(0, (startedAtMs + timeoutMs + SHELL_JOB_WATCH_GRACE_MS) - Date.now()));
|
|
668
|
+
hardStopTimer = setTimeout(() => fire('timeout'), hardStopMs);
|
|
669
|
+
if (typeof hardStopTimer.unref === 'function') hardStopTimer.unref();
|
|
670
|
+
}
|
|
665
671
|
} catch (err) {
|
|
666
672
|
cleanup();
|
|
667
673
|
try { process.stderr.write(`[shell-jobs] watchBackgroundShellJob arm failed: jobId=${jobId} err=${err?.message ?? String(err)}\n`); } catch { /* ignore */ }
|
|
@@ -759,7 +765,10 @@ function _startBackgroundShellJobImpl({ command, timeoutMs, workDir, mergeStderr
|
|
|
759
765
|
const stderrPath = shellJobStderrPath(jobId);
|
|
760
766
|
const exitPath = shellJobExitPath(jobId);
|
|
761
767
|
const donePath = shellJobDonePath(jobId);
|
|
762
|
-
|
|
768
|
+
// timeoutMs <= 0 means unlimited (async omitted default): no kernel
|
|
769
|
+
// `timeout` wrapper, no enforced marker — exec the inner shell directly.
|
|
770
|
+
const enforceTimeout = Number(timeoutMs) > 0;
|
|
771
|
+
const timeoutSeconds = enforceTimeout ? Math.max(1, Math.ceil(timeoutMs / 1000)) : 0;
|
|
763
772
|
// P2 fix: wrap with POSIX `timeout` so the kernel terminates the process
|
|
764
773
|
// at deadline even if the JS-side timer is interrupted. --preserve-status
|
|
765
774
|
// keeps the user command's exit code on success; on timeout the wrapper
|
|
@@ -791,7 +800,11 @@ function _startBackgroundShellJobImpl({ command, timeoutMs, workDir, mergeStderr
|
|
|
791
800
|
// status for processes that actually exited 0. `rm -- "$0"` removes
|
|
792
801
|
// the staged wrapper .cmd.sh after donePath is published so a host
|
|
793
802
|
// crash before this point still leaves the file for the sweep to GC.
|
|
794
|
-
const
|
|
803
|
+
const _innerRun = `${innerShellQ} ${innerArgsQ} ${userCmdQuoted}`;
|
|
804
|
+
const _execPart = enforceTimeout
|
|
805
|
+
? `if command -v timeout >/dev/null 2>&1; then _to=timeout; elif command -v gtimeout >/dev/null 2>&1; then _to=gtimeout; else _to=; fi; if [ -n "$_to" ]; then touch ${shellQuoteSingle(enforcedPath)}; "$_to" ${timeoutSeconds} ${_innerRun}; else ${_innerRun}; fi`
|
|
806
|
+
: _innerRun;
|
|
807
|
+
const wrapped = `{ ${_execPart}; rc=$?; printf '%s' "$rc" > ${shellQuoteSingle(exitPath)}; touch ${shellQuoteSingle(donePath)}; rm -- "$0" 2>/dev/null; exit $rc; }`;
|
|
795
808
|
// Stage the wrapped command to a .sh and let the script open its own
|
|
796
809
|
// output files via `exec > … 2> …`. The parent does NOT pass file
|
|
797
810
|
// descriptors via stdio inheritance (`stdio: 'ignore'` for all three).
|
|
@@ -845,8 +858,13 @@ function _startBackgroundShellJobImpl({ command, timeoutMs, workDir, mergeStderr
|
|
|
845
858
|
startedAt: new Date().toISOString(),
|
|
846
859
|
};
|
|
847
860
|
writeShellJobDetail(detail);
|
|
848
|
-
|
|
849
|
-
|
|
861
|
+
// Deadline cleanup poke only when a timeout is enforced; an unlimited
|
|
862
|
+
// (timeoutMs<=0) job has no deadline — completion is observed via the
|
|
863
|
+
// fs.watch/poll watcher and refreshShellJob on task queries.
|
|
864
|
+
if (enforceTimeout) {
|
|
865
|
+
const timer = setTimeout(() => { refreshShellJob(jobId); }, Math.min(TIMER_MAX_MS, timeoutMs + 25));
|
|
866
|
+
if (typeof timer.unref === 'function') timer.unref();
|
|
867
|
+
}
|
|
850
868
|
return detail;
|
|
851
869
|
}
|
|
852
870
|
|
|
@@ -857,23 +875,39 @@ function startBackgroundPowerShellJob({ command, timeoutMs, workDir, mergeStderr
|
|
|
857
875
|
const exitPath = shellJobExitPath(jobId);
|
|
858
876
|
const donePath = shellJobDonePath(jobId);
|
|
859
877
|
const wrappedTempPath = `${exitPath}.cmd.ps1`;
|
|
860
|
-
|
|
878
|
+
// Stage the USER command as its own .ps1 and run it via `-File` instead of
|
|
879
|
+
// `-EncodedCommand <base64>`: the base64 token landed on the grandchild's
|
|
880
|
+
// visible command line, which is a prime Defender obfuscation signature
|
|
881
|
+
// (same family as the PowhidSubExec false positive). A file path on the
|
|
882
|
+
// command line carries no such signature, and the payload stays opaque to
|
|
883
|
+
// outer-shell quoting exactly as base64 did. UTF-8 BOM so Windows
|
|
884
|
+
// PowerShell 5.1 doesn't misread non-ASCII as ANSI.
|
|
885
|
+
// Trailer mirrors -Command/-EncodedCommand exit semantics (-File alone
|
|
886
|
+
// does NOT propagate the last native command's exit code).
|
|
887
|
+
const innerTempPath = `${exitPath}.user.ps1`;
|
|
888
|
+
const innerScript = `\ufeff${command}\nif ($null -ne $LASTEXITCODE) { exit $LASTEXITCODE }\n`;
|
|
861
889
|
const mergeLiteral = mergeStderr ? '$true' : '$false';
|
|
862
890
|
const wrapper = [
|
|
863
891
|
"$ErrorActionPreference = 'Continue'",
|
|
864
892
|
'[Console]::OutputEncoding=[System.Text.Encoding]::UTF8',
|
|
865
893
|
'$OutputEncoding=[System.Text.Encoding]::UTF8',
|
|
866
894
|
'$exe = (Get-Process -Id $PID).Path',
|
|
867
|
-
`$
|
|
895
|
+
`$innerPath = ${psSingleQuote(innerTempPath)}`,
|
|
868
896
|
`$stdoutPath = ${psSingleQuote(stdoutPath)}`,
|
|
869
897
|
`$stderrPath = ${psSingleQuote(rawStderrPath)}`,
|
|
870
898
|
`$exitPath = ${psSingleQuote(exitPath)}`,
|
|
871
899
|
`$donePath = ${psSingleQuote(donePath)}`,
|
|
872
900
|
`$mergeStderr = ${mergeLiteral}`,
|
|
873
|
-
|
|
901
|
+
// 0 (or negative) = unlimited: the wrapper's `if ($timeoutMs -gt 0 ...`
|
|
902
|
+
// guard falls through to a plain WaitForExit() with no deadline. Do NOT
|
|
903
|
+
// floor to 1 — that would enforce a 1ms timeout on unlimited jobs.
|
|
904
|
+
`$timeoutMs = ${Math.max(0, Math.floor(timeoutMs || 0))}`,
|
|
874
905
|
'$code = 1',
|
|
875
906
|
'try {',
|
|
876
|
-
|
|
907
|
+
// -ExecutionPolicy Bypass: unlike -EncodedCommand, -File is subject to
|
|
908
|
+
// the execution policy on Windows PowerShell; pwsh on macOS/Linux
|
|
909
|
+
// accepts the parameter as a no-op, so it is safe unconditionally.
|
|
910
|
+
" $argList = @('-NoLogo', '-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-File', $innerPath)",
|
|
877
911
|
// -WindowStyle is a Windows-only Start-Process parameter; pwsh on
|
|
878
912
|
// macOS/Linux throws "not supported on this platform". Add it only on win32.
|
|
879
913
|
' $spArgs = @{ FilePath = $exe; ArgumentList = $argList; RedirectStandardOutput = $stdoutPath; RedirectStandardError = $stderrPath; PassThru = $true }',
|
|
@@ -912,23 +946,27 @@ function startBackgroundPowerShellJob({ command, timeoutMs, workDir, mergeStderr
|
|
|
912
946
|
'}',
|
|
913
947
|
'try { Set-Content -LiteralPath $exitPath -Value ([string]$code) -NoNewline -Encoding ascii } catch {}',
|
|
914
948
|
'try { Set-Content -LiteralPath $donePath -Value "" -NoNewline -Encoding ascii } catch {}',
|
|
949
|
+
'try { Remove-Item -LiteralPath $innerPath -Force -ErrorAction SilentlyContinue } catch {}',
|
|
915
950
|
'try { Remove-Item -LiteralPath $PSCommandPath -Force -ErrorAction SilentlyContinue } catch {}',
|
|
916
951
|
'exit $code',
|
|
917
952
|
'',
|
|
918
953
|
].join('\n');
|
|
919
954
|
try {
|
|
920
955
|
writeFileSync(wrappedTempPath, wrapper, 'utf-8');
|
|
956
|
+
writeFileSync(innerTempPath, innerScript, 'utf-8');
|
|
921
957
|
} catch (e) {
|
|
922
958
|
return { jobId, kind: 'bash', status: 'failed', error: `failed to stage PowerShell background task: ${e?.message || e}` };
|
|
923
959
|
}
|
|
924
960
|
|
|
925
961
|
const shellStem = basename(String(shell || '')).toLowerCase().replace(/\.exe$/, '');
|
|
926
|
-
// `-WindowStyle Hidden`
|
|
927
|
-
//
|
|
928
|
-
//
|
|
962
|
+
// No `-WindowStyle Hidden` CLI switch: windowsHide:true on the spawn below
|
|
963
|
+
// already gives CREATE_NO_WINDOW, and the visible command-line token trips
|
|
964
|
+
// Defender's hidden-PowerShell dropper signature (PowhidSubExec). The
|
|
965
|
+
// in-wrapper Start-Process WindowStyle=Hidden (above) stays — it lives in
|
|
966
|
+
// the staged .ps1, not on the command line, and is still needed there.
|
|
967
|
+
// `-ExecutionPolicy` only applies to Windows PowerShell; build per-platform.
|
|
929
968
|
const isWin = process.platform === 'win32';
|
|
930
969
|
const wrapperArgs = ['-NoLogo', '-NoProfile', '-NonInteractive'];
|
|
931
|
-
if (isWin) wrapperArgs.push('-WindowStyle', 'Hidden');
|
|
932
970
|
if (isWin && shellStem === 'powershell') wrapperArgs.push('-ExecutionPolicy', 'Bypass');
|
|
933
971
|
wrapperArgs.push('-File', wrappedTempPath);
|
|
934
972
|
// Spawn the staged wrapper directly. detached MUST be false on Windows:
|
|
@@ -973,21 +1011,23 @@ function startBackgroundPowerShellJob({ command, timeoutMs, workDir, mergeStderr
|
|
|
973
1011
|
pid: childPid,
|
|
974
1012
|
mergeStderr,
|
|
975
1013
|
timeoutMs,
|
|
976
|
-
timeoutSeconds: Math.max(1, Math.ceil(timeoutMs / 1000)),
|
|
1014
|
+
timeoutSeconds: Number(timeoutMs) > 0 ? Math.max(1, Math.ceil(timeoutMs / 1000)) : 0,
|
|
977
1015
|
stdoutPath,
|
|
978
1016
|
stderrPath: mergeStderr ? stdoutPath : rawStderrPath,
|
|
979
1017
|
exitPath,
|
|
980
1018
|
donePath,
|
|
981
|
-
// The PS wrapper enforces timeoutMs
|
|
982
|
-
//
|
|
983
|
-
//
|
|
984
|
-
timeoutEnforced:
|
|
1019
|
+
// The PS wrapper enforces the deadline (WaitForExit($timeoutMs) →
|
|
1020
|
+
// Stop-Process → 124) only when timeoutMs>0; an unlimited job waits
|
|
1021
|
+
// with no deadline, so don't falsely claim enforcement.
|
|
1022
|
+
timeoutEnforced: Number(timeoutMs) > 0,
|
|
985
1023
|
// Per-terminal session stamp (see resolveJobOwnerHostPid).
|
|
986
1024
|
ownerHostPid: resolveJobOwnerHostPid(clientHostPid),
|
|
987
1025
|
startedAt: new Date().toISOString(),
|
|
988
1026
|
};
|
|
989
1027
|
writeShellJobDetail(detail);
|
|
990
|
-
|
|
991
|
-
|
|
1028
|
+
if (Number(timeoutMs) > 0) {
|
|
1029
|
+
const timer = setTimeout(() => { refreshShellJob(jobId); }, Math.min(TIMER_MAX_MS, timeoutMs + 25));
|
|
1030
|
+
if (typeof timer.unref === 'function') timer.unref();
|
|
1031
|
+
}
|
|
992
1032
|
return detail;
|
|
993
1033
|
}
|