@visual-z/xeno 0.5.0 → 0.7.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 +19 -0
- package/README.md +19 -4
- package/docs/app-server.md +32 -3
- package/docs/foundations.md +88 -0
- package/docs/install.md +23 -2
- 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/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 +95 -0
- package/src/apps/shell.ts +55 -1
- package/src/apps/startup.ts +157 -22
- package/src/apps/ui/app.ts +89 -38
- package/src/apps/ui/image-cache.ts +71 -28
- 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/activity-status.ts +28 -0
- package/src/core/agent-profile.ts +130 -0
- package/src/core/control-protocol.ts +8 -1
- package/src/core/file-precondition.ts +6 -0
- package/src/core/git-status.ts +29 -0
- package/src/core/image-limits.ts +72 -0
- package/src/core/lsp-framing.ts +34 -0
- package/src/core/mcp-config.ts +68 -0
- package/src/core/patch.ts +130 -0
- package/src/core/permissions.ts +143 -4
- package/src/core/progress.ts +9 -0
- package/src/core/question.ts +94 -0
- package/src/core/request-budget.ts +13 -0
- package/src/core/review.ts +68 -0
- package/src/core/session-exit.ts +26 -0
- package/src/core/settings.ts +1 -1
- package/src/core/stall.ts +18 -6
- package/src/core/stop-report.ts +25 -0
- 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 +2 -2
- package/src/runtime/control-client.ts +149 -0
- package/src/runtime/control-connection.ts +39 -4
- package/src/runtime/exec-sessions.ts +40 -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/images.ts +6 -2
- package/src/runtime/integrations.ts +8 -2
- package/src/runtime/loop.ts +124 -21
- 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/openai-responses.ts +6 -4
- package/src/runtime/openai.ts +6 -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/pi-provider.ts +5 -4
- package/src/runtime/provider-tool-policy.ts +33 -0
- package/src/runtime/provider.ts +8 -1
- package/src/runtime/question-tool.ts +62 -0
- package/src/runtime/review-tool.ts +48 -0
- package/src/runtime/session-control.ts +61 -2
- package/src/runtime/session-hub.ts +85 -0
- package/src/runtime/tool-discovery.ts +4 -3
- package/src/runtime/tools.ts +44 -10
- 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/blobs.ts +2 -1
- 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/git-status.ts +15 -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/review.ts +109 -0
- package/src/store/search.ts +48 -9
- package/src/tui/ui/layout.ts +7 -7
- package/src/tui/ui/messages.ts +1 -1
- package/src/tui/ui/pictures.ts +26 -9
- package/src/tui/ui/screen.ts +12 -8
- package/src/tui/ui/status.ts +32 -6
package/src/runtime/tools.ts
CHANGED
|
@@ -54,7 +54,6 @@ import { parseQueryInput, MAX_SQL_CHARS, SQL_QUERY_HELP } from "../core/sql-quer
|
|
|
54
54
|
import { readQuerySources } from "../store/query-source.ts";
|
|
55
55
|
import {
|
|
56
56
|
askWithoutTtyMessage,
|
|
57
|
-
decide,
|
|
58
57
|
deniedMessage,
|
|
59
58
|
permissionRequest,
|
|
60
59
|
type PermissionRequest,
|
|
@@ -76,6 +75,10 @@ import { observeEnvironment } from "./environment-tools.ts";
|
|
|
76
75
|
import { beginCommandArtifacts, prepareArtifactArguments, commandArtifactSummary } from "./command-artifacts.ts";
|
|
77
76
|
import { MAX_COMMAND_ARTIFACTS } from "../core/command-artifacts.ts";
|
|
78
77
|
import { checkSourceCancellation } from "../store/source-cancellation.ts";
|
|
78
|
+
import { canonicalPermissionRequest } from "../store/permission-paths.ts";
|
|
79
|
+
import { permissionResourceAction } from "./permission-resources.ts";
|
|
80
|
+
import { parsePatch, patchPaths } from "../core/patch.ts";
|
|
81
|
+
import { decideWithSessionRules } from "../core/permissions.ts";
|
|
79
82
|
|
|
80
83
|
export interface ToolResult {
|
|
81
84
|
/** Broker-owned identity, returned to a composer only after the original receipt commits. */
|
|
@@ -98,6 +101,15 @@ export interface ToolResult {
|
|
|
98
101
|
}
|
|
99
102
|
|
|
100
103
|
export interface ToolContext {
|
|
104
|
+
historyEvents?: () => Promise<import("../core/events.ts").LogEvent[]>;
|
|
105
|
+
checkpointBlobs?: string;
|
|
106
|
+
frozenSystem?: () => string;
|
|
107
|
+
instructionCheck?: (ctx: ToolContext, name: string, input: unknown) => Promise<ToolResult | null>;
|
|
108
|
+
workflowMode?: "build" | "plan";
|
|
109
|
+
/** CLI owns its approval defaults; SDK callers retain their supplied host policy across reload. */
|
|
110
|
+
appOwnsPermissions?: true;
|
|
111
|
+
hostPermissionRules?: PermissionRule[];
|
|
112
|
+
questionSlot?: import("./question-tool.ts").QuestionSlot;
|
|
101
113
|
/** R10: model-visible names constrain new advice without granting tool admission. */
|
|
102
114
|
advice?: readonly string[];
|
|
103
115
|
/** Runtime adapter identity, never supplied by a model tool argument. */
|
|
@@ -115,6 +127,9 @@ export interface ToolContext {
|
|
|
115
127
|
historyCache?: HistoryCache;
|
|
116
128
|
/** O.14: opaque process ownership is supplied by Session, never by model input. */
|
|
117
129
|
execOwner?: string;
|
|
130
|
+
/** Session-owned empty stdin waits; low-level/native callers retain bounded polling. */
|
|
131
|
+
processWait?: { wake(): AbortSignal };
|
|
132
|
+
onProcessWait?: (session: number, active: boolean) => void;
|
|
118
133
|
/** Session-owned registrations never mutate the built-in table (D13). */
|
|
119
134
|
extensions?: ReadonlyMap<string, ToolDefinition>;
|
|
120
135
|
/** R11: current locally selected optional schemas, never restored as authority from historical text. */
|
|
@@ -285,6 +300,8 @@ export interface ToolDefinition extends ToolSchema {
|
|
|
285
300
|
/** Accepted by admission but not advertised to the model: harness and test switches. */
|
|
286
301
|
hiddenInputs?: readonly string[];
|
|
287
302
|
prepareArguments?: (input: unknown) => unknown;
|
|
303
|
+
/** Protocol adapters with complete schemas own validation, rather than the builtin vocabulary. */
|
|
304
|
+
validateInput?: (input: unknown) => void;
|
|
288
305
|
run?: (ctx: ToolContext, input: Record<string, unknown>) => Promise<ToolResult>;
|
|
289
306
|
}
|
|
290
307
|
|
|
@@ -615,7 +632,7 @@ export const TOOL_DEFINITIONS: ToolDefinition[] = [
|
|
|
615
632
|
access: "shell", execution: "exclusive", branch: "write", run: toolWriteStdin,
|
|
616
633
|
description:
|
|
617
634
|
"Send characters to an owned running process handle and return the output since the last call. " +
|
|
618
|
-
"
|
|
635
|
+
"In an agent session, empty chars waits for new output, exit, hard timeout, cancellation or user steering without periodic model polling. Include \\n to submit a line; \\u0003 is Ctrl-C, \\u0004 is Ctrl-D (end of input). " +
|
|
619
636
|
"After exit, empty chars retrieves the last receipt without rerunning or producing fresh verification. Source and byte cursor are in data; full_output recovers all captured output. " +
|
|
620
637
|
"Lost handles cannot accept input; a last running observation is not an exit. Ctrl-C may interrupt a PTY foreground job while its REPL stays alive.",
|
|
621
638
|
input_schema: {
|
|
@@ -623,7 +640,7 @@ export const TOOL_DEFINITIONS: ToolDefinition[] = [
|
|
|
623
640
|
properties: {
|
|
624
641
|
session_id: { type: "integer", minimum: 1 },
|
|
625
642
|
chars: { type: "string", description: "Characters to write; empty to poll" },
|
|
626
|
-
yield_time_ms: { type: "integer", minimum: 100, maximum: 30000, description: "
|
|
643
|
+
yield_time_ms: { type: "integer", minimum: 100, maximum: 30000, description: "Bounded wait when sending input or using low-level polling; agent empty waits are owned until an observation changes" },
|
|
627
644
|
},
|
|
628
645
|
required: ["session_id"],
|
|
629
646
|
},
|
|
@@ -702,7 +719,7 @@ export function adviceTools(ctx: ToolContext): string[] {
|
|
|
702
719
|
return TOOL_DEFINITIONS.map(tool => tool.name).filter(name => !ctx.extensions?.has(name) &&
|
|
703
720
|
(ctx.advice === undefined || ctx.advice.includes(name)) &&
|
|
704
721
|
(ctx.allow === undefined || ctx.allow.includes(name)) &&
|
|
705
|
-
|
|
722
|
+
decideWithSessionRules(ctx.rules ?? [], ctx.sessionRules ?? [], { tool: name }) !== "deny");
|
|
706
723
|
}
|
|
707
724
|
|
|
708
725
|
/** A proposed recovery is data, not execution. Validate it against today's exact admission inputs. */
|
|
@@ -716,7 +733,7 @@ function scopeToolAdvice(ctx: ToolContext, result: ToolResult): ToolResult {
|
|
|
716
733
|
const input = normalizeToolInput(ctx, recover.tool, recover.input);
|
|
717
734
|
const definition = toolDefinition(recover.tool, ctx)!;
|
|
718
735
|
allowed = inputError(definition.input_schema, input, "input", definition.hiddenInputs) === null &&
|
|
719
|
-
|
|
736
|
+
decideWithSessionRules(ctx.rules ?? [], ctx.sessionRules ?? [], permissionRequest(recover.tool, input)) !== "deny";
|
|
720
737
|
} catch { /* Unavailable or invalid advice is omitted; the original failure remains. */ }
|
|
721
738
|
}
|
|
722
739
|
return scopeFailureGuidance(diagnostic, names, allowed);
|
|
@@ -1094,7 +1111,8 @@ async function toolWriteStdin(ctx: ToolContext, input: Record<string, unknown>):
|
|
|
1094
1111
|
data: { kind: "process", version: 1, session_id: id, state: "unknown", capability: "unknown", source: null, running: null, exit: null, replayed: true } };
|
|
1095
1112
|
}
|
|
1096
1113
|
const outputStore = ctx.outputStore ?? join(ctx.root, ".xeno", "observations");
|
|
1097
|
-
return sessionResult(ctx, session.command, session.timeoutMs, await pollSession(session, outputStore, ctx.maxOutputBytes, clampYield(input["yield_time_ms"]), chars, ctx.signal
|
|
1114
|
+
return sessionResult(ctx, session.command, session.timeoutMs, await pollSession(session, outputStore, ctx.maxOutputBytes, clampYield(input["yield_time_ms"]), chars, ctx.signal,
|
|
1115
|
+
ctx.processWait && chars.length === 0 ? { wake: ctx.processWait.wake(), onWaiting: active => ctx.onProcessWait?.(id, active) } : undefined));
|
|
1098
1116
|
}
|
|
1099
1117
|
|
|
1100
1118
|
/** A successful historical query proves only that the receipt was found, never a new command exit. */
|
|
@@ -1190,6 +1208,7 @@ async function toolGrep(ctx: ToolContext, input: Record<string, unknown>): Promi
|
|
|
1190
1208
|
const limit = optionalLimit(input);
|
|
1191
1209
|
const result = await grep({
|
|
1192
1210
|
root: searchRoot(ctx, abs),
|
|
1211
|
+
...(ctx.rules ? { canRead: async (path: string) => await permissionResourceAction(searchRoot(ctx, abs), path, ctx.rules ?? [], ctx.sessionRules ?? [], "grep") === "allow" } : {}),
|
|
1193
1212
|
path: abs,
|
|
1194
1213
|
pattern: String(input["pattern"] ?? ""),
|
|
1195
1214
|
engine: grepEngine(input),
|
|
@@ -1249,6 +1268,7 @@ async function toolFind(ctx: ToolContext, input: Record<string, unknown>): Promi
|
|
|
1249
1268
|
const limit = optionalLimit(input);
|
|
1250
1269
|
const result = await findFiles({
|
|
1251
1270
|
root: searchRoot(ctx, abs),
|
|
1271
|
+
...(ctx.rules ? { canRead: async (path: string) => await permissionResourceAction(searchRoot(ctx, abs), path, ctx.rules ?? [], ctx.sessionRules ?? [], "find") === "allow" } : {}),
|
|
1252
1272
|
path: abs,
|
|
1253
1273
|
pattern: String(input["pattern"] ?? ""),
|
|
1254
1274
|
timeoutMs: ctx.timeoutMs,
|
|
@@ -1264,6 +1284,7 @@ async function toolLs(ctx: ToolContext, input: Record<string, unknown>): Promise
|
|
|
1264
1284
|
const limit = optionalLimit(input);
|
|
1265
1285
|
const result = await ls({
|
|
1266
1286
|
root: searchRoot(ctx, abs),
|
|
1287
|
+
...(ctx.rules ? { canRead: async (path: string) => await permissionResourceAction(searchRoot(ctx, abs), path, ctx.rules ?? [], ctx.sessionRules ?? [], "ls") === "allow" } : {}),
|
|
1267
1288
|
path: abs,
|
|
1268
1289
|
...(ctx.signal ? { signal: ctx.signal } : {}),
|
|
1269
1290
|
...(limit !== undefined ? { limit } : {}),
|
|
@@ -1315,8 +1336,14 @@ export async function gateTool(
|
|
|
1315
1336
|
return failure("permission_denied", `refused: ${name} is not available here (allowed: ${ctx.allow.join(", ")})`);
|
|
1316
1337
|
}
|
|
1317
1338
|
const definition = toolDefinition(name, ctx);
|
|
1339
|
+
if (ctx.workflowMode === "plan" && definition && definition.access !== "read" &&
|
|
1340
|
+
!(definition.access === "state" && (name === "note" || name === "question" && ctx.questionSlot)))
|
|
1341
|
+
return failure("permission_denied", `${name} is unavailable in Plan mode; select Build at an idle checkpoint.`);
|
|
1318
1342
|
input = repairToolInput(name, input);
|
|
1319
|
-
|
|
1343
|
+
let invalid: string | null = null;
|
|
1344
|
+
if (definition?.validateInput) {
|
|
1345
|
+
try { definition.validateInput(input); } catch (error) { return caughtFailure(error, NOT_STARTED, "Invalid tool arguments: "); }
|
|
1346
|
+
} else if (definition) invalid = inputError(definition.input_schema, input, "input", definition.hiddenInputs);
|
|
1320
1347
|
if (invalid) {
|
|
1321
1348
|
const example = minimalInput(definition!.input_schema);
|
|
1322
1349
|
return failure("invalid_input", invalid, NOT_STARTED, example !== null && typeof example === "object" && !Array.isArray(example) &&
|
|
@@ -1324,9 +1351,14 @@ export async function gateTool(
|
|
|
1324
1351
|
}
|
|
1325
1352
|
if (ctx.rules === undefined) return null;
|
|
1326
1353
|
|
|
1327
|
-
const
|
|
1328
|
-
const
|
|
1329
|
-
|
|
1354
|
+
const permissionInput = name === "apply_patch" && input && typeof input === "object" && "patchText" in input ? { paths: patchPaths(parsePatch(String(input.patchText))) } : input;
|
|
1355
|
+
const request = await canonicalPermissionRequest(ctx.root, name, permissionInput);
|
|
1356
|
+
let decision = decideWithSessionRules(ctx.rules, ctx.sessionRules ?? [], request);
|
|
1357
|
+
if (definition?.access === "read" && request.paths) for (const path of request.paths) {
|
|
1358
|
+
const resource = await permissionResourceAction(ctx.root, path, ctx.rules, ctx.sessionRules ?? [], name);
|
|
1359
|
+
if (resource === "deny") { decision = "deny"; break; }
|
|
1360
|
+
if (resource === "ask" && decision === "allow") decision = "ask";
|
|
1361
|
+
}
|
|
1330
1362
|
switch (decision) {
|
|
1331
1363
|
case "allow":
|
|
1332
1364
|
return null;
|
|
@@ -1405,6 +1437,8 @@ export async function prepareNormalizedTool(ctx: ToolContext, name: string, inpu
|
|
|
1405
1437
|
try {
|
|
1406
1438
|
const gated = await gateTool(ctx, name, input);
|
|
1407
1439
|
if (gated) return scopeToolAdvice(ctx, finishToolResult(gated));
|
|
1440
|
+
const instructions = await ctx.instructionCheck?.(ctx, name, input);
|
|
1441
|
+
if (instructions) return scopeToolAdvice(ctx, finishToolResult(instructions));
|
|
1408
1442
|
const veto = await scoped(() => ctx.extensionHooks?.before(name, observerValue(input), ctx.callId, ctx.parentCallId));
|
|
1409
1443
|
await ctx.drainNested?.();
|
|
1410
1444
|
if (veto) return scopeToolAdvice(ctx, finishToolResult(failure("permission_denied", veto)));
|
|
@@ -5,9 +5,10 @@
|
|
|
5
5
|
* timeout, falling back to the package's `latest` document. It carries
|
|
6
6
|
* Bun's default User-Agent and nothing that identifies the person: the
|
|
7
7
|
* registry sees an IP address, the time and the package name. Failure of
|
|
8
|
-
* any kind is "no answer";
|
|
8
|
+
* any kind is "no answer"; callers decide whether an explicit check needs feedback.
|
|
9
9
|
*/
|
|
10
10
|
import { distTagsUrl } from "../core/update-check.ts";
|
|
11
|
+
import { parseVersion } from "../core/version.ts";
|
|
11
12
|
|
|
12
13
|
/** How long to wait for the registry. Codex and pi wait longer; startup never waits on it anyway. Assumed. */
|
|
13
14
|
export const UPDATE_TIMEOUT_MS = 3000;
|
|
@@ -40,7 +41,7 @@ export async function fetchLatestVersion(registry: string | undefined, options:
|
|
|
40
41
|
const response = await fetcher(url, { signal, headers: { accept: "application/json" } });
|
|
41
42
|
if (!response.ok) continue;
|
|
42
43
|
const value = pick(await response.json() as Record<string, unknown>);
|
|
43
|
-
if (typeof value === "string" && value.length <= 64) return value;
|
|
44
|
+
if (typeof value === "string" && value.length <= 64 && parseVersion(value)) return value.trim();
|
|
44
45
|
} catch { if (signal.aborted) return null; }
|
|
45
46
|
}
|
|
46
47
|
return null;
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/** Shared bounded network leaves; request errors never include arbitrary peer text or credentials. */
|
|
2
|
+
import { webUrl } from "../core/web-content.ts";
|
|
3
|
+
import { READ_ATTEMPTED, ToolFault } from "../core/tool-failure.ts";
|
|
4
|
+
|
|
5
|
+
// Assumed resource bounds follow OpenCode's 5 MiB response cap; streamed enforcement prevents oversized allocation.
|
|
6
|
+
export const MAX_WEB_RESPONSE_BYTES = 5 * 1024 * 1024;
|
|
7
|
+
export const MAX_WEB_REDIRECTS = 5;
|
|
8
|
+
export type WebFetch = (input: string | URL | Request, init?: RequestInit) => Promise<Response>;
|
|
9
|
+
export async function boundedResponseBytes(response: Response, limit = MAX_WEB_RESPONSE_BYTES): Promise<Uint8Array> {
|
|
10
|
+
const length = Number(response.headers.get("content-length"));
|
|
11
|
+
if (Number.isFinite(length) && length > limit) {
|
|
12
|
+
await response.body?.cancel(); throw new ToolFault("execution_failed", `Response exceeds ${limit} bytes`, READ_ATTEMPTED);
|
|
13
|
+
}
|
|
14
|
+
if (!response.body) return new Uint8Array();
|
|
15
|
+
const reader = response.body.getReader(), chunks: Uint8Array[] = [];
|
|
16
|
+
let size = 0;
|
|
17
|
+
try {
|
|
18
|
+
for (;;) {
|
|
19
|
+
const next = await reader.read();
|
|
20
|
+
if (next.done) break;
|
|
21
|
+
size += next.value.byteLength;
|
|
22
|
+
if (size > limit) { await reader.cancel(); throw new ToolFault("execution_failed", `Response exceeds ${limit} bytes`, READ_ATTEMPTED); }
|
|
23
|
+
chunks.push(next.value);
|
|
24
|
+
}
|
|
25
|
+
} finally { reader.releaseLock(); }
|
|
26
|
+
const bytes = new Uint8Array(size); let offset = 0;
|
|
27
|
+
for (const chunk of chunks) { bytes.set(chunk, offset); offset += chunk.byteLength; }
|
|
28
|
+
return bytes;
|
|
29
|
+
}
|
|
30
|
+
export async function boundedWebRequest(url: string, init: RequestInit, timeoutMs: number, signal: AbortSignal | undefined,
|
|
31
|
+
fetcher: WebFetch = fetch, redirects = MAX_WEB_REDIRECTS): Promise<{ response: Response; bytes: Uint8Array; url: string; redirects: string[] }> {
|
|
32
|
+
const deadline = new AbortController(), combined = AbortSignal.any([deadline.signal, ...(signal ? [signal] : [])]);
|
|
33
|
+
const timer = setTimeout(() => deadline.abort(), timeoutMs), history: string[] = [];
|
|
34
|
+
let target = webUrl(url);
|
|
35
|
+
try {
|
|
36
|
+
combined.throwIfAborted();
|
|
37
|
+
for (;;) {
|
|
38
|
+
const response = await fetcher(target, { ...init, redirect: "manual", signal: combined });
|
|
39
|
+
if ([301, 302, 303, 307, 308].includes(response.status)) {
|
|
40
|
+
await response.body?.cancel();
|
|
41
|
+
const location = response.headers.get("location");
|
|
42
|
+
if (!location || history.length >= redirects) throw new ToolFault("execution_failed", "Redirect limit exceeded or Location missing", READ_ATTEMPTED);
|
|
43
|
+
const next = webUrl(new URL(location, target).href);
|
|
44
|
+
if (target.startsWith("https:") && next.startsWith("http:")) throw new ToolFault("execution_failed", "HTTPS redirects cannot downgrade to HTTP", READ_ATTEMPTED);
|
|
45
|
+
history.push(target); target = next;
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
return { response, bytes: await boundedResponseBytes(response), url: target, redirects: history };
|
|
49
|
+
}
|
|
50
|
+
} catch (error) {
|
|
51
|
+
if (signal?.aborted) throw new ToolFault("cancelled", "Network request cancelled", READ_ATTEMPTED);
|
|
52
|
+
if (deadline.signal.aborted) throw new ToolFault("timeout", "Network request timed out", READ_ATTEMPTED);
|
|
53
|
+
if (error instanceof ToolFault) throw error;
|
|
54
|
+
throw new ToolFault("execution_failed", "Network request failed; verify the URL and network connection", READ_ATTEMPTED);
|
|
55
|
+
} finally { clearTimeout(timer); }
|
|
56
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/** Network adapters share the normal tool admission and append-only observation path (D11/D13). */
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { webFetchArguments, webSearchArguments, webSearchHits, webSearchProjection, decodeWebEntities,
|
|
4
|
+
type WebFormat, type WebSearchConfig } from "../core/web-content.ts";
|
|
5
|
+
import { ToolFault, NOT_STARTED, READ_ATTEMPTED } from "../core/tool-failure.ts";
|
|
6
|
+
import { archiveObservation } from "../store/observations.ts";
|
|
7
|
+
import { presentObservation, type ToolContext, type ToolDefinition } from "./tools.ts";
|
|
8
|
+
import { boundedWebRequest, type WebFetch } from "./web-http.ts";
|
|
9
|
+
|
|
10
|
+
/** Bun's native HTML parser handles malformed/quoted markup; no homegrown HTML tokeniser or new dependency. */
|
|
11
|
+
export async function extractWebHtml(html: string, url: string, format: Exclude<WebFormat, "html">): Promise<{ title: string; content: string }> {
|
|
12
|
+
let title = "";
|
|
13
|
+
const cleaned = await new HTMLRewriter().on("title", { text(chunk) { title += chunk.text; } })
|
|
14
|
+
.on("head,script,style,noscript,iframe,object,embed,template", { element(element) { element.remove(); } })
|
|
15
|
+
.transform(new Response(html)).text();
|
|
16
|
+
const blocks = new Set(["p", "div", "section", "article", "main", "header", "footer", "blockquote", "ul", "ol", "table", "tr", "pre", "h1", "h2", "h3", "h4", "h5", "h6"]);
|
|
17
|
+
const content = await new HTMLRewriter().on("*", { element(element) {
|
|
18
|
+
const tag = element.tagName;
|
|
19
|
+
if (blocks.has(tag) || tag === "br" || tag === "li") element.before("\n");
|
|
20
|
+
if (format === "markdown" && /^h[1-6]$/.test(tag)) element.before("#".repeat(Number(tag[1])) + " ");
|
|
21
|
+
if (format === "markdown" && tag === "li") element.before("- ");
|
|
22
|
+
if (format === "markdown" && tag === "pre") element.before("```\n");
|
|
23
|
+
if (tag === "a") {
|
|
24
|
+
const href = element.getAttribute("href");
|
|
25
|
+
if (href) try {
|
|
26
|
+
const link = new URL(decodeWebEntities(href), url);
|
|
27
|
+
if (link.protocol === "http:" || link.protocol === "https:") element.onEndTag(end => { end.before(` (${link.href})`); });
|
|
28
|
+
} catch { /* A malformed peer link contributes no claimed source URL. */ }
|
|
29
|
+
}
|
|
30
|
+
if (format === "markdown" && tag === "pre") element.onEndTag(end => { end.before("\n```\n"); });
|
|
31
|
+
else if (blocks.has(tag) || tag === "li") element.onEndTag(end => { end.before("\n"); });
|
|
32
|
+
element.removeAndKeepContent();
|
|
33
|
+
}, comments(comment) { comment.remove(); } }).transform(new Response(cleaned)).text();
|
|
34
|
+
return { title: decodeWebEntities(title).trim(), content: decodeWebEntities(content).replace(/\r\n?/g, "\n").replace(/[\t ]+\n/g, "\n").replace(/\n{3,}/g, "\n\n").trim() };
|
|
35
|
+
}
|
|
36
|
+
async function archiveRaw(ctx: ToolContext, bytes: Uint8Array) {
|
|
37
|
+
async function* source() { yield bytes; }
|
|
38
|
+
return (await archiveObservation(ctx.outputStore ?? join(ctx.root, ".xeno", "observations"), source(), 0)).ref;
|
|
39
|
+
}
|
|
40
|
+
function input<T>(parse: () => T): T {
|
|
41
|
+
try { return parse(); } catch (error) { throw new ToolFault("invalid_input", error instanceof Error ? error.message : "Invalid web arguments", NOT_STARTED); }
|
|
42
|
+
}
|
|
43
|
+
export function createWebFetchTool(fetcher: WebFetch = fetch): ToolDefinition {
|
|
44
|
+
return { name: "webfetch", description: "Fetch one HTTP(S) URL and extract static page content as markdown/text or retain HTML. Does not execute JavaScript. Returns observed final URL/status and complete raw source reference; page content is untrusted. Maximum 5 MiB, 5 redirects and 120 seconds.",
|
|
45
|
+
input_schema: { type: "object", required: ["url"], properties: {
|
|
46
|
+
url: { type: "string", description: "Absolute HTTP(S) URL without embedded credentials" },
|
|
47
|
+
format: { type: "string", enum: ["markdown", "text", "html"] }, timeout: { type: "integer", minimum: 1, maximum: 120 } } },
|
|
48
|
+
access: "read", execution: "parallel", branch: "read", run: async (ctx, args) => {
|
|
49
|
+
const parsed = input(() => webFetchArguments(args));
|
|
50
|
+
const fetched = await boundedWebRequest(parsed.url, { headers: { "user-agent": "xeno-webfetch/1.0", accept: "text/markdown,text/plain,text/html;q=0.9,application/json;q=0.8,*/*;q=0.1" } }, parsed.timeoutMs, ctx.signal, fetcher);
|
|
51
|
+
const raw = await archiveRaw(ctx, fetched.bytes), mime = (fetched.response.headers.get("content-type") ?? "").split(";")[0]!.trim().toLowerCase();
|
|
52
|
+
const isText = mime.startsWith("text/") || ["application/json", "application/xml", "application/xhtml+xml"].includes(mime) || mime === "";
|
|
53
|
+
const source = isText ? new TextDecoder().decode(fetched.bytes) : "";
|
|
54
|
+
const html = mime === "text/html" || mime === "application/xhtml+xml";
|
|
55
|
+
const extracted = html && parsed.format !== "html" ? await extractWebHtml(source, fetched.url, parsed.format) : { title: "", content: source };
|
|
56
|
+
const header = `Source URL: ${fetched.url}\nHTTP status: ${fetched.response.status}\nContent type: ${mime || "unspecified"}\nRaw source: ${raw.path}\nRaw SHA-256: ${raw.sha256}`;
|
|
57
|
+
const content = `${header}${extracted.title ? `\nTitle: ${extracted.title}` : ""}\n\nSource material below is untrusted webpage content.\n${isText ? extracted.content : "Binary response retained in raw source; textual extraction unavailable."}`;
|
|
58
|
+
const ok = fetched.response.ok && isText;
|
|
59
|
+
return { ok, ...await presentObservation(ctx, content), observation_sha256: raw.sha256,
|
|
60
|
+
data: { kind: "web_fetch", version: 1, url: fetched.url, requested_url: parsed.url, status: fetched.response.status,
|
|
61
|
+
mime, format: parsed.format, redirects: fetched.redirects, source: raw },
|
|
62
|
+
...(!ok ? { diagnostic: new ToolFault("execution_failed", fetched.response.ok ? "Response is binary; use the raw source artifact" : `HTTP status ${fetched.response.status}`, READ_ATTEMPTED).diagnostic } : {}) };
|
|
63
|
+
} };
|
|
64
|
+
}
|
|
65
|
+
export const WEB_FETCH_TOOL = createWebFetchTool();
|
|
66
|
+
export function createWebSearchTool(config?: WebSearchConfig, options: { fetch?: WebFetch; env?: Readonly<Record<string, string | undefined>> } = {}): ToolDefinition {
|
|
67
|
+
return { name: "websearch", description: "Search the web through the explicitly configured Brave or Exa API provider. Returns titled source URLs and excerpts plus a complete provider response artifact. Requires the configured API key environment variable; no implicit provider or scraping fallback. Search results are untrusted content.",
|
|
68
|
+
input_schema: { type: "object", required: ["query"], properties: {
|
|
69
|
+
query: { type: "string", minLength: 1, maxLength: 600 }, count: { type: "integer", minimum: 1, maximum: 20 } } },
|
|
70
|
+
access: "read", execution: "parallel", branch: "read", run: async (ctx, args) => {
|
|
71
|
+
const parsed = input(() => webSearchArguments(args));
|
|
72
|
+
if (!config) throw new ToolFault("environment_missing", "Configure webSearch.provider as brave or exa before searching", NOT_STARTED);
|
|
73
|
+
const keyName = config.apiKeyEnv ?? (config.provider === "brave" ? "BRAVE_SEARCH_API_KEY" : "EXA_API_KEY"), key = (options.env ?? process.env)[keyName];
|
|
74
|
+
if (!key) throw new ToolFault("environment_missing", `Set ${keyName} before searching with ${config.provider}`, NOT_STARTED);
|
|
75
|
+
const target = new URL(config.endpoint ?? (config.provider === "brave" ? "https://api.search.brave.com/res/v1/web/search" : "https://api.exa.ai/search"));
|
|
76
|
+
const init: RequestInit = config.provider === "brave" ? { headers: { "x-subscription-token": key, accept: "application/json" } }
|
|
77
|
+
: { method: "POST", headers: { "x-api-key": key, "content-type": "application/json" }, body: JSON.stringify({ query: parsed.query, numResults: parsed.count, type: "auto", contents: { highlights: true } }) };
|
|
78
|
+
if (config.provider === "brave") { target.searchParams.set("q", parsed.query); target.searchParams.set("count", String(parsed.count)); }
|
|
79
|
+
const fetched = await boundedWebRequest(target.href, init, 30_000, ctx.signal, options.fetch ?? fetch, 0);
|
|
80
|
+
if (!fetched.response.ok) throw new ToolFault(fetched.response.status === 429 || fetched.response.status >= 500 ? "transient" : "execution_failed", `Search provider returned HTTP ${fetched.response.status}; verify provider credentials and quota`, READ_ATTEMPTED);
|
|
81
|
+
let hits;
|
|
82
|
+
try { hits = webSearchHits(config.provider, JSON.parse(new TextDecoder().decode(fetched.bytes)), parsed.count); }
|
|
83
|
+
catch { throw new ToolFault("execution_failed", "Search provider response failed schema validation", READ_ATTEMPTED); }
|
|
84
|
+
const raw = await archiveRaw(ctx, fetched.bytes);
|
|
85
|
+
return { ok: true, ...await presentObservation(ctx, webSearchProjection(config.provider, parsed.query, hits) + `\n\nProvider response: ${raw.path}\nSHA-256: ${raw.sha256}`),
|
|
86
|
+
observation_sha256: raw.sha256, data: { kind: "web_search", version: 1, provider: config.provider, query: parsed.query, results: hits.map(hit => ({ ...hit })), source: raw } };
|
|
87
|
+
} };
|
|
88
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/** P.3: execution owns checkpoints; session opening owns no filesystem baseline. */
|
|
2
2
|
import { randomUUID } from "node:crypto";
|
|
3
|
-
import type { CheckpointEvent, FileVersion, WorkspaceObservationEvent } from "../core/checkpoints.ts";
|
|
3
|
+
import type { CheckpointEvent, FileVersion, FileChanges, WorkspaceObservationEvent } from "../core/checkpoints.ts";
|
|
4
4
|
import { sameVersion } from "../core/checkpoints.ts";
|
|
5
5
|
import { blobAddress } from "../store/blobs.ts";
|
|
6
6
|
import { archiveEdit, replaceFile, readEditableFile } from "../store/file-checkpoints.ts";
|
|
@@ -28,6 +28,19 @@ export class WorkspaceLifecycle {
|
|
|
28
28
|
private interfere(): void {
|
|
29
29
|
for (const checkpoint of this.pending.values()) checkpoint.unavailable = "overlapping mutations prevent attributing a command checkpoint";
|
|
30
30
|
}
|
|
31
|
+
async patch(changes: FileChanges, execute: () => Promise<void>, signal?: AbortSignal): Promise<void> {
|
|
32
|
+
signal?.throwIfAborted(); this.interfere();
|
|
33
|
+
const operation = randomUUID();
|
|
34
|
+
await this.record({ t: "checkpoint", operation, source: "edit", phase: "prepared", changes });
|
|
35
|
+
try {
|
|
36
|
+
await execute();
|
|
37
|
+
for (const [path, change] of Object.entries(changes)) if (!sameVersion((await readEditableFile(this.root, path)).version, change.after)) throw Error(`file changed concurrently after patch: ${path}`);
|
|
38
|
+
await this.record({ t: "checkpoint", operation, source: "edit", phase: "applied", changes });
|
|
39
|
+
} catch (error) {
|
|
40
|
+
await this.record({ t: "checkpoint", operation, source: "edit", phase: "unavailable", changes, reason: "patch did not establish the expected versions; inspect before retrying" });
|
|
41
|
+
throw error;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
31
44
|
async edit(path: string, before: { bytes: Uint8Array | null; version: FileVersion }, after: Uint8Array, signal?: AbortSignal): Promise<void> {
|
|
32
45
|
this.interfere();
|
|
33
46
|
await archiveEdit(this.blobs, before.bytes, after);
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/** R13: profile files are inert JSON; project trust is decided by the app. */
|
|
2
|
+
import { readFile } from "node:fs/promises";
|
|
3
|
+
|
|
4
|
+
export async function readAgentProfileFile(path: string): Promise<{ value: unknown; diagnostic?: string } | null> {
|
|
5
|
+
let body: string;
|
|
6
|
+
try { body = await readFile(path, "utf8"); }
|
|
7
|
+
catch (error) {
|
|
8
|
+
if (error && typeof error === "object" && "code" in error && error.code === "ENOENT") return null;
|
|
9
|
+
return { value: null, diagnostic: `${path}: unable to read agents configuration` };
|
|
10
|
+
}
|
|
11
|
+
try { return { value: JSON.parse(body) as unknown }; }
|
|
12
|
+
catch { return { value: null, diagnostic: `${path}: invalid JSON in agents configuration` }; }
|
|
13
|
+
}
|
package/src/store/blobs.ts
CHANGED
|
@@ -121,8 +121,9 @@ export async function getBlob(dir: string, sha: string): Promise<string | null>
|
|
|
121
121
|
}
|
|
122
122
|
|
|
123
123
|
/** Restore uses bytes; decoding binary data is an irreversible transformation. */
|
|
124
|
-
export async function getBlobBytes(dir: string, sha: string): Promise<Uint8Array | null> {
|
|
124
|
+
export async function getBlobBytes(dir: string, sha: string, maxBytes?: number): Promise<Uint8Array | null> {
|
|
125
125
|
try {
|
|
126
|
+
if (maxBytes !== undefined && (await stat(blobFilePath(dir, sha))).size > maxBytes) throw new RangeError("Blob exceeds byte limit");
|
|
126
127
|
const bytes = await readFile(blobFilePath(dir, sha));
|
|
127
128
|
if (blobAddress(bytes) !== sha) throw new Error(`blob integrity failure: ${sha}`);
|
|
128
129
|
return bytes;
|
|
@@ -18,9 +18,11 @@
|
|
|
18
18
|
* frozen at startup; an explicit reload checkpoints a changed prefix.
|
|
19
19
|
*/
|
|
20
20
|
|
|
21
|
-
import { readFile, stat } from "node:fs/promises";
|
|
21
|
+
import { readFile, stat, lstat } from "node:fs/promises";
|
|
22
22
|
import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
23
23
|
import { assertNever } from "../core/exhaustive.ts";
|
|
24
|
+
import { blobAddress } from "./blobs.ts";
|
|
25
|
+
import { gitBytes } from "./workspace-observation.ts";
|
|
24
26
|
|
|
25
27
|
/**
|
|
26
28
|
* Per-file cap. Assumed, not measured: a rule file larger than this is a
|
|
@@ -354,3 +356,77 @@ export function formatNotice(notice: ContextNotice): string {
|
|
|
354
356
|
return assertNever(notice, "formatNotice");
|
|
355
357
|
}
|
|
356
358
|
}
|
|
359
|
+
|
|
360
|
+
/** Startup uses the actual repository instruction chain; loadContext remains explicit/compatible. */
|
|
361
|
+
export async function resolveContextWorkspace(cwd: string): Promise<string> {
|
|
362
|
+
try {
|
|
363
|
+
const bytes = await gitBytes(cwd, ["--no-pager", "rev-parse", "--show-prefix"]);
|
|
364
|
+
const prefix = new TextDecoder("utf-8", { fatal: true }).decode(bytes).replace(/\n$/, "");
|
|
365
|
+
// Git canonicalizes macOS /var to /private/var. Ascending the known prefix
|
|
366
|
+
// keeps root and cwd in the same spelling, preserving the instruction chain.
|
|
367
|
+
let root = resolve(cwd);
|
|
368
|
+
const levels = prefix.split("/").filter(Boolean).length;
|
|
369
|
+
for (let level = 0; level < levels; level++) root = dirname(root);
|
|
370
|
+
return root;
|
|
371
|
+
} catch { return resolve(cwd); }
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
export interface TargetInstruction extends LoadedFile { scope: string; key: string }
|
|
375
|
+
export interface TargetInstructionOptions {
|
|
376
|
+
workspace: string;
|
|
377
|
+
paths: readonly string[];
|
|
378
|
+
/** Digests from frozen startup files and durable instruction events, never model claims. */
|
|
379
|
+
known?: ReadonlySet<string>;
|
|
380
|
+
noContextFiles?: boolean;
|
|
381
|
+
/** Admission runs before instruction bytes are captured. */
|
|
382
|
+
canRead?: (path: string) => Promise<boolean>;
|
|
383
|
+
}
|
|
384
|
+
export const instructionKey = (file: Pick<LoadedFile, "path" | "content">): string =>
|
|
385
|
+
`${resolve(file.path)}:${blobAddress(file.content)}`;
|
|
386
|
+
|
|
387
|
+
/** Target paths are explicit. No recursive scan, sibling instruction loading or prefix surgery. */
|
|
388
|
+
export async function discoverTargetInstructions(options: TargetInstructionOptions): Promise<{ files: TargetInstruction[]; notices: ContextNotice[] }> {
|
|
389
|
+
if (options.noContextFiles) return { files: [], notices: [] };
|
|
390
|
+
const root = resolve(options.workspace), directories = new Set<string>();
|
|
391
|
+
for (const path of options.paths) {
|
|
392
|
+
const absolute = resolve(root, path);
|
|
393
|
+
if (!isInside(absolute, root)) throw new Error(`instruction target escapes workspace: ${path}`);
|
|
394
|
+
for (const directory of projectDirectories(root, dirname(absolute))) directories.add(directory);
|
|
395
|
+
}
|
|
396
|
+
const ordered = [...directories].sort((a, b) => a.split(sep).length - b.split(sep).length || a.localeCompare(b));
|
|
397
|
+
const files: TargetInstruction[] = [], notices: ContextNotice[] = [], budget = new Budget();
|
|
398
|
+
for (const directory of ordered) {
|
|
399
|
+
// A target directory's symlink must not import another project's ambient authority.
|
|
400
|
+
let safe = true, current = root;
|
|
401
|
+
for (const part of relative(root, directory).split(sep).filter(Boolean)) {
|
|
402
|
+
current = join(current, part);
|
|
403
|
+
try { const info = await lstat(current); if (info.isSymbolicLink() || !info.isDirectory()) { safe = false; break; } }
|
|
404
|
+
catch (error) { if ((error as { code?: string }).code !== "ENOENT") throw error; }
|
|
405
|
+
}
|
|
406
|
+
if (!safe) { notices.push({ kind: "skipped", path: directory, reason: "instruction scope crosses a symlink or non-directory" }); continue; }
|
|
407
|
+
for (const name of AGENTS_CANDIDATES) {
|
|
408
|
+
const path = join(directory, name);
|
|
409
|
+
try {
|
|
410
|
+
const info = await lstat(path);
|
|
411
|
+
if (info.isDirectory()) continue;
|
|
412
|
+
if (info.isSymbolicLink() || !info.isFile()) { notices.push({ kind: "skipped", path, reason: "instruction file must be ordinary, not a symlink" }); break; }
|
|
413
|
+
} catch (error) { if ((error as { code?: string }).code === "ENOENT") continue; throw error; }
|
|
414
|
+
if (options.canRead && !await options.canRead(path)) { notices.push({ kind: "skipped", path, reason: "instruction read permission is unavailable" }); break; }
|
|
415
|
+
const got = await tryRead(path);
|
|
416
|
+
if (got.status !== "ok") { if (got.status === "skip") notices.push({ kind: "skipped", path, reason: got.reason }); break; }
|
|
417
|
+
const file = absorbText(path, got.text, "agents", got.bytes, budget, notices), key = instructionKey(file);
|
|
418
|
+
if (!options.known?.has(key)) files.push({ ...file, scope: directory, key });
|
|
419
|
+
break;
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
return { files, notices };
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
/** Append as a durable in-context event; closer scope overrides only files under that directory. */
|
|
426
|
+
export function renderTargetInstructions(files: readonly TargetInstruction[]): string {
|
|
427
|
+
if (!files.length) return "";
|
|
428
|
+
return "# Instructions discovered for the current file targets\n\n" +
|
|
429
|
+
"Apply each file only within its directory scope. Deeper directories take precedence. " +
|
|
430
|
+
"This observed version supersedes earlier versions at the same path; other scopes remain unchanged.\n\n" +
|
|
431
|
+
files.map(file => `<instruction_scope directory="${escapeAttr(file.scope)}" key="${escapeAttr(file.key)}">\n${renderFile(file)}\n</instruction_scope>`).join("\n\n");
|
|
432
|
+
}
|
|
@@ -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,15 @@
|
|
|
1
|
+
/** Local Git reads are asynchronous and bounded; a paint never launches a process. */
|
|
2
|
+
import { execFile } from "node:child_process";
|
|
3
|
+
import { parseGitStatus, type GitStatus } from "../core/git-status.ts";
|
|
4
|
+
/** Assumed cold-disk allowance; Git failure remains unavailable rather than "clean". */
|
|
5
|
+
const GIT_STATUS_TIMEOUT_MS = 1000;
|
|
6
|
+
/** A busy repository cannot flood memory or the status view with paths. */
|
|
7
|
+
const GIT_STATUS_MAX_BYTES = 1024 * 1024;
|
|
8
|
+
|
|
9
|
+
export function readGitStatus(cwd: string): Promise<GitStatus | null> {
|
|
10
|
+
return new Promise(resolve => {
|
|
11
|
+
execFile("git", ["--no-optional-locks", "status", "--porcelain=v2", "--branch", "-z", "--untracked-files=normal"],
|
|
12
|
+
{ cwd, timeout: GIT_STATUS_TIMEOUT_MS, maxBuffer: GIT_STATUS_MAX_BYTES, encoding: "utf8" },
|
|
13
|
+
(error, output) => resolve(error ? null : parseGitStatus(output)));
|
|
14
|
+
});
|
|
15
|
+
}
|
|
@@ -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
|
+
}
|