mixdog 0.9.149 → 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 -12
- package/package.json +1 -1
- package/src/headless-exec.mjs +40 -4
- package/src/headless-exec.test.mjs +116 -1
- package/src/rules/lead/01-general.md +3 -2
- package/src/rules/shared/00-general.md +1 -0
- package/src/rules/shared/10-tool-workflow.md +11 -12
- package/src/rules/shared/30-exploration.md +4 -3
- package/src/rules/shared/60-verification.md +4 -3
- package/src/runtime/agent/orchestrator/providers/openai-codex-identity.test.mjs +0 -2
- package/src/runtime/agent/orchestrator/providers/openai-codex-metadata.mjs +1 -4
- package/src/runtime/agent/orchestrator/providers/openai-codex-model.mjs +0 -18
- package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +0 -3
- package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +0 -1
- package/src/runtime/agent/orchestrator/providers/openai-responses-payload.mjs +4 -72
- package/src/runtime/agent/orchestrator/session/approval-mode.mjs +0 -8
- 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 +50 -8
- package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.test.mjs +31 -1
- package/src/runtime/agent/orchestrator/session/store/serialize.mjs +27 -4
- package/src/runtime/agent/orchestrator/session/store/serialize.test.mjs +60 -0
- package/src/runtime/agent/orchestrator/session/task-wait-control.mjs +78 -0
- package/src/runtime/agent/orchestrator/session/task-wait-control.test.mjs +78 -0
- package/src/runtime/agent/orchestrator/tools/builtin/absence-absorption.test.mjs +43 -0
- package/src/runtime/agent/orchestrator/tools/builtin/absolute-glob-expand.test.mjs +63 -0
- package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.mjs +8 -0
- package/src/runtime/agent/orchestrator/tools/builtin/atomic-write.mjs +29 -10
- package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +7 -7
- 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 +50 -23
- package/src/runtime/agent/orchestrator/tools/builtin/git-command-tool.test.mjs +50 -1
- package/src/runtime/agent/orchestrator/tools/builtin/grep-output-budget.test.mjs +31 -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-context-expander.mjs +2 -1
- package/src/runtime/agent/orchestrator/tools/builtin/lib/grep-single-file-rescue.mjs +218 -0
- package/src/runtime/agent/orchestrator/tools/builtin/lib/list-helpers.mjs +9 -2
- package/src/runtime/agent/orchestrator/tools/builtin/list-tool-integrity.test.mjs +6 -0
- package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +50 -2
- package/src/runtime/agent/orchestrator/tools/builtin/native-search-client.mjs +7 -1
- 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-glob-survey.test.mjs +71 -0
- package/src/runtime/agent/orchestrator/tools/builtin/read-image-resize.mjs +6 -1
- package/src/runtime/agent/orchestrator/tools/builtin/read-single-tool.mjs +7 -0
- package/src/runtime/agent/orchestrator/tools/builtin/read-tool.mjs +54 -8
- package/src/runtime/agent/orchestrator/tools/builtin/runtime-capabilities.mjs +90 -51
- package/src/runtime/agent/orchestrator/tools/builtin/runtime-capabilities.test.mjs +56 -0
- package/src/runtime/agent/orchestrator/tools/builtin/search-builders.mjs +25 -0
- package/src/runtime/agent/orchestrator/tools/builtin/search-builders.test.mjs +16 -0
- package/src/runtime/agent/orchestrator/tools/builtin/search-glob-tool.mjs +30 -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/task-tool.mjs +49 -24
- package/src/runtime/agent/orchestrator/tools/builtin/tool-output-limit.mjs +1 -0
- package/src/runtime/agent/orchestrator/tools/builtin/write-symlink.test.mjs +153 -0
- package/src/runtime/agent/orchestrator/tools/builtin.mjs +11 -1
- 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/env-scrub.mjs +16 -0
- package/src/runtime/agent/orchestrator/tools/env-scrub.test.mjs +33 -1
- package/src/runtime/agent/orchestrator/tools/graph-manifest.json +11 -11
- package/src/runtime/agent/orchestrator/tools/lib/native-spawn-client.mjs +9 -1
- 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/agent/orchestrator/tools/shell-command.mjs +42 -23
- package/src/runtime/media/renditions.mjs +7 -1
- 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 +13 -19
- package/src/runtime/memory/lib/recall-format.mjs +13 -31
- package/src/runtime/memory/tool-defs.mjs +2 -2
- package/src/runtime/shared/memory-snapshot.mjs +57 -4
- package/src/runtime/shared/pristine-execution-contract.json +3 -0
- package/src/runtime/shared/pristine-execution.mjs +6 -3
- package/src/runtime/shared/tool-surface.mjs +6 -0
- package/src/runtime/shared/tool-surface.test.mjs +8 -0
- package/src/session-runtime/lifecycle-api.mjs +1 -0
- package/src/session-runtime/session-turn-api.mjs +5 -0
- package/src/standalone/agent-dispatch-broker.mjs +25 -80
- package/src/standalone/channel-worker-heartbeat.test.mjs +30 -0
- package/src/standalone/channel-worker.mjs +31 -1
- package/src/standalone/daemon.mjs +9 -9
- package/src/standalone/memory-runtime-proxy.mjs +5 -2
- package/src/standalone/session-client.mjs +23 -3
- package/src/standalone/session-runtime-host-health.test.mjs +36 -0
- package/src/standalone/session-runtime-host.mjs +26 -76
- package/src/standalone/session-runtime-worker.mjs +97 -0
- package/src/tui/App.jsx +3 -2
- package/src/tui/app/use-global-key-input.mjs +9 -0
- package/src/tui/app/use-mouse-input.mjs +77 -14
- package/src/tui/components/Spinner.jsx +1 -1
- package/src/tui/dist/index.mjs +83 -23
- package/src/tui/lib/voice-setup.mjs +3 -2
- package/src/tui/session/completion-card-restore.test.mjs +18 -0
- package/src/tui/session/session-api-ext.mjs +2 -1
- package/src/tui/session/session-api.mjs +8 -1
- package/src/tui/session/task-wait-submit.test.mjs +80 -0
- package/src/tui/session/turn.mjs +58 -6
- package/src/tui/spinner-verbs.mjs +1 -0
- package/src/workflows/default/WORKFLOW.md +12 -19
- package/src/workflows/headless/WORKFLOW.md +11 -0
- package/src/workflows/solo/WORKFLOW.md +6 -11
|
@@ -20,21 +20,32 @@ import {
|
|
|
20
20
|
|
|
21
21
|
const DEFAULT_TIMEOUT_MS = 120_000;
|
|
22
22
|
const MAX_CAPTURE_BYTES = 128 * 1024 * 1024;
|
|
23
|
-
const
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
23
|
+
const GIT_OUTPUT_LIMIT_MAX = 200;
|
|
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']);
|
|
38
49
|
// `git --version` / `git --help` are the standard availability probes and
|
|
39
50
|
// arrive in global-flag position, not as subcommands. Map them onto their
|
|
40
51
|
// subcommand form so the probe answers instead of erroring out.
|
|
@@ -63,12 +74,12 @@ export const GIT_TOOL_DEF = {
|
|
|
63
74
|
openWorldHint: true,
|
|
64
75
|
compressible: true,
|
|
65
76
|
},
|
|
66
|
-
description: 'Run one Git command directly, without a shell.
|
|
77
|
+
description: 'Run one Git command directly, without a shell. Owns repository state, diffs, history, and mutations. Use diff for known targets; otherwise use status alone to discover targets. Shell operators and substitution are rejected. Repository mutations are serialized. Successful output is compacted.',
|
|
67
78
|
inputSchema: {
|
|
68
79
|
type: 'object',
|
|
69
80
|
properties: {
|
|
70
81
|
command: { type: 'string', description: 'Full command beginning with git. Quote arguments as for a shell; shell operators are not allowed.' },
|
|
71
|
-
output_limit: { type: 'integer', minimum: 1, maximum:
|
|
82
|
+
output_limit: { type: 'integer', minimum: 1, maximum: GIT_OUTPUT_LIMIT_MAX, description: 'Item/line cap. Default 50; git log defaults to 10.' },
|
|
72
83
|
},
|
|
73
84
|
required: ['command'],
|
|
74
85
|
additionalProperties: false,
|
|
@@ -98,7 +109,7 @@ export const GIT_STAGE_TOOL_DEF = {
|
|
|
98
109
|
],
|
|
99
110
|
description: 'Exact change ID or IDs to stage.',
|
|
100
111
|
},
|
|
101
|
-
output_limit: { type: 'integer', minimum: 1, maximum:
|
|
112
|
+
output_limit: { type: 'integer', minimum: 1, maximum: GIT_OUTPUT_LIMIT_MAX, description: 'Status line cap; default 50.' },
|
|
102
113
|
},
|
|
103
114
|
required: ['diff_id', 'change_ids'],
|
|
104
115
|
additionalProperties: false,
|
|
@@ -196,7 +207,13 @@ function parseCommand(command, workDir) {
|
|
|
196
207
|
}
|
|
197
208
|
const rawOperation = String(tokens[index] || '').toLowerCase();
|
|
198
209
|
const operation = OPERATION_ALIASES.get(rawOperation) ?? rawOperation;
|
|
199
|
-
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
|
+
}
|
|
200
217
|
return { command: String(command), cwd, globalArgs, operation, args: tokens.slice(index + 1) };
|
|
201
218
|
}
|
|
202
219
|
|
|
@@ -273,12 +290,22 @@ function commandFailure(plan, result, limit = 50) {
|
|
|
273
290
|
// Bounded like every success path: an unbounded failure dump from a large
|
|
274
291
|
// repository cost far more context than the diagnosis it carried.
|
|
275
292
|
const max = Math.min(Math.max(limit, 20), 40);
|
|
276
|
-
|
|
293
|
+
const detail = {
|
|
277
294
|
exit: result.exitCode,
|
|
278
295
|
signal: result.signal,
|
|
279
296
|
stderr: failureText(result.stderr, max),
|
|
280
297
|
stdout: failureText(result.stdout, max),
|
|
281
|
-
}
|
|
298
|
+
};
|
|
299
|
+
// Exit 128 is git's state/precondition verdict (identity unset, bare
|
|
300
|
+
// repository, dubious ownership, missing ref) and stderr carries git's
|
|
301
|
+
// own explanation — the ANSWER the caller acts on next. The error
|
|
302
|
+
// envelope pushed callers into shell fallbacks for ordinary repository
|
|
303
|
+
// state. Spawn errors, timeouts, aborts, overflow and usage exits (129)
|
|
304
|
+
// keep failure semantics.
|
|
305
|
+
if (result.exitCode === 128 && !result.error && !result.timedOut && !result.aborted && !result.overflow) {
|
|
306
|
+
return JSON.stringify({ ok: false, reason, ...detail });
|
|
307
|
+
}
|
|
308
|
+
return fail(reason, detail);
|
|
282
309
|
}
|
|
283
310
|
|
|
284
311
|
// Non-zero exits that ARE the answer rather than a failure: git reports
|
|
@@ -761,7 +788,7 @@ export async function executeGitStageTool(input, workDir, options = {}) {
|
|
|
761
788
|
hint: 'Run git diff in the current Project and use its diff_id/change_ids.',
|
|
762
789
|
});
|
|
763
790
|
}
|
|
764
|
-
const limit = Math.min(
|
|
791
|
+
const limit = Math.min(GIT_OUTPUT_LIMIT_MAX, Math.max(1, Number(input?.output_limit) || 50));
|
|
765
792
|
const signal = options?.signal || options?.abortSignal || null;
|
|
766
793
|
const repo = snapshot.repo;
|
|
767
794
|
return withGitRepoWriteLock(repo, () => withBuiltinPathLocks([repo], () => withAdvisoryLocks([repo], async () => {
|
|
@@ -815,7 +842,7 @@ export async function executeGitTool(input, workDir, options = {}) {
|
|
|
815
842
|
return fail('git archive requires -o/--output; binary stdout is not returned');
|
|
816
843
|
}
|
|
817
844
|
const limitDefault = plan.operation === 'log' ? 10 : 50;
|
|
818
|
-
const limit = Math.min(
|
|
845
|
+
const limit = Math.min(GIT_OUTPUT_LIMIT_MAX, Math.max(1, Number(input.output_limit) || limitDefault));
|
|
819
846
|
const signal = options?.signal || options?.abortSignal || null;
|
|
820
847
|
if (plan.operation === 'init' || plan.operation === 'clone') {
|
|
821
848
|
const target = creationTarget(plan);
|
|
@@ -187,17 +187,38 @@ test('git and deferred git_stage expose separate compact contracts', () => {
|
|
|
187
187
|
assert.deepEqual(Object.keys(properties), ['command', 'output_limit']);
|
|
188
188
|
assert.deepEqual(GIT_TOOL_DEF.inputSchema.required, ['command']);
|
|
189
189
|
assert.equal(properties.command.minLength, undefined);
|
|
190
|
+
assert.equal(properties.output_limit.maximum, 200);
|
|
190
191
|
const stageProperties = GIT_STAGE_TOOL_DEF.inputSchema.properties;
|
|
191
192
|
assert.deepEqual(Object.keys(stageProperties), ['diff_id', 'change_ids', 'output_limit']);
|
|
192
193
|
assert.deepEqual(GIT_STAGE_TOOL_DEF.inputSchema.required, ['diff_id', 'change_ids']);
|
|
193
194
|
assert.equal(stageProperties.change_ids.anyOf[1].maxItems, 50);
|
|
195
|
+
assert.equal(stageProperties.output_limit.maximum, 200);
|
|
194
196
|
assert.equal(GIT_STAGE_TOOL_DEF.annotations.destructiveHint, true);
|
|
195
197
|
assert.doesNotMatch(GIT_TOOL_DEF.description, /confirm/i);
|
|
196
|
-
assert.match(GIT_TOOL_DEF.description, /
|
|
198
|
+
assert.match(GIT_TOOL_DEF.description, /Owns repository state, diffs, history, and mutations/i);
|
|
199
|
+
assert.match(GIT_TOOL_DEF.description, /Use diff for known targets; otherwise use status alone to discover targets/i);
|
|
197
200
|
assert.match(GIT_TOOL_DEF.description, /Run one Git command directly, without a shell/i);
|
|
198
201
|
assert.match(GIT_TOOL_DEF.description, /repository mutations are serialized/i);
|
|
199
202
|
});
|
|
200
203
|
|
|
204
|
+
test('git clamps oversized output requests to 200 lines', async (t) => {
|
|
205
|
+
const root = mkdtempSync(join(tmpdir(), 'mixdog-git-output-cap-'));
|
|
206
|
+
t.after(() => rmSync(root, { recursive: true, force: true }));
|
|
207
|
+
const repo = join(root, 'repo');
|
|
208
|
+
parseOk(await executeGitTool({ command: `git init ${quote(repo)}` }, root));
|
|
209
|
+
parseOk(await git(repo, 'config user.name "Mixdog Test"'));
|
|
210
|
+
parseOk(await git(repo, 'config user.email mixdog@example.invalid'));
|
|
211
|
+
writeFileSync(
|
|
212
|
+
join(repo, 'large.txt'),
|
|
213
|
+
`${Array.from({ length: 250 }, (_, index) => `line-${index}`).join('\n')}\n`,
|
|
214
|
+
);
|
|
215
|
+
parseOk(await git(repo, 'add -- large.txt'));
|
|
216
|
+
parseOk(await git(repo, 'commit -m large'));
|
|
217
|
+
const shown = parseOk(await git(repo, 'show HEAD:large.txt', { output_limit: 500 }));
|
|
218
|
+
assert.equal(shown.lines.length, 200);
|
|
219
|
+
assert.equal(shown.omitted, 50);
|
|
220
|
+
});
|
|
221
|
+
|
|
201
222
|
// `git --version` is how a caller checks whether git exists at all; rejecting
|
|
202
223
|
// it as an "unsupported subcommand" turned the probe into a dead turn.
|
|
203
224
|
test('git availability probes answer instead of erroring', async (t) => {
|
|
@@ -208,6 +229,34 @@ test('git availability probes answer instead of erroring', async (t) => {
|
|
|
208
229
|
assert.match(probe, /git version/i);
|
|
209
230
|
});
|
|
210
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
|
+
|
|
211
260
|
test('git stages selected change IDs and rejects stale diff snapshots without touching the index', async (t) => {
|
|
212
261
|
const root = mkdtempSync(join(tmpdir(), 'mixdog-git-stage-'));
|
|
213
262
|
t.after(() => rmSync(root, { recursive: true, force: true }));
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import test from 'node:test';
|
|
6
|
+
|
|
7
|
+
import { executeBuiltinTool } from '../builtin.mjs';
|
|
8
|
+
|
|
9
|
+
test('grep rendered output is capped at 10 KiB', async () => {
|
|
10
|
+
const root = await mkdtemp(join(tmpdir(), 'mixdog-grep-budget-'));
|
|
11
|
+
try {
|
|
12
|
+
const file = join(root, 'large.txt');
|
|
13
|
+
const body = Array.from(
|
|
14
|
+
{ length: 400 },
|
|
15
|
+
(_, index) => `${index + 1} needle ${'x'.repeat(160)}`,
|
|
16
|
+
).join('\n');
|
|
17
|
+
await writeFile(file, body);
|
|
18
|
+
const out = await executeBuiltinTool('grep', {
|
|
19
|
+
pattern: 'needle',
|
|
20
|
+
path: file,
|
|
21
|
+
mode: 'content',
|
|
22
|
+
limit: 250,
|
|
23
|
+
offset: 0,
|
|
24
|
+
context: 2,
|
|
25
|
+
}, root);
|
|
26
|
+
assert.ok(Buffer.byteLength(out, 'utf8') <= 10 * 1024);
|
|
27
|
+
assert.match(out, /\[grep output capped at 10240 bytes;/);
|
|
28
|
+
} finally {
|
|
29
|
+
await rm(root, { recursive: true, force: true });
|
|
30
|
+
}
|
|
31
|
+
});
|
|
@@ -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
|
+
}
|
|
@@ -12,11 +12,12 @@ import {
|
|
|
12
12
|
import {
|
|
13
13
|
relativePathPrefix,
|
|
14
14
|
} from '../search-path-diagnostics.mjs';
|
|
15
|
+
import { GREP_OUTPUT_MAX_BYTES } from '../tool-output-limit.mjs';
|
|
15
16
|
import {
|
|
16
17
|
relativeGrepLine,
|
|
17
18
|
} from './search-input-helpers.mjs';
|
|
18
19
|
|
|
19
|
-
export const GREP_CONTEXT_CHAR_BUDGET_DEFAULT =
|
|
20
|
+
export const GREP_CONTEXT_CHAR_BUDGET_DEFAULT = GREP_OUTPUT_MAX_BYTES;
|
|
20
21
|
const GREP_FOCUSED_CONTEXT_RADIUS = 12;
|
|
21
22
|
const GREP_FOCUSED_RAW_BLOCKS = 3;
|
|
22
23
|
// Anchors must stay usable as evidence without a follow-up read: keep the
|