mixdog 0.9.150 → 0.9.151
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 +28 -11
- package/package.json +1 -1
- package/src/headless-exec.mjs +16 -3
- package/src/headless-exec.test.mjs +44 -1
- package/src/rules/shared/30-exploration.md +2 -1
- package/src/runtime/agent/orchestrator/session/compact/handoff.mjs +2 -4
- package/src/runtime/agent/orchestrator/session/compact/runner.mjs +18 -33
- package/src/runtime/agent/orchestrator/session/loop/recall-fasttrack.mjs +21 -42
- package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +20 -125
- package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +8 -20
- package/src/runtime/agent/orchestrator/tools/builtin/absolute-glob-expand.test.mjs +63 -0
- package/src/runtime/agent/orchestrator/tools/builtin/atomic-write.mjs +29 -10
- package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +1 -1
- package/src/runtime/agent/orchestrator/tools/builtin/enoent-outside-project.test.mjs +56 -0
- package/src/runtime/agent/orchestrator/tools/builtin/external-tool-adapters.mjs +6 -2
- package/src/runtime/agent/orchestrator/tools/builtin/git-command-tool.mjs +32 -16
- package/src/runtime/agent/orchestrator/tools/builtin/git-command-tool.test.mjs +28 -0
- package/src/runtime/agent/orchestrator/tools/builtin/grep-single-file-rescue.test.mjs +106 -0
- package/src/runtime/agent/orchestrator/tools/builtin/lib/absolute-glob-expand.mjs +129 -0
- package/src/runtime/agent/orchestrator/tools/builtin/lib/grep-single-file-rescue.mjs +218 -0
- package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +33 -0
- package/src/runtime/agent/orchestrator/tools/builtin/noise-dir-visibility.test.mjs +69 -0
- package/src/runtime/agent/orchestrator/tools/builtin/path-diagnostics.mjs +33 -6
- package/src/runtime/agent/orchestrator/tools/builtin/read-tool.mjs +26 -6
- package/src/runtime/agent/orchestrator/tools/builtin/runtime-capabilities.mjs +7 -66
- package/src/runtime/agent/orchestrator/tools/builtin/search-builders.mjs +11 -0
- package/src/runtime/agent/orchestrator/tools/builtin/search-glob-tool.mjs +25 -1
- package/src/runtime/agent/orchestrator/tools/builtin/search-grep-tool.mjs +60 -5
- package/src/runtime/agent/orchestrator/tools/builtin/search-path-diagnostics.mjs +54 -1
- package/src/runtime/agent/orchestrator/tools/builtin/write-symlink.test.mjs +153 -0
- package/src/runtime/agent/orchestrator/tools/code-graph/aggregate-anchor-relocation.test.mjs +55 -0
- package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +54 -3
- package/src/runtime/agent/orchestrator/tools/graph-manifest.json +11 -11
- package/src/runtime/agent/orchestrator/tools/patch/dispatch.mjs +44 -10
- package/src/runtime/agent/orchestrator/tools/patch/orchestrator.mjs +57 -5
- package/src/runtime/agent/orchestrator/tools/patch/patch-symlink.test.mjs +141 -0
- package/src/runtime/agent/orchestrator/tools/patch/v4a-convert.mjs +7 -3
- package/src/runtime/agent/orchestrator/tools/patch/v4a-pure-move.test.mjs +57 -0
- package/src/runtime/agent/orchestrator/tools/patch/v4a-section-coalesce.test.mjs +81 -0
- package/src/runtime/memory/index.mjs +2 -4
- package/src/runtime/memory/lib/pg/process.mjs +29 -2
- package/src/runtime/memory/lib/pg/process.test.mjs +36 -0
- package/src/runtime/memory/lib/pg/supervisor.mjs +5 -1
- package/src/runtime/memory/lib/query-handlers.mjs +7 -6
- package/src/runtime/memory/lib/recall-format.mjs +13 -31
- package/src/runtime/shared/pristine-execution-contract.json +3 -0
- package/src/runtime/shared/pristine-execution.mjs +5 -2
- package/src/session-runtime/lifecycle-api.mjs +1 -0
- package/src/standalone/channel-worker-heartbeat.test.mjs +30 -0
- package/src/standalone/channel-worker.mjs +31 -1
- package/src/standalone/memory-runtime-proxy.mjs +5 -2
- package/src/standalone/session-client.mjs +23 -3
- package/src/tui/session/turn.mjs +12 -0
|
@@ -41,11 +41,7 @@ import { ensureCodexWireSessionId, mintSessionId, mintUuidV7 } from './session-i
|
|
|
41
41
|
import { providerCacheKey } from './provider-cache-key.mjs';
|
|
42
42
|
import { clearTurnCheckpoint, recoverTurnCheckpoint } from './turn-checkpoint.mjs';
|
|
43
43
|
import { IMPLICIT_APPROVAL_MODE } from '../approval-mode.mjs';
|
|
44
|
-
import {
|
|
45
|
-
describeGitStartupState,
|
|
46
|
-
describeShellStartupPolicy,
|
|
47
|
-
detectPathCapabilities,
|
|
48
|
-
} from '../../tools/builtin/runtime-capabilities.mjs';
|
|
44
|
+
import { describeGitStartupState } from '../../tools/builtin/runtime-capabilities.mjs';
|
|
49
45
|
import { captureOriginalUserCwd } from '../../../../shared/user-cwd.mjs';
|
|
50
46
|
import { refreshSessionBp3Environment } from './prompt-utils.mjs';
|
|
51
47
|
|
|
@@ -315,12 +311,7 @@ export function createSession(opts) {
|
|
|
315
311
|
// resolves explicit session signals first and never leaks the daemon's
|
|
316
312
|
// install root (user-cwd.mjs safe fallback chain).
|
|
317
313
|
const sessionCwdLine = opts.cwd || captureOriginalUserCwd();
|
|
318
|
-
const wantsShellStartupLine = toolsForRouting.some((tool) => tool?.name === 'shell');
|
|
319
314
|
const wantsGitStartupLine = toolsForRouting.some((tool) => tool?.name === 'git');
|
|
320
|
-
// One PATH walk feeds every startup line below.
|
|
321
|
-
const startupCapabilities = wantsShellStartupLine || wantsGitStartupLine
|
|
322
|
-
? detectPathCapabilities()
|
|
323
|
-
: null;
|
|
324
315
|
const shellEnvironmentContext = [
|
|
325
316
|
sessionCwdLine
|
|
326
317
|
? `- Cwd: ${sessionCwdLine} — the active Project root; relative paths and shell commands resolve here.`
|
|
@@ -328,18 +319,15 @@ export function createSession(opts) {
|
|
|
328
319
|
!ownerIsAgent
|
|
329
320
|
? `- Shell: ${process.platform === 'win32' ? 'PowerShell' : 'Bash'}. Use ${process.platform === 'win32' ? 'PowerShell' : 'Bash'} syntax unless the user specifies otherwise.`
|
|
330
321
|
: '',
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
//
|
|
335
|
-
//
|
|
336
|
-
// Without it a session spends a call discovering `exited 128`, and
|
|
322
|
+
// A startup inventory of PATH binaries used to sit here; see
|
|
323
|
+
// runtime-capabilities.mjs for why it cannot be stated truthfully
|
|
324
|
+
// before a command has run. Whether the cwd is inside a repository is
|
|
325
|
+
// different: it is a property of this directory, true at startup and
|
|
326
|
+
// observable without spawning anything, and the git tool cannot infer
|
|
327
|
+
// it. Without it a session spends a call discovering `exited 128`, and
|
|
337
328
|
// repeats it per candidate path.
|
|
338
329
|
wantsGitStartupLine
|
|
339
|
-
? describeGitStartupState({
|
|
340
|
-
capabilities: startupCapabilities,
|
|
341
|
-
...(sessionCwdLine ? { cwd: sessionCwdLine } : {}),
|
|
342
|
-
})
|
|
330
|
+
? describeGitStartupState(sessionCwdLine ? { cwd: sessionCwdLine } : {})
|
|
343
331
|
: '',
|
|
344
332
|
].filter(Boolean).join('\n');
|
|
345
333
|
const { baseRules, stableSystemContext, sessionMarkerCore, sessionEnvironment } = composeSystemPrompt({
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// An absolute glob is expanded straight off the filesystem when the inventory
|
|
2
|
+
// walker cannot see its targets (symlink farms such as /sys and /proc). The
|
|
3
|
+
// pattern bounds the walk; anything unbounded or unsupported declines.
|
|
4
|
+
import assert from 'node:assert/strict';
|
|
5
|
+
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
|
|
6
|
+
import { tmpdir } from 'node:os';
|
|
7
|
+
import { join } from 'node:path';
|
|
8
|
+
import test from 'node:test';
|
|
9
|
+
|
|
10
|
+
import { expandAbsoluteGlob, expandAbsoluteGlobs } from './lib/absolute-glob-expand.mjs';
|
|
11
|
+
|
|
12
|
+
async function withBlockTree(run) {
|
|
13
|
+
const root = await mkdtemp(join(tmpdir(), 'mixdog-abs-glob-'));
|
|
14
|
+
try {
|
|
15
|
+
for (const disk of ['sda', 'sdb', 'nvme0n1']) {
|
|
16
|
+
await mkdir(join(root, 'block', disk, 'device'), { recursive: true });
|
|
17
|
+
await writeFile(join(root, 'block', disk, 'size'), '2048\n');
|
|
18
|
+
await writeFile(join(root, 'block', disk, 'device', 'model'), `${disk}-model\n`);
|
|
19
|
+
}
|
|
20
|
+
return await run(root);
|
|
21
|
+
} finally {
|
|
22
|
+
await rm(root, { recursive: true, force: true });
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
test('a magic middle segment expands to every matching path', async () => {
|
|
27
|
+
await withBlockTree(async (root) => {
|
|
28
|
+
const sizes = await expandAbsoluteGlob(join(root, 'block', 'sd*', 'size'));
|
|
29
|
+
assert.equal(sizes.length, 2);
|
|
30
|
+
assert.ok(sizes.every((p) => p.endsWith('/size')));
|
|
31
|
+
assert.ok(sizes.some((p) => p.includes('/sda/')));
|
|
32
|
+
assert.ok(sizes.some((p) => p.includes('/sdb/')));
|
|
33
|
+
|
|
34
|
+
const models = await expandAbsoluteGlob(join(root, 'block', '*', 'device', 'model'));
|
|
35
|
+
assert.equal(models.length, 3);
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test('a pattern with no matches answers empty, not null', async () => {
|
|
40
|
+
await withBlockTree(async (root) => {
|
|
41
|
+
assert.deepEqual(await expandAbsoluteGlob(join(root, 'block', 'hd*', 'size')), []);
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test('unbounded or non-absolute patterns decline so the caller keeps its answer', async () => {
|
|
46
|
+
await withBlockTree(async (root) => {
|
|
47
|
+
assert.equal(await expandAbsoluteGlob(join(root, '**', 'size')), null);
|
|
48
|
+
assert.equal(await expandAbsoluteGlob('block/sd*/size'), null);
|
|
49
|
+
assert.equal(await expandAbsoluteGlob(join(root, 'block', 'sda', 'size')), null);
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test('the batch form dedupes and honours its limit', async () => {
|
|
54
|
+
await withBlockTree(async (root) => {
|
|
55
|
+
const found = await expandAbsoluteGlobs([
|
|
56
|
+
join(root, 'block', 'sd*', 'size'),
|
|
57
|
+
join(root, 'block', 's*a', 'size'),
|
|
58
|
+
], { limit: 5 });
|
|
59
|
+
assert.equal(found.length, 2);
|
|
60
|
+
const limited = await expandAbsoluteGlobs([join(root, 'block', '*', 'size')], { limit: 1 });
|
|
61
|
+
assert.equal(limited.length, 1);
|
|
62
|
+
});
|
|
63
|
+
});
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { statSync, createWriteStream } from 'fs';
|
|
1
|
+
import { statSync, lstatSync, realpathSync, createWriteStream } from 'fs';
|
|
2
2
|
import * as fsPromises from 'fs/promises';
|
|
3
3
|
import { basename, dirname, join } from 'path';
|
|
4
4
|
import { performance } from 'perf_hooks';
|
|
@@ -73,6 +73,21 @@ async function cleanupEmptyWxTarget(targetPath) {
|
|
|
73
73
|
} catch { /* already gone or unreadable — leave it */ }
|
|
74
74
|
}
|
|
75
75
|
|
|
76
|
+
// The real file a write aimed at `targetPath` must land on when the leaf is a
|
|
77
|
+
// symlink, or null when it is not a link (or is dangling, so there is nothing
|
|
78
|
+
// to resolve). Writers rename a temp file into place, which would replace the
|
|
79
|
+
// LINK itself with a regular file and silently detach layouts like nginx's
|
|
80
|
+
// sites-enabled/default from sites-available/default. Engines that write a
|
|
81
|
+
// path themselves use this as "do not hand this target to the engine".
|
|
82
|
+
export function symlinkWriteTarget(targetPath) {
|
|
83
|
+
try {
|
|
84
|
+
if (!lstatSync(targetPath).isSymbolicLink()) return null;
|
|
85
|
+
return realpathSync(targetPath);
|
|
86
|
+
} catch {
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
76
91
|
export async function atomicWrite(targetPath, content, {
|
|
77
92
|
mode,
|
|
78
93
|
signal,
|
|
@@ -95,12 +110,16 @@ export async function atomicWrite(targetPath, content, {
|
|
|
95
110
|
};
|
|
96
111
|
if (resolvedSignal?.aborted) throw abortReason();
|
|
97
112
|
|
|
98
|
-
|
|
113
|
+
// Write THROUGH a leaf symlink: resolve it first so the rename replaces the
|
|
114
|
+
// file the link points at, not the link. Resolving also keeps the temp file
|
|
115
|
+
// beside the real target, so the rename stays on one filesystem.
|
|
116
|
+
const writeTarget = symlinkWriteTarget(targetPath) ?? targetPath;
|
|
117
|
+
const dir = dirname(writeTarget);
|
|
99
118
|
const rnd = randomBytes(4).toString('hex');
|
|
100
|
-
const tmp = join(dir, `.${basename(
|
|
119
|
+
const tmp = join(dir, `.${basename(writeTarget)}.mixdog-tmp-${rnd}`);
|
|
101
120
|
let effectiveMode = mode;
|
|
102
121
|
let existingStat = null;
|
|
103
|
-
try { existingStat = statSync(
|
|
122
|
+
try { existingStat = statSync(writeTarget); } catch { /* target doesn't exist */ }
|
|
104
123
|
if (effectiveMode === undefined && existingStat) {
|
|
105
124
|
effectiveMode = existingStat.mode & 0o777;
|
|
106
125
|
}
|
|
@@ -165,7 +184,7 @@ export async function atomicWrite(targetPath, content, {
|
|
|
165
184
|
if (flags === 'wx') {
|
|
166
185
|
let excl = null;
|
|
167
186
|
try {
|
|
168
|
-
excl = await fsPromises.open(
|
|
187
|
+
excl = await fsPromises.open(writeTarget, 'wx');
|
|
169
188
|
await excl.close();
|
|
170
189
|
} catch (exclErr) {
|
|
171
190
|
if (excl) try { await excl.close(); } catch { /* already closed */ }
|
|
@@ -179,7 +198,7 @@ export async function atomicWrite(targetPath, content, {
|
|
|
179
198
|
|
|
180
199
|
if (resolvedSignal?.aborted) {
|
|
181
200
|
try { await fsPromises.unlink(tmp); } catch { /* already gone */ }
|
|
182
|
-
if (flags === 'wx') await cleanupEmptyWxTarget(
|
|
201
|
+
if (flags === 'wx') await cleanupEmptyWxTarget(writeTarget);
|
|
183
202
|
throw abortReason();
|
|
184
203
|
}
|
|
185
204
|
|
|
@@ -188,16 +207,16 @@ export async function atomicWrite(targetPath, content, {
|
|
|
188
207
|
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
|
189
208
|
if (expectedTargetSnapshot) {
|
|
190
209
|
let currentStat = null;
|
|
191
|
-
try { currentStat = statSync(
|
|
210
|
+
try { currentStat = statSync(writeTarget); } catch { currentStat = null; }
|
|
192
211
|
if (expectedTargetSnapshotChanged(currentStat, expectedTargetSnapshot)) {
|
|
193
212
|
try { await fsPromises.unlink(tmp); } catch { /* already gone */ }
|
|
194
|
-
const err = new Error(`target changed between preflight and rename (TOCTOU): ${
|
|
213
|
+
const err = new Error(`target changed between preflight and rename (TOCTOU): ${writeTarget}`);
|
|
195
214
|
err.code = 'ESTALE_TARGET';
|
|
196
215
|
throw err;
|
|
197
216
|
}
|
|
198
217
|
}
|
|
199
218
|
try {
|
|
200
|
-
await fsPromises.rename(tmp,
|
|
219
|
+
await fsPromises.rename(tmp, writeTarget);
|
|
201
220
|
// When fsync is requested, also fsync the parent directory so
|
|
202
221
|
// the rename itself is durable across power-loss. Directory
|
|
203
222
|
// fsync is a no-op / unsupported on Windows; swallow EPERM /
|
|
@@ -231,6 +250,6 @@ export async function atomicWrite(targetPath, content, {
|
|
|
231
250
|
}
|
|
232
251
|
}
|
|
233
252
|
try { await fsPromises.unlink(tmp); } catch { /* already gone */ }
|
|
234
|
-
if (flags === 'wx') await cleanupEmptyWxTarget(
|
|
253
|
+
if (flags === 'wx') await cleanupEmptyWxTarget(writeTarget);
|
|
235
254
|
throw lastErr;
|
|
236
255
|
}
|
|
@@ -44,7 +44,7 @@ export const BUILTIN_TOOLS = [
|
|
|
44
44
|
name: 'read',
|
|
45
45
|
title: 'Read',
|
|
46
46
|
annotations: { title: 'Read', readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false, compressible: false },
|
|
47
|
-
description: 'Read-only; safe to batch in parallel. Known-file contents or line ranges
|
|
47
|
+
description: 'Read-only; safe to batch in parallel. Known-file contents or line ranges, bounded to the narrowest range that answers the question — when the answer depends on content not yet seen, read the wider range once instead of deciding from a partial view. Images render for viewing; not directories. Replaces cat/head/tail.',
|
|
48
48
|
inputSchema: {
|
|
49
49
|
type: 'object',
|
|
50
50
|
properties: {
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// A missing ABSOLUTE path outside the project is recovered from its OWN
|
|
2
|
+
// filesystem, never from a project-wide scan: no file inside the project can
|
|
3
|
+
// be the correction for /sys/block/sd*/size, and the scan is what turned three
|
|
4
|
+
// such reads into 17.5s each.
|
|
5
|
+
import assert from 'node:assert/strict';
|
|
6
|
+
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
|
|
7
|
+
import { tmpdir } from 'node:os';
|
|
8
|
+
import { join } from 'node:path';
|
|
9
|
+
import test from 'node:test';
|
|
10
|
+
|
|
11
|
+
import { executeBuiltinTool } from '../builtin.mjs';
|
|
12
|
+
|
|
13
|
+
async function withDirs(run) {
|
|
14
|
+
const project = await mkdtemp(join(tmpdir(), 'mixdog-enoent-project-'));
|
|
15
|
+
const outside = await mkdtemp(join(tmpdir(), 'mixdog-enoent-outside-'));
|
|
16
|
+
try {
|
|
17
|
+
return await run({ project, outside });
|
|
18
|
+
} finally {
|
|
19
|
+
await rm(project, { recursive: true, force: true });
|
|
20
|
+
await rm(outside, { recursive: true, force: true });
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
test('an outside absolute miss names its own nearest directory, not the project', async () => {
|
|
25
|
+
await withDirs(async ({ project, outside }) => {
|
|
26
|
+
await mkdir(join(project, 'src'), { recursive: true });
|
|
27
|
+
await writeFile(join(project, 'src', 'size'), 'decoy\n');
|
|
28
|
+
await mkdir(join(outside, 'block'), { recursive: true });
|
|
29
|
+
await writeFile(join(outside, 'block', 'sda'), 'disk\n');
|
|
30
|
+
|
|
31
|
+
const out = String(await executeBuiltinTool('read', {
|
|
32
|
+
file_path: join(outside, 'block', 'sd*', 'size'),
|
|
33
|
+
}, project));
|
|
34
|
+
|
|
35
|
+
assert.match(out, /\[path absent\]/);
|
|
36
|
+
assert.match(out, /nearest existing directory/);
|
|
37
|
+
assert.match(out, /"sda"/);
|
|
38
|
+
// The decoy inside the project must never be offered for an outside path.
|
|
39
|
+
assert.doesNotMatch(out, /same filename exists at/);
|
|
40
|
+
assert.doesNotMatch(out, /project scan/);
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test('a miss INSIDE the project keeps its same-name recovery', async () => {
|
|
45
|
+
await withDirs(async ({ project }) => {
|
|
46
|
+
await mkdir(join(project, 'src', 'tools'), { recursive: true });
|
|
47
|
+
await writeFile(join(project, 'src', 'tools', 'widget.mjs'), 'export const widget = 1;\n');
|
|
48
|
+
|
|
49
|
+
const out = String(await executeBuiltinTool('read', {
|
|
50
|
+
file_path: join(project, 'lib', 'widget.mjs'),
|
|
51
|
+
}, project));
|
|
52
|
+
|
|
53
|
+
assert.match(out, /widget\.mjs/);
|
|
54
|
+
assert.doesNotMatch(out, /nearest existing directory/);
|
|
55
|
+
});
|
|
56
|
+
});
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
// the existing EXTERNAL_TOOL_REDIRECTS guidance message in that case.
|
|
14
14
|
import { readFileSync, mkdirSync, existsSync, lstatSync, realpathSync, statSync } from 'node:fs';
|
|
15
15
|
import { dirname } from 'node:path';
|
|
16
|
-
import { atomicWrite } from './atomic-write.mjs';
|
|
16
|
+
import { atomicWrite, symlinkWriteTarget } from './atomic-write.mjs';
|
|
17
17
|
import { assertPathsReachable } from './fs-reachability.mjs';
|
|
18
18
|
import { normalizeInputPath, resolveAgainstCwd, normalizeOutputPath } from './path-utils.mjs';
|
|
19
19
|
import { isUncPath, isWindowsDevicePath, hasUnsafeWin32Component, isBlockedDevicePath, isSpecialFileStat } from './device-paths.mjs';
|
|
@@ -587,7 +587,11 @@ async function adaptStrReplace(args, workDir, options) {
|
|
|
587
587
|
// An unverified native edit session never receives work: the in-process
|
|
588
588
|
// apply below is the fallback, so a stale/spoofed engine cannot touch bytes.
|
|
589
589
|
const nativeEditVerified = await nativeEditSessionSatisfiesContract();
|
|
590
|
-
|
|
590
|
+
// A symlinked target never reaches the native engine either: it writes the
|
|
591
|
+
// path it is handed, which would replace the link with a regular file. The
|
|
592
|
+
// in-process apply goes through atomicWrite, which writes through the link.
|
|
593
|
+
const symlinkedTarget = symlinkWriteTarget(fullPath) !== null;
|
|
594
|
+
if (!nativeEditVerified || symlinkedTarget || fileEnc.encoding !== 'utf8' || editPlan.rebased || !editPlan.uniform) {
|
|
591
595
|
const occurrences = editPlan.positions.length;
|
|
592
596
|
if (occurrences === 0) {
|
|
593
597
|
const message = 'old_string not found';
|
|
@@ -21,21 +21,31 @@ import {
|
|
|
21
21
|
const DEFAULT_TIMEOUT_MS = 120_000;
|
|
22
22
|
const MAX_CAPTURE_BYTES = 128 * 1024 * 1024;
|
|
23
23
|
const GIT_OUTPUT_LIMIT_MAX = 200;
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
24
|
+
// git's subcommand namespace is open-ended: any `git-*` executable on PATH
|
|
25
|
+
// (git-filter-repo, git-lfs, in-house wrappers) dispatches as a subcommand, so
|
|
26
|
+
// a finite allowlist can never keep up. It rejected `git filter-repo` as an
|
|
27
|
+
// "unsupported subcommand" while the shell tool still ran the very same
|
|
28
|
+
// command unblocked — buying no safety and costing a whole turn. So nothing is
|
|
29
|
+
// pre-rejected for being unknown: git itself reports a genuinely unknown
|
|
30
|
+
// subcommand, and that error tells the model what to do next.
|
|
31
|
+
//
|
|
32
|
+
// Destructive history/tree rewrites stay allowed on purpose. They are usually
|
|
33
|
+
// the actual job, reflog and loose objects make most of them recoverable, and
|
|
34
|
+
// the workflow already gates destructive git work.
|
|
35
|
+
//
|
|
36
|
+
// What this tool genuinely cannot host is a command that never returns, since
|
|
37
|
+
// each call occupies one synchronous turn. The classic hangs are already dead
|
|
38
|
+
// via the pinned GIT_PAGER / GIT_EDITOR / GIT_SEQUENCE_EDITOR /
|
|
39
|
+
// GIT_TERMINAL_PROMPT environment in runProcess, which leaves two shapes.
|
|
40
|
+
//
|
|
41
|
+
// A GUI subcommand blocks on a window or a stdin prompt that this process has
|
|
42
|
+
// no way to satisfy.
|
|
43
|
+
const GUI_OPERATIONS = new Set(['gui', 'citool', 'gitk', 'difftool', 'mergetool']);
|
|
44
|
+
// A resident server's correct behaviour IS to keep running, so it can only
|
|
45
|
+
// burn the full timeout here. It is not a bad command, just one in the wrong
|
|
46
|
+
// tool: shell promotes a long-running process to a background task, so point
|
|
47
|
+
// there instead of failing it outright.
|
|
48
|
+
const SERVER_OPERATIONS = new Set(['daemon', 'instaweb']);
|
|
39
49
|
// `git --version` / `git --help` are the standard availability probes and
|
|
40
50
|
// arrive in global-flag position, not as subcommands. Map them onto their
|
|
41
51
|
// subcommand form so the probe answers instead of erroring out.
|
|
@@ -197,7 +207,13 @@ function parseCommand(command, workDir) {
|
|
|
197
207
|
}
|
|
198
208
|
const rawOperation = String(tokens[index] || '').toLowerCase();
|
|
199
209
|
const operation = OPERATION_ALIASES.get(rawOperation) ?? rawOperation;
|
|
200
|
-
if (!
|
|
210
|
+
if (!operation) throw new Error('git requires a subcommand');
|
|
211
|
+
if (GUI_OPERATIONS.has(operation)) {
|
|
212
|
+
throw new Error(`git ${operation} opens an interactive GUI and would never return here; use its non-interactive form (diff, merge, add -p)`);
|
|
213
|
+
}
|
|
214
|
+
if (SERVER_OPERATIONS.has(operation)) {
|
|
215
|
+
throw new Error(`git ${operation} is a long-running server; start it with the shell tool so it becomes a background task`);
|
|
216
|
+
}
|
|
201
217
|
return { command: String(command), cwd, globalArgs, operation, args: tokens.slice(index + 1) };
|
|
202
218
|
}
|
|
203
219
|
|
|
@@ -229,6 +229,34 @@ test('git availability probes answer instead of erroring', async (t) => {
|
|
|
229
229
|
assert.match(probe, /git version/i);
|
|
230
230
|
});
|
|
231
231
|
|
|
232
|
+
// git dispatches any `git-*` executable on PATH as a subcommand, so a finite
|
|
233
|
+
// allowlist could only ever go stale. An unknown name must reach git itself —
|
|
234
|
+
// whatever git answers is actionable, an "unsupported subcommand" refusal was
|
|
235
|
+
// not, and the shell tool ran the same command anyway.
|
|
236
|
+
test('git forwards unknown subcommands to git instead of pre-rejecting them', async (t) => {
|
|
237
|
+
const root = mkdtempSync(join(tmpdir(), 'mixdog-git-open-'));
|
|
238
|
+
t.after(() => rmSync(root, { recursive: true, force: true }));
|
|
239
|
+
const repo = join(root, 'repo');
|
|
240
|
+
parseOk(await executeGitTool({ command: `git init ${quote(repo)}` }, root));
|
|
241
|
+
for (const command of ['git filter-repo --version', 'git fast-export --help']) {
|
|
242
|
+
assert.doesNotMatch(String(await executeGitTool({ command }, repo)), /unsupported git subcommand/);
|
|
243
|
+
}
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
// The only commands this tool cannot host are the ones that never return: a
|
|
247
|
+
// GUI blocks on a window nobody can click, and a resident server burns the
|
|
248
|
+
// full timeout by working correctly. The server case is a routing hint, not a
|
|
249
|
+
// verdict — shell can hold it as a background task.
|
|
250
|
+
test('git refuses only non-returning subcommands and routes servers to shell', async (t) => {
|
|
251
|
+
const root = mkdtempSync(join(tmpdir(), 'mixdog-git-deny-'));
|
|
252
|
+
t.after(() => rmSync(root, { recursive: true, force: true }));
|
|
253
|
+
const repo = join(root, 'repo');
|
|
254
|
+
parseOk(await executeGitTool({ command: `git init ${quote(repo)}` }, root));
|
|
255
|
+
assert.match(String(await executeGitTool({ command: 'git mergetool' }, repo)), /interactive GUI/i);
|
|
256
|
+
assert.match(String(await executeGitTool({ command: 'git daemon --export-all' }, repo)), /shell tool/i);
|
|
257
|
+
assert.match(String(await executeGitTool({ command: 'git' }, repo)), /requires a subcommand/i);
|
|
258
|
+
});
|
|
259
|
+
|
|
232
260
|
test('git stages selected change IDs and rejects stale diff snapshots without touching the index', async (t) => {
|
|
233
261
|
const root = mkdtempSync(join(tmpdir(), 'mixdog-git-stage-'));
|
|
234
262
|
t.after(() => rmSync(root, { recursive: true, force: true }));
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
// A file scope the shared native search server could not serve is answered by
|
|
2
|
+
// reading that one file, so a starved queue costs a bounded wait instead of a
|
|
3
|
+
// whole turn. The scanner declines whenever it cannot reproduce the request.
|
|
4
|
+
import assert from 'node:assert/strict';
|
|
5
|
+
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
|
|
6
|
+
import { tmpdir } from 'node:os';
|
|
7
|
+
import { join } from 'node:path';
|
|
8
|
+
import test from 'node:test';
|
|
9
|
+
|
|
10
|
+
import { runGrepSingleFileRescue } from './lib/grep-single-file-rescue.mjs';
|
|
11
|
+
|
|
12
|
+
const STATUS = 'Name:\tnode\nUid:\t0\t0\t0\t0\nGid:\t0\t0\t0\t0\nCapEff:\t000001ffffffffff\n';
|
|
13
|
+
|
|
14
|
+
function request(overrides) {
|
|
15
|
+
return {
|
|
16
|
+
caseInsensitive: false,
|
|
17
|
+
multilineMode: false,
|
|
18
|
+
onlyMatching: false,
|
|
19
|
+
fileType: null,
|
|
20
|
+
outputMode: 'content',
|
|
21
|
+
showLineNumbers: true,
|
|
22
|
+
withFilename: false,
|
|
23
|
+
filenameOmitted: true,
|
|
24
|
+
beforeN: 0,
|
|
25
|
+
afterN: 0,
|
|
26
|
+
contextN: 0,
|
|
27
|
+
headLimit: 250,
|
|
28
|
+
offset: 0,
|
|
29
|
+
patternCapNote: '',
|
|
30
|
+
globPatterns: [],
|
|
31
|
+
...overrides,
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async function withStatusFile(run) {
|
|
36
|
+
const root = await mkdtemp(join(tmpdir(), 'mixdog-grep-rescue-'));
|
|
37
|
+
try {
|
|
38
|
+
const file = join(root, 'status');
|
|
39
|
+
await writeFile(file, STATUS);
|
|
40
|
+
return await run({ root, file });
|
|
41
|
+
} finally {
|
|
42
|
+
await rm(root, { recursive: true, force: true });
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
test('a missed file scope is scanned directly and reports the same matches', async () => {
|
|
47
|
+
await withStatusFile(async ({ root, file }) => {
|
|
48
|
+
const out = await runGrepSingleFileRescue(request({
|
|
49
|
+
filePath: file,
|
|
50
|
+
searchPath: file,
|
|
51
|
+
grepResolvedPath: file,
|
|
52
|
+
workDir: root,
|
|
53
|
+
patterns: ['^(Uid|Gid|Cap(Inh|Prm|Eff|Bnd|Amb))'],
|
|
54
|
+
}));
|
|
55
|
+
assert.match(out, /2:Uid:/);
|
|
56
|
+
assert.match(out, /3:Gid:/);
|
|
57
|
+
assert.match(out, /4:CapEff:/);
|
|
58
|
+
assert.doesNotMatch(out, /Name:/);
|
|
59
|
+
assert.match(out, /scanned directly/);
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test('counts and no-match answers keep the ordinary grep shapes', async () => {
|
|
64
|
+
await withStatusFile(async ({ root, file }) => {
|
|
65
|
+
const counted = await runGrepSingleFileRescue(request({
|
|
66
|
+
filePath: file,
|
|
67
|
+
searchPath: file,
|
|
68
|
+
grepResolvedPath: file,
|
|
69
|
+
workDir: root,
|
|
70
|
+
outputMode: 'count',
|
|
71
|
+
patterns: ['^(Uid|Gid)'],
|
|
72
|
+
}));
|
|
73
|
+
assert.match(counted, /\b2\b/);
|
|
74
|
+
|
|
75
|
+
const empty = await runGrepSingleFileRescue(request({
|
|
76
|
+
filePath: file,
|
|
77
|
+
searchPath: file,
|
|
78
|
+
grepResolvedPath: file,
|
|
79
|
+
workDir: root,
|
|
80
|
+
patterns: ['NoSuchFieldHere'],
|
|
81
|
+
}));
|
|
82
|
+
assert.match(empty, /\(no matches\)/);
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test('a request the scanner cannot reproduce declines instead of guessing', async () => {
|
|
87
|
+
await withStatusFile(async ({ root, file }) => {
|
|
88
|
+
const base = {
|
|
89
|
+
filePath: file,
|
|
90
|
+
searchPath: file,
|
|
91
|
+
grepResolvedPath: file,
|
|
92
|
+
workDir: root,
|
|
93
|
+
};
|
|
94
|
+
// Regex JS cannot compile: the caller keeps the native error.
|
|
95
|
+
assert.equal(await runGrepSingleFileRescue(request({
|
|
96
|
+
...base,
|
|
97
|
+
patterns: ['a{2,1}'],
|
|
98
|
+
})), null);
|
|
99
|
+
// Multiline matching spans lines; this scanner is line-oriented.
|
|
100
|
+
assert.equal(await runGrepSingleFileRescue(request({
|
|
101
|
+
...base,
|
|
102
|
+
patterns: ['Uid'],
|
|
103
|
+
multilineMode: true,
|
|
104
|
+
})), null);
|
|
105
|
+
});
|
|
106
|
+
});
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
// Direct filesystem expansion for an ABSOLUTE glob pattern.
|
|
2
|
+
//
|
|
3
|
+
// The inventory walker never follows a symlinked directory — the right call on
|
|
4
|
+
// a project tree, where following them means cycles and duplicated subtrees.
|
|
5
|
+
// But a virtual filesystem is built almost entirely out of symlinks:
|
|
6
|
+
// /sys/block/sda is a link into /sys/devices, and /sys/block/sda/device is
|
|
7
|
+
// another one. A pattern like /sys/block/sd*/size therefore enumerates NOTHING
|
|
8
|
+
// through the walker, and the caller is told the path does not exist while it
|
|
9
|
+
// plainly does.
|
|
10
|
+
//
|
|
11
|
+
// The pattern itself is the bound here: only segments that match are descended,
|
|
12
|
+
// one readdir per surviving directory, no recursive `**`, plus a candidate cap
|
|
13
|
+
// and a wall-clock deadline. Returns the matches (possibly empty), or null when
|
|
14
|
+
// the pattern is not expandable this way and the caller should keep its own
|
|
15
|
+
// answer.
|
|
16
|
+
import { readdir, stat } from 'node:fs/promises';
|
|
17
|
+
import { isAbsolute, join, parse } from 'node:path';
|
|
18
|
+
|
|
19
|
+
import { hasGlobMagic, normalizeOutputPath } from '../path-utils.mjs';
|
|
20
|
+
|
|
21
|
+
const DEFAULT_DEADLINE_MS = 1_500;
|
|
22
|
+
const DEFAULT_CANDIDATE_CAP = 200;
|
|
23
|
+
|
|
24
|
+
function escapeRegExp(value) {
|
|
25
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** One path SEGMENT's matcher, or null when the segment uses a construct this
|
|
29
|
+
* expander will not approximate. */
|
|
30
|
+
function segmentMatcher(segment) {
|
|
31
|
+
if (segment.includes('**')) return null;
|
|
32
|
+
let source = '';
|
|
33
|
+
for (let index = 0; index < segment.length; index += 1) {
|
|
34
|
+
const char = segment[index];
|
|
35
|
+
if (char === '*') { source += '[^/]*'; continue; }
|
|
36
|
+
if (char === '?') { source += '[^/]'; continue; }
|
|
37
|
+
if (char === '[') {
|
|
38
|
+
const end = segment.indexOf(']', index + 1);
|
|
39
|
+
if (end < 0) return null;
|
|
40
|
+
source += segment.slice(index, end + 1);
|
|
41
|
+
index = end;
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
if (char === '{') {
|
|
45
|
+
const end = segment.indexOf('}', index + 1);
|
|
46
|
+
if (end < 0) return null;
|
|
47
|
+
const alternatives = segment.slice(index + 1, end).split(',').map(escapeRegExp);
|
|
48
|
+
source += `(?:${alternatives.join('|')})`;
|
|
49
|
+
index = end;
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
source += escapeRegExp(char);
|
|
53
|
+
}
|
|
54
|
+
try {
|
|
55
|
+
return new RegExp(`^${source}$`, process.platform === 'win32' ? 'i' : '');
|
|
56
|
+
} catch {
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export async function expandAbsoluteGlob(pattern, {
|
|
62
|
+
limit = 25,
|
|
63
|
+
deadlineMs = DEFAULT_DEADLINE_MS,
|
|
64
|
+
candidateCap = DEFAULT_CANDIDATE_CAP,
|
|
65
|
+
} = {}) {
|
|
66
|
+
const raw = String(pattern || '').replace(/\\/g, '/');
|
|
67
|
+
if (!raw || !isAbsolute(raw) || !hasGlobMagic(raw)) return null;
|
|
68
|
+
const root = parse(raw).root;
|
|
69
|
+
if (!root) return null;
|
|
70
|
+
const segments = raw.slice(root.length).split('/').filter(Boolean);
|
|
71
|
+
if (segments.length === 0) return null;
|
|
72
|
+
const stopAt = Date.now() + Math.max(1, deadlineMs);
|
|
73
|
+
|
|
74
|
+
let current = [root];
|
|
75
|
+
for (const segment of segments) {
|
|
76
|
+
if (Date.now() >= stopAt) return null;
|
|
77
|
+
if (!hasGlobMagic(segment)) {
|
|
78
|
+
current = current.map((dir) => join(dir, segment));
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
const matcher = segmentMatcher(segment);
|
|
82
|
+
if (!matcher) return null;
|
|
83
|
+
const next = [];
|
|
84
|
+
for (const dir of current) {
|
|
85
|
+
if (next.length >= candidateCap || Date.now() >= stopAt) break;
|
|
86
|
+
let entries;
|
|
87
|
+
try { entries = await readdir(dir); } catch { continue; }
|
|
88
|
+
for (const name of entries) {
|
|
89
|
+
if (!matcher.test(name)) continue;
|
|
90
|
+
next.push(join(dir, name));
|
|
91
|
+
if (next.length >= candidateCap) break;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
current = next;
|
|
95
|
+
if (current.length === 0) return [];
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// stat (not lstat) so a symlinked target counts as the file/dir it names —
|
|
99
|
+
// that is the whole point of expanding these scopes.
|
|
100
|
+
const found = [];
|
|
101
|
+
for (const candidate of current) {
|
|
102
|
+
if (found.length >= limit || Date.now() >= stopAt) break;
|
|
103
|
+
try {
|
|
104
|
+
const info = await stat(candidate);
|
|
105
|
+
if (info.isFile() || info.isDirectory()) found.push(normalizeOutputPath(candidate));
|
|
106
|
+
} catch { /* a pattern match that cannot be stat'ed is not a result */ }
|
|
107
|
+
}
|
|
108
|
+
return found;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Expand every absolute pattern in a list; returns deduped matches in order. */
|
|
112
|
+
export async function expandAbsoluteGlobs(patterns, options = {}) {
|
|
113
|
+
const list = (Array.isArray(patterns) ? patterns : [patterns]).filter(
|
|
114
|
+
(value) => typeof value === 'string' && value,
|
|
115
|
+
);
|
|
116
|
+
const seen = new Set();
|
|
117
|
+
const out = [];
|
|
118
|
+
for (const pattern of list) {
|
|
119
|
+
if (out.length >= (options.limit ?? 25)) break;
|
|
120
|
+
const matches = await expandAbsoluteGlob(pattern, options);
|
|
121
|
+
if (!matches) continue;
|
|
122
|
+
for (const match of matches) {
|
|
123
|
+
if (seen.has(match)) continue;
|
|
124
|
+
seen.add(match);
|
|
125
|
+
out.push(match);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
return out;
|
|
129
|
+
}
|