@piagent/platform 1.37.1 → 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 (283) hide show
  1. package/CHANGELOG.md +47 -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 +2 -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 +17 -0
  22. package/docs-site/content/index.html +4 -4
  23. package/docs-site/content/whats-new.html +17 -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 +18 -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 +18 -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/patch-snapshot.mjs +6 -8
  116. package/packages/piagent-core/managed/permission.mjs +29 -6
  117. package/packages/piagent-core/managed/repository-operation.mjs +2 -1
  118. package/packages/piagent-core/managed/review-snapshot-request.mjs +21 -0
  119. package/packages/piagent-core/managed/sandbox-diagnostic.mjs +2 -0
  120. package/packages/piagent-core/managed/session-mutation-observations.mjs +37 -0
  121. package/packages/piagent-core/managed/session.mjs +2 -2
  122. package/packages/piagent-core/managed/source-revert-requests.mjs +21 -0
  123. package/packages/piagent-core/managed/terminal-boundary.mjs +88 -0
  124. package/packages/piagent-core/managed/terminal-channel.mjs +66 -0
  125. package/packages/piagent-core/managed/terminal-runtime.mjs +64 -0
  126. package/packages/piagent-core/managed/terminal-seatbelt.mjs +48 -0
  127. package/packages/piagent-core/managed/terminal-worker.mjs +70 -0
  128. package/packages/piagent-core/managed/tool-boundary.mjs +45 -35
  129. package/packages/piagent-core/managed/tool-freshness.mjs +47 -0
  130. package/packages/piagent-core/managed/tool-worker.mjs +18 -3
  131. package/packages/piagent-core/managed/workspace-worker-pins.mjs +26 -0
  132. package/packages/piagent-core/package.json +1 -1
  133. package/packages/piagent-core/policies/base-policy.json +2 -1
  134. package/packages/piagent-core/runtime/checkpoints/checkpoint-authority.ts +43 -0
  135. package/packages/piagent-core/runtime/checkpoints/checkpoint-refs.mjs +51 -0
  136. package/packages/piagent-core/runtime/checkpoints/checkpoint-store.mjs +179 -0
  137. package/packages/piagent-core/runtime/checkpoints/checkpoint-worker.ts +28 -0
  138. package/packages/piagent-core/runtime/checkpoints/git-adapter.mjs +36 -0
  139. package/packages/piagent-core/runtime/checkpoints/persistent-history.mjs +96 -0
  140. package/packages/piagent-core/runtime/checkpoints/personal-git.ts +272 -0
  141. package/packages/piagent-core/runtime/checkpoints/personal-runtime.ts +158 -0
  142. package/packages/piagent-core/runtime/checkpoints/prune.mjs +29 -0
  143. package/packages/piagent-core/runtime/checkpoints/restore-preview.mjs +46 -0
  144. package/packages/piagent-core/runtime/checkpoints/turn-checkpoints.mjs +167 -0
  145. package/packages/piagent-core/runtime/hooks/session-hooks.ts +19 -16
  146. package/packages/piagent-core/runtime/hooks/session-start-hook.ts +3 -0
  147. package/packages/piagent-core/runtime/hooks/tool-call-hook.ts +2 -2
  148. package/packages/piagent-core/runtime/inspection/diff-projection.ts +2 -1
  149. package/packages/piagent-core/runtime/inspection/managed-source-revert.ts +90 -0
  150. package/packages/piagent-core/runtime/inspection/review-state-projection.ts +19 -8
  151. package/packages/piagent-core/runtime/inspection/session-diff-projection.ts +47 -0
  152. package/packages/piagent-core/runtime/inspection/session-hunk-provenance.ts +56 -0
  153. package/packages/piagent-core/runtime/inspection/session-mutation-observer.ts +125 -0
  154. package/packages/piagent-core/runtime/inspection/source-action-contract.ts +123 -0
  155. package/packages/piagent-core/runtime/inspection/source-change-projection.ts +3 -9
  156. package/packages/piagent-core/runtime/inspection/source-mutation-projection.ts +3 -41
  157. package/packages/piagent-core/runtime/inspection/source-mutation-store.ts +6 -36
  158. package/packages/piagent-core/runtime/inspection/source-revert-projection.ts +36 -30
  159. package/packages/piagent-core/runtime/inspection/text-diff.ts +5 -4
  160. package/packages/piagent-core/runtime/inspection/workspace-worker.ts +79 -0
  161. package/packages/piagent-core/runtime/policy/source-index-transaction.ts +3 -10
  162. package/packages/piagent-core/runtime/policy/source-mutation-guard-binding.ts +27 -2
  163. package/packages/piagent-core/runtime/policy/source-mutation-guard.ts +42 -12
  164. package/packages/piagent-core/runtime/policy/source-worktree-transaction.ts +10 -5
  165. package/packages/piagent-core/runtime/policy/workspace-write-authority.ts +51 -0
  166. package/packages/piagent-core/runtime/process-owner.mjs +20 -0
  167. package/packages/piagent-core/runtime/process-tree-watch.mjs +46 -0
  168. package/packages/piagent-core/runtime/quality/model-mutation-proof.ts +8 -5
  169. package/packages/piagent-core/runtime/registration/permission-commands.ts +10 -2
  170. package/packages/piagent-core/runtime/registration/task-evidence-tools.ts +1 -1
  171. package/packages/piagent-core/runtime/session/workspace-member-edits.d.mts +7 -0
  172. package/packages/piagent-core/runtime/session/workspace-member-edits.mjs +41 -0
  173. package/packages/piagent-core/security/workspace-file-reader.ts +2 -1
  174. package/packages/piagent-webui/dist/client/assets/Alert-DXF8-XqE.js +1 -0
  175. package/packages/piagent-webui/dist/client/assets/Button-DZJosXQi.js +1 -0
  176. package/packages/piagent-webui/dist/client/assets/DialogTitle-CL-fzCnT.js +3 -0
  177. package/packages/piagent-webui/dist/client/assets/MarkdownMessage-k4tq1y8s.js +29 -0
  178. package/packages/piagent-webui/dist/client/assets/TerminalPanel-DvOPgrGI.js +2 -0
  179. package/packages/piagent-webui/dist/client/assets/TerminalView-B_a16moC.js +9 -0
  180. package/packages/piagent-webui/dist/client/assets/TerminalView-kHJ-D0s7.css +1 -0
  181. package/packages/piagent-webui/dist/client/assets/Typography-DMZL1kLS.js +1 -0
  182. package/packages/piagent-webui/dist/client/assets/WorkspaceCodeEditor-BCiYM9NA.js +2 -0
  183. package/packages/piagent-webui/dist/client/assets/WorkspaceExplorer-DttpQtpf.js +5 -0
  184. package/packages/piagent-webui/dist/client/assets/WorkspaceQuickOpen-fgXwQ46B.js +1 -0
  185. package/packages/piagent-webui/dist/client/assets/bootstrap-CedkNtRG.js +1 -0
  186. package/packages/piagent-webui/dist/client/assets/dist-B1M7IjfA.js +10 -0
  187. package/packages/piagent-webui/dist/client/assets/dist-BUYtGaUP.js +1 -0
  188. package/packages/piagent-webui/dist/client/assets/dist-C82AtDDT.js +6 -0
  189. package/packages/piagent-webui/dist/client/assets/dist-D9b8j6pJ.js +1 -0
  190. package/packages/piagent-webui/dist/client/assets/dist-DLfpWwLs.js +23 -0
  191. package/packages/piagent-webui/dist/client/assets/dist-DcEu_WYV.js +1 -0
  192. package/packages/piagent-webui/dist/client/assets/dist-_JsfplOG.js +9 -0
  193. package/packages/piagent-webui/dist/client/assets/dist-idN8f9Fc.js +1 -0
  194. package/packages/piagent-webui/dist/client/assets/dist-nvgpa2Us.js +1 -0
  195. package/packages/piagent-webui/dist/client/assets/index-BW8-BUMx.js +102 -0
  196. package/packages/piagent-webui/dist/client/assets/index-Brs_HjIo.css +1 -0
  197. package/packages/piagent-webui/dist/client/assets/rolldown-runtime-hePW80VL.js +1 -0
  198. package/packages/piagent-webui/dist/client/assets/tab-close-HE0XxoJf.js +1 -0
  199. package/packages/piagent-webui/dist/client/assets/ui-preferences-DiddStu0.js +124 -0
  200. package/packages/piagent-webui/dist/client/assets/useTheme-_ESOj7MI.js +10 -0
  201. package/packages/piagent-webui/dist/client/assets/workspace-api-O7auv_td.js +1 -0
  202. package/packages/piagent-webui/dist/client/index.html +10 -2
  203. package/packages/piagent-webui/extension/same-session-bridge.ts +2 -3
  204. package/packages/piagent-webui/extension/source-revert-controller.ts +14 -208
  205. package/packages/piagent-webui/gateway/checkpoint-sessions.ts +99 -0
  206. package/packages/piagent-webui/gateway/checkpoint-store.ts +6 -0
  207. package/packages/piagent-webui/gateway/company-relay.ts +5 -0
  208. package/packages/piagent-webui/gateway/company-terminal-cache.ts +54 -0
  209. package/packages/piagent-webui/gateway/company-terminal-channel.ts +117 -0
  210. package/packages/piagent-webui/gateway/company-terminal-relay.ts +75 -0
  211. package/packages/piagent-webui/gateway/gateway-protocol-service.ts +12 -0
  212. package/packages/piagent-webui/gateway/gateway-service.ts +48 -4
  213. package/packages/piagent-webui/gateway/managed-gateway.mjs +30 -3
  214. package/packages/piagent-webui/gateway/managed-projection.ts +5 -3
  215. package/packages/piagent-webui/gateway/managed-source-revert-controller.ts +45 -0
  216. package/packages/piagent-webui/gateway/managed-source-revert-sessions.ts +74 -0
  217. package/packages/piagent-webui/gateway/managed-terminal-binding.ts +30 -0
  218. package/packages/piagent-webui/gateway/managed-terminal-processes.ts +74 -0
  219. package/packages/piagent-webui/gateway/managed-terminal-sessions.ts +9 -0
  220. package/packages/piagent-webui/gateway/path-suggestions.ts +1 -124
  221. package/packages/piagent-webui/gateway/personal-terminal-binding.ts +36 -0
  222. package/packages/piagent-webui/gateway/personal-terminal-diagnostic.mjs +16 -0
  223. package/packages/piagent-webui/gateway/personal-terminal-processes.ts +119 -0
  224. package/packages/piagent-webui/gateway/personal-terminal-sessions.ts +22 -0
  225. package/packages/piagent-webui/gateway/runtime-session-controls.ts +2 -1
  226. package/packages/piagent-webui/gateway/session-catalog.ts +3 -2
  227. package/packages/piagent-webui/gateway/session-command-controller.ts +4 -4
  228. package/packages/piagent-webui/gateway/session-inspection-registry.ts +29 -5
  229. package/packages/piagent-webui/gateway/session-runtime-supervisor.ts +8 -8
  230. package/packages/piagent-webui/gateway/signed-session-journal.ts +77 -0
  231. package/packages/piagent-webui/gateway/source-revert-sessions.ts +74 -0
  232. package/packages/piagent-webui/gateway/source-revert-store.ts +47 -0
  233. package/packages/piagent-webui/gateway/terminal-command-ledger.ts +33 -0
  234. package/packages/piagent-webui/gateway/terminal-mentions.ts +29 -0
  235. package/packages/piagent-webui/gateway/terminal-output-buffer.ts +58 -0
  236. package/packages/piagent-webui/gateway/terminal-router.ts +12 -0
  237. package/packages/piagent-webui/gateway/terminal-sessions.ts +247 -0
  238. package/packages/piagent-webui/gateway/workspace-write-sessions.ts +69 -0
  239. package/packages/piagent-webui/gateway/workspace-write-store.ts +6 -0
  240. package/packages/piagent-webui/package.json +14 -2
  241. package/packages/piagent-webui/server/checkpoint-command.ts +30 -0
  242. package/packages/piagent-webui/server/checkpoint-controller.ts +87 -0
  243. package/packages/piagent-webui/server/checkpoint-routes.ts +38 -0
  244. package/packages/piagent-webui/server/control-command-digest.ts +5 -0
  245. package/packages/piagent-webui/server/core-inspection-provider.ts +16 -22
  246. package/packages/piagent-webui/server/gateway-websocket.ts +36 -8
  247. package/packages/piagent-webui/server/loopback-server.ts +9 -0
  248. package/packages/piagent-webui/server/read-model-provider.ts +3 -0
  249. package/packages/piagent-webui/server/read-only-router.ts +3 -0
  250. package/packages/piagent-webui/server/session-auth.ts +4 -1
  251. package/packages/piagent-webui/server/session-source-revert-routes.ts +54 -0
  252. package/packages/piagent-webui/server/source-projection-binding.ts +18 -0
  253. package/packages/piagent-webui/server/source-revert-controller.ts +225 -0
  254. package/packages/piagent-webui/server/terminal-command.ts +35 -0
  255. package/packages/piagent-webui/server/terminal-websocket-stream.ts +56 -0
  256. package/packages/piagent-webui/server/transcript-projection.ts +2 -1
  257. package/packages/piagent-webui/server/workspace-read-routes.ts +32 -0
  258. package/packages/piagent-webui/server/workspace-reader.ts +23 -0
  259. package/packages/piagent-webui/server/workspace-write-controller.ts +118 -0
  260. package/packages/piagent-webui/server/workspace-write-routes.ts +38 -0
  261. package/packages/piagent-webui/shared/terminal-message.ts +23 -0
  262. package/schemas/piagent-webui/catalog-v1.json +63 -0
  263. package/schemas/piagent-webui/checkpoint-v1.schema.json +466 -0
  264. package/schemas/piagent-webui/control-command-v1.schema.json +68 -3
  265. package/schemas/piagent-webui/diff-v1.schema.json +4 -0
  266. package/schemas/piagent-webui/gateway-protocol-v1.schema.json +123 -3
  267. package/schemas/piagent-webui/session-catalog-v1.schema.json +1 -1
  268. package/schemas/piagent-webui/session-command-v1.schema.json +1 -1
  269. package/schemas/piagent-webui/snapshot-v1.schema.json +2 -1
  270. package/schemas/piagent-webui/source-revert-v1.schema.json +22 -3
  271. package/schemas/piagent-webui/terminal-v1.schema.json +495 -0
  272. package/schemas/piagent-webui/workspace-file-v1.schema.json +79 -0
  273. package/schemas/piagent-webui/workspace-paths-v1.schema.json +71 -0
  274. package/schemas/piagent-webui/workspace-search-v1.schema.json +80 -0
  275. package/schemas/piagent-webui/workspace-tree-v1.schema.json +87 -0
  276. package/schemas/piagent-webui/workspace-write-v1.schema.json +276 -0
  277. package/schemas/project-profile.schema.json +8 -3
  278. package/scripts/company-terminal.mjs +16 -11
  279. package/scripts/pi-auto.sh +5 -1
  280. package/scripts/team-doctor.sh +8 -0
  281. package/packages/piagent-webui/dist/client/assets/MarkdownMessage-CfgyQQbW.js +0 -29
  282. package/packages/piagent-webui/dist/client/assets/index-HyzWKvDu.js +0 -234
  283. package/packages/piagent-webui/dist/client/assets/index-_0nGP2n3.css +0 -1
@@ -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
  });
@@ -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.1",
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": {
@@ -0,0 +1,43 @@
1
+ import fs from 'node:fs';
2
+ import type { TurnCheckpointService } from './turn-checkpoints.mjs';
3
+
4
+ export type CheckpointPort = Pick<TurnCheckpointService, 'list' | 'preview'> & {
5
+ restoreTo(target: string, expected: { digest: string; revision: string; tree: string }, guard?: () => void): ReturnType<TurnCheckpointService['restoreTo']>;
6
+ };
7
+ type Binding = { port: CheckpointPort; revoke(): void };
8
+ const key = (cwd: string, sessionId: string) => `${fs.realpathSync(cwd)}\0${sessionId}`;
9
+
10
+ // Only the live extension may supply a personal checkpoint port. Gateway must
11
+ // never reconstruct mutation authority from persisted session messages.
12
+ export class CheckpointAuthority {
13
+ #bindings = new Map<string, Binding>();
14
+ bind(cwd: string, sessionId: string, binding: Binding) {
15
+ const identity = key(cwd, sessionId), previous = this.#bindings.get(identity);
16
+ if (!previous && this.#bindings.size >= 1024) throw Error('checkpoint-authority-capacity');
17
+ previous?.revoke();
18
+ this.#bindings.set(identity, binding);
19
+ return () => {
20
+ binding.revoke();
21
+ if (this.#bindings.get(identity) === binding) this.#bindings.delete(identity);
22
+ };
23
+ }
24
+ resolve(cwd: string, sessionId: string) {
25
+ const identity = key(cwd, sessionId), binding = this.#bindings.get(identity);
26
+ if (!binding) throw Error('checkpoint-runtime-unavailable');
27
+ const current = () => this.#bindings.get(identity) === binding;
28
+ const check = () => { if (!current()) throw Error('checkpoint-authority-changed'); };
29
+ return {
30
+ current,
31
+ list: () => { check(); return binding.port.list(); },
32
+ preview: (target: string) => { check(); return binding.port.preview(target); },
33
+ restoreTo: (target: string, expected: { digest: string; revision: string; tree: string }, guard?: () => void) => {
34
+ check(); return binding.port.restoreTo(target, expected, () => { check(); guard?.(); });
35
+ },
36
+ };
37
+ }
38
+ }
39
+
40
+ // Pi's extension loader and the native Gateway can load distinct module copies.
41
+ const AUTHORITY_KEY = Symbol.for('piagent.checkpoint-authority.v1');
42
+ const root = globalThis as typeof globalThis & { [AUTHORITY_KEY]?: CheckpointAuthority };
43
+ export const checkpointAuthority = root[AUTHORITY_KEY] ??= new CheckpointAuthority();
@@ -0,0 +1,51 @@
1
+ // Trees referenced only by a private index are unreachable after it is removed.
2
+ // Separate before/after refs keep their blobs alive through GC without commits,
3
+ // branches, reflog entries, or changes to HEAD and the member's index.
4
+ export class CheckpointRefs {
5
+ constructor(execute, sessionKey) {
6
+ if (!/^[a-f0-9]{64}$/.test(sessionKey)) throw Error('checkpoint-session-invalid');
7
+ this.execute = execute;
8
+ this.prefix = `refs/piagent/checkpoints/${sessionKey}/`;
9
+ }
10
+ #entries(point) {
11
+ if (!/^[a-f0-9]{8}(?:-[a-f0-9]{4}){3}-[a-f0-9]{12}$/.test(point.id)) throw Error('checkpoint-state-invalid');
12
+ return ['before', 'after'].flatMap(side => {
13
+ const tree = point[side];
14
+ if (side === 'after' && tree === null) return [];
15
+ if (typeof tree !== 'string' || !/^(?:[a-f0-9]{40}|[a-f0-9]{64})$/.test(tree)) throw Error('checkpoint-state-invalid');
16
+ return [[`${this.prefix}${point.id}/${side}`, tree]];
17
+ });
18
+ }
19
+ async #transaction(lines) {
20
+ const bytes = Buffer.from(['start', ...lines, 'prepare', 'commit', ''].join('\n'));
21
+ if (typeof this.execute !== 'function') return await this.execute.run(['update-ref', '--stdin'], bytes);
22
+ await this.execute(`printf %s ${bytes.toString('base64')} | base64 --decode | git update-ref --stdin`);
23
+ }
24
+ async #current(point) {
25
+ const entries = this.#entries(point), prefix = `${this.prefix}${point.id}/`;
26
+ const output = typeof this.execute === 'function'
27
+ ? await this.execute(`git for-each-ref --format='%(refname) %(objectname)' ${prefix}`)
28
+ : await this.execute.run(['for-each-ref', '--format=%(refname) %(objectname)', prefix]);
29
+ const current = new Map();
30
+ for (const line of output.trim().split('\n').filter(Boolean)) {
31
+ const [ref, tree, extra] = line.split(' ');
32
+ if (extra || !entries.some(([name, oid]) => name === ref && oid === tree) || current.has(ref))
33
+ throw Error('checkpoint-ref-changed');
34
+ current.set(ref, tree);
35
+ }
36
+ return { entries, current };
37
+ }
38
+ async pin(point) {
39
+ const { entries, current } = await this.#current(point);
40
+ // Ref creation and existing-value verification are atomic, so a race cannot
41
+ // replace a ref belonging to another retained checkpoint.
42
+ await this.#transaction(entries.map(([ref, tree]) => `${current.has(ref) ? 'verify' : 'create'} ${ref} ${tree}`));
43
+ }
44
+ async release(point) {
45
+ // A crash after deletion may leave cleanup pending. Missing refs are already
46
+ // released; changed values still refuse, including races before deletion.
47
+ const { entries, current } = await this.#current(point);
48
+ const present = entries.filter(([ref]) => current.has(ref));
49
+ if (present.length) await this.#transaction(present.map(([ref, tree]) => `delete ${ref} ${tree}`));
50
+ }
51
+ }
@@ -0,0 +1,179 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { createHash, randomUUID } from 'node:crypto';
4
+ import { ensurePrivateStateDirectory, resolveLocalStatePath } from '../../extensions/local-state-path.js';
5
+ import { ownerAlive } from '../process-owner.mjs';
6
+
7
+ export const MAX_CHECKPOINTS = 64;
8
+ const MAX_BYTES = 128 * 1024;
9
+ const hash = value => createHash('sha256').update(value).digest('hex');
10
+ const oid = value => typeof value === 'string' && /^(?:[a-f0-9]{40}|[a-f0-9]{64})$/.test(value);
11
+ const id = value => typeof value === 'string' && /^[a-f0-9]{8}(?:-[a-f0-9]{4}){3}-[a-f0-9]{12}$/.test(value);
12
+ const exact = (value, keys) => value && typeof value === 'object' && !Array.isArray(value)
13
+ && Object.keys(value).length === keys.length && Object.keys(value).every(key => keys.includes(key));
14
+
15
+ export function checkpointPoint(value) {
16
+ if (!exact(value, ['id', 'turnRef', 'before', 'after', 'at']) || !id(value.id)
17
+ || typeof value.turnRef !== 'string' || !value.turnRef.length || value.turnRef.length > 160 || /[\x00-\x1f]/.test(value.turnRef)
18
+ || !oid(value.before) || !oid(value.after) || value.before.length !== value.after.length
19
+ || !Number.isSafeInteger(value.at) || value.at < 0) throw Error('checkpoint-state-invalid');
20
+ return { ...value };
21
+ }
22
+
23
+ function refPoint(value) {
24
+ if (value?.after !== null) return checkpointPoint(value);
25
+ checkpointPoint({ ...value, after: value.before });
26
+ return { ...value };
27
+ }
28
+ function captureRecord(value) {
29
+ if (value === null) return null;
30
+ if (!exact(value, ['point', 'pid', 'token']) || !Number.isInteger(value.pid) || value.pid <= 1 || !id(value.token)
31
+ || value.point?.after !== null) throw Error('checkpoint-state-invalid');
32
+ return { point: refPoint(value.point), pid: value.pid, token: value.token };
33
+ }
34
+
35
+ const same = (a, b) => a.dev === b.dev && a.ino === b.ino && a.mode === b.mode && a.nlink === b.nlink
36
+ && a.size === b.size && a.mtimeMs === b.mtimeMs && a.ctimeMs === b.ctimeMs;
37
+ function privateFile(stat) {
38
+ if (!stat.isFile() || stat.nlink !== 1 || stat.size > MAX_BYTES
39
+ || typeof process.getuid === 'function' && (stat.uid !== process.getuid() || (stat.mode & 0o077)))
40
+ throw Error('checkpoint-state-not-private');
41
+ }
42
+
43
+ // A small synchronous CAS transaction keeps every read/write inside the same
44
+ // owner-only directory. The lock also rejects a second process with stale state.
45
+ export class CheckpointStore {
46
+ #operation = null;
47
+ constructor(projectRoot, sessionId) {
48
+ if (typeof sessionId !== 'string' || !sessionId.length || sessionId.length > 4096) throw Error('checkpoint-session-invalid');
49
+ this.root = fs.realpathSync(projectRoot);
50
+ this.key = hash(`${this.root}\0${sessionId}`);
51
+ this.directory = path.join(this.root, '.pi', 'piagent-state', 'checkpoints');
52
+ this.file = path.join(this.directory, `${this.key}.json`);
53
+ this.lock = path.join(this.directory, `${this.key}.lock`);
54
+ }
55
+ #safe(file, kind) { return resolveLocalStatePath(this.root, file, { label: 'Checkpoint state', kind }); }
56
+ #read(file) {
57
+ const safe = this.#safe(file, 'file');
58
+ const fd = fs.openSync(safe, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW | fs.constants.O_NONBLOCK);
59
+ try {
60
+ const before = fs.fstatSync(fd); privateFile(before);
61
+ const bytes = fs.readFileSync(fd);
62
+ if (!same(before, fs.fstatSync(fd)) || !same(before, fs.lstatSync(this.#safe(file, 'file')))) throw Error('checkpoint-state-changed');
63
+ return { bytes, stat: before };
64
+ } finally { fs.closeSync(fd); }
65
+ }
66
+ load() {
67
+ let bytes;
68
+ try { ({ bytes } = this.#read(this.file)); }
69
+ catch (error) { if (error.code === 'ENOENT') return { revision: 'absent', points: [] }; throw error; }
70
+ const value = JSON.parse(bytes.toString('utf8'));
71
+ if (!(value.version === 1 && exact(value, ['version', 'sessionKey', 'points'])
72
+ || value.version === 2 && exact(value, ['version', 'sessionKey', 'points', 'retired'])
73
+ || value.version === 3 && exact(value, ['version', 'sessionKey', 'points', 'retired', 'staged'])
74
+ || value.version === 4 && exact(value, ['version', 'sessionKey', 'points', 'retired', 'staged', 'capture'])) || value.sessionKey !== this.key
75
+ || !Array.isArray(value.points) || value.points.length > MAX_CHECKPOINTS) throw Error('checkpoint-state-invalid');
76
+ const points = value.points.map(checkpointPoint);
77
+ if (new Set(points.map(point => point.id)).size !== points.length) throw Error('checkpoint-state-invalid');
78
+ const retired = value.version === 1 ? [] : value.retired;
79
+ if (!Array.isArray(retired) || retired.length > MAX_CHECKPOINTS + 1) throw Error('checkpoint-state-invalid');
80
+ const checkedRetired = retired.map(refPoint), ids = new Set(points.map(point => point.id));
81
+ for (const point of checkedRetired) {
82
+ if (ids.has(point.id)) throw Error('checkpoint-state-invalid');
83
+ ids.add(point.id);
84
+ }
85
+ const staged = value.version >= 3 ? value.staged : [];
86
+ if (!Array.isArray(staged) || staged.length > MAX_CHECKPOINTS + 1) throw Error('checkpoint-state-invalid');
87
+ const checkedStaged = staged.map(checkpointPoint);
88
+ for (const point of checkedStaged) {
89
+ if (ids.has(point.id)) throw Error('checkpoint-state-invalid');
90
+ ids.add(point.id);
91
+ }
92
+ const capture = value.version === 4 ? captureRecord(value.capture) : null;
93
+ if (capture && ids.has(capture.point.id)) throw Error('checkpoint-state-invalid');
94
+ return { revision: hash(bytes), points, retired: checkedRetired, staged: checkedStaged, capture };
95
+ }
96
+ #acquire() {
97
+ ensurePrivateStateDirectory(this.root, this.directory, 'Checkpoint directory');
98
+ const directory = fs.statSync(this.#safe(this.directory, 'directory'));
99
+ if (typeof process.getuid === 'function' && (directory.uid !== process.getuid() || (directory.mode & 0o077)))
100
+ throw Error('checkpoint-state-not-private');
101
+ const target = this.#safe(this.lock);
102
+ for (let attempt = 0; attempt < 2; attempt++) {
103
+ try {
104
+ const fd = fs.openSync(target, 'wx', 0o600);
105
+ const token = randomUUID();
106
+ try { fs.writeFileSync(fd, JSON.stringify({ pid: process.pid, token })); }
107
+ finally { fs.closeSync(fd); }
108
+ const assertOwner = () => {
109
+ const current = this.#read(this.lock);
110
+ if (JSON.parse(current.bytes).token !== token) throw Error('checkpoint-lock-changed');
111
+ };
112
+ return { assertOwner, release: () => { assertOwner(); fs.unlinkSync(this.#safe(this.lock, 'file')); } };
113
+ } catch (error) {
114
+ if (error.code !== 'EEXIST') throw error;
115
+ const current = this.#read(this.lock), owner = JSON.parse(current.bytes);
116
+ if (!exact(owner, ['pid', 'token']) || !Number.isInteger(owner.pid) || owner.pid <= 1 || !id(owner.token)) throw Error('checkpoint-lock-invalid');
117
+ if (ownerAlive(owner.pid, current.stat.mtimeMs)) throw Error('checkpoint-store-busy');
118
+ if (!same(current.stat, fs.lstatSync(this.#safe(this.lock, 'file')))) throw Error('checkpoint-lock-changed');
119
+ fs.unlinkSync(this.lock);
120
+ }
121
+ }
122
+ throw Error('checkpoint-store-busy');
123
+ }
124
+ // Hold the same lock through asynchronous Git effects and the final CAS save.
125
+ // A second process must refuse before touching files, not only when saving.
126
+ async operation(action) {
127
+ if (this.#operation) throw Error('checkpoint-store-busy');
128
+ const owner = this.#acquire();
129
+ this.#operation = owner;
130
+ try { return await action(); }
131
+ finally { this.#operation = null; owner.release(); }
132
+ }
133
+ save(points, expectedRevision, { clearRetired = false, staged = [], capture = undefined } = {}) {
134
+ if (!Array.isArray(points) || points.length > MAX_CHECKPOINTS + 1) throw Error('checkpoint-state-limit');
135
+ if (!Array.isArray(staged) || staged.length > MAX_CHECKPOINTS + 1) throw Error('checkpoint-state-limit');
136
+ const checkedStaged = staged.map(checkpointPoint);
137
+ const checked = points.map(checkpointPoint);
138
+ if (new Set(checked.map(point => point.id)).size !== checked.length) throw Error('checkpoint-state-invalid');
139
+ const retained = checked.slice(-MAX_CHECKPOINTS), dropped = checked.slice(0, -MAX_CHECKPOINTS);
140
+ const owner = this.#operation ?? this.#acquire();
141
+ owner.assertOwner();
142
+ let temporary;
143
+ try {
144
+ const previous = this.load();
145
+ if (previous.revision !== expectedRevision) throw Error('checkpoint-state-stale');
146
+ const nextCapture = capture === undefined ? previous.capture ?? null : captureRecord(capture);
147
+ const promotedIds = new Set([...retained, ...checkedStaged].map(point => point.id));
148
+ const abandoned = previous.capture && previous.capture.point.id !== nextCapture?.point.id
149
+ && !promotedIds.has(previous.capture.point.id) ? [previous.capture.point] : [];
150
+ const retainedIds = new Set(retained.map(point => point.id));
151
+ const retired = [...new Map([...(clearRetired ? [] : previous.retired ?? []), ...(clearRetired ? [] : previous.staged ?? []), ...previous.points, ...dropped, ...abandoned]
152
+ .filter(point => !retainedIds.has(point.id)).map(point => [point.id, point])).values()];
153
+ if (retired.length > MAX_CHECKPOINTS + 1) throw Error('checkpoint-state-limit');
154
+ const ids = new Set([...retained, ...retired].map(point => point.id));
155
+ for (const point of checkedStaged) {
156
+ if (ids.has(point.id)) throw Error('checkpoint-state-invalid');
157
+ ids.add(point.id);
158
+ }
159
+ if (nextCapture && ids.has(nextCapture.point.id)) throw Error('checkpoint-state-invalid');
160
+ const bytes = Buffer.from(JSON.stringify({ version: 4, sessionKey: this.key, points: retained, retired, staged: checkedStaged, capture: nextCapture }) + '\n');
161
+ if (bytes.length > MAX_BYTES) throw Error('checkpoint-state-limit');
162
+ temporary = this.#safe(path.join(this.directory, `${this.key}.${randomUUID()}.tmp`));
163
+ const fd = fs.openSync(temporary, 'wx', 0o600);
164
+ try { fs.writeFileSync(fd, bytes); fs.fsyncSync(fd); } finally { fs.closeSync(fd); }
165
+ // Revalidate ancestors and destination after writing the private temp file.
166
+ const target = this.#safe(this.file);
167
+ if (this.load().revision !== expectedRevision) throw Error('checkpoint-state-stale');
168
+ fs.renameSync(this.#safe(temporary, 'file'), target); temporary = null;
169
+ if (process.platform !== 'win32') {
170
+ const directory = fs.openSync(this.#safe(this.directory, 'directory'), fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW);
171
+ try { fs.fsyncSync(directory); } finally { fs.closeSync(directory); }
172
+ }
173
+ return { revision: hash(bytes), points: retained, dropped, retired, staged: checkedStaged, capture: nextCapture };
174
+ } finally {
175
+ if (temporary) fs.unlinkSync(this.#safe(temporary, 'file'));
176
+ if (owner !== this.#operation) owner.release();
177
+ }
178
+ }
179
+ }