@piagent/platform 1.37.0 → 1.38.1

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 (292) hide show
  1. package/CHANGELOG.md +70 -0
  2. package/README.md +5 -3
  3. package/README.vi.md +2 -2
  4. package/catalog/capabilities.json +2 -2
  5. package/docs/command-reference-vietnamese.md +4 -3
  6. package/docs/distribution-standard.md +6 -6
  7. package/docs/en/maintainer-guide.md +8 -0
  8. package/docs/en/workspace-editor.md +222 -0
  9. package/docs/languages.json +13 -4
  10. package/docs/operator-manual-vietnamese.md +7 -7
  11. package/docs/publishing-for-teams.md +3 -0
  12. package/docs/release-install-policy.md +2 -2
  13. package/docs/research/ide-m0-spike.md +101 -0
  14. package/docs/team-onboarding.md +2 -2
  15. package/docs/vi/maintainer-guide.md +8 -0
  16. package/docs/vi/workspace-editor.md +217 -0
  17. package/docs-site/architecture.html +1 -1
  18. package/docs-site/benchmark.html +1 -1
  19. package/docs-site/commands.html +1 -1
  20. package/docs-site/content/en/index.html +4 -4
  21. package/docs-site/content/en/whats-new.html +23 -0
  22. package/docs-site/content/index.html +4 -4
  23. package/docs-site/content/whats-new.html +23 -0
  24. package/docs-site/context-engine.html +1 -1
  25. package/docs-site/ecosystem.html +1 -1
  26. package/docs-site/en/architecture.html +1 -1
  27. package/docs-site/en/benchmark.html +1 -1
  28. package/docs-site/en/commands.html +1 -1
  29. package/docs-site/en/context-engine.html +1 -1
  30. package/docs-site/en/ecosystem.html +1 -1
  31. package/docs-site/en/index.html +5 -5
  32. package/docs-site/en/mcp-approval.html +1 -1
  33. package/docs-site/en/mcp-auth.html +1 -1
  34. package/docs-site/en/mcp-servers.html +1 -1
  35. package/docs-site/en/mcp.html +1 -1
  36. package/docs-site/en/permissions.html +1 -1
  37. package/docs-site/en/profiles.html +1 -1
  38. package/docs-site/en/quickstart.html +1 -1
  39. package/docs-site/en/runtime.html +1 -1
  40. package/docs-site/en/scope.html +1 -1
  41. package/docs-site/en/security.html +1 -1
  42. package/docs-site/en/team.html +1 -1
  43. package/docs-site/en/whats-new.html +24 -1
  44. package/docs-site/en/windows.html +1 -1
  45. package/docs-site/en/workflows.html +1 -1
  46. package/docs-site/index.html +5 -5
  47. package/docs-site/mcp-approval.html +1 -1
  48. package/docs-site/mcp-auth.html +1 -1
  49. package/docs-site/mcp-servers.html +1 -1
  50. package/docs-site/mcp.html +1 -1
  51. package/docs-site/permissions.html +1 -1
  52. package/docs-site/profiles.html +1 -1
  53. package/docs-site/quickstart.html +1 -1
  54. package/docs-site/runtime.html +1 -1
  55. package/docs-site/scope.html +1 -1
  56. package/docs-site/security.html +1 -1
  57. package/docs-site/team.html +1 -1
  58. package/docs-site/whats-new.html +24 -1
  59. package/docs-site/windows.html +1 -1
  60. package/docs-site/workflows.html +1 -1
  61. package/evals/fixtures/piagent-webui/checkpoint-v1.invalid.json +21 -0
  62. package/evals/fixtures/piagent-webui/checkpoint-v1.valid.json +20 -0
  63. package/evals/fixtures/piagent-webui/terminal-v1.invalid.json +17 -0
  64. package/evals/fixtures/piagent-webui/terminal-v1.valid.json +16 -0
  65. package/evals/fixtures/piagent-webui/workspace-file-v1.invalid.json +24 -0
  66. package/evals/fixtures/piagent-webui/workspace-file-v1.valid.json +23 -0
  67. package/evals/fixtures/piagent-webui/workspace-paths-v1.invalid.json +24 -0
  68. package/evals/fixtures/piagent-webui/workspace-paths-v1.valid.json +24 -0
  69. package/evals/fixtures/piagent-webui/workspace-search-v1.invalid.json +26 -0
  70. package/evals/fixtures/piagent-webui/workspace-search-v1.valid.json +26 -0
  71. package/evals/fixtures/piagent-webui/workspace-tree-v1.invalid.json +28 -0
  72. package/evals/fixtures/piagent-webui/workspace-tree-v1.valid.json +27 -0
  73. package/evals/fixtures/piagent-webui/workspace-write-v1.invalid.json +19 -0
  74. package/evals/fixtures/piagent-webui/workspace-write-v1.valid.json +19 -0
  75. package/evals/long-task-soak-v1/README.md +17 -0
  76. package/evals/long-task-soak-v1/lib/session-loop.mjs +19 -3
  77. package/evals/long-task-soak-v1/scenarios/ide-editor-terminal.json +19 -0
  78. package/evals/long-task-soak-v1/surfaces/webui-gateway.mjs +17 -2
  79. package/evals/long-task-soak-v1/surfaces/workspace-actions.mjs +95 -0
  80. package/evals/private-holdout-v1/public-exposure.v2.json +2 -2
  81. package/package.json +7 -3
  82. package/packages/piagent-core/extensions/fallback-base-policy.ts +1 -1
  83. package/packages/piagent-core/extensions/guard-types.ts +3 -1
  84. package/packages/piagent-core/extensions/path-suggestions.ts +137 -0
  85. package/packages/piagent-core/extensions/piagent-guard.ts +85 -7
  86. package/packages/piagent-core/extensions/workspace-inspection.ts +145 -0
  87. package/packages/piagent-core/extensions/workspace-policy.d.ts +7 -0
  88. package/packages/piagent-core/extensions/workspace-policy.js +3 -0
  89. package/packages/piagent-core/extensions/workspace-search-index.mjs +8 -0
  90. package/packages/piagent-core/extensions/workspace-search.ts +74 -0
  91. package/packages/piagent-core/extensions/workspace-write.ts +116 -0
  92. package/packages/piagent-core/managed/background-process.mjs +9 -4
  93. package/packages/piagent-core/managed/boundary-package-proxy.mjs +21 -0
  94. package/packages/piagent-core/managed/boundary-queue.mjs +24 -0
  95. package/packages/piagent-core/managed/checkpoint-git.mjs +16 -0
  96. package/packages/piagent-core/managed/checkpoints.mjs +17 -64
  97. package/packages/piagent-core/managed/command-tools.mjs +6 -6
  98. package/packages/piagent-core/managed/continuation.mjs +3 -0
  99. package/packages/piagent-core/managed/dist/workspace/assets/adapters/backend-api/profile.json +88 -0
  100. package/packages/piagent-core/managed/dist/workspace/assets/adapters/be-readonly-fe/profile.json +130 -0
  101. package/packages/piagent-core/managed/dist/workspace/assets/adapters/data/profile.json +88 -0
  102. package/packages/piagent-core/managed/dist/workspace/assets/adapters/devops/profile.json +90 -0
  103. package/packages/piagent-core/managed/dist/workspace/assets/adapters/docs/profile.json +74 -0
  104. package/packages/piagent-core/managed/dist/workspace/assets/adapters/fullstack/profile.json +92 -0
  105. package/packages/piagent-core/managed/dist/workspace/assets/adapters/generic/profile.json +95 -0
  106. package/packages/piagent-core/managed/dist/workspace/assets/adapters/mobile/profile.json +86 -0
  107. package/packages/piagent-core/managed/dist/workspace/assets/adapters/node-typescript/profile.json +74 -0
  108. package/packages/piagent-core/managed/dist/workspace/assets/adapters/python/profile.json +75 -0
  109. package/packages/piagent-core/managed/dist/workspace/assets/adapters/web-frontend/profile.json +99 -0
  110. package/packages/piagent-core/managed/dist/workspace/assets/local-state-path-BrmghxEZ.js +284 -0
  111. package/packages/piagent-core/managed/dist/workspace/assets/packages/piagent-core/policies/base-policy.json +351 -0
  112. package/packages/piagent-core/managed/dist/workspace/manifest.json +65 -0
  113. package/packages/piagent-core/managed/dist/workspace/worker.mjs +7374 -0
  114. package/packages/piagent-core/managed/dist/workspace/workspace-search-index.mjs +1007 -0
  115. package/packages/piagent-core/managed/helper-run.mjs +11 -1
  116. package/packages/piagent-core/managed/helper-settlement.mjs +17 -6
  117. package/packages/piagent-core/managed/ip-policy.mjs +50 -0
  118. package/packages/piagent-core/managed/memory-lessons.mjs +4 -1
  119. package/packages/piagent-core/managed/memory.mjs +1 -1
  120. package/packages/piagent-core/managed/package-proxy.mjs +12 -14
  121. package/packages/piagent-core/managed/patch-snapshot.mjs +6 -8
  122. package/packages/piagent-core/managed/permission.mjs +29 -6
  123. package/packages/piagent-core/managed/repository-operation.mjs +2 -1
  124. package/packages/piagent-core/managed/review-snapshot-request.mjs +21 -0
  125. package/packages/piagent-core/managed/sandbox-diagnostic.mjs +2 -0
  126. package/packages/piagent-core/managed/session-mutation-observations.mjs +37 -0
  127. package/packages/piagent-core/managed/session.mjs +6 -7
  128. package/packages/piagent-core/managed/skill-feedback.mjs +9 -1
  129. package/packages/piagent-core/managed/source-revert-requests.mjs +21 -0
  130. package/packages/piagent-core/managed/studio-link.mjs +16 -0
  131. package/packages/piagent-core/managed/terminal-boundary.mjs +88 -0
  132. package/packages/piagent-core/managed/terminal-channel.mjs +66 -0
  133. package/packages/piagent-core/managed/terminal-runtime.mjs +64 -0
  134. package/packages/piagent-core/managed/terminal-seatbelt.mjs +48 -0
  135. package/packages/piagent-core/managed/terminal-worker.mjs +70 -0
  136. package/packages/piagent-core/managed/tool-boundary.mjs +45 -35
  137. package/packages/piagent-core/managed/tool-freshness.mjs +47 -0
  138. package/packages/piagent-core/managed/tool-worker.mjs +18 -3
  139. package/packages/piagent-core/managed/web-fetch.mjs +3 -14
  140. package/packages/piagent-core/managed/workspace-worker-pins.mjs +26 -0
  141. package/packages/piagent-core/package.json +1 -1
  142. package/packages/piagent-core/policies/base-policy.json +2 -1
  143. package/packages/piagent-core/runtime/checkpoints/checkpoint-authority.ts +43 -0
  144. package/packages/piagent-core/runtime/checkpoints/checkpoint-refs.mjs +51 -0
  145. package/packages/piagent-core/runtime/checkpoints/checkpoint-store.mjs +179 -0
  146. package/packages/piagent-core/runtime/checkpoints/checkpoint-worker.ts +28 -0
  147. package/packages/piagent-core/runtime/checkpoints/git-adapter.mjs +36 -0
  148. package/packages/piagent-core/runtime/checkpoints/persistent-history.mjs +96 -0
  149. package/packages/piagent-core/runtime/checkpoints/personal-git.ts +272 -0
  150. package/packages/piagent-core/runtime/checkpoints/personal-runtime.ts +158 -0
  151. package/packages/piagent-core/runtime/checkpoints/prune.mjs +29 -0
  152. package/packages/piagent-core/runtime/checkpoints/restore-preview.mjs +46 -0
  153. package/packages/piagent-core/runtime/checkpoints/turn-checkpoints.mjs +167 -0
  154. package/packages/piagent-core/runtime/hooks/session-hooks.ts +19 -16
  155. package/packages/piagent-core/runtime/hooks/session-start-hook.ts +3 -0
  156. package/packages/piagent-core/runtime/hooks/tool-call-hook.ts +2 -2
  157. package/packages/piagent-core/runtime/inspection/diff-projection.ts +2 -1
  158. package/packages/piagent-core/runtime/inspection/managed-source-revert.ts +90 -0
  159. package/packages/piagent-core/runtime/inspection/review-state-projection.ts +19 -8
  160. package/packages/piagent-core/runtime/inspection/session-diff-projection.ts +47 -0
  161. package/packages/piagent-core/runtime/inspection/session-hunk-provenance.ts +56 -0
  162. package/packages/piagent-core/runtime/inspection/session-mutation-observer.ts +125 -0
  163. package/packages/piagent-core/runtime/inspection/source-action-contract.ts +123 -0
  164. package/packages/piagent-core/runtime/inspection/source-change-projection.ts +3 -9
  165. package/packages/piagent-core/runtime/inspection/source-mutation-projection.ts +3 -41
  166. package/packages/piagent-core/runtime/inspection/source-mutation-store.ts +6 -36
  167. package/packages/piagent-core/runtime/inspection/source-revert-projection.ts +36 -30
  168. package/packages/piagent-core/runtime/inspection/text-diff.ts +5 -4
  169. package/packages/piagent-core/runtime/inspection/workspace-worker.ts +79 -0
  170. package/packages/piagent-core/runtime/policy/source-index-transaction.ts +3 -10
  171. package/packages/piagent-core/runtime/policy/source-mutation-guard-binding.ts +27 -2
  172. package/packages/piagent-core/runtime/policy/source-mutation-guard.ts +42 -12
  173. package/packages/piagent-core/runtime/policy/source-worktree-transaction.ts +10 -5
  174. package/packages/piagent-core/runtime/policy/workspace-write-authority.ts +51 -0
  175. package/packages/piagent-core/runtime/process-owner.mjs +20 -0
  176. package/packages/piagent-core/runtime/process-tree-watch.mjs +46 -0
  177. package/packages/piagent-core/runtime/quality/model-mutation-proof.ts +8 -5
  178. package/packages/piagent-core/runtime/registration/permission-commands.ts +10 -2
  179. package/packages/piagent-core/runtime/registration/task-evidence-tools.ts +1 -1
  180. package/packages/piagent-core/runtime/session/workspace-member-edits.d.mts +7 -0
  181. package/packages/piagent-core/runtime/session/workspace-member-edits.mjs +41 -0
  182. package/packages/piagent-core/security/workspace-file-reader.ts +2 -1
  183. package/packages/piagent-webui/dist/client/assets/Alert-DXF8-XqE.js +1 -0
  184. package/packages/piagent-webui/dist/client/assets/Button-DZJosXQi.js +1 -0
  185. package/packages/piagent-webui/dist/client/assets/DialogTitle-CL-fzCnT.js +3 -0
  186. package/packages/piagent-webui/dist/client/assets/MarkdownMessage-k4tq1y8s.js +29 -0
  187. package/packages/piagent-webui/dist/client/assets/TerminalPanel-DvOPgrGI.js +2 -0
  188. package/packages/piagent-webui/dist/client/assets/TerminalView-B_a16moC.js +9 -0
  189. package/packages/piagent-webui/dist/client/assets/TerminalView-kHJ-D0s7.css +1 -0
  190. package/packages/piagent-webui/dist/client/assets/Typography-DMZL1kLS.js +1 -0
  191. package/packages/piagent-webui/dist/client/assets/WorkspaceCodeEditor-BCiYM9NA.js +2 -0
  192. package/packages/piagent-webui/dist/client/assets/WorkspaceExplorer-DttpQtpf.js +5 -0
  193. package/packages/piagent-webui/dist/client/assets/WorkspaceQuickOpen-fgXwQ46B.js +1 -0
  194. package/packages/piagent-webui/dist/client/assets/bootstrap-CedkNtRG.js +1 -0
  195. package/packages/piagent-webui/dist/client/assets/dist-B1M7IjfA.js +10 -0
  196. package/packages/piagent-webui/dist/client/assets/dist-BUYtGaUP.js +1 -0
  197. package/packages/piagent-webui/dist/client/assets/dist-C82AtDDT.js +6 -0
  198. package/packages/piagent-webui/dist/client/assets/dist-D9b8j6pJ.js +1 -0
  199. package/packages/piagent-webui/dist/client/assets/dist-DLfpWwLs.js +23 -0
  200. package/packages/piagent-webui/dist/client/assets/dist-DcEu_WYV.js +1 -0
  201. package/packages/piagent-webui/dist/client/assets/dist-_JsfplOG.js +9 -0
  202. package/packages/piagent-webui/dist/client/assets/dist-idN8f9Fc.js +1 -0
  203. package/packages/piagent-webui/dist/client/assets/dist-nvgpa2Us.js +1 -0
  204. package/packages/piagent-webui/dist/client/assets/index-BW8-BUMx.js +102 -0
  205. package/packages/piagent-webui/dist/client/assets/index-Brs_HjIo.css +1 -0
  206. package/packages/piagent-webui/dist/client/assets/rolldown-runtime-hePW80VL.js +1 -0
  207. package/packages/piagent-webui/dist/client/assets/tab-close-HE0XxoJf.js +1 -0
  208. package/packages/piagent-webui/dist/client/assets/ui-preferences-DiddStu0.js +124 -0
  209. package/packages/piagent-webui/dist/client/assets/useTheme-_ESOj7MI.js +10 -0
  210. package/packages/piagent-webui/dist/client/assets/workspace-api-O7auv_td.js +1 -0
  211. package/packages/piagent-webui/dist/client/index.html +10 -2
  212. package/packages/piagent-webui/extension/same-session-bridge.ts +2 -3
  213. package/packages/piagent-webui/extension/source-revert-controller.ts +14 -208
  214. package/packages/piagent-webui/gateway/checkpoint-sessions.ts +99 -0
  215. package/packages/piagent-webui/gateway/checkpoint-store.ts +6 -0
  216. package/packages/piagent-webui/gateway/company-relay.ts +5 -0
  217. package/packages/piagent-webui/gateway/company-terminal-cache.ts +54 -0
  218. package/packages/piagent-webui/gateway/company-terminal-channel.ts +117 -0
  219. package/packages/piagent-webui/gateway/company-terminal-relay.ts +75 -0
  220. package/packages/piagent-webui/gateway/gateway-protocol-service.ts +12 -0
  221. package/packages/piagent-webui/gateway/gateway-service.ts +48 -4
  222. package/packages/piagent-webui/gateway/managed-gateway.mjs +30 -3
  223. package/packages/piagent-webui/gateway/managed-projection.ts +5 -3
  224. package/packages/piagent-webui/gateway/managed-source-revert-controller.ts +45 -0
  225. package/packages/piagent-webui/gateway/managed-source-revert-sessions.ts +74 -0
  226. package/packages/piagent-webui/gateway/managed-terminal-binding.ts +30 -0
  227. package/packages/piagent-webui/gateway/managed-terminal-processes.ts +74 -0
  228. package/packages/piagent-webui/gateway/managed-terminal-sessions.ts +9 -0
  229. package/packages/piagent-webui/gateway/path-suggestions.ts +1 -124
  230. package/packages/piagent-webui/gateway/personal-terminal-binding.ts +36 -0
  231. package/packages/piagent-webui/gateway/personal-terminal-diagnostic.mjs +16 -0
  232. package/packages/piagent-webui/gateway/personal-terminal-processes.ts +119 -0
  233. package/packages/piagent-webui/gateway/personal-terminal-sessions.ts +22 -0
  234. package/packages/piagent-webui/gateway/runtime-session-controls.ts +2 -1
  235. package/packages/piagent-webui/gateway/session-catalog.ts +3 -2
  236. package/packages/piagent-webui/gateway/session-command-controller.ts +4 -4
  237. package/packages/piagent-webui/gateway/session-inspection-registry.ts +29 -5
  238. package/packages/piagent-webui/gateway/session-runtime-supervisor.ts +8 -8
  239. package/packages/piagent-webui/gateway/signed-session-journal.ts +77 -0
  240. package/packages/piagent-webui/gateway/source-revert-sessions.ts +74 -0
  241. package/packages/piagent-webui/gateway/source-revert-store.ts +47 -0
  242. package/packages/piagent-webui/gateway/terminal-command-ledger.ts +33 -0
  243. package/packages/piagent-webui/gateway/terminal-mentions.ts +29 -0
  244. package/packages/piagent-webui/gateway/terminal-output-buffer.ts +58 -0
  245. package/packages/piagent-webui/gateway/terminal-router.ts +12 -0
  246. package/packages/piagent-webui/gateway/terminal-sessions.ts +247 -0
  247. package/packages/piagent-webui/gateway/workspace-write-sessions.ts +69 -0
  248. package/packages/piagent-webui/gateway/workspace-write-store.ts +6 -0
  249. package/packages/piagent-webui/package.json +14 -2
  250. package/packages/piagent-webui/server/checkpoint-command.ts +30 -0
  251. package/packages/piagent-webui/server/checkpoint-controller.ts +87 -0
  252. package/packages/piagent-webui/server/checkpoint-routes.ts +38 -0
  253. package/packages/piagent-webui/server/control-command-digest.ts +5 -0
  254. package/packages/piagent-webui/server/core-inspection-provider.ts +16 -22
  255. package/packages/piagent-webui/server/gateway-websocket.ts +36 -8
  256. package/packages/piagent-webui/server/loopback-server.ts +9 -0
  257. package/packages/piagent-webui/server/read-model-provider.ts +3 -0
  258. package/packages/piagent-webui/server/read-only-router.ts +3 -0
  259. package/packages/piagent-webui/server/session-auth.ts +4 -1
  260. package/packages/piagent-webui/server/session-source-revert-routes.ts +54 -0
  261. package/packages/piagent-webui/server/source-projection-binding.ts +18 -0
  262. package/packages/piagent-webui/server/source-revert-controller.ts +225 -0
  263. package/packages/piagent-webui/server/terminal-command.ts +35 -0
  264. package/packages/piagent-webui/server/terminal-websocket-stream.ts +56 -0
  265. package/packages/piagent-webui/server/transcript-projection.ts +2 -1
  266. package/packages/piagent-webui/server/workspace-read-routes.ts +32 -0
  267. package/packages/piagent-webui/server/workspace-reader.ts +23 -0
  268. package/packages/piagent-webui/server/workspace-write-controller.ts +118 -0
  269. package/packages/piagent-webui/server/workspace-write-routes.ts +38 -0
  270. package/packages/piagent-webui/shared/terminal-message.ts +23 -0
  271. package/schemas/piagent-webui/catalog-v1.json +63 -0
  272. package/schemas/piagent-webui/checkpoint-v1.schema.json +466 -0
  273. package/schemas/piagent-webui/control-command-v1.schema.json +68 -3
  274. package/schemas/piagent-webui/diff-v1.schema.json +4 -0
  275. package/schemas/piagent-webui/gateway-protocol-v1.schema.json +123 -3
  276. package/schemas/piagent-webui/session-catalog-v1.schema.json +1 -1
  277. package/schemas/piagent-webui/session-command-v1.schema.json +1 -1
  278. package/schemas/piagent-webui/snapshot-v1.schema.json +2 -1
  279. package/schemas/piagent-webui/source-revert-v1.schema.json +22 -3
  280. package/schemas/piagent-webui/terminal-v1.schema.json +495 -0
  281. package/schemas/piagent-webui/workspace-file-v1.schema.json +79 -0
  282. package/schemas/piagent-webui/workspace-paths-v1.schema.json +71 -0
  283. package/schemas/piagent-webui/workspace-search-v1.schema.json +80 -0
  284. package/schemas/piagent-webui/workspace-tree-v1.schema.json +87 -0
  285. package/schemas/piagent-webui/workspace-write-v1.schema.json +276 -0
  286. package/schemas/project-profile.schema.json +8 -3
  287. package/scripts/company-terminal.mjs +16 -11
  288. package/scripts/pi-auto.sh +5 -1
  289. package/scripts/team-doctor.sh +8 -0
  290. package/packages/piagent-webui/dist/client/assets/MarkdownMessage-CfgyQQbW.js +0 -29
  291. package/packages/piagent-webui/dist/client/assets/index-HyzWKvDu.js +0 -234
  292. package/packages/piagent-webui/dist/client/assets/index-_0nGP2n3.css +0 -1
@@ -0,0 +1,66 @@
1
+ import { StringDecoder } from 'node:string_decoder';
2
+ import { stopProcessGroup } from './background-process.mjs';
3
+
4
+ // Native effects are acknowledged once. A lost response rejects as uncertain;
5
+ // the Gateway command ledger owns retries and must never redispatch the effect.
6
+ export function terminalChannel(child, emit) {
7
+ const requests = new Map(), decoder = new StringDecoder('utf8');
8
+ let pending = '', next = 0, ready = false, closed = false, closing, exited = false;
9
+ let resolveReady, rejectReady, resolveClosed;
10
+ const initialized = new Promise((resolve, reject) => { resolveReady = resolve; rejectReady = reject; });
11
+ const completed = new Promise(resolve => { resolveClosed = resolve; });
12
+ const timer = setTimeout(() => fail('managed-terminal-start-timeout'), 10000);
13
+ const notify = message => { try { emit(message); } catch { /* consumer never owns process lifetime */ } };
14
+ const rejectAll = reason => {
15
+ rejectReady(new Error(reason));
16
+ for (const request of requests.values()) { clearTimeout(request.timer); request.reject(new Error(reason)); }
17
+ requests.clear();
18
+ };
19
+ const close = () => closing ??= (async () => {
20
+ if (closed) return;
21
+ rejectAll('managed-terminal-effect-uncertain');
22
+ await stopProcessGroup(child);
23
+ let timeout;
24
+ try { await Promise.race([completed, new Promise((_, reject) => { timeout = setTimeout(() => reject(Error('managed-terminal-cleanup-unconfirmed')), 3000); })]); }
25
+ finally { clearTimeout(timeout); }
26
+ })();
27
+ const fail = reason => { rejectAll(reason); void close().catch(() => {}); };
28
+ child.once('error', () => fail('managed-terminal-sandbox-unavailable'));
29
+ child.once('close', () => {
30
+ closed = true; clearTimeout(timer); rejectAll('managed-terminal-effect-uncertain');
31
+ if (!exited) notify({ kind: 'exit', exitCode: null, signal: null });
32
+ resolveClosed();
33
+ });
34
+ child.stdin.on('error', () => fail('managed-terminal-effect-uncertain'));
35
+ child.stderr.resume(); // Sandbox startup errors are reported by code, not raw host paths.
36
+ child.stdout.on('data', chunk => {
37
+ pending += decoder.write(chunk);
38
+ if (Buffer.byteLength(pending) > 128 * 1024) return fail('managed-terminal-protocol-invalid');
39
+ for (;;) {
40
+ const end = pending.indexOf('\n'); if (end < 0) break;
41
+ const line = pending.slice(0, end); pending = pending.slice(end + 1);
42
+ try {
43
+ const value = JSON.parse(line), keys = Object.keys(value).sort().join(',');
44
+ if (value.kind === 'ready' && keys === 'kind' && !ready) { ready = true; clearTimeout(timer); resolveReady(); }
45
+ else if (value.kind === 'unavailable' && !ready) fail('managed-terminal-sandbox-unavailable');
46
+ else if (ready && value.kind === 'ack' && keys === 'id,kind' && requests.has(value.id)) {
47
+ const request = requests.get(value.id); clearTimeout(request.timer); requests.delete(value.id); request.resolve();
48
+ } else if (ready && value.kind === 'output' && keys === 'data,kind' && typeof value.data === 'string' && value.data.length <= 8192) notify(value);
49
+ else if (ready && value.kind === 'exit' && keys === 'exitCode,kind,signal' && (value.exitCode === null || Number.isInteger(value.exitCode))
50
+ && (value.signal === null || Number.isInteger(value.signal)) && !exited) { exited = true; notify(value); }
51
+ else throw Error();
52
+ } catch { return fail('managed-terminal-protocol-invalid'); }
53
+ }
54
+ });
55
+ return { initialized, completed, close, execute(payload) {
56
+ if (!ready || closed || closing || exited) return Promise.reject(Error('managed-terminal-exited'));
57
+ if (requests.size >= 128 || child.stdin.writableLength > 128 * 1024) return Promise.reject(Error('managed-terminal-capacity'));
58
+ const id = String(++next), line = JSON.stringify({ ...payload, id }) + '\n';
59
+ if (Buffer.byteLength(line) > 128 * 1024) return Promise.reject(Error('managed-terminal-input-limit'));
60
+ return new Promise((resolve, reject) => {
61
+ const timer = setTimeout(() => { requests.delete(id); reject(Error('managed-terminal-effect-uncertain')); void close().catch(() => {}); }, 10000);
62
+ requests.set(id, { resolve, reject, timer });
63
+ child.stdin.write(line, error => { if (error) fail('managed-terminal-effect-uncertain'); });
64
+ });
65
+ } };
66
+ }
@@ -0,0 +1,64 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { createRequire } from 'node:module';
4
+ import { createHash } from 'node:crypto';
5
+ import { BWRAP } from './linux-sandbox.mjs';
6
+
7
+ const require = createRequire(import.meta.url);
8
+ const VERSION = '1.2.0-beta.15';
9
+ const fail = () => { throw new Error('managed-terminal-runtime-invalid'); };
10
+
11
+ // Inspect code without loading the native addon. The entire platform package
12
+ // is pinned, including its JS and prebuilt binary. Linux loads it inside the
13
+ // sandbox; macOS loads it on the company Gateway to make the PTY there (the
14
+ // shell itself runs under Seatbelt, terminal-boundary.mjs).
15
+ function inventory(root) {
16
+ const entries = [], queue = [root];
17
+ let bytes = 0, visited = 0;
18
+ while (queue.length) {
19
+ const directory = queue.shift();
20
+ if (!fs.lstatSync(directory).isDirectory() || fs.realpathSync(directory) !== directory) fail();
21
+ for (const name of fs.readdirSync(directory).sort()) {
22
+ if (++visited > 128) fail();
23
+ const file = path.join(directory, name), stat = fs.lstatSync(file);
24
+ if (stat.isSymbolicLink()) fail();
25
+ if (stat.isDirectory()) { queue.push(file); continue; }
26
+ if (!stat.isFile() || stat.size > 16 * 1024 * 1024 || (bytes += stat.size) > 32 * 1024 * 1024) fail();
27
+ const fd = fs.openSync(file, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW);
28
+ try {
29
+ const opened = fs.fstatSync(fd);
30
+ if (!opened.isFile() || opened.dev !== stat.dev || opened.ino !== stat.ino || opened.size !== stat.size) fail();
31
+ const digest = createHash('sha256').update(fs.readFileSync(fd)).digest('hex');
32
+ entries.push([path.relative(root, file), digest, opened.mode & 0o777]);
33
+ } finally { fs.closeSync(fd); }
34
+ }
35
+ }
36
+ return JSON.stringify(entries.sort((a, b) => a[0].localeCompare(b[0])));
37
+ }
38
+
39
+ export function companyTerminalRuntime({ platform = process.platform, arch = process.arch,
40
+ bubblewrap = BWRAP, resolve = specifier => createRequire(require.resolve('@lydell/node-pty')).resolve(specifier) } = {}) {
41
+ if (!['linux', 'darwin'].includes(platform)) throw new Error('managed-terminal-platform-unavailable');
42
+ if (platform === 'linux' && !bubblewrap) throw new Error('managed-terminal-bubblewrap-unavailable');
43
+ if (!['arm64', 'x64'].includes(arch)) throw new Error('managed-terminal-platform-unavailable');
44
+ const name = `@lydell/node-pty-${platform}-${arch}`;
45
+ let entry;
46
+ try { entry = fs.realpathSync(resolve(name)); }
47
+ catch { throw new Error('managed-terminal-native-unavailable'); }
48
+ const root = path.dirname(path.dirname(entry));
49
+ if (entry !== path.join(root, 'lib/index.js')) fail();
50
+ const pinned = inventory(root);
51
+ const manifest = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8'));
52
+ if (manifest.name !== name || manifest.version !== VERSION || manifest.dependencies && Object.keys(manifest.dependencies).length) fail();
53
+ if (!fs.statSync(path.join(root, `prebuilds/${platform}-${arch}/pty.node`)).isFile()) fail();
54
+ return Object.freeze({ entry, root, version: VERSION,
55
+ assertCurrent() { if (inventory(root) !== pinned) throw new Error('managed-terminal-runtime-changed'); } });
56
+ }
57
+
58
+ export function companyTerminalDiagnostic(options = {}) {
59
+ let reasonCode = null;
60
+ try { companyTerminalRuntime(options); }
61
+ catch (error) { reasonCode = /^managed-terminal-/.test(error.message) ? error.message : 'managed-terminal-runtime-invalid'; }
62
+ return { supported: reasonCode === null, reasonCode, platform: options.platform ?? process.platform,
63
+ scope: 'company', probe: 'runtime-files', ptySpawnVerified: false };
64
+ }
@@ -0,0 +1,48 @@
1
+ import { createRequire } from 'node:module';
2
+ import { stopProcessGroup } from './background-process.mjs';
3
+ import { ProcessTreeWatch } from '../runtime/process-tree-watch.mjs';
4
+
5
+ const watch = new ProcessTreeWatch();
6
+
7
+ // macOS: Seatbelt denies creating a PTY, so the company Gateway makes it and
8
+ // only the shell runs sandboxed, under the agent's offline profile plus these
9
+ // rules: ioctls on terminal devices (only the one this shell was handed can be
10
+ // reached, since opening any other stays denied) and /dev/tty, which always
11
+ // names the process's own terminal.
12
+ export const SEATBELT_TERMINAL_RULES = `
13
+ (allow file-ioctl (regex #"^/dev/ttys[0-9]+$"))
14
+ (allow file-read* file-write* file-ioctl (literal "/dev/tty"))
15
+ `;
16
+
17
+ export function seatbeltTerminal({ entry, profile, cwd, env, size, emit }) {
18
+ const pty = createRequire(import.meta.url)(entry);
19
+ const child = pty.spawn('/usr/bin/sandbox-exec', ['-p', profile, '/bin/bash', '--noprofile', '--norc'],
20
+ { cwd, env, cols: size.cols, rows: size.rows, name: 'xterm-256color' });
21
+ watch.watch(child.pid);
22
+ const notify = message => { try { emit(message); } catch { /* consumer never owns process lifetime */ } };
23
+ let exited = false, closing, resolveClosed;
24
+ const completed = new Promise(resolve => { resolveClosed = resolve; });
25
+ child.onData(text => {
26
+ for (let start = 0; start < text.length;) {
27
+ let end = Math.min(start + 8192, text.length);
28
+ if (end < text.length && /[\uD800-\uDBFF]/.test(text[end - 1])) end--;
29
+ notify({ kind: 'output', data: text.slice(start, end) }); start = end;
30
+ }
31
+ });
32
+ child.onExit(({ exitCode, signal }) => {
33
+ exited = true; notify({ kind: 'exit', exitCode, signal: signal || null });
34
+ void watch.stop(child.pid).finally(resolveClosed);
35
+ });
36
+ const close = () => closing ??= (async () => {
37
+ if (exited) return;
38
+ await stopProcessGroup({ pid: child.pid, exitCode: null, signalCode: null });
39
+ let timeout;
40
+ try { await Promise.race([completed, new Promise((_, reject) => { timeout = setTimeout(() => reject(Error('managed-terminal-cleanup-unconfirmed')), 3000); })]); }
41
+ finally { clearTimeout(timeout); }
42
+ })();
43
+ return { initialized: Promise.resolve(), completed, close, execute(payload) {
44
+ if (exited || closing) return Promise.reject(Error('managed-terminal-exited'));
45
+ if (payload.kind === 'input') child.write(payload.data); else child.resize(payload.cols, payload.rows);
46
+ return Promise.resolve();
47
+ } };
48
+ }
@@ -0,0 +1,70 @@
1
+ import fs from 'node:fs';
2
+ import { createRequire } from 'node:module';
3
+ import { StringDecoder } from 'node:string_decoder';
4
+
5
+ // Only the boundary launches this entrypoint. Check both namespaces before
6
+ // loading native code: accidentally invoking it on the host must fail closed.
7
+ const [entry, hostPidNamespace, hostNetworkNamespace, columns, rows] = process.argv.slice(2);
8
+ const size = (cols, rows) => Number.isInteger(cols) && cols >= 2 && cols <= 500 && Number.isInteger(rows) && rows >= 1 && rows <= 200;
9
+ const send = message => {
10
+ if (process.stdout.writableLength > 512 * 1024) process.exit(1);
11
+ process.stdout.write(JSON.stringify(message) + '\n');
12
+ };
13
+ let terminal;
14
+ try {
15
+ if (process.platform !== 'linux' || !/^pid:\[\d+\]$/.test(hostPidNamespace ?? '') || !/^net:\[\d+\]$/.test(hostNetworkNamespace ?? '')
16
+ || fs.readlinkSync('/proc/self/ns/pid') === hostPidNamespace || fs.readlinkSync('/proc/self/ns/net') === hostNetworkNamespace
17
+ || !size(Number(columns), Number(rows))) throw Error('sandbox-required');
18
+ const pty = createRequire(import.meta.url)(entry);
19
+ terminal = pty.spawn('/bin/bash', ['--noprofile', '--norc'], { cols: Number(columns), rows: Number(rows),
20
+ cwd: process.cwd(), env: { ...process.env, TERM: 'xterm-256color' }, name: 'xterm-256color' });
21
+ } catch {
22
+ process.stdout.write(JSON.stringify({ kind: 'unavailable', reasonCode: 'managed-terminal-sandbox-unavailable' }) + '\n', () => process.exit(1));
23
+ }
24
+ if (terminal) {
25
+ let ending = false;
26
+ const finish = message => {
27
+ if (ending) return;
28
+ ending = true;
29
+ // Exiting the namespace's main process also stops detached shell children.
30
+ const timer = setTimeout(() => process.exit(0), 1000); timer.unref();
31
+ process.stdout.write(JSON.stringify(message) + '\n', () => process.exit(0));
32
+ };
33
+ // A writer faster than the Gateway reads (`yes`) waits on the terminal, as
34
+ // on a real one, instead of growing this pipe until the worker gives up.
35
+ let paused = false;
36
+ process.stdout.on('drain', () => { if (paused && !ending) { paused = false; terminal.resume(); } });
37
+ terminal.onData(text => {
38
+ for (let start = 0; start < text.length;) {
39
+ let end = Math.min(start + 8192, text.length);
40
+ if (end < text.length && /[\uD800-\uDBFF]/.test(text[end - 1])) end--;
41
+ send({ kind: 'output', data: text.slice(start, end) }); start = end;
42
+ }
43
+ if (!paused && process.stdout.writableLength > 128 * 1024) { paused = true; terminal.pause(); }
44
+ });
45
+ terminal.onExit(({ exitCode, signal }) => finish({ kind: 'exit', exitCode, signal: signal ?? null }));
46
+ send({ kind: 'ready' });
47
+ let pending = '';
48
+ const decoder = new StringDecoder('utf8');
49
+ process.stdin.on('data', chunk => {
50
+ pending += decoder.write(chunk);
51
+ if (Buffer.byteLength(pending) > 128 * 1024) return process.exit(1);
52
+ for (;;) {
53
+ const end = pending.indexOf('\n'); if (end < 0) break;
54
+ const line = pending.slice(0, end); pending = pending.slice(end + 1);
55
+ try {
56
+ const request = JSON.parse(line);
57
+ if (!request || typeof request !== 'object' || !/^[0-9]{1,16}$/.test(request.id) || ending) throw Error();
58
+ const keys = Object.keys(request).sort().join(',');
59
+ if (request.kind === 'input' && keys === 'data,id,kind' && typeof request.data === 'string' && Buffer.byteLength(request.data) <= 16384)
60
+ terminal.write(request.data);
61
+ else if (request.kind === 'resize' && keys === 'cols,id,kind,rows' && size(request.cols, request.rows)) terminal.resize(request.cols, request.rows);
62
+ else throw Error();
63
+ send({ kind: 'ack', id: request.id });
64
+ } catch { return process.exit(1); }
65
+ }
66
+ });
67
+ process.stdin.on('end', () => finish({ kind: 'exit', exitCode: null, signal: null }));
68
+ process.stdin.on('error', () => process.exit(1));
69
+ process.stdout.on('error', () => process.exit(1));
70
+ }
@@ -1,3 +1,9 @@
1
+ import { consumeReviewSnapshot } from './review-snapshot-request.mjs';
2
+ import { SessionMutationObservations } from "./session-mutation-observations.mjs";
3
+ import { BoundaryTerminals } from './terminal-boundary.mjs';
4
+ import { ToolFreshness } from './tool-freshness.mjs';
5
+ import { BoundaryQueue } from './boundary-queue.mjs';
6
+ import { workspaceWorkerPins, verifyWorkspaceWorker } from './workspace-worker-pins.mjs';
1
7
  import fs from 'node:fs';
2
8
  import os from 'node:os';
3
9
  import path from 'node:path';
@@ -11,10 +17,11 @@ import { languageEnvironment } from './language-environment.mjs';
11
17
  import { PI_HOST_VERSION, hostPackage } from './pi-host.mjs';
12
18
  export { PI_HOST_VERSION, hostPackage };
13
19
  import { containerEngine, dockerCli, engineEnvironment } from './container-engine.mjs';
14
- import { DEFAULT_DOMAINS, PackageProxy, mavenSettings, memberDomains, proxyEnvironment } from './package-proxy.mjs';
20
+ import { proxyEnvironment } from './package-proxy.mjs';
21
+ import { startBoundaryPackageProxy } from './boundary-package-proxy.mjs';
22
+ import { SourceRevertRequests } from './source-revert-requests.mjs';
15
23
  import { BWRAP, bubblewrapArgs, credentialFiles, referenceFolders } from './linux-sandbox.mjs';
16
24
  import { agentWatchDataDirectory } from './store.mjs';
17
-
18
25
  const TOOL_NAMES = Object.freeze(['read', 'bash', 'edit', 'write', 'grep', 'find', 'ls']);
19
26
  const MAX_WIRE_BYTES = 12 * 1024 * 1024;
20
27
  const workerPath = fileURLToPath(new URL('./tool-worker.mjs', import.meta.url));
@@ -25,7 +32,6 @@ function literal(value) {
25
32
  }
26
33
  function inside(file, root) { return file === root || file.startsWith(root + path.sep); }
27
34
  const pattern = value => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
28
-
29
35
  // Folders a member points the agent to for reference (an @ mention, "read
30
36
  // ~/Documents/old-shop"): their own files on this Mac, external drives and
31
37
  // the shared folder, read-only. Hidden entries of the home folder (shell
@@ -41,7 +47,6 @@ function referenceRules(home, roots) {
41
47
  return `(allow file-read* (subpath ${literal(home)}) (subpath "/Volumes") (subpath "/Users/Shared") (subpath "/private/tmp"))
42
48
  (deny file-read-data file-read-xattr (require-all (require-any (regex #"^${pattern(home)}/\\.") (subpath ${literal(path.join(home, 'Library'))}))${opened}))`;
43
49
  }
44
-
45
50
  export function managedSeatbelt({ cwd, sdkRoot, runtimeRoot, temporary, node, denied = [], readOnly = false, repository = [], gitDirs = [], toolchains = [], developer = [], browsers = [], network = false, references = null, skills = [], caches = [], loopback = true, license = null, docker = null, proxyPort = null }) {
46
51
  // Default-deny protects Keychain/SSH agent/Watch IPC and process inspection.
47
52
  // System libraries/toolchains are read-only. Writes stay in this project and
@@ -110,7 +115,6 @@ ${denied.map(root => `(deny file-read* file-write* (require-all (subpath ${liter
110
115
  (require-not (regex #"(^|/)\\.env\\.(example|sample|template|dist|defaults)$"))))
111
116
  `;
112
117
  }
113
-
114
118
  // Toolchains from the pinned node/git and Homebrew, compilers and SDK; the
115
119
  // git identity this project gets on the user's machine (name and email only)
116
120
  // so commits work. Without network, package managers fail fast instead of
@@ -192,7 +196,13 @@ function provisionSearchTools(home, userHome) {
192
196
  }
193
197
 
194
198
  export class ManagedToolBoundary {
195
- #tail = Promise.resolve();
199
+ #queue = new BoundaryQueue();
200
+ #freshness = new ToolFreshness();
201
+ #sourceRequests = new SourceRevertRequests();
202
+ get sourceControlRevision() { return this.#sourceRequests.revision; }
203
+ source(request, binding) { return this.invoke("workspace", this.#sourceRequests.request(request, binding)); }
204
+ #mutationObservations = new SessionMutationObservations();
205
+ mutationObservation(file) { return this.#mutationObservations.get(file); }
196
206
  #children = new Set();
197
207
  #closed = false;
198
208
  // readOnly: the project cannot be written. commands: bash is offered all
@@ -205,10 +215,11 @@ export class ManagedToolBoundary {
205
215
  this.cwd = fs.realpathSync(cwd); this.sdkRoot = fs.realpathSync(sdkRoot);
206
216
  this.node = fs.realpathSync(node);
207
217
  this.runtimeRoot = fs.realpathSync(path.dirname(workerPath));
218
+ this.workspacePins = workspaceWorkerPins(this.runtimeRoot);
208
219
  this.sdkPath = path.join(this.sdkRoot, 'dist/index.js');
209
220
  this.git = managedGit();
210
221
  if (JSON.parse(fs.readFileSync(path.join(this.sdkRoot, 'package.json'), 'utf8')).version !== PI_HOST_VERSION) throw new Error('managed-sdk-version-unqualified');
211
- const workerModules = ['search-fallback.mjs', 'sandbox-diagnostic.mjs', 'ts-diagnostics-server.mjs'].map(name => path.join(this.runtimeRoot, name));
222
+ const workerModules = ['review-snapshot-worker.mjs', 'tool-freshness.mjs', 'search-fallback.mjs', 'sandbox-diagnostic.mjs', 'ts-diagnostics-server.mjs', 'workspace-worker-pins.mjs'].map(name => path.join(this.runtimeRoot, name));
212
223
  this.pins = new Map([this.node, this.git, workerPath, ...workerModules, this.sdkPath].map(file => [file, hash(file)]));
213
224
  const userHome = fs.realpathSync(home ?? os.homedir());
214
225
  if ([userHome, '/', this.sdkRoot, this.runtimeRoot].includes(this.cwd)
@@ -260,6 +271,7 @@ export class ManagedToolBoundary {
260
271
  if (searchTools) provisionSearchTools(this.home, userHome);
261
272
  else fs.writeFileSync(path.join(this.home, '.ripgreprc'), '', { mode: 0o600 });
262
273
  this.allowed = readOnly ? TOOL_NAMES.filter(name => !['write', 'edit'].includes(name) && (name !== 'bash' || commands)) : [...TOOL_NAMES];
274
+ this.terminals = new BoundaryTerminals(this);
263
275
  }
264
276
  // What the machine offers commands: toolchains, the JDK, the Docker engine.
265
277
  // Looked up again after the member installs or starts something
@@ -288,6 +300,8 @@ export class ManagedToolBoundary {
288
300
  if (this.engine) this.environment.docker = { ...this.environment.network, ...engineEnvironment(this.engine, this.home) };
289
301
  this.#applyProxy();
290
302
  }
303
+ // A company terminal's shell (terminal-seatbelt.mjs): offline, its own temporary root.
304
+ terminalProfile(temporary) { return managedSeatbelt({ ...this.base, temporary, references: this.userHome, loopback: false }); }
291
305
  // Plain commands' profile and environment, with the package proxy once it listens.
292
306
  #applyProxy() {
293
307
  const port = this.proxyUrl && !this.linux ? Number(new URL(this.proxyUrl).port) : null;
@@ -313,34 +327,20 @@ export class ManagedToolBoundary {
313
327
  const factories = { read: api.createReadToolDefinition, write: api.createWriteToolDefinition,
314
328
  edit: api.createEditToolDefinition, bash: api.createBashToolDefinition,
315
329
  grep: api.createGrepToolDefinition, find: api.createFindToolDefinition, ls: api.createLsToolDefinition };
316
- return this.allowed.map(name => ({ ...factories[name](this.cwd), execute: (_id, args, signal, onUpdate, context) => {
317
- // One queue spans read and all mutations so two concurrent edit requests
318
- // observe a deterministic order. Helpers use separate read-only workers.
319
- const run = this.#tail.then(() => this.invoke(name, args, signal, onUpdate, context?.model));
320
- this.#tail = run.catch(() => {}); return run;
321
- } }));
330
+ return this.allowed.map(name => ({ ...factories[name](this.cwd), execute: (_id, args, signal, onUpdate, context) =>
331
+ this.invoke(name, args, signal, onUpdate, context?.model) }));
322
332
  }
323
333
  // The proxy listens on loopback (macOS: Seatbelt opens its one port even
324
334
  // while a local proxy closes the rest of loopback) or on a Unix socket that
325
335
  // the worker forwards from inside the command's network namespace (Linux).
326
- async startProxy() {
327
- this.proxy = new PackageProxy({ domains: [...DEFAULT_DOMAINS, ...memberDomains(this.userHome)] });
328
- try {
329
- let url;
330
- if (this.linux) {
331
- this.proxySocket = path.join(os.tmpdir(), `piagent-proxy-${randomUUID().slice(0, 8)}.sock`);
332
- await this.proxy.listen(this.proxySocket);
333
- url = 'http://127.0.0.1:3128';
334
- } else url = `http://127.0.0.1:${(await this.proxy.listen()).port}`;
335
- const settings = path.join(this.home, '.m2/settings.xml');
336
- fs.mkdirSync(path.dirname(settings), { recursive: true, mode: 0o700 }); fs.writeFileSync(settings, mavenSettings(url), { mode: 0o600 });
337
- this.proxyUrl = url; this.#applyProxy();
338
- } catch { await this.proxy.close().catch(() => {}); this.proxy = null; }
336
+ startProxy() { return startBoundaryPackageProxy(this, () => this.#applyProxy()); }
337
+ invoke(name, args, signal, onUpdate, model, options = {}) {
338
+ return this.#queue.run(name, args, () => this.#queuedInvoke(name, args, signal, onUpdate, model, options));
339
339
  }
340
- async invoke(name, args, signal, onUpdate, model, { network = false, docker = false } = {}) {
340
+ async #queuedInvoke(name, args, signal, onUpdate, model, { network = false, docker = false, guard = undefined } = {}) {
341
341
  await this.proxyReady;
342
342
  const before = this.proxy?.deniedCount ?? 0;
343
- try { return await this.#invoke(name, args, signal, onUpdate, model, { network, docker }); }
343
+ try { guard?.(); return await this.#invoke(name, args, signal, onUpdate, model, { network, docker, guard }); }
344
344
  catch (error) {
345
345
  // A command that failed after the proxy refused a host: name the hosts.
346
346
  const refused = !network && !docker && this.proxy ? this.proxy.deniedSince(before) : [];
@@ -348,10 +348,11 @@ export class ManagedToolBoundary {
348
348
  throw new Error(`managed-domain-blocked: Lệnh thường chỉ tải được từ các registry package quen thuộc (npm, PyPI, Go, crates.io, Maven, Gradle, NuGet, RubyGems, Packagist, pub.dev…) và tải về public từ GitHub. Host bị chặn: ${refused.join(', ')}. Nếu lệnh thật sự cần host này, dùng run_with_network để người dùng duyệt đúng lệnh đó.\n${String(error?.message ?? error)}`);
349
349
  }
350
350
  }
351
- async #invoke(name, args, signal, onUpdate, model, { network = false, docker = false } = {}) {
351
+ async #invoke(name, args, signal, onUpdate, model, { network = false, docker = false, guard = undefined } = {}) {
352
352
  if (docker && !this.engine) throw new Error('managed-tool-unavailable');
353
353
  network ||= docker;
354
- if (this.#closed || !this.allowed.includes(name) || network && (name !== 'bash' || !this.networkAllowed)) throw new Error('managed-tool-unavailable');
354
+ if (name === 'workspace') { if (network || docker) throw Error('managed-tool-unavailable'); verifyWorkspaceWorker(this.workspacePins); }
355
+ if (this.#closed || (name !== 'workspace' && !this.allowed.includes(name)) || network && (name !== 'bash' || !this.networkAllowed)) throw new Error('managed-tool-unavailable');
355
356
  if (signal?.aborted) throw new Error('managed-tool-cancelled');
356
357
  for (const [file, digest] of this.pins) if (hash(file) !== digest) throw new Error('managed-runtime-changed');
357
358
  // `~` (as in an @~/… mention) is the member's home folder: the sandbox's
@@ -359,9 +360,15 @@ export class ManagedToolBoundary {
359
360
  if (name !== 'bash' && typeof args?.path === 'string' && /^@?~(\/|$)/.test(args.path)) args = { ...args, path: this.userHome + args.path.replace(/^@?~/, '') };
360
361
  if (!this.linux && !network && Date.now() - this.proxyCheck.at > 5000) this.proxyCheck = { at: Date.now(), listening: localProxyListening(this.proxyPorts) };
361
362
  const isolated = !this.linux && !network && await this.proxyCheck.listening;
362
- const message = JSON.stringify({ name, args, ...(network ? { network: true } : {}), ...(isolated ? { isolated: true } : {}), ...(docker ? { docker: true } : this.engine ? { engine: true } : {}), model: model ? { input: model.input, inputLimits: model.inputLimits } : undefined }) + '\n';
363
+ const observing = ['edit', 'write'].includes(name) && this.workspacePins;
364
+ if (observing) verifyWorkspaceWorker(this.workspacePins);
365
+ const snapshotRead = name === 'bash' && consumeReviewSnapshot(args);
366
+ const mutationObservation = snapshotRead ? undefined : this.#mutationObservations.prepare(this.cwd, name, args, observing);
367
+ const sourceContext = this.#sourceRequests.consume(args, this.#mutationObservations);
368
+ const message = JSON.stringify({ name, args, sourceContext, mutationObservation, expectedFileDigest: this.#freshness.expected(this.cwd, name, args), workspaceReadOnly: this.setup.readOnly, ...(network ? { network: true } : {}), ...(isolated ? { isolated: true } : {}), ...(docker ? { docker: true } : this.engine ? { engine: true } : {}), model: model ? { input: model.input, inputLimits: model.inputLimits } : undefined }) + '\n';
363
369
  if (Buffer.byteLength(message) > MAX_WIRE_BYTES) throw new Error('managed-tool-input-too-large');
364
370
  return await new Promise((resolve, reject) => {
371
+ guard?.();
365
372
  const [sandbox, sandboxArgs] = this.linux ? [BWRAP, this.bubblewrap(network, docker)]
366
373
  : ['/usr/bin/sandbox-exec', ['-p', docker ? this.dockerProfile : network ? this.networkProfile : isolated ? this.isolatedProfile : this.profile]];
367
374
  const child = spawn(sandbox, [...sandboxArgs, this.node, workerPath, this.sdkPath, this.cwd], {
@@ -370,7 +377,7 @@ export class ManagedToolBoundary {
370
377
  });
371
378
  this.#children.add(child);
372
379
  const decoder = new StringDecoder('utf8');
373
- let pending = '', bytes = 0, result, failure, terminated = false, hardStop;
380
+ let pending = '', bytes = 0, result, freshness, observation, sourceObservation, failure, terminated = false, hardStop;
374
381
  const terminate = () => {
375
382
  if (terminated) return; terminated = true;
376
383
  child.stdin.write('{"cancel":true}\n');
@@ -405,7 +412,7 @@ export class ManagedToolBoundary {
405
412
  // A cancel or timeout asked for first stays the reason: the worker
406
413
  // then reports how its command ended ("Command aborted").
407
414
  else if (value.error) failure ??= new Error(String(value.error).slice(0, 1000));
408
- else if (value.result) result = value.result;
415
+ else if (value.result) { result = value.result; freshness = value.freshness; observation = value.mutationObservation; sourceObservation = value.sourceObservation; }
409
416
  else throw new Error('managed-worker-invalid-response');
410
417
  } catch { failure = new Error('managed-worker-invalid-response'); terminate(); }
411
418
  }
@@ -419,7 +426,9 @@ export class ManagedToolBoundary {
419
426
  this.#children.delete(child); signal?.removeEventListener('abort', abort);
420
427
  if (failure) reject(failure);
421
428
  else if (code !== 0 || !result || pending) reject(new Error('managed-worker-failed'));
422
- else resolve(result);
429
+ else { this.#freshness.observe(this.cwd, name, args, freshness);
430
+ this.#mutationObservations.acceptRevert(args, sourceContext, result, sourceObservation);
431
+ this.#mutationObservations.accept(this.cwd, name, args, mutationObservation, observation); resolve(result); }
423
432
  });
424
433
  child.stdin.write(message);
425
434
  if (signal?.aborted) abort();
@@ -462,8 +471,9 @@ export class ManagedToolBoundary {
462
471
  async dispose() {
463
472
  this.#closed = true;
464
473
  const stopping = [...this.#children].map(child => new Promise(resolve => { child.once('close', resolve); child.managedCancel(); }));
465
- await Promise.all(stopping); await this.#tail;
474
+ await Promise.all([this.terminals.dispose(), ...stopping]); await this.#queue.idle();
466
475
  await this.background?.dispose(); await this.stopStrays(); if (!this.linux) await stopMarked(`PIAGENT_PROCESS_SCOPE=${this.scope}:`);
476
+ this.#mutationObservations.clear();
467
477
  await this.proxyReady; await this.proxy?.close().catch(() => {});
468
478
  if (this.proxySocket) fs.rmSync(this.proxySocket, { force: true });
469
479
  fs.rmSync(this.temporary, { recursive: true, force: true });
@@ -0,0 +1,47 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { createHash } from 'node:crypto';
4
+
5
+ const LIMIT = 20 * 1024 * 1024;
6
+ export function freshnessPath(cwd, name, args) {
7
+ if (!['read', 'edit', 'write'].includes(name) || typeof args?.path !== 'string') return null;
8
+ const relative = path.relative(cwd, path.resolve(cwd, args.path));
9
+ return relative && relative !== '..' && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative) ? relative : null;
10
+ }
11
+ // Called only inside the sandbox. The parent stores opaque digests and never
12
+ // opens a company project file to decide whether an edit is fresh.
13
+ export function fileDigest(cwd, relative) {
14
+ if (!relative) return null;
15
+ let descriptor;
16
+ try {
17
+ const file = path.resolve(cwd, relative), real = fs.realpathSync(file);
18
+ if (real !== file) return null;
19
+ descriptor = fs.openSync(file, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW | fs.constants.O_NONBLOCK);
20
+ const stat = fs.fstatSync(descriptor);
21
+ if (!stat.isFile() || stat.size > LIMIT) return null;
22
+ const bytes = Buffer.alloc(stat.size + 1), length = fs.readSync(descriptor, bytes, 0, bytes.length, 0), after = fs.fstatSync(descriptor);
23
+ if (length !== stat.size || after.size !== stat.size || after.mtimeMs !== stat.mtimeMs || after.ctimeMs !== stat.ctimeMs) return null;
24
+ return createHash('sha256').update(bytes.subarray(0, length)).digest('hex');
25
+ } catch (error) { return error?.code === 'ENOENT' ? 'absent' : null; }
26
+ finally { if (descriptor !== undefined) fs.closeSync(descriptor); }
27
+ }
28
+ export function beginToolFreshness(cwd, name, args, expected) {
29
+ const relative = freshnessPath(cwd, name, args), before = fileDigest(cwd, relative);
30
+ if (relative && ['edit', 'write'].includes(name) && expected !== undefined && before !== expected)
31
+ throw new Error(`stale-content: ${relative} changed since the last read. Read its current contents before editing.`);
32
+ return () => {
33
+ const digest = fileDigest(cwd, relative);
34
+ if (name === 'read' && relative && before !== digest) throw new Error(`stale-content: ${relative} changed during the read. Read it again.`);
35
+ return relative && digest ? { path: relative, digest } : null;
36
+ };
37
+ }
38
+ export class ToolFreshness {
39
+ #observed = new Map();
40
+ expected(cwd, name, args) { return this.#observed.get(freshnessPath(cwd, name, args)); }
41
+ observe(cwd, name, args, value) {
42
+ const relative = freshnessPath(cwd, name, args);
43
+ if (!relative || value?.path !== relative || !/^(absent|[a-f0-9]{64})$/.test(value?.digest ?? '')) return;
44
+ this.#observed.delete(relative); this.#observed.set(relative, value.digest);
45
+ while (this.#observed.size > 256) this.#observed.delete(this.#observed.keys().next().value);
46
+ }
47
+ }
@@ -1,9 +1,10 @@
1
+ import { beginToolFreshness } from './tool-freshness.mjs';
1
2
  import { sandboxDiagnostic } from './sandbox-diagnostic.mjs';
2
3
  import { fallbackFind, fallbackGrep } from './search-fallback.mjs';
3
4
  import fs from 'node:fs';
4
5
  import path from 'node:path';
5
6
  import { createInterface } from 'node:readline';
6
- import { pathToFileURL } from 'node:url';
7
+ import { fileURLToPath, pathToFileURL } from 'node:url';
7
8
  // A dedicated, credential-free process already inside Seatbelt. The launch
8
9
  // profile and environment are supplied by the trusted runtime, never the LLM.
9
10
  const [sdkPath, cwd] = process.argv.slice(2);
@@ -33,8 +34,22 @@ input.on('line', async line => {
33
34
  try {
34
35
  const message = JSON.parse(line);
35
36
  if (message.cancel === true) { controller?.abort(); return; }
36
- if (active || !Object.hasOwn(factories, message.name)) throw new Error('managed-worker-request-invalid');
37
+ if (active || (message.name !== 'workspace' && !Object.hasOwn(factories, message.name))) throw new Error('managed-worker-request-invalid');
37
38
  active = true; controller = new AbortController(); network = message.network === true; isolated = message.isolated === true; docker = message.docker === true; engine = message.engine === true;
39
+ if (message.name === 'workspace') {
40
+ const { inspectManagedWorkspace, inspectManagedSource } = await import('./dist/workspace/worker.mjs');
41
+ const assets = fileURLToPath(new URL('./dist/workspace/assets/', import.meta.url));
42
+ emit(message.args?.kind === 'source'
43
+ ? await inspectManagedSource(cwd, assets, message.args.request, message.sourceContext, message.workspaceReadOnly === true)
44
+ : { result: await inspectManagedWorkspace(cwd, assets, message.args, message.workspaceReadOnly === true) }); return;
45
+ }
46
+ let finishObservation = () => null;
47
+ if (message.mutationObservation && ['edit', 'write'].includes(message.name)) {
48
+ const { beginManagedMutationObservation } = await import('./dist/workspace/worker.mjs');
49
+ finishObservation = beginManagedMutationObservation(cwd, fileURLToPath(new URL('./dist/workspace/assets/', import.meta.url)),
50
+ { ...message.mutationObservation, toolName: message.name, args: message.args }, message.workspaceReadOnly === true);
51
+ }
52
+ const finishFreshness = beginToolFreshness(cwd, message.name, message.args, message.expectedFileDigest);
38
53
  const options = message.name === 'bash' ? { shellPath: '/bin/bash', exposeSessionEnvironment: false } : undefined;
39
54
  // The runtime copies ripgrep/fd here when the machine has or can fetch them.
40
55
  const provided = name => fs.existsSync(path.join(process.env.HOME ?? '', '.pi/agent/bin', name));
@@ -46,7 +61,7 @@ input.on('line', async line => {
46
61
  // failures. Pi 0.87 throws on denial and on a non-zero shell exit; Pi 1.x
47
62
  // returns an isError result with the same text instead: read as the throw.
48
63
  if (result?.isError === true) throw new Error((result.content ?? []).filter(part => part.type === 'text').map(part => part.text).join('\n') || 'managed-worker-failed');
49
- emit({ result });
64
+ emit({ result, freshness: finishFreshness(), mutationObservation: finishObservation() });
50
65
  } catch (error) { emit({ error: sandboxDiagnostic(error instanceof Error ? error.message : 'managed-worker-failed', { network, isolated, docker, engine }) }); }
51
66
  finally { if (active) { input.close(); process.stdin.destroy(); } }
52
67
  });
@@ -2,6 +2,9 @@ import dns from 'node:dns/promises';
2
2
  import https from 'node:https';
3
3
  import net from 'node:net';
4
4
  import zlib from 'node:zlib';
5
+ import { privateAddress } from './ip-policy.mjs';
6
+
7
+ export { privateAddress };
5
8
 
6
9
  // Reads one public https page for the company session, from the runtime
7
10
  // process (tools in the sandbox have no network). GET only, no cookies or
@@ -11,20 +14,6 @@ const MAX_BYTES = 5 * 1024 * 1024;
11
14
  const MAX_REDIRECTS = 5;
12
15
  const TEXT = /^(text\/|application\/(json|xml|xhtml\+xml|javascript|x-yaml|yaml|ld\+json|rss\+xml|atom\+xml))/i;
13
16
 
14
- function v4Private(address) {
15
- const [a, b] = address.split('.').map(Number);
16
- return a === 0 || a === 10 || a === 127 || a >= 224 || (a === 100 && b >= 64 && b <= 127) || (a === 169 && b === 254)
17
- || (a === 172 && b >= 16 && b <= 31) || (a === 192 && b === 168) || (a === 192 && b === 0) || (a === 198 && (b === 18 || b === 19));
18
- }
19
- export function privateAddress(address) {
20
- if (net.isIPv4(address)) return v4Private(address);
21
- if (!net.isIPv6(address)) return true;
22
- const value = address.toLowerCase();
23
- const mapped = value.match(/^(?:0*:)*:ffff:(\d+\.\d+\.\d+\.\d+)$/) ?? value.match(/^64:ff9b::(\d+\.\d+\.\d+\.\d+)$/);
24
- if (mapped) return v4Private(mapped[1]);
25
- return value === '::' || value === '::1' || /^f[cd]/.test(value) || /^fe[89ab]/.test(value) || /^ff/.test(value) || value.startsWith('2001:db8');
26
- }
27
-
28
17
  async function publicTarget(url, resolve) {
29
18
  if (url.protocol !== 'https:' || url.username || url.password) throw new Error('web-fetch-url-not-allowed');
30
19
  const host = url.hostname.replace(/^\[|\]$/g, '');
@@ -0,0 +1,26 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { createHash } from 'node:crypto';
4
+ const digest = file => createHash('sha256').update(fs.readFileSync(file)).digest('hex');
5
+
6
+ export function workspaceWorkerPins(runtimeRoot) {
7
+ const root = path.join(runtimeRoot, 'dist/workspace'), manifest = path.join(root, 'manifest.json');
8
+ if (!fs.existsSync(manifest)) return null;
9
+ const source = JSON.parse(fs.readFileSync(manifest, 'utf8'));
10
+ if (source.version !== 1 || !Array.isArray(source.files) || source.files.length > 200) throw Error('workspace-worker-invalid');
11
+ const pins = new Map([[manifest, digest(manifest)]]);
12
+ for (const item of source.files) {
13
+ if (typeof item.file !== 'string' || !/^[a-zA-Z0-9_./-]+$/.test(item.file)
14
+ || item.file.split('/').some(part => !part || part === '.' || part === '..') || !/^[a-f0-9]{64}$/.test(item.sha256)) throw Error('workspace-worker-invalid');
15
+ const file = path.join(root, item.file);
16
+ if (fs.realpathSync(file) !== file || !fs.statSync(file).isFile() || digest(file) !== item.sha256) throw Error('workspace-worker-invalid');
17
+ pins.set(file, item.sha256);
18
+ }
19
+ if (!pins.has(path.join(root, 'worker.mjs'))) throw Error('workspace-worker-invalid');
20
+ return pins;
21
+ }
22
+
23
+ export function verifyWorkspaceWorker(pins) {
24
+ if (!pins) throw Error('workspace-worker-unavailable');
25
+ for (const [file, expected] of pins) if (digest(file) !== expected) throw Error('managed-runtime-changed');
26
+ }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@piagent/core",
3
- "version": "1.37.0",
3
+ "version": "1.38.1",
4
4
  "type": "module",
5
5
  "private": true,
6
6
  "license": "MIT",
@@ -93,7 +93,8 @@
93
93
  "allowedModes": [
94
94
  "read-only",
95
95
  "workspace-write",
96
- "trusted-full-access"
96
+ "trusted-full-access",
97
+ "bypass"
97
98
  ]
98
99
  },
99
100
  "execPolicy": {