@visual-z/xeno 0.4.0 → 0.6.0
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/CHANGELOG.md +20 -0
- package/README.md +41 -10
- package/docs/app-server.md +35 -4
- package/docs/foundations.md +88 -0
- package/docs/install.md +23 -2
- package/docs/providers.md +4 -0
- package/docs/research/foundation-clients-2026-10-09.md +27 -0
- package/docs/research/foundation-editing-2026-10-09.md +56 -0
- package/docs/research/foundation-network-2026-10-09.md +37 -0
- package/docs/research/foundation-workflow-2026-10-09.md +37 -0
- package/package.json +10 -2
- package/src/apps/acp-server.ts +41 -0
- package/src/apps/agent-profiles.ts +19 -0
- package/src/apps/app-server.ts +4 -2
- package/src/apps/cli.ts +18 -2
- package/src/apps/commands.ts +20 -0
- package/src/apps/control-transport.ts +3 -2
- package/src/apps/model-catalog.ts +57 -31
- package/src/apps/preferences.ts +20 -0
- package/src/apps/question-ui.ts +27 -0
- package/src/apps/resources.ts +63 -25
- package/src/apps/review.ts +109 -0
- package/src/apps/session-hub.ts +84 -0
- package/src/apps/shell.ts +88 -30
- package/src/apps/startup-input.ts +7 -2
- package/src/apps/startup.ts +180 -25
- package/src/apps/ui/app.ts +74 -91
- package/src/apps/ui/surface.ts +1 -0
- package/src/apps/update-check.ts +71 -0
- package/src/apps/workflow.ts +58 -0
- package/src/core/agent-profile.ts +130 -0
- package/src/core/completion-review.ts +13 -4
- package/src/core/control-protocol.ts +8 -1
- package/src/core/file-precondition.ts +6 -0
- package/src/core/lsp-framing.ts +34 -0
- package/src/core/mcp-config.ts +68 -0
- package/src/core/model-preferences.ts +28 -0
- package/src/core/patch.ts +130 -0
- package/src/core/permissions.ts +156 -11
- package/src/core/question.ts +94 -0
- package/src/core/review.ts +68 -0
- package/src/core/settings.ts +1 -1
- package/src/core/update-check.ts +24 -5
- package/src/core/web-content.ts +80 -0
- package/src/core/workflow-mode.ts +34 -0
- package/src/runtime/acp-agent.ts +289 -0
- package/src/runtime/agent-profiles.ts +21 -0
- package/src/runtime/catalog.ts +10 -3
- package/src/runtime/control-client.ts +149 -0
- package/src/runtime/control-connection.ts +39 -4
- package/src/runtime/extensions.ts +20 -2
- package/src/runtime/formatter-tool.ts +38 -0
- package/src/runtime/formatter.ts +50 -0
- package/src/runtime/foundation.ts +78 -0
- package/src/runtime/integrations.ts +8 -2
- package/src/runtime/loop.ts +68 -10
- package/src/runtime/lsp-client.ts +201 -0
- package/src/runtime/mcp-client.ts +264 -0
- package/src/runtime/mcp-oauth.ts +168 -0
- package/src/runtime/mcp-schema.ts +51 -0
- package/src/runtime/oauth.ts +1 -1
- package/src/runtime/output-schema.ts +26 -0
- package/src/runtime/patch-tool.ts +38 -0
- package/src/runtime/permission-resources.ts +11 -0
- package/src/runtime/provider.ts +2 -0
- package/src/runtime/question-tool.ts +62 -0
- package/src/runtime/review-tool.ts +48 -0
- package/src/runtime/session-control.ts +60 -2
- package/src/runtime/session-hub.ts +85 -0
- package/src/runtime/tool-discovery.ts +4 -3
- package/src/runtime/tools.ts +48 -16
- package/src/runtime/update-check.ts +3 -2
- package/src/runtime/web-http.ts +56 -0
- package/src/runtime/web-tools.ts +88 -0
- package/src/runtime/workspace-lifecycle.ts +14 -1
- package/src/store/agent-profile-files.ts +13 -0
- package/src/store/context-files.ts +77 -1
- package/src/store/file-checkpoints.ts +5 -4
- package/src/store/foundation-config.ts +24 -0
- package/src/store/lsp-document.ts +30 -0
- package/src/store/mcp-credentials.ts +85 -0
- package/src/store/patch.ts +92 -0
- package/src/store/permission-paths.ts +34 -0
- package/src/store/resources.ts +13 -22
- package/src/store/review.ts +109 -0
- package/src/store/search.ts +48 -9
- package/src/store/user-settings.ts +30 -0
- package/src/tui/ui/control-panel.ts +7 -5
- package/src/tui/ui/control-sidebar.ts +49 -0
- package/src/tui/ui/screen.ts +107 -11
|
@@ -67,8 +67,9 @@ export async function archiveEdit(blobs: string, before: Uint8Array | null, afte
|
|
|
67
67
|
await putBlob(blobs, after);
|
|
68
68
|
}
|
|
69
69
|
|
|
70
|
-
export async function preflightChanges(root: string, blobs: string, changes: FileChanges): Promise<void> {
|
|
70
|
+
export async function preflightChanges(root: string, blobs: string, changes: FileChanges, signal?: AbortSignal): Promise<void> {
|
|
71
71
|
for (const path of changedPaths(changes)) {
|
|
72
|
+
if (signal?.aborted) throw new ToolFault("cancelled", "file operation cancelled", NOT_STARTED);
|
|
72
73
|
const change = changes[path]!;
|
|
73
74
|
if (!sameVersion((await readEditableFile(root, path)).version, change.before)) throw new Error(`file conflict: ${path}; external changes were preserved`);
|
|
74
75
|
if (change.after && !await verifyBlob(blobs, change.after.sha256)) throw new Error(`missing checkpoint object: ${path}`);
|
|
@@ -77,15 +78,15 @@ export async function preflightChanges(root: string, blobs: string, changes: Fil
|
|
|
77
78
|
}
|
|
78
79
|
|
|
79
80
|
/** Preflight the complete plan, then compensate partial writes if an individual replacement fails. */
|
|
80
|
-
export async function applyFileChanges(root: string, blobs: string, changes: FileChanges): Promise<void> {
|
|
81
|
-
await preflightChanges(root, blobs, changes);
|
|
81
|
+
export async function applyFileChanges(root: string, blobs: string, changes: FileChanges, signal?: AbortSignal): Promise<void> {
|
|
82
|
+
await preflightChanges(root, blobs, changes, signal);
|
|
82
83
|
const applied: string[] = [];
|
|
83
84
|
try {
|
|
84
85
|
for (const path of changedPaths(changes)) {
|
|
85
86
|
const { before, after } = changes[path]!;
|
|
86
87
|
const bytes = after === null ? null : await getBlobBytes(blobs, after.sha256);
|
|
87
88
|
if (after && bytes === null) throw new Error(`missing checkpoint object: ${path}`);
|
|
88
|
-
await replaceFile(root, path, before, bytes, after?.mode ?? 0o644);
|
|
89
|
+
await replaceFile(root, path, before, bytes, after?.mode ?? 0o644, signal);
|
|
89
90
|
applied.push(path);
|
|
90
91
|
}
|
|
91
92
|
} catch (error) {
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/** Trusted executable configuration follows the same user/project boundary as extensions. */
|
|
2
|
+
import { readFile } from "node:fs/promises";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
|
|
5
|
+
export async function readFoundationConfig(configDirectory: string, workspace: string, trusted: boolean): Promise<Record<string, unknown>> {
|
|
6
|
+
let value: Record<string, unknown> = {};
|
|
7
|
+
for (const path of [join(configDirectory, "foundation.json"), ...(trusted ? [join(workspace, ".xeno", "foundation.json")] : [])]) {
|
|
8
|
+
let text: string;
|
|
9
|
+
try { text = await readFile(path, "utf8"); }
|
|
10
|
+
catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") continue; throw error; }
|
|
11
|
+
const parsed: unknown = JSON.parse(text);
|
|
12
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) throw Error(`${path}: expected an object`);
|
|
13
|
+
const allowed = ["mcp", "webSearch", "formatters", "lsp", "permissions"];
|
|
14
|
+
if (Object.keys(parsed).some(key => !allowed.includes(key))) throw Error(`${path}: unknown foundation setting`);
|
|
15
|
+
value = { ...value, ...parsed };
|
|
16
|
+
}
|
|
17
|
+
return value;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export async function readOutputSchema(path: string): Promise<unknown> {
|
|
21
|
+
const text = await readFile(path, "utf8");
|
|
22
|
+
if (Buffer.byteLength(text) > 1_048_576) throw Error("output schema exceeds 1 MiB");
|
|
23
|
+
return JSON.parse(text) as unknown;
|
|
24
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { editablePath } from "./file-checkpoints.ts";
|
|
2
|
+
import { open } from "node:fs/promises";
|
|
3
|
+
import { constants } from "node:fs";
|
|
4
|
+
import { blobAddress } from "./blobs.ts";
|
|
5
|
+
import { pathToFileURL } from "node:url";
|
|
6
|
+
import { resolve } from "node:path";
|
|
7
|
+
|
|
8
|
+
// Operational memory ceiling: a language server is optional and must not read arbitrarily large files.
|
|
9
|
+
const MAX_LSP_DOCUMENT = 2 * 1024 * 1024;
|
|
10
|
+
export async function readLspDocument(root: string, path: string, signal?: AbortSignal): Promise<{ uri: string; text: string; sha256: string }> {
|
|
11
|
+
signal?.throwIfAborted();
|
|
12
|
+
const absolute = await editablePath(root, path);
|
|
13
|
+
const file = await open(absolute, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
14
|
+
try {
|
|
15
|
+
const info = await file.stat();
|
|
16
|
+
if (!info.isFile() || info.nlink > 1) throw Error("LSP requires an existing ordinary workspace file");
|
|
17
|
+
if (info.size > MAX_LSP_DOCUMENT) throw Error("LSP document exceeds 2 MiB");
|
|
18
|
+
const bytes = new Uint8Array(MAX_LSP_DOCUMENT + 1); let length = 0;
|
|
19
|
+
for (;;) {
|
|
20
|
+
signal?.throwIfAborted();
|
|
21
|
+
const result = await file.read(bytes, length, bytes.length - length, null);
|
|
22
|
+
if (!result.bytesRead) break;
|
|
23
|
+
length += result.bytesRead;
|
|
24
|
+
if (length > MAX_LSP_DOCUMENT) throw Error("LSP document exceeds 2 MiB");
|
|
25
|
+
}
|
|
26
|
+
signal?.throwIfAborted();
|
|
27
|
+
const content = bytes.subarray(0, length);
|
|
28
|
+
return { uri: pathToFileURL(resolve(root, path)).href, text: new TextDecoder("utf8", { fatal: true }).decode(content), sha256: blobAddress(content) };
|
|
29
|
+
} finally { await file.close(); }
|
|
30
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/** Credential storage is separate from append-only observations: secrets must never become log evidence. */
|
|
2
|
+
import { mkdir, open, rename, rm, lstat } from "node:fs/promises";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
5
|
+
import { constants } from "node:fs";
|
|
6
|
+
import { isJsonValue, type JsonValue } from "../core/tool-contract.ts";
|
|
7
|
+
|
|
8
|
+
export interface McpCredentials {
|
|
9
|
+
version: 1; serverUrl: string; redirectUrl: string;
|
|
10
|
+
clientIdentity?: string;
|
|
11
|
+
clients: Record<string, JsonValue>; tokens: Record<string, JsonValue>; expiresAt: Record<string, number>; activeIssuer: string | null;
|
|
12
|
+
}
|
|
13
|
+
export interface McpCredentialsStore {
|
|
14
|
+
read(serverUrl: string, clientIdentity?: string): Promise<McpCredentials | undefined>;
|
|
15
|
+
write(credentials: McpCredentials): Promise<void>;
|
|
16
|
+
remove(serverUrl: string, clientIdentity?: string): Promise<void>;
|
|
17
|
+
}
|
|
18
|
+
// Assumed bound prevents a replaced credential file from becoming an unbounded JSON allocation.
|
|
19
|
+
const MAX_CREDENTIAL_BYTES = 1024 * 1024;
|
|
20
|
+
function filename(dir: string, url: string, clientIdentity = "") { return join(dir, createHash("sha256").update(JSON.stringify([url, clientIdentity])).digest("hex") + ".json"); }
|
|
21
|
+
function record(value: unknown): value is Record<string, JsonValue> {
|
|
22
|
+
return value !== null && typeof value === "object" && !Array.isArray(value) && isJsonValue(value);
|
|
23
|
+
}
|
|
24
|
+
function validate(value: unknown, serverUrl: string, clientIdentity = ""): McpCredentials {
|
|
25
|
+
if (!record(value) || value["version"] !== 1 || value["serverUrl"] !== serverUrl || typeof value["redirectUrl"] !== "string"
|
|
26
|
+
|| (value["clientIdentity"] ?? "") !== clientIdentity
|
|
27
|
+
|| !record(value["clients"]) || !record(value["tokens"]) || !record(value["expiresAt"])
|
|
28
|
+
|| Object.values(value["expiresAt"]).some(time => typeof time !== "number" || !Number.isFinite(time))
|
|
29
|
+
|| value["activeIssuer"] !== null && typeof value["activeIssuer"] !== "string") throw Error("MCP credential record is invalid");
|
|
30
|
+
return { version: 1, serverUrl, redirectUrl: value["redirectUrl"], clients: value["clients"], tokens: value["tokens"],
|
|
31
|
+
expiresAt: Object.fromEntries(Object.entries(value["expiresAt"]).map(([issuer, time]) => [issuer, Number(time)])), activeIssuer: value["activeIssuer"],
|
|
32
|
+
...(typeof value["clientIdentity"] === "string" ? { clientIdentity: value["clientIdentity"] } : {}) };
|
|
33
|
+
}
|
|
34
|
+
/** Explicit restricted-file backend, compatible with Codex's fallback; applications may provide an OS vault adapter. */
|
|
35
|
+
export class FileMcpCredentialsStore implements McpCredentialsStore {
|
|
36
|
+
private readonly dir: string;
|
|
37
|
+
private writes = Promise.resolve();
|
|
38
|
+
constructor(dir: string) { this.dir = dir; }
|
|
39
|
+
private async directory(): Promise<void> {
|
|
40
|
+
const stat = await lstat(this.dir);
|
|
41
|
+
if (!stat.isDirectory() || stat.isSymbolicLink() || (stat.mode & 0o077) !== 0) throw Error("MCP credential directory permissions are invalid");
|
|
42
|
+
}
|
|
43
|
+
async read(serverUrl: string, clientIdentity?: string): Promise<McpCredentials | undefined> {
|
|
44
|
+
const path = filename(this.dir, serverUrl, clientIdentity);
|
|
45
|
+
try {
|
|
46
|
+
await this.directory();
|
|
47
|
+
const stat = await lstat(path);
|
|
48
|
+
if (!stat.isFile() || stat.isSymbolicLink() || (stat.mode & 0o077) !== 0 || stat.size > MAX_CREDENTIAL_BYTES) throw Error("MCP credential file permissions or size are invalid");
|
|
49
|
+
const file = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
50
|
+
try {
|
|
51
|
+
const opened = await file.stat();
|
|
52
|
+
if (!opened.isFile() || opened.size > MAX_CREDENTIAL_BYTES || (opened.mode & 0o077) !== 0) throw Error("Unsafe credential handle");
|
|
53
|
+
return validate(JSON.parse(await file.readFile("utf8")), serverUrl, clientIdentity);
|
|
54
|
+
} finally { await file.close(); }
|
|
55
|
+
} catch (error) {
|
|
56
|
+
if (error && typeof error === "object" && "code" in error && error.code === "ENOENT") return undefined;
|
|
57
|
+
throw Error("MCP credential store could not be read safely");
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
write(credentials: McpCredentials): Promise<void> {
|
|
61
|
+
const serialized = JSON.stringify(validate(credentials, credentials.serverUrl, credentials.clientIdentity));
|
|
62
|
+
const target = filename(this.dir, credentials.serverUrl, credentials.clientIdentity);
|
|
63
|
+
if (Buffer.byteLength(serialized) > MAX_CREDENTIAL_BYTES) return Promise.reject(Error("MCP credential record exceeds size limit"));
|
|
64
|
+
const operation = this.writes.then(async () => {
|
|
65
|
+
await mkdir(this.dir, { recursive: true, mode: 0o700 });
|
|
66
|
+
await this.directory();
|
|
67
|
+
const temporary = join(this.dir, `.${randomUUID()}.tmp`);
|
|
68
|
+
try {
|
|
69
|
+
const file = await open(temporary, "wx", 0o600);
|
|
70
|
+
try { await file.writeFile(serialized); await file.sync(); } finally { await file.close(); }
|
|
71
|
+
await rename(temporary, target);
|
|
72
|
+
} finally { await rm(temporary, { force: true }); }
|
|
73
|
+
});
|
|
74
|
+
this.writes = operation.catch(() => {});
|
|
75
|
+
return operation;
|
|
76
|
+
}
|
|
77
|
+
async remove(serverUrl: string, clientIdentity?: string): Promise<void> {
|
|
78
|
+
await this.writes;
|
|
79
|
+
try { await this.directory(); await rm(filename(this.dir, serverUrl, clientIdentity), { force: true }); }
|
|
80
|
+
catch (error) {
|
|
81
|
+
if (error && typeof error === "object" && "code" in error && error.code === "ENOENT") return;
|
|
82
|
+
throw Error("MCP credential store could not be removed safely");
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/** R4 / P.3: one fully prepared plan shares the existing confined checkpoint CAS. */
|
|
2
|
+
import { relative, resolve } from "node:path";
|
|
3
|
+
import { parsePatch, applyPatchChunks, patchPaths, PatchError, type PatchOperation } from "../core/patch.ts";
|
|
4
|
+
import { sameVersion, changedPaths, type FileChanges } from "../core/checkpoints.ts";
|
|
5
|
+
import { unifiedFileDiff } from "../core/review.ts";
|
|
6
|
+
import { isFileDigest } from "../core/file-precondition.ts";
|
|
7
|
+
import { ToolFault, NOT_STARTED, READ_ATTEMPTED } from "../core/tool-failure.ts";
|
|
8
|
+
import { editablePath, readEditableFile, applyFileChanges } from "./file-checkpoints.ts";
|
|
9
|
+
import { putBlob } from "./blobs.ts";
|
|
10
|
+
|
|
11
|
+
export interface PreparedPatch {
|
|
12
|
+
root: string;
|
|
13
|
+
changes: FileChanges;
|
|
14
|
+
operations: readonly PatchOperation[];
|
|
15
|
+
diffs: readonly { path: string; destination?: string; kind: "add" | "update" | "move" | "delete"; diff: string }[];
|
|
16
|
+
}
|
|
17
|
+
export interface PreparePatchOptions {
|
|
18
|
+
observedFileVersion?: (absolute: string) => Promise<string | undefined>;
|
|
19
|
+
expectedSha256?: Readonly<Record<string, string>>;
|
|
20
|
+
signal?: AbortSignal;
|
|
21
|
+
}
|
|
22
|
+
const decode = (bytes: Uint8Array) => {
|
|
23
|
+
try { return new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(bytes); }
|
|
24
|
+
catch { throw new ToolFault("invalid_input", "patch requires valid UTF-8 files; use a trusted command for binary data", READ_ATTEMPTED); }
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
/** No workspace write occurs until every source, destination and hunk passes. */
|
|
28
|
+
export async function preparePatch(root: string, blobs: string, text: string, options: PreparePatchOptions = {}): Promise<PreparedPatch> {
|
|
29
|
+
let operations: PatchOperation[];
|
|
30
|
+
try { operations = parsePatch(text); }
|
|
31
|
+
catch (error) { if (error instanceof PatchError) throw new ToolFault(error.code, error.message, NOT_STARTED); throw error; }
|
|
32
|
+
const names = new Map<string, string>();
|
|
33
|
+
const targets = new Set<string>();
|
|
34
|
+
for (const path of patchPaths(operations)) {
|
|
35
|
+
const absolute = await editablePath(root, path), canonical = relative(resolve(root), absolute);
|
|
36
|
+
if (targets.has(canonical) || [...targets].some(existing => existing.startsWith(canonical + "/") || canonical.startsWith(existing + "/")))
|
|
37
|
+
throw new ToolFault("invalid_input", `patch has duplicate or overlapping targets: ${path}`, NOT_STARTED);
|
|
38
|
+
names.set(path, canonical); targets.add(canonical);
|
|
39
|
+
}
|
|
40
|
+
const canonical = (path: string) => names.get(path)!;
|
|
41
|
+
const observed = new Map<string, Awaited<ReturnType<typeof readEditableFile>>>();
|
|
42
|
+
for (const path of targets) {
|
|
43
|
+
if (options.signal?.aborted) throw new ToolFault("cancelled", "patch preparation cancelled", NOT_STARTED);
|
|
44
|
+
observed.set(path, await readEditableFile(root, path));
|
|
45
|
+
}
|
|
46
|
+
const changes: FileChanges = Object.create(null) as FileChanges;
|
|
47
|
+
const diffs: PreparedPatch["diffs"][number][] = [];
|
|
48
|
+
for (const operation of operations) {
|
|
49
|
+
const path = canonical(operation.path), source = observed.get(path)!;
|
|
50
|
+
if (operation.kind !== "add") {
|
|
51
|
+
if (source.bytes === null || source.version === null) throw new ToolFault("not_found", `patch source is missing: ${path}`, READ_ATTEMPTED);
|
|
52
|
+
const explicit = options.expectedSha256?.[operation.path] ?? options.expectedSha256?.[path];
|
|
53
|
+
const expected = explicit ?? await options.observedFileVersion?.(resolve(root, path));
|
|
54
|
+
if (!isFileDigest(expected) || expected !== source.version.sha256)
|
|
55
|
+
throw new ToolFault("version_mismatch", `patch source version was not observed or changed: ${path}; read it before editing`, READ_ATTEMPTED, { recover: { tool: "read", input: { path } } });
|
|
56
|
+
} else if (source.version !== null) {
|
|
57
|
+
throw new ToolFault("version_mismatch", `patch add requires an absent destination: ${path}; existing bytes were preserved`, READ_ATTEMPTED);
|
|
58
|
+
}
|
|
59
|
+
let destination = path, content: string | null;
|
|
60
|
+
const before = source.bytes === null ? null : decode(source.bytes);
|
|
61
|
+
if (operation.kind === "delete") content = null;
|
|
62
|
+
else if (operation.kind === "add") content = operation.content;
|
|
63
|
+
else {
|
|
64
|
+
try { content = applyPatchChunks(before!, operation.chunks); }
|
|
65
|
+
catch (error) { if (error instanceof PatchError) throw new ToolFault(error.code, `${path}: ${error.message}`, READ_ATTEMPTED); throw error; }
|
|
66
|
+
if (operation.movePath !== undefined) {
|
|
67
|
+
destination = canonical(operation.movePath);
|
|
68
|
+
if (observed.get(destination)!.version !== null) throw new ToolFault("version_mismatch", `patch move requires an absent destination: ${destination}`, READ_ATTEMPTED);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
if (source.bytes !== null) await putBlob(blobs, source.bytes);
|
|
72
|
+
const after = content === null ? null : { sha256: await putBlob(blobs, content), mode: source.version?.mode ?? (0o666 & ~process.umask()) };
|
|
73
|
+
if (destination !== path) {
|
|
74
|
+
changes[path] = { before: source.version, after: null };
|
|
75
|
+
changes[destination] = { before: null, after };
|
|
76
|
+
} else changes[path] = { before: source.version, after };
|
|
77
|
+
diffs.push({ path, ...(destination === path ? {} : { destination }), kind: destination !== path ? "move" : operation.kind,
|
|
78
|
+
diff: unifiedFileDiff(path, before, content, destination) });
|
|
79
|
+
}
|
|
80
|
+
return { root: resolve(root), operations, changes, diffs };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** The caller persists prepared/applied checkpoint phases around this operation. */
|
|
84
|
+
export async function applyPreparedPatch(root: string, blobs: string, prepared: PreparedPatch, signal?: AbortSignal): Promise<void> {
|
|
85
|
+
if (resolve(root) !== prepared.root) throw new ToolFault("invalid_input", "prepared patch names another workspace", NOT_STARTED);
|
|
86
|
+
if (signal?.aborted) throw new ToolFault("cancelled", "patch application cancelled", NOT_STARTED);
|
|
87
|
+
await applyFileChanges(root, blobs, prepared.changes, signal);
|
|
88
|
+
for (const path of changedPaths(prepared.changes)) {
|
|
89
|
+
if (!sameVersion((await readEditableFile(root, path)).version, prepared.changes[path]!.after))
|
|
90
|
+
throw new Error(`file changed concurrently after patch: ${path}; inspect the prepared checkpoint`);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/** R13 / D13: path policy sees aliases and actual resources; confinement remains separate. */
|
|
2
|
+
import { realpath } from "node:fs/promises";
|
|
3
|
+
import { dirname, basename, resolve, relative, isAbsolute } from "node:path";
|
|
4
|
+
import { permissionRequest, normalizePermissionPath, type PermissionRequest } from "../core/permissions.ts";
|
|
5
|
+
|
|
6
|
+
async function canonicalPath(path: string): Promise<string> {
|
|
7
|
+
try { return await realpath(path); }
|
|
8
|
+
catch (error) {
|
|
9
|
+
if (!error || typeof error !== "object" || !("code" in error) || !["ENOENT", "ENOTDIR"].includes(String(error.code))) throw error;
|
|
10
|
+
const parent = dirname(path);
|
|
11
|
+
if (parent === path) throw error;
|
|
12
|
+
// New files have no realpath yet; existing ancestors still reveal aliases.
|
|
13
|
+
return resolve(await canonicalPath(parent), basename(path));
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
function resource(root: string, absolute: string): string {
|
|
17
|
+
const path = relative(root, absolute);
|
|
18
|
+
return normalizePermissionPath(path === ".." || path.startsWith("../") || isAbsolute(path) ? absolute : path || ".");
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export async function permissionPaths(root: string, paths: readonly string[]): Promise<string[]> {
|
|
22
|
+
const absoluteRoot = resolve(root), canonicalRoot = await canonicalPath(absoluteRoot), resources: string[] = [];
|
|
23
|
+
for (const path of paths) {
|
|
24
|
+
const absolute = resolve(absoluteRoot, path);
|
|
25
|
+
resources.push(resource(absoluteRoot, absolute), resource(canonicalRoot, await canonicalPath(absolute)));
|
|
26
|
+
}
|
|
27
|
+
return [...new Set(resources)];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Models never supply the root or canonical target; the current runtime does. */
|
|
31
|
+
export async function canonicalPermissionRequest(root: string, name: string, input: unknown): Promise<PermissionRequest> {
|
|
32
|
+
const request = permissionRequest(name, input);
|
|
33
|
+
return request.paths ? { ...request, paths: await permissionPaths(root, request.paths) } : request;
|
|
34
|
+
}
|
package/src/store/resources.ts
CHANGED
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
* Discover and read UI resources: settings, theme, keybindings, skills,
|
|
3
3
|
* prompt templates.
|
|
4
4
|
*
|
|
5
|
-
* I/O lives here; validation is core.
|
|
6
|
-
*
|
|
5
|
+
* I/O lives here; validation is core. Reads diagnose corrupt files and fall
|
|
6
|
+
* back so startup survives a missing comma. Writes preserve them and report failure.
|
|
7
7
|
*
|
|
8
8
|
* Project-level files follow the same trust rule as `.xeno/settings.json`.
|
|
9
9
|
* Theme and keymap are UI state and are not logged.
|
|
@@ -12,7 +12,9 @@
|
|
|
12
12
|
import { readdir, readFile } from "node:fs/promises";
|
|
13
13
|
import { watch } from "node:fs";
|
|
14
14
|
import { basename, dirname, join } from "node:path";
|
|
15
|
-
import { configDir, readJsonFile
|
|
15
|
+
import { configDir, readJsonFile } from "./config.ts";
|
|
16
|
+
import { editUserSettings } from "./user-settings.ts";
|
|
17
|
+
import { mergePreferenceJson } from "../core/model-preferences.ts";
|
|
16
18
|
import { discoverResources, themeResourcePath, type ResourceIndex } from "./resource-index.ts";
|
|
17
19
|
import { parseSkill, type Skill } from "../core/skills.ts";
|
|
18
20
|
import { parsePromptTemplate, type PromptTemplate } from "../core/prompts.ts";
|
|
@@ -29,7 +31,6 @@ import {
|
|
|
29
31
|
import {
|
|
30
32
|
defaultSettings,
|
|
31
33
|
mergeSettings,
|
|
32
|
-
settingsToWrite,
|
|
33
34
|
type Settings,
|
|
34
35
|
} from "../core/settings.ts";
|
|
35
36
|
|
|
@@ -90,28 +91,18 @@ export function selectedThemePath(index: ResourceIndex, name: string, noThemes =
|
|
|
90
91
|
}
|
|
91
92
|
|
|
92
93
|
export async function writeUserSettings(overrides: Partial<Settings>): Promise<void> {
|
|
93
|
-
const
|
|
94
|
-
|
|
95
|
-
error === null && typeof value === "object" && value !== null && !Array.isArray(value)
|
|
96
|
-
? (value as { readonly [k: string]: unknown })
|
|
97
|
-
: {};
|
|
98
|
-
const next = { ...prev, ...settingsToWrite(overrides),
|
|
99
|
-
...(overrides.terminal ? { terminal: { ...(typeof prev["terminal"] === "object" ? prev["terminal"] : {}), ...overrides.terminal } } : {}),
|
|
100
|
-
...(overrides.modelThinkingLevels ? { modelThinkingLevels: { ...(typeof prev["modelThinkingLevels"] === "object" ? prev["modelThinkingLevels"] : {}), ...overrides.modelThinkingLevels } } : {}),
|
|
101
|
-
...Object.fromEntries((["images", "markdown"] as const).filter(key => overrides[key]).map(key => [key, { ...(typeof prev[key] === "object" ? prev[key] : {}), ...overrides[key] }])),
|
|
102
|
-
};
|
|
103
|
-
await writeJsonFile(settingsPath(), next, false);
|
|
94
|
+
const patch = structuredClone(overrides);
|
|
95
|
+
await editUserSettings(previous => mergePreferenceJson(previous, patch));
|
|
104
96
|
}
|
|
105
97
|
|
|
106
98
|
/** Remove only the user's override; project defaults and unrelated keys retain their ownership. */
|
|
107
99
|
export async function removeModelThinkingOverride(identity: string): Promise<void> {
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
await writeJsonFile(settingsPath(), { ...value, modelThinkingLevels: next }, false);
|
|
100
|
+
await editUserSettings(previous => {
|
|
101
|
+
const levels = previous["modelThinkingLevels"];
|
|
102
|
+
if (!levels || typeof levels !== "object" || Array.isArray(levels)) return null;
|
|
103
|
+
const next = Object.fromEntries(Object.entries(levels).filter(([key]) => key !== identity));
|
|
104
|
+
return { ...previous, modelThinkingLevels: next };
|
|
105
|
+
});
|
|
115
106
|
}
|
|
116
107
|
|
|
117
108
|
export async function resolveTheme(spec: string, diagnostics: string[]): Promise<Theme> {
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/** Read-only Git review with fixed argv; project diff drivers cannot execute code. */
|
|
2
|
+
import { lstat } from "node:fs/promises";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import type { FileChanges } from "../core/checkpoints.ts";
|
|
5
|
+
import { changedPaths } from "../core/checkpoints.ts";
|
|
6
|
+
import { ToolFault, READ_ATTEMPTED } from "../core/tool-failure.ts";
|
|
7
|
+
import { unifiedFileDiff, type ReviewScope, type ReviewSnapshot } from "../core/review.ts";
|
|
8
|
+
import { gitBytes, MAX_UNTRACKED_BYTES } from "./workspace-observation.ts";
|
|
9
|
+
import { readEditableFile } from "./file-checkpoints.ts";
|
|
10
|
+
import { getBlobBytes, blobAddress } from "./blobs.ts";
|
|
11
|
+
|
|
12
|
+
// Assumed bounded review memory. Exceeding it is unavailable, never silently partial.
|
|
13
|
+
export const MAX_REVIEW_BYTES = 16 * 1024 * 1024;
|
|
14
|
+
const decode = (bytes: Uint8Array) => new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(bytes);
|
|
15
|
+
const flags = ["--no-pager", "--literal-pathspecs", "-c", "core.hooksPath=/dev/null"];
|
|
16
|
+
const diffFlags = ["--no-ext-diff", "--no-textconv", "--no-renames", "--relative"];
|
|
17
|
+
const checkedText = (bytes: Uint8Array) => {
|
|
18
|
+
if (bytes.byteLength > MAX_REVIEW_BYTES) throw new Error("review exceeds the byte budget; choose a narrower scope");
|
|
19
|
+
return decode(bytes);
|
|
20
|
+
};
|
|
21
|
+
const ordinaryText = (bytes: Uint8Array): string | null => {
|
|
22
|
+
if (bytes.includes(0)) return null;
|
|
23
|
+
try { return decode(bytes); } catch { return null; }
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export async function readReview(root: string, scope: Exclude<ReviewScope, { kind: "last-turn" }>, signal?: AbortSignal, canRead?: (path: string) => Promise<boolean>): Promise<ReviewSnapshot> {
|
|
27
|
+
const git = (args: readonly string[], input?: Uint8Array) => gitBytes(root, [...flags, ...args], input, signal);
|
|
28
|
+
const revision = async (value: string) => {
|
|
29
|
+
if (!value || value.includes("\0") || value.startsWith("-")) throw new Error("review revision must name a commit or branch");
|
|
30
|
+
const sha = decode(await git(["rev-parse", "--verify", "--end-of-options", value + "^{commit}"])).trim();
|
|
31
|
+
if (!/^[a-f0-9]{40,64}$/.test(sha)) throw new Error("review revision did not resolve to a commit");
|
|
32
|
+
return sha;
|
|
33
|
+
};
|
|
34
|
+
const emptyTree = async () => decode(await git(["hash-object", "-t", "tree", "--stdin"], new Uint8Array())).trim();
|
|
35
|
+
let base: string, end: string | undefined, label: string;
|
|
36
|
+
if (scope.kind === "commit") {
|
|
37
|
+
end = await revision(scope.commit);
|
|
38
|
+
const parents = decode(await git(["rev-list", "--parents", "-n", "1", end])).trim().split(" ");
|
|
39
|
+
base = parents[1] ?? await emptyTree();
|
|
40
|
+
label = `commit ${end.slice(0, 12)}${parents.length > 2 ? " against its first parent" : ""}`;
|
|
41
|
+
} else if (scope.kind === "branch") {
|
|
42
|
+
const branch = await revision(scope.base), head = await revision("HEAD");
|
|
43
|
+
base = decode(await git(["merge-base", head, branch])).trim();
|
|
44
|
+
label = `changes against ${scope.base} (merge base ${base.slice(0, 12)})`;
|
|
45
|
+
} else {
|
|
46
|
+
try { base = await revision("HEAD"); }
|
|
47
|
+
catch {
|
|
48
|
+
// An unborn branch has no HEAD; a corrupt/missing existing HEAD stays unavailable.
|
|
49
|
+
const heads = decode(await git(["rev-list", "--all", "--count"])).trim();
|
|
50
|
+
if (heads !== "0") throw new Error("working review cannot resolve HEAD");
|
|
51
|
+
base = await emptyTree();
|
|
52
|
+
}
|
|
53
|
+
label = "current staged, unstaged and untracked changes";
|
|
54
|
+
}
|
|
55
|
+
const range = [base, ...(end === undefined ? [] : [end])];
|
|
56
|
+
const paths = decode(await git(["diff", ...diffFlags, "--name-only", "-z", ...range, "--", "."])).split("\0").filter(Boolean);
|
|
57
|
+
const untracked = scope.kind === "commit" ? [] : decode(await git(["ls-files", "--others", "--exclude-standard", "-z", "--", "."])).split("\0").filter(Boolean);
|
|
58
|
+
for (const path of [...new Set([...paths, ...untracked])]) if (canRead && !await canRead(path))
|
|
59
|
+
throw new ToolFault("permission_denied", `diff resource access denied: ${path}`, READ_ATTEMPTED);
|
|
60
|
+
// Freeze the authorized path set in the actual collection argv. A concurrent
|
|
61
|
+
// change to a different path must not widen this observation after admission.
|
|
62
|
+
const diff = paths.length ? checkedText(await git(["diff", ...diffFlags, ...range, "--", ...paths])) : "";
|
|
63
|
+
const parts = [diff], limitations: string[] = [];
|
|
64
|
+
let bytes = Buffer.byteLength(diff);
|
|
65
|
+
if (scope.kind !== "commit") {
|
|
66
|
+
for (const path of untracked.sort()) {
|
|
67
|
+
if (signal?.aborted) throw new Error("review cancelled");
|
|
68
|
+
const info = await lstat(join(root, path));
|
|
69
|
+
if (info.size > MAX_UNTRACKED_BYTES) { limitations.push(`Untracked file ${JSON.stringify(path)} exceeds ${MAX_UNTRACKED_BYTES} bytes and is unavailable.`); continue; }
|
|
70
|
+
let source: Awaited<ReturnType<typeof readEditableFile>>;
|
|
71
|
+
try { source = await readEditableFile(root, path); }
|
|
72
|
+
catch { limitations.push(`Untracked file ${JSON.stringify(path)} is not an ordinary confined file.`); continue; }
|
|
73
|
+
if (source.bytes === null) throw new Error(`untracked file disappeared during review: ${path}`);
|
|
74
|
+
const text = ordinaryText(source.bytes);
|
|
75
|
+
const addition = text === null ? `Binary untracked file ${JSON.stringify(path)}: ${source.bytes.byteLength} bytes, sha256 ${source.version!.sha256}\n`
|
|
76
|
+
: unifiedFileDiff(path, null, text);
|
|
77
|
+
bytes += Buffer.byteLength(addition);
|
|
78
|
+
if (bytes > MAX_REVIEW_BYTES) throw new Error("review exceeds the byte budget; choose a narrower scope");
|
|
79
|
+
parts.push(addition); paths.push(path);
|
|
80
|
+
if (text === null) limitations.push(`Binary untracked content is identified but cannot be reviewed as text: ${JSON.stringify(path)}.`);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return { scope, label, diff: parts.join("\n"), paths: [...new Set(paths)].sort(), limitations, base };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Build last-turn review from verified checkpoint blobs, independent of present disk bytes. */
|
|
87
|
+
export async function readCheckpointReview(blobs: string, changes: FileChanges, limitations: readonly string[] = [], canRead?: (path: string) => Promise<boolean>): Promise<ReviewSnapshot> {
|
|
88
|
+
const parts: string[] = [], notices = [...limitations], paths = changedPaths(changes);
|
|
89
|
+
for (const path of paths) if (canRead && !await canRead(path))
|
|
90
|
+
throw new ToolFault("permission_denied", `diff resource access denied: ${path}`, READ_ATTEMPTED);
|
|
91
|
+
let bytes = 0;
|
|
92
|
+
for (const path of paths) {
|
|
93
|
+
const firstPart = parts.length;
|
|
94
|
+
const { before, after } = changes[path]!;
|
|
95
|
+
const source = before === null ? null : await getBlobBytes(blobs, before.sha256);
|
|
96
|
+
const target = after === null ? null : await getBlobBytes(blobs, after.sha256);
|
|
97
|
+
if (before && (source === null || blobAddress(source) !== before.sha256) || after && (target === null || blobAddress(target) !== after.sha256))
|
|
98
|
+
throw new Error(`missing or corrupt last-turn checkpoint object: ${path}`);
|
|
99
|
+
const oldText = source === null ? null : ordinaryText(source), newText = target === null ? null : ordinaryText(target);
|
|
100
|
+
if (source !== null && oldText === null || target !== null && newText === null) {
|
|
101
|
+
parts.push(`Binary checkpoint change ${JSON.stringify(path)}: ${before?.sha256 ?? "absent"} -> ${after?.sha256 ?? "absent"}\n`);
|
|
102
|
+
notices.push(`Binary checkpoint content cannot be reviewed as text: ${JSON.stringify(path)}.`);
|
|
103
|
+
} else parts.push(unifiedFileDiff(path, oldText, newText));
|
|
104
|
+
if (before && after && before.mode !== after.mode) parts.push(`mode ${JSON.stringify(path)}: ${before.mode.toString(8)} -> ${after.mode.toString(8)}\n`);
|
|
105
|
+
bytes += parts.slice(firstPart).reduce((total, part) => total + Buffer.byteLength(part), 0);
|
|
106
|
+
if (bytes > MAX_REVIEW_BYTES) throw new Error("last-turn review exceeds the byte budget");
|
|
107
|
+
}
|
|
108
|
+
return { scope: { kind: "last-turn" }, label: "captured file changes in the last user turn", diff: parts.join("\n"), paths, limitations: notices };
|
|
109
|
+
}
|
package/src/store/search.ts
CHANGED
|
@@ -94,6 +94,8 @@ export interface GrepOpts {
|
|
|
94
94
|
/** Trusted harness budgets, not model arguments. */
|
|
95
95
|
readonly maxCaptureBytes?: number;
|
|
96
96
|
readonly maxIndexEntries?: number;
|
|
97
|
+
/** Trusted admission for each POSIX workspace-relative resource, before content is read. */
|
|
98
|
+
readonly canRead?: (path: string) => Promise<boolean>;
|
|
97
99
|
}
|
|
98
100
|
|
|
99
101
|
export interface FindOpts {
|
|
@@ -106,6 +108,7 @@ export interface FindOpts {
|
|
|
106
108
|
readonly signal?: AbortSignal;
|
|
107
109
|
readonly maxCaptureBytes?: number;
|
|
108
110
|
readonly maxIndexEntries?: number;
|
|
111
|
+
readonly canRead?: (path: string) => Promise<boolean>;
|
|
109
112
|
}
|
|
110
113
|
|
|
111
114
|
export interface LsOpts {
|
|
@@ -113,6 +116,7 @@ export interface LsOpts {
|
|
|
113
116
|
readonly path: string;
|
|
114
117
|
readonly limit?: number;
|
|
115
118
|
readonly signal?: AbortSignal;
|
|
119
|
+
readonly canRead?: (path: string) => Promise<boolean>;
|
|
116
120
|
}
|
|
117
121
|
|
|
118
122
|
function observed(observation: SearchObservation, diagnostic?: ToolDiagnostic): SearchResult {
|
|
@@ -256,6 +260,29 @@ async function listedFiles(abs: string, rel: string, kind: "file" | "dir", optio
|
|
|
256
260
|
return { paths: index.paths.map((p) => (rel.length === 0 ? p : `${rel}/${p}`)), issues: [...(index.issues ?? [])] };
|
|
257
261
|
}
|
|
258
262
|
|
|
263
|
+
/** Denied filenames cannot reappear in scan diagnostics or archived observations. */
|
|
264
|
+
function policyIssues(issues: SearchObservation["scan"]["reasons"], canRead: GrepOpts["canRead"]): SearchObservation["scan"]["reasons"] {
|
|
265
|
+
return canRead ? issues.map(issue => ({ code: issue.code })) : [...issues];
|
|
266
|
+
}
|
|
267
|
+
async function resourceAdmitted(path: string, canRead: GrepOpts["canRead"], reasons: SearchObservation["scan"]["reasons"],
|
|
268
|
+
signal?: AbortSignal, deadline?: number): Promise<boolean> {
|
|
269
|
+
if (!canRead) return true;
|
|
270
|
+
let allowed: boolean | undefined = false, abort = () => {};
|
|
271
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
272
|
+
const stopped = new Promise<undefined>(resolve => {
|
|
273
|
+
abort = () => resolve(undefined); signal?.addEventListener("abort", abort, { once: true });
|
|
274
|
+
if (deadline !== undefined) timer = setTimeout(abort, Math.max(0, deadline - performance.now()));
|
|
275
|
+
if (signal?.aborted) abort();
|
|
276
|
+
});
|
|
277
|
+
try { allowed = await Promise.race([Promise.resolve().then(() => canRead(path)).catch(() => false), stopped]); }
|
|
278
|
+
finally { clearTimeout(timer); signal?.removeEventListener("abort", abort); }
|
|
279
|
+
if (allowed === undefined || signal?.aborted || deadline !== undefined && performance.now() >= deadline) {
|
|
280
|
+
reasons.push({ code: signal?.aborted ? "cancelled" : "timeout" }); return false;
|
|
281
|
+
}
|
|
282
|
+
if (!allowed && !reasons.some(reason => reason.code === "permission_omitted")) reasons.push({ code: "permission_omitted" });
|
|
283
|
+
return allowed;
|
|
284
|
+
}
|
|
285
|
+
|
|
259
286
|
interface WorkerMatch {
|
|
260
287
|
readonly ok: boolean;
|
|
261
288
|
readonly hits?: ReadonlyArray<{ readonly line: number; readonly text: string }>;
|
|
@@ -475,7 +502,7 @@ async function grepWalk(opts: GrepOpts, abs: string, kind: "file" | "dir", rel:
|
|
|
475
502
|
const filtered = glob === undefined ? files.paths : files.paths.filter((p) => globMatches(glob, p));
|
|
476
503
|
const matcher = new LineMatcher();
|
|
477
504
|
const hits: MatchLine[] = [];
|
|
478
|
-
const reasons: SearchObservation["scan"]["reasons"] =
|
|
505
|
+
const reasons: SearchObservation["scan"]["reasons"] = policyIssues(files.issues, opts.canRead);
|
|
479
506
|
const sources: SearchObservation["sources"] = [];
|
|
480
507
|
let examined = 0, capturedBytes = 0;
|
|
481
508
|
const budget = Math.min(opts.maxCaptureBytes ?? MAX_SEARCH_CAPTURE_BYTES, MAX_SEARCH_CAPTURE_BYTES);
|
|
@@ -483,6 +510,7 @@ async function grepWalk(opts: GrepOpts, abs: string, kind: "file" | "dir", rel:
|
|
|
483
510
|
for (const fileRel of filtered) {
|
|
484
511
|
if (opts.signal?.aborted || performance.now() >= deadline) { reasons.push({ code: opts.signal?.aborted ? "cancelled" : "timeout" }); break; }
|
|
485
512
|
if (ignoredRel(fileRel)) continue;
|
|
513
|
+
if (!await resourceAdmitted(fileRel, opts.canRead, reasons, opts.signal, deadline)) continue;
|
|
486
514
|
const fileAbs = join(opts.root, fileRel);
|
|
487
515
|
let st;
|
|
488
516
|
try {
|
|
@@ -541,7 +569,9 @@ export async function grep(opts: GrepOpts): Promise<SearchResult> {
|
|
|
541
569
|
if ("error" in target) return { content: target.error, ok: false, diagnostic: target.diagnostic };
|
|
542
570
|
|
|
543
571
|
const limit = opts.limit !== undefined && opts.limit > 0 ? Math.floor(opts.limit) : DEFAULT_MATCH_LIMIT;
|
|
544
|
-
|
|
572
|
+
// External search captures bytes before the runtime can inspect each
|
|
573
|
+
// resource. A policy callback therefore always uses the admitted walk.
|
|
574
|
+
const engine = opts.canRead ? "walk" : opts.engine ?? "auto";
|
|
545
575
|
|
|
546
576
|
switch (engine) {
|
|
547
577
|
case "walk":
|
|
@@ -607,13 +637,18 @@ async function findFd(opts: FindOpts, abs: string, limit: number): Promise<Searc
|
|
|
607
637
|
}
|
|
608
638
|
|
|
609
639
|
async function findWalk(opts: FindOpts, abs: string, kind: "file" | "dir", rel: string, limit: number): Promise<SearchResult> {
|
|
610
|
-
const
|
|
640
|
+
const deadline = performance.now() + (opts.timeoutMs ?? PROCESS_TIMEOUT_MS);
|
|
641
|
+
const files = await listedFiles(abs, rel, kind, { deadline,
|
|
611
642
|
...(opts.signal ? { signal: opts.signal } : {}), ...(opts.maxIndexEntries === undefined ? {} : { maxEntries: opts.maxIndexEntries }) });
|
|
612
|
-
const
|
|
643
|
+
const reasons = policyIssues(files.issues, opts.canRead), paths: string[] = [];
|
|
644
|
+
for (const path of files.paths) {
|
|
645
|
+
if (opts.signal?.aborted || performance.now() >= deadline) { reasons.push({ code: opts.signal?.aborted ? "cancelled" : "timeout" }); break; }
|
|
646
|
+
if (!ignoredRel(path) && globMatches(opts.pattern, path) && await resourceAdmitted(path, opts.canRead, reasons, opts.signal, deadline)) paths.push(path);
|
|
647
|
+
}
|
|
613
648
|
paths.sort((a, b) => (a < b ? -1 : 1));
|
|
614
|
-
const stopped =
|
|
649
|
+
const stopped = reasons.find(reason => ["timeout", "cancelled"].includes(reason.code));
|
|
615
650
|
return observed({ engine: "walk", rows: paths.map(text => ({ prefix: "", text })), sources: [], limit, empty: "No files found matching pattern",
|
|
616
|
-
scan: { status:
|
|
651
|
+
scan: { status: reasons.length ? "partial" : "complete", reasons, examined_files: files.paths.length, source_versions: "unknown" } },
|
|
617
652
|
stopped ? failureDiagnostic(stopped.code === "cancelled" ? "cancelled" : "timeout", `search ${stopped.code}`, READ_ATTEMPTED) : undefined);
|
|
618
653
|
}
|
|
619
654
|
|
|
@@ -622,7 +657,7 @@ export async function find(opts: FindOpts): Promise<SearchResult> {
|
|
|
622
657
|
const target = await inspectTarget(opts.root, opts.path);
|
|
623
658
|
if ("error" in target) return { content: target.error, ok: false, diagnostic: target.diagnostic };
|
|
624
659
|
const limit = opts.limit !== undefined && opts.limit > 0 ? Math.floor(opts.limit) : DEFAULT_FIND_LIMIT;
|
|
625
|
-
const engine = opts.engine ?? "auto";
|
|
660
|
+
const engine = opts.canRead ? "walk" : opts.engine ?? "auto";
|
|
626
661
|
switch (engine) {
|
|
627
662
|
case "walk":
|
|
628
663
|
return findWalk(opts, opts.path, target.kind, target.rel, limit);
|
|
@@ -656,11 +691,14 @@ export async function ls(opts: LsOpts): Promise<SearchResult> {
|
|
|
656
691
|
return { content: `cannot list ${target.rel || "."}: ${(err as Error).message}`, ok: false, diagnostic: failureFromError(err, READ_ATTEMPTED) };
|
|
657
692
|
}
|
|
658
693
|
|
|
659
|
-
const names: string[] = [];
|
|
694
|
+
const names: string[] = [], reasons: SearchObservation["scan"]["reasons"] = [];
|
|
695
|
+
const deadline = performance.now() + PROCESS_TIMEOUT_MS;
|
|
660
696
|
for (const entry of entries) {
|
|
661
697
|
if (DEFAULT_IGNORE.includes(entry.name)) continue;
|
|
662
698
|
const childRel = target.rel.length === 0 ? entry.name : `${target.rel}/${entry.name}`;
|
|
663
699
|
if (ignoredRel(childRel)) continue;
|
|
700
|
+
if (opts.signal?.aborted || performance.now() >= deadline) { reasons.push({ code: opts.signal?.aborted ? "cancelled" : "timeout" }); break; }
|
|
701
|
+
if (!await resourceAdmitted(childRel, opts.canRead, reasons, opts.signal, deadline)) continue;
|
|
664
702
|
// Do not follow: a symlink is listed as a name, never as the directory
|
|
665
703
|
// it points at, so a link to / cannot walk the disk.
|
|
666
704
|
if (entry.isSymbolicLink()) {
|
|
@@ -679,5 +717,6 @@ export async function ls(opts: LsOpts): Promise<SearchResult> {
|
|
|
679
717
|
|
|
680
718
|
const limit = opts.limit !== undefined && opts.limit > 0 ? Math.floor(opts.limit) : DEFAULT_LS_LIMIT;
|
|
681
719
|
return observed({ engine: "directory", rows: names.map(text => ({ prefix: "", text })), sources: [], limit, empty: "(empty directory)",
|
|
682
|
-
scan: { status: "complete", reasons
|
|
720
|
+
scan: { status: reasons.length ? "partial" : "complete", reasons, examined_files: names.length, source_versions: "unknown" } },
|
|
721
|
+
reasons.some(reason => reason.code === "cancelled" || reason.code === "timeout") ? failureDiagnostic(opts.signal?.aborted ? "cancelled" : "timeout", "directory listing stopped", READ_ATTEMPTED) : undefined);
|
|
683
722
|
}
|