@visual-z/xeno 0.2.1 → 0.3.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.
Files changed (258) hide show
  1. package/CHANGELOG.md +9 -0
  2. package/README.md +106 -26
  3. package/docs/install.md +51 -13
  4. package/docs/providers.md +11 -0
  5. package/package.json +5 -4
  6. package/src/apps/approve.ts +3 -3
  7. package/src/apps/commands.ts +38 -5
  8. package/src/apps/completions.ts +73 -19
  9. package/src/apps/image-draft.ts +46 -0
  10. package/src/apps/project-trust.ts +2 -42
  11. package/src/apps/provider-connect.ts +346 -0
  12. package/src/apps/render.ts +22 -47
  13. package/src/apps/resources.ts +5 -2
  14. package/src/apps/shell.ts +298 -1526
  15. package/src/apps/startup-input.ts +45 -9
  16. package/src/apps/startup.ts +128 -86
  17. package/src/apps/ui/app.ts +1383 -0
  18. package/src/apps/ui/extension-host.ts +194 -0
  19. package/src/apps/ui/image-cache.ts +78 -0
  20. package/src/apps/ui/palette.ts +88 -0
  21. package/src/apps/ui/project.ts +127 -0
  22. package/src/apps/ui/standalone.ts +146 -0
  23. package/src/apps/ui/status-model.ts +68 -0
  24. package/src/apps/ui/surface.ts +84 -0
  25. package/src/apps/ui/theme.ts +86 -0
  26. package/src/core/acceptance.ts +107 -0
  27. package/src/core/action-defaults.ts +47 -295
  28. package/src/core/benchmark.ts +2 -2
  29. package/src/core/branch-receipt.ts +24 -0
  30. package/src/core/checkpoints.ts +64 -0
  31. package/src/core/churn.ts +2 -0
  32. package/src/core/command-artifacts.ts +94 -0
  33. package/src/core/command-contract.ts +35 -0
  34. package/src/core/completion-review.ts +21 -5
  35. package/src/core/context-detail.ts +37 -0
  36. package/src/core/context-policy.ts +74 -1
  37. package/src/core/continuation.ts +17 -2
  38. package/src/core/derive.ts +29 -3
  39. package/src/core/draft-images.ts +56 -0
  40. package/src/core/environment.ts +145 -0
  41. package/src/core/evaluation-coverage.ts +74 -0
  42. package/src/core/event-validation.ts +80 -5
  43. package/src/core/events.ts +97 -3
  44. package/src/core/evidence-links.ts +7 -0
  45. package/src/core/evidence.ts +13 -3
  46. package/src/core/execution.ts +8 -2
  47. package/src/core/experiment.ts +133 -0
  48. package/src/core/export.ts +9 -0
  49. package/src/core/failure-recovery.ts +173 -0
  50. package/src/core/file-precondition.ts +72 -0
  51. package/src/core/handoff.ts +65 -4
  52. package/src/core/history-query.ts +38 -2
  53. package/src/core/history.ts +38 -1
  54. package/src/core/integration-health.ts +34 -0
  55. package/src/core/keymap.ts +8 -20
  56. package/src/core/model-picker.ts +64 -0
  57. package/src/core/native-eval.ts +25 -0
  58. package/src/core/note-guidance.ts +34 -0
  59. package/src/core/observation-view.ts +49 -8
  60. package/src/core/permissions.ts +24 -2
  61. package/src/core/process-receipt.ts +105 -0
  62. package/src/core/process-state.ts +37 -0
  63. package/src/core/project.ts +45 -18
  64. package/src/core/prompt-policy.ts +37 -0
  65. package/src/core/query-eval-contract.ts +14 -0
  66. package/src/core/read-observation.ts +82 -0
  67. package/src/core/recovery-corpus.ts +17 -0
  68. package/src/core/recovery-evaluation.ts +60 -0
  69. package/src/core/recovery-sources.ts +74 -0
  70. package/src/core/runtime-target.ts +94 -0
  71. package/src/core/search-observation.ts +97 -0
  72. package/src/core/session-tree.ts +147 -0
  73. package/src/core/settings.ts +26 -10
  74. package/src/core/skills.ts +44 -0
  75. package/src/core/sql-query.ts +211 -0
  76. package/src/core/theme.ts +36 -58
  77. package/src/core/tool-contract.ts +51 -2
  78. package/src/core/tool-discovery.ts +87 -0
  79. package/src/core/tool-failure.ts +60 -0
  80. package/src/core/tool-mode-protocol.ts +25 -0
  81. package/src/core/transcript.ts +19 -4
  82. package/src/core/trial-lifecycle.ts +14 -0
  83. package/src/core/tui-mode.ts +29 -0
  84. package/src/core/update-check.ts +97 -0
  85. package/src/core/version.ts +55 -0
  86. package/src/core/work-state.ts +49 -3
  87. package/src/core/working-set.ts +91 -0
  88. package/src/runtime/auth.ts +2 -2
  89. package/src/runtime/branch-jobs.ts +36 -0
  90. package/src/runtime/clipboard.ts +15 -4
  91. package/src/runtime/command-artifacts.ts +64 -0
  92. package/src/runtime/environment-tools.ts +60 -0
  93. package/src/runtime/evidence-notes.ts +4 -2
  94. package/src/runtime/exec-sessions.ts +206 -61
  95. package/src/runtime/execution-control.ts +1 -0
  96. package/src/runtime/extension-ui.ts +22 -30
  97. package/src/runtime/extensions.ts +95 -16
  98. package/src/runtime/harness-comparison.ts +27 -7
  99. package/src/runtime/harness-eval.ts +460 -0
  100. package/src/runtime/history-cache.ts +57 -0
  101. package/src/runtime/history-tools.ts +62 -12
  102. package/src/runtime/integrations.ts +210 -0
  103. package/src/runtime/loop.ts +1021 -268
  104. package/src/runtime/model-discovery.ts +208 -0
  105. package/src/runtime/native-eval-fixtures.ts +33 -0
  106. package/src/runtime/native-eval.ts +143 -0
  107. package/src/runtime/oauth.ts +1 -1
  108. package/src/runtime/provider-connect.ts +109 -0
  109. package/src/runtime/provider-presets.ts +87 -0
  110. package/src/runtime/query-eval-verifier.ts +28 -0
  111. package/src/runtime/recovery-coverage.ts +71 -0
  112. package/src/runtime/recovery-evidence.ts +76 -0
  113. package/src/runtime/recovery-fixtures.ts +116 -0
  114. package/src/runtime/runtime-target.ts +149 -0
  115. package/src/runtime/tool-broker.ts +137 -0
  116. package/src/runtime/tool-discovery.ts +57 -0
  117. package/src/runtime/tool-mode-eval.ts +146 -0
  118. package/src/runtime/tool-queue.ts +77 -0
  119. package/src/runtime/tools.ts +628 -183
  120. package/src/runtime/trial-control.ts +45 -0
  121. package/src/runtime/update-check.ts +47 -0
  122. package/src/runtime/window-trial.ts +2 -1
  123. package/src/runtime/working-notes.ts +33 -3
  124. package/src/runtime/workspace-lifecycle.ts +97 -0
  125. package/src/store/acceptance.ts +15 -0
  126. package/src/store/checkpoint-history.ts +43 -0
  127. package/src/store/command-artifacts.ts +96 -0
  128. package/src/store/command.ts +65 -37
  129. package/src/store/file-checkpoints.ts +104 -0
  130. package/src/store/file-index.ts +13 -1
  131. package/src/store/git-checkpoints.ts +67 -0
  132. package/src/store/observations.ts +61 -20
  133. package/src/store/process-receipts.ts +34 -0
  134. package/src/store/query-source.ts +13 -8
  135. package/src/store/recovery.ts +2 -3
  136. package/src/store/replay.ts +16 -4
  137. package/src/store/runtime-target.ts +64 -0
  138. package/src/store/search.ts +171 -132
  139. package/src/store/session-archive.ts +4 -0
  140. package/src/store/source-cancellation.ts +6 -0
  141. package/src/store/update-cache.ts +36 -0
  142. package/src/store/workspace-observation.ts +113 -0
  143. package/src/tui/ansi-spans.ts +126 -0
  144. package/src/tui/style.ts +19 -16
  145. package/src/tui/ui/atomic-draft.ts +121 -0
  146. package/src/tui/ui/canvas.ts +96 -0
  147. package/src/tui/ui/colors.ts +70 -0
  148. package/src/tui/ui/composer.ts +199 -0
  149. package/src/tui/ui/dialog.ts +379 -0
  150. package/src/tui/ui/draft.ts +23 -0
  151. package/src/tui/ui/edge.ts +186 -0
  152. package/src/tui/ui/image-size.ts +40 -0
  153. package/src/tui/ui/ink.ts +210 -0
  154. package/src/tui/ui/items.ts +40 -0
  155. package/src/tui/ui/keys.ts +40 -0
  156. package/src/tui/ui/layout.ts +74 -0
  157. package/src/tui/ui/list-model.ts +86 -0
  158. package/src/tui/ui/list-view.ts +172 -0
  159. package/src/tui/ui/mascot.ts +67 -0
  160. package/src/tui/ui/messages.ts +518 -0
  161. package/src/tui/ui/motion.ts +28 -0
  162. package/src/tui/ui/paths.ts +10 -0
  163. package/src/tui/ui/pictures.ts +78 -0
  164. package/src/tui/ui/screen.ts +421 -0
  165. package/src/tui/ui/status.ts +271 -0
  166. package/src/tui/ui/styled.ts +85 -0
  167. package/src/tui/ui/testing.ts +5 -0
  168. package/src/tui/ui/tool-card.ts +150 -0
  169. package/src/apps/extension-ui.ts +0 -330
  170. package/src/apps/resource-browser.ts +0 -75
  171. package/src/apps/session-browser.ts +0 -113
  172. package/src/apps/session-picker.ts +0 -55
  173. package/src/tui/ansi.ts +0 -67
  174. package/src/tui/autocomplete.ts +0 -29
  175. package/src/tui/chat-viewport.ts +0 -47
  176. package/src/tui/complete.ts +0 -87
  177. package/src/tui/editor.ts +0 -940
  178. package/src/tui/host.ts +0 -264
  179. package/src/tui/input.ts +0 -75
  180. package/src/tui/keys.ts +0 -378
  181. package/src/tui/markdown.ts +0 -17
  182. package/src/tui/render.ts +0 -153
  183. package/src/tui/select.ts +0 -258
  184. package/src/tui/settings.ts +0 -44
  185. package/src/tui/statusline.ts +0 -162
  186. package/src/tui/system-theme.ts +0 -700
  187. package/src/tui/terminal.ts +0 -30
  188. package/src/tui/text.ts +0 -52
  189. package/src/tui/toolview.ts +0 -386
  190. package/src/tui/transcript.ts +0 -123
  191. package/src/tui/tree.ts +0 -471
  192. package/src/vendor/pi-tui/LICENSE.upstream +0 -21
  193. package/src/vendor/pi-tui/UPSTREAM.json +0 -50
  194. package/src/vendor/pi-tui/VENDOR.md +0 -25
  195. package/src/vendor/pi-tui/alt-screen-search.ts +0 -327
  196. package/src/vendor/pi-tui/autocomplete.ts +0 -860
  197. package/src/vendor/pi-tui/colors.ts +0 -367
  198. package/src/vendor/pi-tui/components/alt-screen-flash.ts +0 -51
  199. package/src/vendor/pi-tui/components/box.ts +0 -166
  200. package/src/vendor/pi-tui/components/cancellable-loader.ts +0 -40
  201. package/src/vendor/pi-tui/components/editor.ts +0 -2472
  202. package/src/vendor/pi-tui/components/h-stack.ts +0 -44
  203. package/src/vendor/pi-tui/components/image.ts +0 -127
  204. package/src/vendor/pi-tui/components/input.ts +0 -494
  205. package/src/vendor/pi-tui/components/loader.ts +0 -101
  206. package/src/vendor/pi-tui/components/markdown.ts +0 -1021
  207. package/src/vendor/pi-tui/components/mouse-region.ts +0 -33
  208. package/src/vendor/pi-tui/components/scroll-view.ts +0 -224
  209. package/src/vendor/pi-tui/components/select-list.ts +0 -273
  210. package/src/vendor/pi-tui/components/settings-list.ts +0 -328
  211. package/src/vendor/pi-tui/components/spacer.ts +0 -28
  212. package/src/vendor/pi-tui/components/stack.ts +0 -154
  213. package/src/vendor/pi-tui/components/text.ts +0 -107
  214. package/src/vendor/pi-tui/components/truncated-text.ts +0 -65
  215. package/src/vendor/pi-tui/components/v-stack.ts +0 -33
  216. package/src/vendor/pi-tui/editor-component.ts +0 -74
  217. package/src/vendor/pi-tui/fuzzy.ts +0 -138
  218. package/src/vendor/pi-tui/index.ts +0 -186
  219. package/src/vendor/pi-tui/keybindings.ts +0 -320
  220. package/src/vendor/pi-tui/keys.ts +0 -1401
  221. package/src/vendor/pi-tui/kill-ring.ts +0 -46
  222. package/src/vendor/pi-tui/latex.ts +0 -1506
  223. package/src/vendor/pi-tui/layout-node.ts +0 -51
  224. package/src/vendor/pi-tui/layout.ts +0 -449
  225. package/src/vendor/pi-tui/native/UPSTREAM.json +0 -23
  226. package/src/vendor/pi-tui/native/clipboard.h +0 -208
  227. package/src/vendor/pi-tui/native/darwin/README.md +0 -22
  228. package/src/vendor/pi-tui/native/darwin/build.sh +0 -69
  229. package/src/vendor/pi-tui/native/darwin/prebuilds/darwin-arm64/darwin-platform.node +0 -0
  230. package/src/vendor/pi-tui/native/darwin/prebuilds/darwin-x64/darwin-platform.node +0 -0
  231. package/src/vendor/pi-tui/native/darwin/src/darwin-platform.m +0 -132
  232. package/src/vendor/pi-tui/native/linux/README.md +0 -23
  233. package/src/vendor/pi-tui/native/linux/build.sh +0 -65
  234. package/src/vendor/pi-tui/native/linux/prebuilds/linux-arm64/linux-platform-x11.node +0 -0
  235. package/src/vendor/pi-tui/native/linux/prebuilds/linux-x64/linux-platform-x11.node +0 -0
  236. package/src/vendor/pi-tui/native/linux/src/clipboard-worker.h +0 -70
  237. package/src/vendor/pi-tui/native/linux/src/linux-platform-x11.c +0 -274
  238. package/src/vendor/pi-tui/native/napi.h +0 -91
  239. package/src/vendor/pi-tui/native/win32/README.md +0 -31
  240. package/src/vendor/pi-tui/native/win32/build.mjs +0 -232
  241. package/src/vendor/pi-tui/native/win32/prebuilds/win32-arm64/win32-platform.node +0 -0
  242. package/src/vendor/pi-tui/native/win32/prebuilds/win32-x64/win32-platform.node +0 -0
  243. package/src/vendor/pi-tui/native/win32/src/win32-platform.c +0 -241
  244. package/src/vendor/pi-tui/native-modifiers.ts +0 -13
  245. package/src/vendor/pi-tui/native-module-path.ts +0 -31
  246. package/src/vendor/pi-tui/native-platform.ts +0 -65
  247. package/src/vendor/pi-tui/oklab.ts +0 -233
  248. package/src/vendor/pi-tui/stdin-buffer.ts +0 -444
  249. package/src/vendor/pi-tui/terminal-colors.ts +0 -91
  250. package/src/vendor/pi-tui/terminal-image.ts +0 -731
  251. package/src/vendor/pi-tui/terminal.ts +0 -554
  252. package/src/vendor/pi-tui/tui-alt-screen.ts +0 -1750
  253. package/src/vendor/pi-tui/tui-main-screen.ts +0 -655
  254. package/src/vendor/pi-tui/tui.ts +0 -1493
  255. package/src/vendor/pi-tui/undo-stack.ts +0 -28
  256. package/src/vendor/pi-tui/utils.ts +0 -1385
  257. package/src/vendor/pi-tui/wheel-scroll.ts +0 -82
  258. package/src/vendor/pi-tui/word-navigation.ts +0 -117
@@ -0,0 +1,104 @@
1
+ /** P.3: bounded path operations with durable before-images and conflict checks. */
2
+ import { lstat, readFile, mkdir, open, rename, rm } from "node:fs/promises";
3
+ import { resolve, relative, dirname, join, sep } from "node:path";
4
+ import { randomUUID } from "node:crypto";
5
+ import { blobAddress, putBlob, getBlobBytes, verifyBlob } from "./blobs.ts";
6
+ import { sameVersion, changedPaths, type FileVersion, type FileChanges } from "../core/checkpoints.ts";
7
+ import { ToolFault, NOT_STARTED, READ_ATTEMPTED, failureFromError } from "../core/tool-failure.ts";
8
+
9
+ export async function editablePath(root: string, path: string): Promise<string> {
10
+ const base = resolve(root), absolute = resolve(base, path), rel = relative(base, absolute);
11
+ if (!rel || rel === ".." || rel.startsWith(`..${sep}`) || resolve(rel) === rel ||
12
+ rel.split(sep).some(part => part === ".git" || part === ".xeno")) throw new ToolFault("permission_denied", `unsafe file checkpoint path: ${path}`, NOT_STARTED);
13
+ let current = base;
14
+ for (const part of rel.split(sep)) {
15
+ current = join(current, part);
16
+ try {
17
+ const info = await lstat(current);
18
+ if (info.isSymbolicLink() || current !== absolute && !info.isDirectory() || current === absolute && (!info.isFile() || info.nlink > 1))
19
+ throw new ToolFault("permission_denied", `unsupported file checkpoint path: ${path}`, NOT_STARTED);
20
+ } catch (error) { if ((error as { code?: string }).code !== "ENOENT") throw error; }
21
+ }
22
+ return absolute;
23
+ }
24
+
25
+ export async function readEditableFile(root: string, path: string): Promise<{ bytes: Uint8Array | null; version: FileVersion }> {
26
+ const absolute = await editablePath(root, path);
27
+ try {
28
+ const info = await lstat(absolute);
29
+ const bytes = await readFile(absolute);
30
+ return { bytes, version: { sha256: blobAddress(bytes), mode: info.mode & 0o777 } };
31
+ } catch (error) {
32
+ if ((error as { code?: string }).code === "ENOENT") return { bytes: null, version: null };
33
+ const diagnostic = failureFromError(error, READ_ATTEMPTED);
34
+ throw new ToolFault(diagnostic.code, diagnostic.message, READ_ATTEMPTED);
35
+ }
36
+ }
37
+
38
+ /** The prepared record is durable before rename; cancellation and conflicts leave bytes untouched. */
39
+ export async function replaceFile(root: string, path: string, expected: FileVersion, bytes: Uint8Array | null,
40
+ mode: number, signal?: AbortSignal): Promise<void> {
41
+ const absolute = await editablePath(root, path);
42
+ if (signal?.aborted) throw new ToolFault("cancelled", "file operation cancelled", NOT_STARTED);
43
+ if (!sameVersion((await readEditableFile(root, path)).version, expected)) throw new ToolFault("version_mismatch", `file conflict: ${path}; read it again before editing`, READ_ATTEMPTED, { recover: { tool: "read", input: { path } } });
44
+ if (bytes === null) {
45
+ if (expected === null) return;
46
+ await rm(absolute);
47
+ const directory = await open(dirname(absolute), "r");
48
+ try { await directory.sync(); } finally { await directory.close(); }
49
+ return;
50
+ }
51
+ await mkdir(dirname(absolute), { recursive: true });
52
+ const temporary = join(dirname(absolute), `.xeno-edit-${randomUUID()}`);
53
+ try {
54
+ const file = await open(temporary, "wx", mode);
55
+ try { await file.writeFile(bytes); await file.chmod(mode); await file.sync(); } finally { await file.close(); }
56
+ await editablePath(root, path);
57
+ if (signal?.aborted) throw new ToolFault("cancelled", "file operation cancelled", READ_ATTEMPTED);
58
+ if (!sameVersion((await readEditableFile(root, path)).version, expected)) throw new ToolFault("version_mismatch", `file conflict: ${path}; read it again before editing`, READ_ATTEMPTED, { recover: { tool: "read", input: { path } } });
59
+ await rename(temporary, absolute);
60
+ const directory = await open(dirname(absolute), "r");
61
+ try { await directory.sync(); } finally { await directory.close(); }
62
+ } finally { await rm(temporary, { force: true }); }
63
+ }
64
+
65
+ export async function archiveEdit(blobs: string, before: Uint8Array | null, after: Uint8Array): Promise<void> {
66
+ if (before !== null) await putBlob(blobs, before);
67
+ await putBlob(blobs, after);
68
+ }
69
+
70
+ export async function preflightChanges(root: string, blobs: string, changes: FileChanges): Promise<void> {
71
+ for (const path of changedPaths(changes)) {
72
+ const change = changes[path]!;
73
+ if (!sameVersion((await readEditableFile(root, path)).version, change.before)) throw new Error(`file conflict: ${path}; external changes were preserved`);
74
+ if (change.after && !await verifyBlob(blobs, change.after.sha256)) throw new Error(`missing checkpoint object: ${path}`);
75
+ if (change.before && !await verifyBlob(blobs, change.before.sha256)) throw new Error(`missing recovery object: ${path}`);
76
+ }
77
+ }
78
+
79
+ /** 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);
82
+ const applied: string[] = [];
83
+ try {
84
+ for (const path of changedPaths(changes)) {
85
+ const { before, after } = changes[path]!;
86
+ const bytes = after === null ? null : await getBlobBytes(blobs, after.sha256);
87
+ if (after && bytes === null) throw new Error(`missing checkpoint object: ${path}`);
88
+ await replaceFile(root, path, before, bytes, after?.mode ?? 0o644);
89
+ applied.push(path);
90
+ }
91
+ } catch (error) {
92
+ const failures: string[] = [];
93
+ for (const path of applied.reverse()) {
94
+ const { before, after } = changes[path]!;
95
+ try {
96
+ const bytes = before === null ? null : await getBlobBytes(blobs, before.sha256);
97
+ if (before && bytes === null) throw new Error("missing recovery object");
98
+ await replaceFile(root, path, after, bytes, before?.mode ?? 0o644);
99
+ } catch { failures.push(path); }
100
+ }
101
+ if (failures.length) throw new Error(`restore interrupted; inspect partially applied paths: ${failures.join(", ")}; ${String(error)}`);
102
+ throw error;
103
+ }
104
+ }
@@ -23,11 +23,14 @@ export const MAX_INDEX_ENTRIES = 20_000;
23
23
  export interface FileIndex {
24
24
  readonly paths: readonly string[];
25
25
  readonly truncated: boolean;
26
+ readonly issues?: readonly { code: string; path?: string }[];
26
27
  }
27
28
 
28
29
  export interface IndexOptions {
29
30
  readonly ignore?: readonly string[];
30
31
  readonly maxEntries?: number;
32
+ readonly deadline?: number;
33
+ readonly signal?: AbortSignal;
31
34
  }
32
35
 
33
36
  function isIgnored(rel: string, ignore: readonly string[]): boolean {
@@ -40,17 +43,25 @@ export async function buildFileIndex(root: string, opts: IndexOptions = {}): Pro
40
43
  const max = opts.maxEntries ?? MAX_INDEX_ENTRIES;
41
44
  const paths: string[] = [];
42
45
  let truncated = false;
46
+ const issues: { code: string; path?: string }[] = [];
43
47
 
44
48
  async function walk(dir: string): Promise<void> {
45
49
  if (truncated) return;
50
+ if (opts.signal?.aborted || opts.deadline !== undefined && performance.now() >= opts.deadline) {
51
+ truncated = true; issues.push({ code: opts.signal?.aborted ? "cancelled" : "timeout" }); return;
52
+ }
46
53
  let entries: Dirent[];
47
54
  try {
48
55
  entries = await readdir(dir, { withFileTypes: true });
49
56
  } catch {
57
+ issues.push({ code: "unreadable_directory", path: relative(root, dir) || "." });
50
58
  return;
51
59
  }
52
60
  for (const entry of entries.sort((a, b) => (a.name < b.name ? -1 : 1))) {
53
61
  if (truncated) return;
62
+ if (opts.signal?.aborted || opts.deadline !== undefined && performance.now() >= opts.deadline) {
63
+ truncated = true; issues.push({ code: opts.signal?.aborted ? "cancelled" : "timeout" }); return;
64
+ }
54
65
  // Do not follow symlinks: a link to / walks the disk, and
55
66
  // node_modules/.bin is nothing but links. Same judgment as
56
67
  // feat-workspace-snapshots, which also skips them via isFile().
@@ -65,6 +76,7 @@ export async function buildFileIndex(root: string, opts: IndexOptions = {}): Pro
65
76
  if (!entry.isFile()) continue;
66
77
  if (paths.length >= max) {
67
78
  truncated = true;
79
+ issues.push({ code: "file_index_limit" });
68
80
  return;
69
81
  }
70
82
  paths.push(rel.split(sep).join("/"));
@@ -72,7 +84,7 @@ export async function buildFileIndex(root: string, opts: IndexOptions = {}): Pro
72
84
  }
73
85
 
74
86
  await walk(root);
75
- return { paths, truncated };
87
+ return { paths, truncated, ...(issues.length ? { issues } : {}) };
76
88
  }
77
89
 
78
90
  export function indexTruncationNote(index: FileIndex): string | null {
@@ -0,0 +1,67 @@
1
+ /** P.3/OpenCode v2: retained private index, scoped capture, path-only CAS export. */
2
+ import { mkdir, lstat } from "node:fs/promises";
3
+ import { join } from "node:path";
4
+ import { blobAddress, putBlob } from "./blobs.ts";
5
+ import { gitBytes, sourceInventory } from "./workspace-observation.ts";
6
+ import type { FileChanges, FileVersion } from "../core/checkpoints.ts";
7
+ import { atomicWrite } from "./atomic.ts";
8
+
9
+ const text = (bytes: Uint8Array) => new TextDecoder("utf-8", { fatal: true }).decode(bytes);
10
+ export interface GitCheckpoint { tree: string; modes: ReadonlyMap<string, number>; }
11
+ export class GitCheckpoints {
12
+ readonly root: string;
13
+ private readonly ownedPaths: readonly string[];
14
+ private initialized = false;
15
+ readonly directory: string;
16
+ constructor(root: string, storage: string, ownedPaths: readonly string[] = []) {
17
+ this.root = root; this.directory = join(storage, "git-checkpoints", blobAddress(root)); this.ownedPaths = ownedPaths;
18
+ }
19
+ private git(args: readonly string[], input?: Uint8Array, signal?: AbortSignal) {
20
+ return gitBytes(this.root, ["--literal-pathspecs", "-c", "core.autocrlf=false", "-c", "core.safecrlf=false", "-c", "core.attributesFile=/dev/null", "-c", "core.hooksPath=/dev/null", "-c", "gc.auto=0", ...args], input, signal,
21
+ { GIT_DIR: this.directory, GIT_WORK_TREE: this.root, GIT_INDEX_FILE: join(this.directory, "index"), GIT_CONFIG_NOSYSTEM: "1", GIT_CONFIG_GLOBAL: "/dev/null", GIT_ATTR_NOSYSTEM: "1" });
22
+ }
23
+ async capture(signal?: AbortSignal): Promise<GitCheckpoint> {
24
+ const inventory = await sourceInventory(this.root, signal, true, this.ownedPaths);
25
+ if (!this.initialized) {
26
+ await mkdir(this.directory, { recursive: true });
27
+ await gitBytes(this.root, ["init", "--bare", "--quiet", this.directory], undefined, signal);
28
+ // Git's highest-precedence attributes keep checkpoint bytes exact, even if the project uses LFS, encoding or EOL conversion.
29
+ await atomicWrite(join(this.directory, "info", "attributes"), "* -text -crlf -ident -filter -working-tree-encoding -eol\n");
30
+ this.initialized = true;
31
+ }
32
+ // Git trees retain only the executable bit; recovery needs the observed POSIX permissions as well.
33
+ const modes = new Map<string, number>();
34
+ for (const path of inventory.paths) {
35
+ const info = await lstat(join(this.root, path));
36
+ if (!info.isFile() || info.nlink > 1) throw new Error(`unsupported Git checkpoint path: ${path}`);
37
+ modes.set(path, info.mode & 0o777);
38
+ }
39
+ const previous = text(await this.git(["ls-files", "-z"], undefined, signal)).split("\0").filter(Boolean);
40
+ const present = new Set(inventory.paths);
41
+ const removed = previous.filter(path => !present.has(path));
42
+ if (removed.length) await this.git(["update-index", "--force-remove", "-z", "--stdin"], Buffer.from(removed.join("\0") + "\0"), signal);
43
+ // Feeding explicit paths avoids scanning ignored descendants and preserves names with newlines or leading dashes.
44
+ if (inventory.paths.length) await this.git(["add", "--force", "--pathspec-from-file=-", "--pathspec-file-nul"], Buffer.from(inventory.paths.join("\0") + "\0"), signal);
45
+ const tree = text(await this.git(["write-tree"], undefined, signal)).trim();
46
+ // Durable reachability is owned by Xeno, independent of the user's Git GC/index/HEAD.
47
+ await this.git(["update-ref", `refs/checkpoints/${tree}`, tree], undefined, signal);
48
+ return { tree, modes };
49
+ }
50
+ async changes(before: GitCheckpoint, after: GitCheckpoint, blobs: string, signal?: AbortSignal): Promise<FileChanges> {
51
+ const paths = new Set(before.tree === after.tree ? [] : text(await this.git(["diff-tree", "--no-renames", "--name-only", "-r", "-z", before.tree, after.tree], undefined, signal)).split("\0").filter(Boolean));
52
+ for (const [path, mode] of before.modes) if (mode !== after.modes.get(path)) paths.add(path);
53
+ for (const path of after.modes.keys()) if (!before.modes.has(path)) paths.add(path);
54
+ const version = async (snapshot: GitCheckpoint, path: string): Promise<FileVersion> => {
55
+ const entry = text(await this.git(["ls-tree", "-z", snapshot.tree, "--", path], undefined, signal));
56
+ if (!entry) return null;
57
+ const match = /^(100644|100755) blob ([a-f0-9]+)\t/.exec(entry);
58
+ const mode = snapshot.modes.get(path);
59
+ if (!match || mode === undefined) throw new Error(`unsupported Git checkpoint entry: ${path}`);
60
+ const bytes = await this.git(["cat-file", "blob", match[2]!], undefined, signal);
61
+ return { sha256: await putBlob(blobs, bytes), mode };
62
+ };
63
+ const changes: FileChanges = Object.create(null) as FileChanges;
64
+ for (const path of paths) changes[path] = { before: await version(before, path), after: await version(after, path) };
65
+ return changes;
66
+ }
67
+ }
@@ -53,41 +53,72 @@ const EXIT_DRAIN_MS = 1_000;
53
53
  async function spoolMerged(
54
54
  dir: string, streams: readonly ReadableStream<Uint8Array>[], exited: Promise<number>,
55
55
  onOutput?: (chunk: Uint8Array) => void,
56
- ): Promise<string> {
56
+ separate = false,
57
+ limitBytes?: number,
58
+ ): Promise<{ path: string; channels: string[]; complete: boolean }> {
57
59
  await mkdir(dir, { recursive: true });
58
60
  const path = join(dir, `.${randomUUID()}.stream`);
59
61
  const file = await open(path, "wx", 0o600);
62
+ const channels: string[] = [];
63
+ const channelFiles: Awaited<ReturnType<typeof open>>[] = [];
64
+ try {
65
+ if (separate) for (let i = 0; i < streams.length; i++) {
66
+ const channel = `${path}.${i}`;
67
+ channelFiles.push(await open(channel, "wx", 0o600)); channels.push(channel);
68
+ }
69
+ } catch (error) {
70
+ await Promise.allSettled([file.close(), ...channelFiles.map(item => item.close())]);
71
+ await Promise.all([path, ...channels].map(item => rm(item, { force: true })));
72
+ throw error;
73
+ }
60
74
  const readers = streams.map((stream) => stream.getReader());
61
75
  let queue: Promise<unknown> = Promise.resolve();
62
76
  let exitedAt: number | undefined;
63
77
  let idle: ReturnType<typeof setTimeout> | undefined;
64
78
  let drain: ReturnType<typeof setTimeout> | undefined;
65
- const stop = () => { for (const reader of readers) reader.cancel().catch(() => {}); };
79
+ let finished = false, complete = true, capturedBytes = 0;
80
+ const stop = () => { if (finished) return; complete = false; for (const reader of readers) reader.cancel().catch(() => {}); };
66
81
  const armIdle = () => {
67
- if (exitedAt === undefined) return;
82
+ if (finished || exitedAt === undefined) return;
68
83
  clearTimeout(idle);
69
84
  idle = setTimeout(stop, EXIT_IDLE_MS);
70
85
  };
71
- void exited.then(() => { exitedAt = performance.now(); armIdle(); drain = setTimeout(stop, EXIT_DRAIN_MS); }, () => {});
86
+ void exited.then(() => { if (finished) return; exitedAt = performance.now(); armIdle(); drain = setTimeout(stop, EXIT_DRAIN_MS); }, () => {});
72
87
  try {
73
- const drained = await Promise.allSettled(readers.map(async (reader) => {
74
- for (;;) {
75
- const result = await reader.read();
76
- if (result.done) return;
77
- armIdle();
78
- queue = queue.then(async () => { await file.writeFile(result.value); onOutput?.(result.value); });
79
- await queue;
80
- }
88
+ const drained = await Promise.allSettled(readers.map(async (reader, index) => {
89
+ try {
90
+ for (;;) {
91
+ const result = await reader.read();
92
+ if (result.done) return;
93
+ armIdle();
94
+ queue = queue.then(async () => {
95
+ await file.writeFile(result.value);
96
+ await channelFiles[index]?.writeFile(result.value);
97
+ onOutput?.(result.value);
98
+ capturedBytes += result.value.byteLength;
99
+ // Retain every delivered chunk, then cancel both readers. At most
100
+ // the in-flight crossing chunks exceed the bound; SIGTERM grace
101
+ // cannot turn an output limit into unlimited spool growth (R6).
102
+ if (limitBytes !== undefined && capturedBytes > limitBytes) stop();
103
+ });
104
+ await queue;
105
+ }
106
+ } catch (error) { stop(); throw error; }
81
107
  }));
82
108
  await queue;
83
109
  for (const result of drained) if (result.status === "rejected") throw result.reason;
84
- } catch (error) { await file.close(); await rm(path, { force: true }); throw error; }
110
+ } catch (error) {
111
+ await Promise.allSettled([file.close(), ...channelFiles.map(item => item.close())]);
112
+ await Promise.all([path, ...channels].map(item => rm(item, { force: true })));
113
+ throw error;
114
+ }
85
115
  finally {
116
+ finished = true;
86
117
  clearTimeout(idle); clearTimeout(drain);
87
118
  for (const reader of readers) reader.releaseLock();
88
119
  }
89
- await file.close();
90
- return path;
120
+ await Promise.all([file.close(), ...channelFiles.map(item => item.close())]);
121
+ return { path, channels, complete };
91
122
  }
92
123
 
93
124
  /** Last `limit` bytes of a stream, in O(limit) memory however long it runs. */
@@ -167,17 +198,27 @@ export async function captureCommandObservation(
167
198
  dir: string, stdout: ReadableStream<Uint8Array>, stderr: ReadableStream<Uint8Array>,
168
199
  exited: Promise<number>, previewBytes: number,
169
200
  onOutput?: (chunk: Uint8Array) => void,
170
- ): Promise<ArchivedObservation & { exit: number; prefixBytes: number }> {
171
- const merged = await spoolMerged(dir, [stdout, stderr], exited, onOutput);
201
+ separate = false,
202
+ limitBytes?: number,
203
+ ): Promise<ArchivedObservation & { exit: number; prefixBytes: number; channels: ArtifactRef[]; capture: "complete" | "partial" }> {
204
+ const merged = await spoolMerged(dir, [stdout, stderr], exited, onOutput, separate, limitBytes);
172
205
  try {
173
206
  const exit = await exited;
174
207
  const prefix = new TextEncoder().encode(`exit ${exit}\n`);
175
208
  async function* combined(): AsyncGenerator<Uint8Array> {
176
209
  yield prefix;
177
- for await (const chunk of createReadStream(merged)) yield chunk as Uint8Array;
210
+ for await (const chunk of createReadStream(merged.path)) yield chunk as Uint8Array;
211
+ }
212
+ async function* channel(path: string): AsyncGenerator<Uint8Array> {
213
+ for await (const chunk of createReadStream(path)) yield chunk as Uint8Array;
178
214
  }
179
- return { ...await archiveObservation(dir, combined(), previewBytes), exit, prefixBytes: prefix.byteLength };
215
+ // Drain all archive attempts before removing their source files, including failures.
216
+ const attempts = await Promise.allSettled([archiveObservation(dir, combined(), previewBytes),
217
+ ...merged.channels.map(path => archiveObservation(dir, channel(path), 0))]);
218
+ const stored = attempts.map(result => { if (result.status === "rejected") throw result.reason; return result.value; });
219
+ return { ...stored[0]!, exit, prefixBytes: prefix.byteLength, channels: stored.slice(1).map(item => item.ref),
220
+ capture: merged.complete ? "complete" : "partial" };
180
221
  } finally {
181
- await rm(merged, { force: true });
222
+ await Promise.all([merged.path, ...merged.channels].map(path => rm(path, { force: true })));
182
223
  }
183
224
  }
@@ -0,0 +1,34 @@
1
+ /** R7/R8: data_ref is the original receipt, not permission to substitute current process/file state. */
2
+ import type { LogEvent } from "../core/events.ts";
3
+ import { isLogEvent } from "../core/event-validation.ts";
4
+ import { readObservation } from "./observations.ts";
5
+
6
+ // Assumed per-query metadata budget. Exhaustion is explicit unknown, never fallback to older success.
7
+ export const PROCESS_RECEIPT_READ_BYTES = 8 * 1024 * 1024;
8
+ export async function hydrateProcessReceipts(nodes: readonly { node: number; events: readonly LogEvent[] }[], archive: string):
9
+ Promise<{ node: number; events: LogEvent[] }[]> {
10
+ const calls = new Map<string, string>();
11
+ for (const part of nodes) for (const event of part.events) {
12
+ if (event.t === "tool_invocation") calls.set(event.call_id, event.name);
13
+ if (event.t === "assistant") for (const block of event.blocks) if (block.type === "tool_use") calls.set(block.call_id, block.name);
14
+ }
15
+ let remaining = PROCESS_RECEIPT_READ_BYTES;
16
+ const result: { node: number; events: LogEvent[] }[] = [];
17
+ for (const part of nodes) {
18
+ const events: LogEvent[] = [];
19
+ for (const event of part.events) {
20
+ if (event.t !== "tool_result" || !event.data_ref || !["exec_command", "write_stdin", "target"].includes(calls.get(event.call_id) ?? "")) { events.push(event); continue; }
21
+ const ref = event.data_ref;
22
+ if (ref.bytes > remaining) throw new Error("process receipt metadata exceeds observation budget; state remains unknown");
23
+ remaining -= ref.bytes;
24
+ const page = await readObservation(archive, ref.sha256, 0, Math.max(1, ref.bytes));
25
+ if (page.bytes !== ref.bytes || page.next !== null) throw new Error("process receipt metadata length mismatch");
26
+ const { data_ref: _ref, ...original } = event;
27
+ const restored: unknown = { ...original, data: JSON.parse(page.text) };
28
+ if (!isLogEvent(restored)) throw new Error("archived process receipt contradicts its original event");
29
+ events.push(restored);
30
+ }
31
+ result.push({ node: part.node, events });
32
+ }
33
+ return result;
34
+ }
@@ -3,38 +3,43 @@ import { lstat, open } from "node:fs/promises";
3
3
  import { constants } from "node:fs";
4
4
  import { isAbsolute, relative, resolve, sep } from "node:path";
5
5
  import { createHash } from "node:crypto";
6
+ import { ToolFault, NOT_STARTED, READ_ATTEMPTED } from "../core/tool-failure.ts";
7
+ import { checkSourceCancellation } from "./source-cancellation.ts";
6
8
 
7
9
  // Assumed working-memory safety budget for the synchronous pure query engine.
8
10
  // Exceeding it is an explicit refusal, never a truncated calculation.
9
11
  export const MAX_QUERY_SOURCE_BYTES = 32 * 1024 * 1024;
10
- export async function readQuerySources(root: string, paths: unknown): Promise<{
12
+ export async function readQuerySources(root: string, paths: unknown, signal?: AbortSignal): Promise<{
11
13
  text: string; sources: { path: string; sha256: string; bytes: number }[];
12
14
  }> {
13
- if (!Array.isArray(paths) || paths.length === 0 || paths.length > 1024 || paths.some((p) => typeof p !== "string" || !p)) throw new Error("query paths must contain 1..1024 file paths in concatenation order");
15
+ if (!Array.isArray(paths) || paths.length === 0 || paths.length > 1024 || paths.some((p) => typeof p !== "string" || !p)) throw new ToolFault("invalid_input", "query paths must contain 1..1024 file paths in concatenation order", NOT_STARTED);
14
16
  const sources: { path: string; sha256: string; bytes: number }[] = [];
15
17
  const chunks: Uint8Array[] = [];
16
18
  let bytes = 0;
17
19
  for (const path of paths as string[]) {
20
+ checkSourceCancellation(signal);
18
21
  const absolute = resolve(root, path), rel = relative(root, absolute);
19
- if (isAbsolute(rel) || rel === ".." || rel.startsWith(`..${sep}`)) throw new Error("query path escapes workspace");
22
+ if (isAbsolute(rel) || rel === ".." || rel.startsWith(`..${sep}`)) throw new ToolFault("permission_denied", "query path escapes workspace", READ_ATTEMPTED);
20
23
  let current = resolve(root);
21
24
  for (const part of rel.split(sep)) {
22
25
  current = resolve(current, part);
23
- if ((await lstat(current)).isSymbolicLink()) throw new Error("query does not follow symbolic links");
26
+ if ((await lstat(current)).isSymbolicLink()) throw new ToolFault("permission_denied", "query does not follow symbolic links", READ_ATTEMPTED);
24
27
  }
25
- const file = await open(absolute, constants.O_RDONLY | constants.O_NOFOLLOW);
28
+ const file = await open(absolute, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
26
29
  try {
27
30
  const info = await file.stat();
28
- if (!info.isFile()) throw new Error("query source must be a regular file");
29
- if (bytes + info.size > MAX_QUERY_SOURCE_BYTES) throw new Error("query source exceeds 32 MiB resource budget; use a smaller input");
31
+ if (!info.isFile()) throw new ToolFault("invalid_input", "query source must be a regular file", READ_ATTEMPTED);
32
+ if (bytes + info.size > MAX_QUERY_SOURCE_BYTES) throw new ToolFault("invalid_input", "query source exceeds 32 MiB resource budget; use a smaller input", READ_ATTEMPTED);
30
33
  const hash = createHash("sha256");
31
34
  let sourceBytes = 0;
32
35
  for (;;) {
36
+ checkSourceCancellation(signal);
33
37
  const buffer = Buffer.alloc(Math.min(65536, MAX_QUERY_SOURCE_BYTES - bytes + 1));
34
38
  const result = await file.read(buffer, 0, buffer.length, null);
39
+ checkSourceCancellation(signal);
35
40
  if (result.bytesRead === 0) break;
36
41
  bytes += result.bytesRead; sourceBytes += result.bytesRead;
37
- if (bytes > MAX_QUERY_SOURCE_BYTES) throw new Error("query source grew past resource budget");
42
+ if (bytes > MAX_QUERY_SOURCE_BYTES) throw new ToolFault("invalid_input", "query source grew past resource budget", READ_ATTEMPTED);
38
43
  const data = buffer.subarray(0, result.bytesRead); hash.update(data); chunks.push(data);
39
44
  }
40
45
  sources.push({ path: rel.split(sep).join("/"), sha256: hash.digest("hex"), bytes: sourceBytes });
@@ -1,9 +1,8 @@
1
1
  /** Complete an explicitly logged cut; never infer a user's chosen branch. */
2
2
  import type { Cursor } from "./log.ts";
3
3
  import { closeNode, digestNode, initNode, listNodes, nodePath, readNode, readManifest, writeCursor } from "./log.ts";
4
- import { replayContext } from "./replay.ts";
4
+ import { replayContext, carriedHandoff } from "./replay.ts";
5
5
  import { deriveHandoff } from "../core/derive.ts";
6
- import { boundHandoff } from "../core/history.ts";
7
6
 
8
7
  export async function recoverCut(dir: string, cursor: Cursor): Promise<Cursor> {
9
8
  const source = await readNode(dir, cursor.node);
@@ -18,7 +17,7 @@ export async function recoverCut(dir: string, cursor: Cursor): Promise<Cursor> {
18
17
  self: { node: cursor.node, log_ref: nodePath(dir, cursor.node), sha256: await digestNode(dir, cursor.node) },
19
18
  });
20
19
  await closeNode(dir, cursor.node,
21
- cut.handoff_tokens === undefined ? handoff : boundHandoff(handoff, cut.handoff_tokens), source.events.length, cut.at);
20
+ await carriedHandoff(dir, cursor.node, handoff, cut, manifest), source.events.length, cut.at);
22
21
  }
23
22
  const successor = await readNode(dir, cut.next_node);
24
23
  if (successor.header === null) {
@@ -6,10 +6,11 @@
6
6
  */
7
7
  import { deriveHandoff } from "../core/derive.ts";
8
8
  import type { Handoff } from "../core/handoff.ts";
9
- import type { LogEvent } from "../core/events.ts";
10
- import { boundHandoff } from "../core/history.ts";
9
+ import type { CutEvent, LogEvent } from "../core/events.ts";
10
+ import { boundHandoff, boundWorkingHandoff, boundRecoveryHandoff } from "../core/history.ts";
11
11
  import { retainedContext } from "../core/context-window.ts";
12
12
  import { digestNode, nodePath, readAncestry, readManifest, type Manifest } from "./log.ts";
13
+ import { readNodeHistory } from "./restore.ts";
13
14
 
14
15
  export interface ReplayedContext {
15
16
  /** Events not already included in fullHandoff; use these for derivation. */
@@ -22,6 +23,18 @@ export interface ReplayedContext {
22
23
  depth: number;
23
24
  }
24
25
 
26
+ /** A live cut, cold replay and interrupted-cut repair must select the same version. */
27
+ export async function carriedHandoff(dir: string, node: number, full: Handoff,
28
+ policy: Pick<CutEvent, "handoff_tokens" | "working_set_version" | "guidance_tools">, manifest?: Manifest): Promise<Handoff> {
29
+ if (policy.handoff_tokens === undefined) return full;
30
+ if (policy.working_set_version === 2) {
31
+ const history = await readNodeHistory(dir, node, manifest, "context");
32
+ if (!history.ok) throw new Error(history.error);
33
+ return boundRecoveryHandoff(full, history.nodes, policy.handoff_tokens, policy.guidance_tools);
34
+ }
35
+ return (policy.working_set_version === 1 ? boundWorkingHandoff : boundHandoff)(full, policy.handoff_tokens);
36
+ }
37
+
25
38
  export async function replayContext(
26
39
  dir: string, node: number, manifest?: Manifest,
27
40
  ): Promise<ReplayedContext> {
@@ -54,8 +67,7 @@ export async function replayContext(
54
67
  self: { node: part.node, log_ref: nodePath(dir, part.node), sha256: await digestNode(dir, part.node) },
55
68
  });
56
69
  const tail = part.events.at(-1);
57
- carried = tail?.t === "cut" && tail.handoff_tokens !== undefined
58
- ? boundHandoff(handoff, tail.handoff_tokens) : handoff;
70
+ carried = tail?.t === "cut" ? await carriedHandoff(dir, part.node, handoff, tail, index) : handoff;
59
71
  retained = tail?.t === "cut" && tail.retained_from !== undefined
60
72
  ? retainedContext(contextEvents, tail.retained_from) : [];
61
73
  }
@@ -0,0 +1,64 @@
1
+ /** R8: OS inspection is confined to the exact owned PID; never discover a target by its name or port. */
2
+ import { realpath, readlink, stat } from "node:fs/promises";
3
+ import { dirname, basename, resolve, relative, sep } from "node:path";
4
+ import { connect } from "node:net";
5
+ import { observeArgvCommand } from "./command.ts";
6
+ import { readObservation } from "./observations.ts";
7
+ import { observeArtifactFiles, archiveCommandArtifacts } from "./command-artifacts.ts";
8
+ import { lsofOwnsImage, lsofOwnsPort } from "../core/runtime-target.ts";
9
+ import type { ArtifactRef } from "../core/events.ts";
10
+
11
+ // Assumed probe limits. Output beyond the budget and unavailable OS fields remain unknown.
12
+ const PROBE_BYTES = 64 * 1024;
13
+ const PROBE_MS = 2_000;
14
+ export async function targetDirectory(root: string, path: string): Promise<string> {
15
+ const base = await realpath(root), cwd = await realpath(resolve(base, path)), rel = relative(base, cwd);
16
+ if (rel === ".." || rel.startsWith(`..${sep}`) || rel.startsWith(sep) || !(await stat(cwd)).isDirectory()) throw Error("target workdir escapes the workspace or is not a directory");
17
+ return cwd;
18
+ }
19
+ export async function targetProgram(path: string, signal?: AbortSignal) {
20
+ const absolute = await realpath(path);
21
+ const [file] = await observeArtifactFiles(dirname(absolute), [basename(absolute)], signal);
22
+ if (file?.state !== "observed") throw Error("target executable cannot be observed as a stable ordinary file");
23
+ return { path: absolute, sha256: file.sha256, device: file.identity.device, inode: file.identity.inode };
24
+ }
25
+ export async function observeRuntimeIdentity(pid: number, program: { path: string; device: string; inode: string }, port: number | undefined,
26
+ outputStore: string, signal?: AbortSignal): Promise<{ image: boolean; port: boolean | null; known: boolean; raw: ArtifactRef[]; reason: string }> {
27
+ const raw: ArtifactRef[] = [];
28
+ const lsof = process.platform === "darwin" ? "/usr/sbin/lsof" : "/usr/bin/lsof";
29
+ const probe = async (args: string[]) => {
30
+ const result = await observeArgvCommand({ argv: [lsof, ...args], cwd: "/", outputStore, maxOutputBytes: PROBE_BYTES,
31
+ timeoutMs: PROBE_MS, outputLimitBytes: PROBE_BYTES, ...(signal ? { signal } : {}) });
32
+ raw.push(result.stdout, result.stderr);
33
+ raw.push(await archiveCommandArtifacts(outputStore, { argv: [lsof, ...args], cwd: "/", exit: result.exit, capture: result.capture,
34
+ stdout: result.stdout, stderr: result.stderr, stopped: result.stopped ?? null, cleanup: result.cleanup ?? null }));
35
+ if (result.stopped || result.cleanup || result.capture !== "complete" || result.stdout.bytes > PROBE_BYTES || ![0, 1].includes(result.exit)) throw Error("OS target observation was incomplete");
36
+ return (await readObservation(outputStore, result.stdout.sha256, 0, PROBE_BYTES)).text;
37
+ };
38
+ try {
39
+ let image: boolean;
40
+ if (process.platform === "darwin") image = lsofOwnsImage(await probe(["-a", "-p", String(pid), "-d", "txt", "-F0pftDin"]), pid, program.device, program.inode);
41
+ else if (process.platform === "linux") {
42
+ const path = `/proc/${pid}/exe`, observed = await stat(path, { bigint: true });
43
+ const link = await readlink(path), device = String(observed.dev), inode = String(observed.ino);
44
+ raw.push(await archiveCommandArtifacts(outputStore, { path, link, device, inode }));
45
+ image = link === program.path && device === program.device && inode === program.inode;
46
+ } else return { image: false, port: port === undefined ? null : false, known: false, raw, reason: "OS executable identity is unsupported on this platform" };
47
+ const ownsPort = port === undefined ? null : lsofOwnsPort(await probe(["-nP", "-a", "-p", String(pid), `-iTCP:${port}`, "-sTCP:LISTEN", "-F0pftPnT"]), pid, port);
48
+ const reachable = port === undefined ? null : ownsPort && await loopbackReady(port, signal);
49
+ return { image, port: reachable, known: true, raw, reason: !image ? "owned process executable does not match the launch observation" : reachable === false ? "the owned PID does not serve the declared IPv4 loopback port" : "owned executable and declared endpoint observed" };
50
+ } catch { return { image: false, port: port === undefined ? null : false, known: false, raw, reason: "OS target identity could not be established" }; }
51
+ }
52
+
53
+ async function loopbackReady(port: number, signal?: AbortSignal): Promise<boolean> {
54
+ if (signal?.aborted) return false;
55
+ return new Promise(resolve => {
56
+ const socket = connect({ host: "127.0.0.1", port });
57
+ const done = (ok: boolean) => { signal?.removeEventListener("abort", abort); socket.destroy(); resolve(ok); };
58
+ const abort = () => done(false);
59
+ socket.setTimeout(PROBE_MS, () => done(false));
60
+ socket.once("connect", () => done(true)); socket.once("error", () => done(false));
61
+ signal?.addEventListener("abort", abort, { once: true });
62
+ if (signal?.aborted) abort();
63
+ });
64
+ }