@xingular/harness 1.1.1 → 1.3.1-unstable.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 (264) hide show
  1. package/CHANGELOG.md +48 -0
  2. package/README.md +36 -28
  3. package/dist/cli.js +10837 -6159
  4. package/dist/sdk/accounts/accounts.d.ts +27 -3
  5. package/dist/sdk/accounts/accounts.js +78 -24
  6. package/dist/sdk/accounts/secrets.d.ts +12 -0
  7. package/dist/sdk/accounts/secrets.js +114 -7
  8. package/dist/sdk/accounts/store.d.ts +26 -1
  9. package/dist/sdk/accounts/store.js +1 -1
  10. package/dist/sdk/agents/builtin.js +18 -4
  11. package/dist/sdk/agents/planResearch.d.ts +27 -0
  12. package/dist/sdk/agents/planResearch.js +82 -0
  13. package/dist/sdk/agents/profiles.d.ts +5 -1
  14. package/dist/sdk/agents/profiles.js +21 -5
  15. package/dist/sdk/annotations/pageScript.js +349 -48
  16. package/dist/sdk/annotations/part.js +34 -23
  17. package/dist/sdk/annotations/store.d.ts +7 -0
  18. package/dist/sdk/annotations/store.js +13 -5
  19. package/dist/sdk/browser/backend.d.ts +16 -0
  20. package/dist/sdk/browser/backend.js +17 -0
  21. package/dist/sdk/browser/contracts.d.ts +19 -0
  22. package/dist/sdk/browser/contracts.js +116 -0
  23. package/dist/sdk/browser/tools.d.ts +1 -4
  24. package/dist/sdk/browser/tools.js +14 -91
  25. package/dist/sdk/capabilities/runSnapshot.js +10 -4
  26. package/dist/sdk/cli.js +5 -4
  27. package/dist/sdk/commands/list.d.ts +3 -0
  28. package/dist/sdk/commands/list.js +75 -0
  29. package/dist/sdk/commands/registry.d.ts +258 -0
  30. package/dist/sdk/commands/registry.js +48 -0
  31. package/dist/sdk/commands/routes.d.ts +16 -0
  32. package/dist/sdk/commands/routes.js +33 -0
  33. package/dist/sdk/core/commandBudget.d.ts +23 -0
  34. package/dist/sdk/core/commandBudget.js +57 -0
  35. package/dist/sdk/core/index.js +17 -1
  36. package/dist/sdk/core/interactiveProfile.d.ts +46 -0
  37. package/dist/sdk/core/interactiveProfile.js +143 -0
  38. package/dist/sdk/core/shell.d.ts +20 -0
  39. package/dist/sdk/core/shell.js +73 -0
  40. package/dist/sdk/core/workspace.d.ts +13 -0
  41. package/dist/sdk/core/workspace.js +88 -0
  42. package/dist/sdk/core/workspaceBashTool.d.ts +20 -0
  43. package/dist/sdk/core/workspaceBashTool.js +50 -0
  44. package/dist/sdk/core/workspaceSandbox.d.ts +41 -0
  45. package/dist/sdk/core/workspaceSandbox.js +360 -0
  46. package/dist/sdk/core/workspaceWriteOperations.d.ts +33 -0
  47. package/dist/sdk/core/workspaceWriteOperations.js +224 -0
  48. package/dist/sdk/doctor/checks.js +71 -12
  49. package/dist/sdk/doctor/types.d.ts +2 -2
  50. package/dist/sdk/harness.d.ts +7 -0
  51. package/dist/sdk/harness.js +42 -2
  52. package/dist/sdk/hub/hub.js +5 -3
  53. package/dist/sdk/index.d.ts +8 -3
  54. package/dist/sdk/index.js +4 -1
  55. package/dist/sdk/pi/runtime.d.ts +2 -1
  56. package/dist/sdk/pi/runtime.js +2 -2
  57. package/dist/sdk/plugins/builtin.js +32 -15
  58. package/dist/sdk/plugins/builtinRegistry.d.ts +10 -3
  59. package/dist/sdk/plugins/builtinRegistry.js +30 -8
  60. package/dist/sdk/plugins/catalogue.js +2 -1
  61. package/dist/sdk/plugins/commandPrefix.d.ts +8 -0
  62. package/dist/sdk/plugins/commandPrefix.js +21 -0
  63. package/dist/sdk/plugins/firstParty.d.ts +1 -1
  64. package/dist/sdk/plugins/firstParty.js +8 -2
  65. package/dist/sdk/plugins/install.d.ts +6 -2
  66. package/dist/sdk/plugins/install.js +29 -5
  67. package/dist/sdk/plugins/inventory.js +6 -5
  68. package/dist/sdk/plugins/manifest.d.ts +2 -0
  69. package/dist/sdk/plugins/manifest.js +2 -1
  70. package/dist/sdk/plugins/surfaces.d.ts +5 -0
  71. package/dist/sdk/plugins/surfaces.js +11 -0
  72. package/dist/sdk/plugins/types.d.ts +15 -0
  73. package/dist/sdk/prompt/compile.d.ts +14 -0
  74. package/dist/sdk/prompt/compile.js +65 -31
  75. package/dist/sdk/prompt/standard.d.ts +17 -0
  76. package/dist/sdk/prompt/standard.js +21 -0
  77. package/dist/sdk/server/methods.js +22 -2
  78. package/dist/sdk/server/remote.js +36 -11
  79. package/dist/sdk/sessions/compactionCheckpoint.d.ts +11 -0
  80. package/dist/sdk/sessions/compactionCheckpoint.js +79 -0
  81. package/dist/sdk/sessions/continuation.d.ts +7 -2
  82. package/dist/sdk/sessions/continuation.js +11 -6
  83. package/dist/sdk/sessions/contracts.d.ts +190 -4
  84. package/dist/sdk/sessions/contracts.js +71 -8
  85. package/dist/sdk/sessions/control.d.ts +72 -0
  86. package/dist/sdk/sessions/control.js +158 -0
  87. package/dist/sdk/sessions/initialBash.d.ts +7 -0
  88. package/dist/sdk/sessions/initialBash.js +7 -0
  89. package/dist/sdk/sessions/planMode.d.ts +14 -3
  90. package/dist/sdk/sessions/planMode.js +74 -17
  91. package/dist/sdk/sessions/registry.d.ts +1 -1
  92. package/dist/sdk/sessions/registry.js +2 -2
  93. package/dist/sdk/sessions/runStore.d.ts +14 -0
  94. package/dist/sdk/sessions/runStore.js +38 -1
  95. package/dist/sdk/sessions/runner.d.ts +40 -2
  96. package/dist/sdk/sessions/runner.js +161 -13
  97. package/dist/sdk/sessions/sessions.d.ts +23 -3
  98. package/dist/sdk/sessions/sessions.js +225 -22
  99. package/dist/sdk/sessions/subagentService.d.ts +24 -0
  100. package/dist/sdk/sessions/subagentService.js +225 -26
  101. package/dist/sdk/sessions/subagents.d.ts +10 -1
  102. package/dist/sdk/sessions/subagents.js +4 -0
  103. package/dist/sdk/sessions/workSlice.d.ts +106 -0
  104. package/dist/sdk/sessions/workSlice.js +329 -0
  105. package/dist/sdk/sessions/workspaceBoundary.d.ts +15 -0
  106. package/dist/sdk/sessions/workspaceBoundary.js +46 -0
  107. package/dist/sdk/terminal/browserControl.js +2 -1
  108. package/dist/sdk/terminal/controller.d.ts +31 -3
  109. package/dist/sdk/terminal/controller.js +81 -29
  110. package/dist/sdk/terminal/opentui/ansi.d.ts +25 -0
  111. package/dist/sdk/terminal/opentui/ansi.js +192 -0
  112. package/dist/sdk/terminal/opentui/model.d.ts +163 -0
  113. package/dist/sdk/terminal/opentui/model.js +482 -0
  114. package/dist/sdk/terminal/opentui/subagentsWidget.d.ts +3 -3
  115. package/dist/sdk/terminal/opentui/subagentsWidget.js +7 -5
  116. package/dist/sdk/terminal/opentui/theme.d.ts +43 -0
  117. package/dist/sdk/terminal/opentui/theme.js +88 -0
  118. package/dist/sdk/terminal/piExtension.d.ts +6 -1
  119. package/dist/sdk/terminal/piExtension.js +177 -63
  120. package/dist/sdk/terminal/standalone.d.ts +7 -0
  121. package/dist/sdk/terminal/standalone.js +72 -13
  122. package/dist/sdk/terminal/subagentMonitor.d.ts +28 -6
  123. package/dist/sdk/terminal/subagentMonitor.js +61 -9
  124. package/dist/sdk/terminal/terminals.d.ts +26 -2
  125. package/dist/sdk/terminal/terminals.js +325 -43
  126. package/dist/sdk/terminal/trajectory.d.ts +6 -1
  127. package/dist/sdk/terminal/trajectory.js +8 -0
  128. package/dist/sdk/terminal/tui/annotateCommand.d.ts +1 -1
  129. package/dist/sdk/terminal/tui/annotateCommand.js +3 -3
  130. package/dist/sdk/terminal/tui/chrome.d.ts +4 -0
  131. package/dist/sdk/terminal/tui/chrome.js +13 -0
  132. package/dist/sdk/terminal/tui/commandPages.d.ts +3 -1
  133. package/dist/sdk/terminal/tui/commandPages.js +17 -4
  134. package/dist/sdk/terminal/tui/commands.d.ts +6 -0
  135. package/dist/sdk/terminal/tui/commands.js +213 -124
  136. package/dist/sdk/terminal/tui/list.d.ts +14 -1
  137. package/dist/sdk/terminal/tui/list.js +96 -14
  138. package/dist/sdk/terminal/tui/pageOverlay.js +21 -5
  139. package/dist/sdk/terminal/tui/pages/annotatePage.d.ts +1 -1
  140. package/dist/sdk/terminal/tui/pages/browserPage.js +5 -4
  141. package/dist/sdk/terminal/tui/pages/hubPage.d.ts +14 -1
  142. package/dist/sdk/terminal/tui/pages/hubPage.js +192 -28
  143. package/dist/sdk/terminal/tui/pages/notesPage.d.ts +5 -5
  144. package/dist/sdk/terminal/tui/pages/notesPage.js +18 -18
  145. package/dist/sdk/terminal/tui/pages/pluginsPage.d.ts +8 -6
  146. package/dist/sdk/terminal/tui/pages/pluginsPage.js +206 -28
  147. package/dist/sdk/terminal/tui/pages/subagentsPage.d.ts +1 -0
  148. package/dist/sdk/terminal/tui/pages/subagentsPage.js +104 -19
  149. package/dist/sdk/terminal/tui/pages/usagePage.d.ts +43 -0
  150. package/dist/sdk/terminal/tui/pages/usagePage.js +370 -0
  151. package/dist/sdk/terminal/tui/piAdapter.d.ts +1 -2
  152. package/dist/sdk/terminal/tui/piAdapter.js +3 -5
  153. package/dist/sdk/terminal/tui/pluginCommands.d.ts +2 -0
  154. package/dist/sdk/terminal/tui/pluginCommands.js +13 -9
  155. package/dist/sdk/terminal/tui/runtime.js +23 -3
  156. package/dist/sdk/terminal/tui/sessionPicker.d.ts +40 -0
  157. package/dist/sdk/terminal/tui/sessionPicker.js +141 -0
  158. package/dist/sdk/tools/checkpoint.d.ts +27 -0
  159. package/dist/sdk/tools/checkpoint.js +154 -0
  160. package/dist/sdk/tools/getCapabilities.d.ts +31 -0
  161. package/dist/sdk/tools/getCapabilities.js +34 -0
  162. package/dist/sdk/tools/sessionName.d.ts +2 -0
  163. package/dist/sdk/tools/sessionName.js +5 -3
  164. package/dist/sdk/tools/spawnSubagent.js +11 -8
  165. package/dist/sdk/tools/todo.d.ts +15 -0
  166. package/dist/sdk/tools/todo.js +64 -0
  167. package/dist/sdk/usage/analytics.d.ts +20 -0
  168. package/dist/sdk/usage/analytics.js +151 -0
  169. package/dist/sdk/usage/ledger.d.ts +38 -0
  170. package/dist/sdk/usage/ledger.js +142 -0
  171. package/dist/sdk/usage/limits/adapters.d.ts +11 -0
  172. package/dist/sdk/usage/limits/adapters.js +349 -0
  173. package/dist/sdk/usage/limits/capture.d.ts +8 -0
  174. package/dist/sdk/usage/limits/capture.js +47 -0
  175. package/dist/sdk/usage/limits/http.d.ts +12 -0
  176. package/dist/sdk/usage/limits/http.js +51 -0
  177. package/dist/sdk/usage/limits/normalize.d.ts +15 -0
  178. package/dist/sdk/usage/limits/normalize.js +211 -0
  179. package/dist/sdk/usage/limits/service.d.ts +30 -0
  180. package/dist/sdk/usage/limits/service.js +176 -0
  181. package/dist/sdk/usage/limits/types.d.ts +65 -0
  182. package/dist/sdk/usage/limits/types.js +1 -0
  183. package/dist/sdk/usage/serverMethods.d.ts +3 -0
  184. package/dist/sdk/usage/serverMethods.js +22 -0
  185. package/dist/sdk/usage/service.d.ts +32 -0
  186. package/dist/sdk/usage/service.js +66 -0
  187. package/dist/sdk/usage/settings.d.ts +7 -0
  188. package/dist/sdk/usage/settings.js +28 -0
  189. package/dist/sdk/usage/types.d.ts +60 -0
  190. package/dist/sdk/usage/types.js +4 -0
  191. package/dist/terminal-opentui.js +9204 -4812
  192. package/dist/terminal-pi.js +1848 -272
  193. package/docs/architecture/00-overview.md +1 -1
  194. package/docs/architecture/01-extension-points.md +1 -1
  195. package/docs/architecture/02-sessions-and-runs.md +169 -36
  196. package/docs/architecture/04-prompt.md +55 -15
  197. package/docs/architecture/06-plugins-and-capabilities.md +36 -15
  198. package/docs/architecture/07-tools-and-gate.md +131 -25
  199. package/docs/architecture/08-memory-and-history.md +4 -2
  200. package/docs/architecture/09-accounts-and-connectors.md +39 -3
  201. package/docs/architecture/10-server-protocol.md +71 -9
  202. package/docs/architecture/11-terminal.md +148 -60
  203. package/docs/architecture/12-storage.md +30 -2
  204. package/docs/architecture/13-world-tools.md +22 -1
  205. package/docs/architecture/14-surfaces-and-parity.md +58 -8
  206. package/docs/architecture/README.md +20 -17
  207. package/docs/architecture/proposals/PROP-0006-run-checklist.md +2 -0
  208. package/docs/architecture/proposals/PROP-0017-page-annotations.md +6 -2
  209. package/docs/architecture/proposals/PROP-0018-annotation-queue-and-app-targets.md +1 -1
  210. package/docs/architecture/proposals/PROP-0031-plugin-command-owner-namespaces.md +54 -0
  211. package/docs/architecture/proposals/PROP-0032-planning-quality.md +196 -0
  212. package/docs/architecture/proposals/PROP-0033-compaction-plan-checkpoints.md +75 -0
  213. package/docs/architecture/proposals/PROP-0034-conversation-fast-mode.md +120 -0
  214. package/docs/architecture/proposals/PROP-0035-retain-subagent-status.md +47 -0
  215. package/docs/architecture/proposals/PROP-0036-first-class-harness-commands.md +59 -0
  216. package/docs/architecture/proposals/PROP-0037-workspace-write-boundary.md +63 -0
  217. package/docs/architecture/proposals/PROP-0038-annotate-open-shadow-roots.md +73 -0
  218. package/docs/architecture/proposals/PROP-0039-workspace-boundary-hardening.md +139 -0
  219. package/docs/architecture/proposals/PROP-0040-one-launch-path.md +87 -0
  220. package/docs/architecture/proposals/PROP-0041-bounded-work-slices.md +258 -0
  221. package/docs/architecture/proposals/PROP-0042-usage-and-provider-limits.md +127 -0
  222. package/docs/architecture/proposals/PROP-0043-read-only-plan-bash.md +74 -0
  223. package/docs/architecture/proposals/PROP-0044-work-slice-defaults.md +93 -0
  224. package/docs/architecture/proposals/PROP-0045-rename-checklist-to-todo.md +132 -0
  225. package/docs/architecture/proposals/PROP-0046-progress-aware-stall-guards.md +82 -0
  226. package/docs/architecture/proposals/PROP-0047-plugins-hub-separation.md +122 -0
  227. package/docs/architecture/proposals/README.md +24 -6
  228. package/docs/decisions/ADR-0001-owned-terminal.md +1 -1
  229. package/docs/decisions/ADR-0021-required-command-namespaces.md +1 -1
  230. package/docs/decisions/ADR-0031-plugin-command-owner-namespaces.md +37 -0
  231. package/docs/decisions/ADR-0032-compaction-run-checkpoints.md +33 -0
  232. package/docs/decisions/ADR-0033-plan-mode-research-delegation.md +47 -0
  233. package/docs/decisions/ADR-0034-first-class-harness-terminal-commands.md +25 -0
  234. package/docs/decisions/ADR-0035-one-launch-path.md +40 -0
  235. package/docs/decisions/ADR-0036-workspace-write-operation-boundary.md +54 -0
  236. package/docs/decisions/ADR-0037-usage-ledger-and-provider-limits.md +44 -0
  237. package/docs/decisions/ADR-0038-plan-bash-os-read-only.md +29 -0
  238. package/docs/decisions/ADR-0039-progress-aware-stall-guards.md +39 -0
  239. package/docs/decisions/ADR-0040-architecture-change-workflow.md +31 -0
  240. package/docs/decisions/ADR-0041-plugins-hub-separation.md +41 -0
  241. package/docs/decisions/ADR-0042-windows-shell-without-sandbox.md +40 -0
  242. package/docs/decisions/ADR-0043-parents-continue-stopped-subagents.md +49 -0
  243. package/docs/doctor.md +14 -1
  244. package/docs/experiments/EXP-0003-performance-audit/data/session-runtime-replication.json +420 -0
  245. package/docs/experiments/EXP-0003-performance-audit/data/session-runtime.json +420 -0
  246. package/docs/experiments/EXP-0003-performance-audit/data/terminal-compact.json +376 -0
  247. package/docs/experiments/EXP-0003-performance-audit/data/terminal-history.json +422 -0
  248. package/docs/experiments/EXP-0003-performance-audit/data/terminal-question.json +95 -0
  249. package/docs/experiments/EXP-0003-performance-audit/data/terminal-wide.json +1036 -0
  250. package/docs/experiments/EXP-0003-performance-audit/plan.md +109 -0
  251. package/docs/experiments/EXP-0003-performance-audit/results.md +151 -0
  252. package/docs/handoff-2026-10-25-app-parity-and-solo-tabs.md +202 -0
  253. package/docs/parity.md +83 -27
  254. package/docs/releases/1.1.1.md +28 -0
  255. package/docs/releases/1.2.0.md +49 -0
  256. package/docs/releases/1.3.0.md +21 -0
  257. package/docs/releases/4.0.0.md +10 -9
  258. package/docs/releases/README.md +35 -30
  259. package/docs/study/00-index.md +3 -0
  260. package/docs/study/STUDY-0003-long-horizon-runs.md +210 -0
  261. package/docs/terminal-tui.md +162 -82
  262. package/package.json +4 -3
  263. package/dist/sdk/tools/checklist.d.ts +0 -9
  264. package/dist/sdk/tools/checklist.js +0 -54
@@ -0,0 +1,360 @@
1
+ import { AsyncLocalStorage } from 'node:async_hooks';
2
+ import { spawn } from 'node:child_process';
3
+ import { randomUUID } from 'node:crypto';
4
+ import { accessSync, constants, existsSync, lstatSync, mkdtempSync, realpathSync, readdirSync, rmSync, statSync } from 'node:fs';
5
+ import { tmpdir } from 'node:os';
6
+ import { join } from 'node:path';
7
+ import { spawnSync } from 'node:child_process';
8
+ import { hostShell, shellArguments } from "./shell.js";
9
+ import { isWithinWorkspace, verifyWorkspaceRoot } from "./workspace.js";
10
+ const MARKER_PREFIX = '__XINGULAR_WORKSPACE_SANDBOX_STARTED_';
11
+ const bashWriteScope = new AsyncLocalStorage();
12
+ export function runWithWorkspaceWriteScope(scope, action) {
13
+ return bashWriteScope.run(scope, action);
14
+ }
15
+ /** Why a Windows command asks first: Windows has no write-boundary sandbox the harness can use. */
16
+ export const WINDOWS_UNSANDBOXED_REASON = 'Windows has no workspace write-boundary sandbox, so this command would run without one. It will not run until this one-call request is approved.';
17
+ const SANDBOX_BACKENDS = {
18
+ darwin: ['/usr/bin/sandbox-exec'],
19
+ linux: ['/usr/bin/bwrap', '/bin/bwrap']
20
+ };
21
+ /**
22
+ * The operating system's write-boundary sandbox for this platform, or null when it is not
23
+ * installed. Bash cannot run without it, so readiness diagnostics report it before a command runs.
24
+ */
25
+ export function workspaceSandboxBackend(platform = process.platform) {
26
+ const candidates = SANDBOX_BACKENDS[platform];
27
+ return candidates ? systemExecutable(candidates) : null;
28
+ }
29
+ function systemExecutable(candidates) {
30
+ for (const candidate of candidates) {
31
+ try {
32
+ accessSync(candidate, constants.X_OK);
33
+ if (statSync(candidate).isFile())
34
+ return candidate;
35
+ }
36
+ catch {
37
+ // Keep looking only at known system locations.
38
+ }
39
+ }
40
+ return null;
41
+ }
42
+ function shellPath() {
43
+ const shell = ['/bin/bash', '/usr/bin/bash'].find((candidate) => existsSync(candidate));
44
+ if (!shell)
45
+ throw new Error('The workspace write-boundary sandbox requires Bash, but no Bash executable was found.');
46
+ return shell;
47
+ }
48
+ function requireShell(resolve) {
49
+ const shell = resolve();
50
+ if (!shell) {
51
+ throw new Error(process.platform === 'win32'
52
+ ? 'No shell was found: neither Git Bash nor PowerShell is available. Bash was not run.'
53
+ : 'No Bash executable was found. Bash was not run.');
54
+ }
55
+ return shell;
56
+ }
57
+ function unconfinedCommand(shell, command) {
58
+ return { executable: shell.executable, args: shellArguments(shell, command), marker: '' };
59
+ }
60
+ function sbplString(value) {
61
+ return `"${value.replaceAll('\\', '\\\\').replaceAll('"', '\\"').replaceAll('\n', '\\n')}"`;
62
+ }
63
+ // Read-only commands need no mach services; denying them all keeps a command from acting through
64
+ // system daemons (preferences, Apple Events, Launch Services), which the file rules do not cover.
65
+ function macReadOnlyProfile(tempRoot) {
66
+ return [
67
+ '(version 1)',
68
+ '(deny default)',
69
+ '(allow file-read*)',
70
+ `(allow file-write* (subpath ${sbplString(tempRoot)}))`,
71
+ '(allow file-write* (literal "/dev/null"))',
72
+ '(allow file-write* (literal "/dev/tty"))',
73
+ '(deny file-link)',
74
+ '(allow process-fork)',
75
+ '(allow process-exec)',
76
+ '(allow signal (target same-sandbox))',
77
+ '(allow sysctl-read)'
78
+ ].join('');
79
+ }
80
+ function macSandboxProfile(writeRoots) {
81
+ return [
82
+ '(version 1)',
83
+ '(deny default)',
84
+ '(allow file-read*)',
85
+ ...writeRoots.map((root) => `(allow file-write* (subpath ${sbplString(root)}))`),
86
+ '(allow file-write* (literal "/dev/null"))',
87
+ '(allow file-write* (literal "/dev/tty"))',
88
+ '(deny file-link)',
89
+ '(allow process*)',
90
+ '(allow network*)',
91
+ '(allow sysctl-read)',
92
+ '(allow mach-lookup)'
93
+ ].join('');
94
+ }
95
+ export function workspaceSandboxCommand(platform, workspaceRoot, cwd, command, additionalWriteRoots = [], readOnlyTempRoot = null) {
96
+ const shell = shellPath();
97
+ const marker = `${MARKER_PREFIX}${randomUUID()}__`;
98
+ const wrappedCommand = `printf '%s\\n' '${marker}'\n${command}`;
99
+ if (readOnlyTempRoot !== null)
100
+ return readOnlySandboxCommand(platform, realpathSync(readOnlyTempRoot), cwd, shell, wrappedCommand, marker);
101
+ const writeRoots = [...new Set([workspaceRoot, ...additionalWriteRoots].map((root) => realpathSync(root)))].sort((left, right) => left.length - right.length);
102
+ if (platform === 'darwin') {
103
+ const executable = workspaceSandboxBackend(platform);
104
+ if (!executable)
105
+ throw new Error('Workspace write-boundary sandbox unavailable: sandbox-exec was not found. Bash was not run.');
106
+ return { executable, args: ['-p', macSandboxProfile(writeRoots), shell, '-c', wrappedCommand], marker };
107
+ }
108
+ if (platform === 'linux') {
109
+ const executable = workspaceSandboxBackend(platform);
110
+ if (!executable)
111
+ throw new Error('Workspace write-boundary sandbox unavailable: install bubblewrap (bwrap). Bash was not run.');
112
+ return {
113
+ executable,
114
+ marker,
115
+ args: [
116
+ '--die-with-parent', '--new-session', '--unshare-pid',
117
+ '--ro-bind', '/', '/',
118
+ '--dev', '/dev',
119
+ '--proc', '/proc',
120
+ ...writeRoots.flatMap((root) => ['--bind', root, root]),
121
+ '--chdir', cwd,
122
+ shell, '-c', wrappedCommand
123
+ ]
124
+ };
125
+ }
126
+ throw new Error(`Workspace write-boundary sandbox unavailable on ${platform}. Bash was not run.`);
127
+ }
128
+ function readOnlySandboxCommand(platform, tempRoot, cwd, shell, wrappedCommand, marker) {
129
+ if (platform === 'darwin') {
130
+ const executable = workspaceSandboxBackend(platform);
131
+ if (!executable)
132
+ throw new Error('Read-only Bash sandbox unavailable: sandbox-exec was not found. Bash was not run.');
133
+ return { executable, args: ['-p', macReadOnlyProfile(tempRoot), shell, '-c', wrappedCommand], marker };
134
+ }
135
+ if (platform === 'linux') {
136
+ const executable = workspaceSandboxBackend(platform);
137
+ if (!executable)
138
+ throw new Error('Read-only Bash sandbox unavailable: install bubblewrap (bwrap). Bash was not run.');
139
+ return {
140
+ executable,
141
+ marker,
142
+ args: [
143
+ '--die-with-parent', '--new-session', '--unshare-pid', '--unshare-net', '--unshare-ipc',
144
+ '--ro-bind', '/', '/',
145
+ '--dev', '/dev',
146
+ '--proc', '/proc',
147
+ // No session bus or other host sockets under /run.
148
+ '--tmpfs', '/run',
149
+ '--bind', tempRoot, tempRoot,
150
+ '--chdir', cwd,
151
+ shell, '-c', wrappedCommand
152
+ ]
153
+ };
154
+ }
155
+ throw new Error(`Read-only Bash sandbox unavailable on ${platform}. Bash was not run.`);
156
+ }
157
+ function killProcess(child) {
158
+ if (child.pid && process.platform === 'win32') {
159
+ // Git Bash and PowerShell start their own children; end the whole tree.
160
+ spawnSync('taskkill', ['/pid', String(child.pid), '/T', '/F'], { stdio: 'ignore', windowsHide: true });
161
+ return;
162
+ }
163
+ if (child.pid) {
164
+ try {
165
+ process.kill(-child.pid, 'SIGKILL');
166
+ return;
167
+ }
168
+ catch {
169
+ // Fall back to the direct child if it has already left its process group.
170
+ }
171
+ }
172
+ child.kill('SIGKILL');
173
+ }
174
+ function runProcess(spec, cwd, options, marker) {
175
+ if (options.signal?.aborted)
176
+ return Promise.reject(new Error('aborted'));
177
+ return new Promise((resolve, reject) => {
178
+ const child = spawn(spec.executable, spec.args, {
179
+ cwd,
180
+ detached: process.platform !== 'win32',
181
+ env: options.env ?? process.env,
182
+ stdio: ['ignore', 'pipe', 'pipe'],
183
+ windowsHide: true
184
+ });
185
+ let pending = '';
186
+ let started = marker === null;
187
+ let timedOut = false;
188
+ let settled = false;
189
+ let timeoutHandle;
190
+ const deliver = (chunk) => {
191
+ const text = chunk.toString('utf8');
192
+ if (!started && marker) {
193
+ pending += text;
194
+ const markerAt = pending.indexOf(marker);
195
+ if (markerAt === -1) {
196
+ // Preserve only a short prefix in case the shell emitted a startup warning.
197
+ if (pending.length > marker.length + 2048) {
198
+ options.onData(Buffer.from(pending.slice(0, pending.length - marker.length - 2048)));
199
+ pending = pending.slice(-(marker.length + 2048));
200
+ }
201
+ return;
202
+ }
203
+ const before = pending.slice(0, markerAt);
204
+ const after = pending.slice(markerAt + marker.length).replace(/^\r?\n/u, '');
205
+ if (before)
206
+ options.onData(Buffer.from(before));
207
+ if (after)
208
+ options.onData(Buffer.from(after));
209
+ pending = '';
210
+ started = true;
211
+ return;
212
+ }
213
+ options.onData(chunk);
214
+ };
215
+ const finish = (code) => {
216
+ if (settled)
217
+ return;
218
+ settled = true;
219
+ if (timeoutHandle)
220
+ clearTimeout(timeoutHandle);
221
+ options.signal?.removeEventListener('abort', onAbort);
222
+ if (!started && pending)
223
+ options.onData(Buffer.from(pending));
224
+ if (options.signal?.aborted)
225
+ reject(new Error('aborted'));
226
+ else if (timedOut)
227
+ reject(new Error(`timeout:${options.timeout}`));
228
+ else
229
+ resolve({ exitCode: code });
230
+ };
231
+ const onAbort = () => killProcess(child);
232
+ if (options.timeout !== undefined && options.timeout > 0) {
233
+ timeoutHandle = setTimeout(() => {
234
+ timedOut = true;
235
+ killProcess(child);
236
+ }, options.timeout * 1000);
237
+ }
238
+ options.signal?.addEventListener('abort', onAbort, { once: true });
239
+ child.stdout?.on('data', deliver);
240
+ child.stderr?.on('data', deliver);
241
+ child.once('error', (error) => {
242
+ if (settled)
243
+ return;
244
+ settled = true;
245
+ if (timeoutHandle)
246
+ clearTimeout(timeoutHandle);
247
+ options.signal?.removeEventListener('abort', onAbort);
248
+ reject(error);
249
+ });
250
+ child.once('close', finish);
251
+ });
252
+ }
253
+ function assertNoExternalHardlinks(workspaceRoot) {
254
+ const inodes = new Map();
255
+ const visit = (directory) => {
256
+ let entries;
257
+ try {
258
+ entries = readdirSync(directory, { withFileTypes: true });
259
+ }
260
+ catch (error) {
261
+ throw new Error(`Workspace hard-link inspection failed at ${directory}; Bash was not run: ${error instanceof Error ? error.message : String(error)}`);
262
+ }
263
+ for (const entry of entries) {
264
+ const path = `${directory}/${entry.name}`;
265
+ let stats;
266
+ try {
267
+ stats = lstatSync(path);
268
+ }
269
+ catch (error) {
270
+ throw new Error(`Workspace hard-link inspection failed at ${path}; Bash was not run: ${error instanceof Error ? error.message : String(error)}`);
271
+ }
272
+ if (stats.isSymbolicLink())
273
+ continue;
274
+ if (stats.isDirectory()) {
275
+ visit(path);
276
+ continue;
277
+ }
278
+ if (!stats.isFile())
279
+ continue;
280
+ const key = `${stats.dev}:${stats.ino}`;
281
+ const inode = inodes.get(key);
282
+ if (inode)
283
+ inode.internalLinks += 1;
284
+ else
285
+ inodes.set(key, { internalLinks: 1, totalLinks: stats.nlink, samplePath: path });
286
+ }
287
+ };
288
+ visit(workspaceRoot);
289
+ for (const inode of inodes.values()) {
290
+ if (inode.totalLinks > inode.internalLinks) {
291
+ throw new Error(`Workspace write-boundary sandbox cannot safely run Bash because ${inode.samplePath} has a hard-link alias outside the workspace; remove the alias or replace the file with a copy.`);
292
+ }
293
+ }
294
+ }
295
+ export function createWorkspaceBashOperations(workspaceRoot, options = {}) {
296
+ return {
297
+ async exec(command, cwd, execOptions) {
298
+ const rootValue = typeof workspaceRoot === 'function' ? workspaceRoot() : workspaceRoot;
299
+ if (!rootValue)
300
+ throw new Error('The active project workspace is not available. Bash was not run.');
301
+ const root = verifyWorkspaceRoot(rootValue);
302
+ const canonicalCwd = realpathSync(cwd);
303
+ if (!isWithinWorkspace(root, canonicalCwd))
304
+ throw new Error('The Bash working directory is outside the active project workspace.');
305
+ const scope = options.workspaceWriteScope?.() ?? bashWriteScope.getStore() ?? 'workspace';
306
+ const platform = options.platform ?? process.platform;
307
+ const shell = options.shell ?? hostShell;
308
+ // Nothing widens a read-only scope: no Full Permission bypass, no outside request.
309
+ if (scope === 'read-only') {
310
+ if (platform === 'win32')
311
+ throw new Error('Plan-mode Bash needs a read-only sandbox, which Windows does not have. Bash was not run.');
312
+ const tempRoot = realpathSync(mkdtempSync(join(tmpdir(), 'harness-read-only-bash-')));
313
+ try {
314
+ const spec = workspaceSandboxCommand(platform, root, canonicalCwd, command, [], tempRoot);
315
+ const env = { ...(execOptions.env ?? process.env), TMPDIR: tempRoot };
316
+ const result = await runProcess(spec, canonicalCwd, { ...execOptions, env }, spec.marker);
317
+ return { exitCode: result.exitCode };
318
+ }
319
+ finally {
320
+ rmSync(tempRoot, { recursive: true, force: true });
321
+ }
322
+ }
323
+ let bypass = false;
324
+ try {
325
+ bypass = await options.bypassWorkspaceBoundary?.({ command, cwd: canonicalCwd, workspaceRoot: root }) ?? false;
326
+ }
327
+ catch {
328
+ bypass = false;
329
+ }
330
+ if (bypass) {
331
+ const result = await runProcess(unconfinedCommand(requireShell(shell), command), canonicalCwd, execOptions, null);
332
+ return { exitCode: result.exitCode };
333
+ }
334
+ // Without a sandbox every Windows command is an outside request: a person approves it once.
335
+ if (scope === 'outside' || platform === 'win32') {
336
+ const resolved = requireShell(shell);
337
+ const reason = scope === 'outside'
338
+ ? 'This command requests filesystem writes outside the active project workspace. It will not run until this one-call request is approved.'
339
+ : WINDOWS_UNSANDBOXED_REASON;
340
+ let approved = false;
341
+ try {
342
+ approved = await options.requestWorkspaceWriteScope?.({ command, cwd: canonicalCwd, workspaceRoot: root, reason }) ?? false;
343
+ }
344
+ catch {
345
+ approved = false;
346
+ }
347
+ if (!approved)
348
+ throw new Error(`${reason} Bash was not run.`);
349
+ if (scope === 'outside')
350
+ execOptions.onData(Buffer.from('\n[Outside-workspace writes approved for this command; running once.]\n'));
351
+ const result = await runProcess(unconfinedCommand(resolved, command), canonicalCwd, execOptions, null);
352
+ return { exitCode: result.exitCode };
353
+ }
354
+ assertNoExternalHardlinks(root);
355
+ const spec = workspaceSandboxCommand(platform, root, canonicalCwd, command);
356
+ const result = await runProcess(spec, canonicalCwd, execOptions, spec.marker);
357
+ return { exitCode: result.exitCode };
358
+ }
359
+ };
360
+ }
@@ -0,0 +1,33 @@
1
+ export interface WorkspaceWriteAuthorization {
2
+ workspaceRoot: string;
3
+ cwd: string;
4
+ path: string;
5
+ outside: boolean;
6
+ authorized: boolean;
7
+ expectedContentHash?: string;
8
+ signal?: AbortSignal;
9
+ }
10
+ export interface AtomicWriteLaunch {
11
+ executable: string;
12
+ args: string[];
13
+ env: NodeJS.ProcessEnv;
14
+ }
15
+ /**
16
+ * How the atomic write helper starts. macOS and Linux run it inside the write-boundary sandbox.
17
+ * Windows has none, so Node runs the helper directly; the target was still canonicalized and
18
+ * authorized, and the rename replaces the directory entry rather than writing through an alias.
19
+ */
20
+ export declare function atomicWriteLaunch(platform: NodeJS.Platform, root: string, additionalRoots: readonly string[]): AtomicWriteLaunch;
21
+ declare function writeWorkspaceFile(path: string, content: string): Promise<void>;
22
+ export declare const workspaceWriteOperations: {
23
+ mkdir: (_directory: string) => Promise<void>;
24
+ writeFile: typeof writeWorkspaceFile;
25
+ };
26
+ export declare const workspaceEditOperations: {
27
+ access: (path: string) => Promise<void>;
28
+ readFile: (path: string) => Promise<Buffer>;
29
+ writeFile: typeof writeWorkspaceFile;
30
+ };
31
+ /** Binds one policy-approved path and abort signal to Pi's native write/edit operation. */
32
+ export declare function withWorkspaceWriteAuthorization<T>(tool: T, authorizations: Map<string, WorkspaceWriteAuthorization>): T;
33
+ export {};
@@ -0,0 +1,224 @@
1
+ import { AsyncLocalStorage } from 'node:async_hooks';
2
+ import { spawn } from 'node:child_process';
3
+ import { createHash } from 'node:crypto';
4
+ import { constants as fsConstants, realpathSync, statSync } from 'node:fs';
5
+ import { access, readFile } from 'node:fs/promises';
6
+ import { dirname, resolve } from 'node:path';
7
+ import { isWithinWorkspace, resolveWritePath, verifyWorkspaceRoot } from "./workspace.js";
8
+ import { workspaceSandboxCommand } from "./workspaceSandbox.js";
9
+ const writeAuthorization = new AsyncLocalStorage();
10
+ const atomicWriteScript = `
11
+ const fs = require('node:fs')
12
+ const path = require('node:path')
13
+ const crypto = require('node:crypto')
14
+ const input = JSON.parse(fs.readFileSync(0, 'utf8'))
15
+ const target = input.path
16
+ const parent = path.dirname(target)
17
+ fs.mkdirSync(parent, { recursive: true })
18
+ let mode = 0o666
19
+ let preserveMode = false
20
+ try {
21
+ const existing = fs.lstatSync(target)
22
+ if (existing.isSymbolicLink() || !existing.isFile()) throw new Error('The write target is not a regular file.')
23
+ fs.accessSync(target, fs.constants.W_OK)
24
+ if (input.expectedContentHash) {
25
+ const current = fs.readFileSync(target)
26
+ if (crypto.createHash('sha256').update(current).digest('hex') !== input.expectedContentHash) {
27
+ throw new Error('The file changed after the edit tool read it; retry against the current contents.')
28
+ }
29
+ }
30
+ mode = existing.mode & 0o7777
31
+ preserveMode = true
32
+ } catch (error) {
33
+ if (error && error.code !== 'ENOENT') throw error
34
+ }
35
+ const temporary = path.join(parent, '.' + path.basename(target) + '.' + process.pid + '.' + crypto.randomBytes(8).toString('hex') + '.tmp')
36
+ let descriptor
37
+ try {
38
+ descriptor = fs.openSync(temporary, 'wx', mode)
39
+ fs.writeFileSync(descriptor, input.content, 'utf8')
40
+ if (preserveMode) fs.fchmodSync(descriptor, mode)
41
+ fs.fsyncSync(descriptor)
42
+ fs.closeSync(descriptor)
43
+ descriptor = undefined
44
+ fs.renameSync(temporary, target)
45
+ try {
46
+ const directory = fs.openSync(parent, 'r')
47
+ try { fs.fsyncSync(directory) } finally { fs.closeSync(directory) }
48
+ } catch {}
49
+ } catch (error) {
50
+ if (descriptor !== undefined) fs.closeSync(descriptor)
51
+ try { fs.unlinkSync(temporary) } catch {}
52
+ throw error
53
+ }
54
+ `;
55
+ /**
56
+ * How the atomic write helper starts. macOS and Linux run it inside the write-boundary sandbox.
57
+ * Windows has none, so Node runs the helper directly; the target was still canonicalized and
58
+ * authorized, and the rename replaces the directory entry rather than writing through an alias.
59
+ */
60
+ export function atomicWriteLaunch(platform, root, additionalRoots) {
61
+ if (platform === 'win32') {
62
+ // Node needs SystemRoot to start on Windows; keep only what the helper requires.
63
+ const env = {};
64
+ for (const name of ['SystemRoot', 'windir', 'TEMP', 'TMP', 'USERPROFILE']) {
65
+ if (process.env[name])
66
+ env[name] = process.env[name];
67
+ }
68
+ return { executable: process.execPath, args: ['-e', atomicWriteScript], env };
69
+ }
70
+ const command = `exec ${shellQuote(process.execPath)} -e ${shellQuote(atomicWriteScript)}`;
71
+ const spec = workspaceSandboxCommand(platform, root, root, command, additionalRoots);
72
+ return {
73
+ executable: spec.executable,
74
+ args: spec.args,
75
+ env: { PATH: '/usr/bin:/bin', ...(process.env.HOME ? { HOME: process.env.HOME } : {}) }
76
+ };
77
+ }
78
+ function shellQuote(value) {
79
+ return `'${value.replaceAll("'", "'\\''")}'`;
80
+ }
81
+ function nearestExistingDirectory(path) {
82
+ let current = dirname(path);
83
+ while (true) {
84
+ try {
85
+ const canonical = realpathSync(current);
86
+ if (!statSync(canonical).isDirectory())
87
+ throw new Error(`Write scope is not a directory: ${canonical}`);
88
+ return canonical;
89
+ }
90
+ catch (error) {
91
+ if (error.code !== 'ENOENT')
92
+ throw error;
93
+ const parent = dirname(current);
94
+ if (parent === current)
95
+ throw new Error(`Could not find a writable parent for ${path}.`);
96
+ current = parent;
97
+ }
98
+ }
99
+ }
100
+ function killProcess(child) {
101
+ if (child.pid && process.platform !== 'win32') {
102
+ try {
103
+ process.kill(-child.pid, 'SIGKILL');
104
+ return;
105
+ }
106
+ catch {
107
+ // Fall back to the direct child if it has already left its process group.
108
+ }
109
+ }
110
+ child.kill('SIGKILL');
111
+ }
112
+ async function runAtomicWrite(authorization, path, content) {
113
+ const root = verifyWorkspaceRoot(authorization.workspaceRoot);
114
+ const canonicalPath = resolveWritePath(path, authorization.cwd);
115
+ if (canonicalPath !== authorization.path)
116
+ throw new Error('The write target changed after workspace approval; the write was blocked.');
117
+ const outside = !isWithinWorkspace(root, canonicalPath);
118
+ if (outside !== authorization.outside || (outside && !authorization.authorized)) {
119
+ throw new Error('The write authorization no longer matches the active workspace target.');
120
+ }
121
+ const additionalRoots = outside ? [nearestExistingDirectory(canonicalPath)] : [];
122
+ const launch = atomicWriteLaunch(process.platform, root, additionalRoots);
123
+ await new Promise((resolvePromise, rejectPromise) => {
124
+ const child = spawn(launch.executable, launch.args, {
125
+ cwd: root,
126
+ detached: process.platform !== 'win32',
127
+ env: launch.env,
128
+ stdio: ['pipe', 'pipe', 'pipe'],
129
+ windowsHide: true
130
+ });
131
+ const output = [];
132
+ let outputBytes = 0;
133
+ let settled = false;
134
+ const timeout = setTimeout(() => {
135
+ killProcess(child);
136
+ finish(new Error('Workspace-contained atomic write timed out.'));
137
+ }, 30_000);
138
+ const capture = (chunk) => {
139
+ if (outputBytes >= 8_192)
140
+ return;
141
+ const remaining = 8_192 - outputBytes;
142
+ output.push(chunk.subarray(0, remaining));
143
+ outputBytes += Math.min(chunk.length, remaining);
144
+ };
145
+ const finish = (error) => {
146
+ if (settled)
147
+ return;
148
+ settled = true;
149
+ clearTimeout(timeout);
150
+ authorization.signal?.removeEventListener('abort', abort);
151
+ if (error)
152
+ rejectPromise(error);
153
+ else
154
+ resolvePromise();
155
+ };
156
+ const abort = () => {
157
+ killProcess(child);
158
+ finish(new Error('Workspace-contained atomic write was aborted.'));
159
+ };
160
+ authorization.signal?.addEventListener('abort', abort, { once: true });
161
+ if (authorization.signal?.aborted) {
162
+ abort();
163
+ return;
164
+ }
165
+ child.stdout?.on('data', capture);
166
+ child.stderr?.on('data', capture);
167
+ child.once('error', (error) => finish(error));
168
+ child.once('close', (code) => {
169
+ if (code === 0) {
170
+ finish();
171
+ return;
172
+ }
173
+ const detail = Buffer.concat(output).toString('utf8').trim();
174
+ finish(new Error(`Workspace-contained atomic write failed${detail ? `: ${detail.slice(-2000)}` : ` with exit code ${code ?? 'unknown'}`}.`));
175
+ });
176
+ child.stdin?.on('error', () => { });
177
+ child.stdin?.end(JSON.stringify({
178
+ path: canonicalPath,
179
+ content,
180
+ ...(authorization.expectedContentHash ? { expectedContentHash: authorization.expectedContentHash } : {})
181
+ }));
182
+ });
183
+ }
184
+ async function writeWorkspaceFile(path, content) {
185
+ const authorization = writeAuthorization.getStore();
186
+ if (!authorization?.authorized)
187
+ throw new Error('The file write has no current workspace authorization.');
188
+ await runAtomicWrite(authorization, path, content);
189
+ }
190
+ export const workspaceWriteOperations = {
191
+ mkdir: async (_directory) => { },
192
+ writeFile: writeWorkspaceFile
193
+ };
194
+ export const workspaceEditOperations = {
195
+ access: async (path) => access(path, fsConstants.R_OK | fsConstants.W_OK),
196
+ readFile: async (path) => {
197
+ const content = await readFile(path);
198
+ const authorization = writeAuthorization.getStore();
199
+ if (authorization && resolve(path) === authorization.path) {
200
+ authorization.expectedContentHash = createHash('sha256').update(content).digest('hex');
201
+ }
202
+ return content;
203
+ },
204
+ writeFile: writeWorkspaceFile
205
+ };
206
+ /** Binds one policy-approved path and abort signal to Pi's native write/edit operation. */
207
+ export function withWorkspaceWriteAuthorization(tool, authorizations) {
208
+ const native = tool;
209
+ const execute = native.execute.bind(tool);
210
+ return {
211
+ ...tool,
212
+ execute: (...args) => {
213
+ const toolCallId = String(args[0] ?? '');
214
+ const authorization = authorizations.get(toolCallId);
215
+ authorizations.delete(toolCallId);
216
+ if (!authorization?.authorized)
217
+ throw new Error('The file write has no current workspace authorization.');
218
+ const signal = args[2];
219
+ if (signal && typeof signal === 'object' && 'aborted' in signal)
220
+ authorization.signal = signal;
221
+ return writeAuthorization.run(authorization, () => execute(...args));
222
+ }
223
+ };
224
+ }