@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.
- package/CHANGELOG.md +47 -0
- package/README.md +5 -3
- package/README.vi.md +2 -2
- package/catalog/capabilities.json +2 -2
- package/docs/command-reference-vietnamese.md +4 -3
- package/docs/distribution-standard.md +6 -6
- package/docs/en/maintainer-guide.md +8 -0
- package/docs/en/workspace-editor.md +222 -0
- package/docs/languages.json +13 -4
- package/docs/operator-manual-vietnamese.md +7 -7
- package/docs/publishing-for-teams.md +2 -0
- package/docs/release-install-policy.md +2 -2
- package/docs/research/ide-m0-spike.md +101 -0
- package/docs/team-onboarding.md +2 -2
- package/docs/vi/maintainer-guide.md +8 -0
- package/docs/vi/workspace-editor.md +217 -0
- package/docs-site/architecture.html +1 -1
- package/docs-site/benchmark.html +1 -1
- package/docs-site/commands.html +1 -1
- package/docs-site/content/en/index.html +4 -4
- package/docs-site/content/en/whats-new.html +17 -0
- package/docs-site/content/index.html +4 -4
- package/docs-site/content/whats-new.html +17 -0
- package/docs-site/context-engine.html +1 -1
- package/docs-site/ecosystem.html +1 -1
- package/docs-site/en/architecture.html +1 -1
- package/docs-site/en/benchmark.html +1 -1
- package/docs-site/en/commands.html +1 -1
- package/docs-site/en/context-engine.html +1 -1
- package/docs-site/en/ecosystem.html +1 -1
- package/docs-site/en/index.html +5 -5
- package/docs-site/en/mcp-approval.html +1 -1
- package/docs-site/en/mcp-auth.html +1 -1
- package/docs-site/en/mcp-servers.html +1 -1
- package/docs-site/en/mcp.html +1 -1
- package/docs-site/en/permissions.html +1 -1
- package/docs-site/en/profiles.html +1 -1
- package/docs-site/en/quickstart.html +1 -1
- package/docs-site/en/runtime.html +1 -1
- package/docs-site/en/scope.html +1 -1
- package/docs-site/en/security.html +1 -1
- package/docs-site/en/team.html +1 -1
- package/docs-site/en/whats-new.html +18 -1
- package/docs-site/en/windows.html +1 -1
- package/docs-site/en/workflows.html +1 -1
- package/docs-site/index.html +5 -5
- package/docs-site/mcp-approval.html +1 -1
- package/docs-site/mcp-auth.html +1 -1
- package/docs-site/mcp-servers.html +1 -1
- package/docs-site/mcp.html +1 -1
- package/docs-site/permissions.html +1 -1
- package/docs-site/profiles.html +1 -1
- package/docs-site/quickstart.html +1 -1
- package/docs-site/runtime.html +1 -1
- package/docs-site/scope.html +1 -1
- package/docs-site/security.html +1 -1
- package/docs-site/team.html +1 -1
- package/docs-site/whats-new.html +18 -1
- package/docs-site/windows.html +1 -1
- package/docs-site/workflows.html +1 -1
- package/evals/fixtures/piagent-webui/checkpoint-v1.invalid.json +21 -0
- package/evals/fixtures/piagent-webui/checkpoint-v1.valid.json +20 -0
- package/evals/fixtures/piagent-webui/terminal-v1.invalid.json +17 -0
- package/evals/fixtures/piagent-webui/terminal-v1.valid.json +16 -0
- package/evals/fixtures/piagent-webui/workspace-file-v1.invalid.json +24 -0
- package/evals/fixtures/piagent-webui/workspace-file-v1.valid.json +23 -0
- package/evals/fixtures/piagent-webui/workspace-paths-v1.invalid.json +24 -0
- package/evals/fixtures/piagent-webui/workspace-paths-v1.valid.json +24 -0
- package/evals/fixtures/piagent-webui/workspace-search-v1.invalid.json +26 -0
- package/evals/fixtures/piagent-webui/workspace-search-v1.valid.json +26 -0
- package/evals/fixtures/piagent-webui/workspace-tree-v1.invalid.json +28 -0
- package/evals/fixtures/piagent-webui/workspace-tree-v1.valid.json +27 -0
- package/evals/fixtures/piagent-webui/workspace-write-v1.invalid.json +19 -0
- package/evals/fixtures/piagent-webui/workspace-write-v1.valid.json +19 -0
- package/evals/long-task-soak-v1/README.md +17 -0
- package/evals/long-task-soak-v1/lib/session-loop.mjs +19 -3
- package/evals/long-task-soak-v1/scenarios/ide-editor-terminal.json +19 -0
- package/evals/long-task-soak-v1/surfaces/webui-gateway.mjs +17 -2
- package/evals/long-task-soak-v1/surfaces/workspace-actions.mjs +95 -0
- package/evals/private-holdout-v1/public-exposure.v2.json +2 -2
- package/package.json +7 -3
- package/packages/piagent-core/extensions/fallback-base-policy.ts +1 -1
- package/packages/piagent-core/extensions/guard-types.ts +3 -1
- package/packages/piagent-core/extensions/path-suggestions.ts +137 -0
- package/packages/piagent-core/extensions/piagent-guard.ts +85 -7
- package/packages/piagent-core/extensions/workspace-inspection.ts +145 -0
- package/packages/piagent-core/extensions/workspace-policy.d.ts +7 -0
- package/packages/piagent-core/extensions/workspace-policy.js +3 -0
- package/packages/piagent-core/extensions/workspace-search-index.mjs +8 -0
- package/packages/piagent-core/extensions/workspace-search.ts +74 -0
- package/packages/piagent-core/extensions/workspace-write.ts +116 -0
- package/packages/piagent-core/managed/background-process.mjs +9 -4
- package/packages/piagent-core/managed/boundary-package-proxy.mjs +21 -0
- package/packages/piagent-core/managed/boundary-queue.mjs +24 -0
- package/packages/piagent-core/managed/checkpoint-git.mjs +16 -0
- package/packages/piagent-core/managed/checkpoints.mjs +17 -64
- package/packages/piagent-core/managed/command-tools.mjs +6 -6
- package/packages/piagent-core/managed/continuation.mjs +3 -0
- package/packages/piagent-core/managed/dist/workspace/assets/adapters/backend-api/profile.json +88 -0
- package/packages/piagent-core/managed/dist/workspace/assets/adapters/be-readonly-fe/profile.json +130 -0
- package/packages/piagent-core/managed/dist/workspace/assets/adapters/data/profile.json +88 -0
- package/packages/piagent-core/managed/dist/workspace/assets/adapters/devops/profile.json +90 -0
- package/packages/piagent-core/managed/dist/workspace/assets/adapters/docs/profile.json +74 -0
- package/packages/piagent-core/managed/dist/workspace/assets/adapters/fullstack/profile.json +92 -0
- package/packages/piagent-core/managed/dist/workspace/assets/adapters/generic/profile.json +95 -0
- package/packages/piagent-core/managed/dist/workspace/assets/adapters/mobile/profile.json +86 -0
- package/packages/piagent-core/managed/dist/workspace/assets/adapters/node-typescript/profile.json +74 -0
- package/packages/piagent-core/managed/dist/workspace/assets/adapters/python/profile.json +75 -0
- package/packages/piagent-core/managed/dist/workspace/assets/adapters/web-frontend/profile.json +99 -0
- package/packages/piagent-core/managed/dist/workspace/assets/local-state-path-BrmghxEZ.js +284 -0
- package/packages/piagent-core/managed/dist/workspace/assets/packages/piagent-core/policies/base-policy.json +351 -0
- package/packages/piagent-core/managed/dist/workspace/manifest.json +65 -0
- package/packages/piagent-core/managed/dist/workspace/worker.mjs +7374 -0
- package/packages/piagent-core/managed/dist/workspace/workspace-search-index.mjs +1007 -0
- package/packages/piagent-core/managed/patch-snapshot.mjs +6 -8
- package/packages/piagent-core/managed/permission.mjs +29 -6
- package/packages/piagent-core/managed/repository-operation.mjs +2 -1
- package/packages/piagent-core/managed/review-snapshot-request.mjs +21 -0
- package/packages/piagent-core/managed/sandbox-diagnostic.mjs +2 -0
- package/packages/piagent-core/managed/session-mutation-observations.mjs +37 -0
- package/packages/piagent-core/managed/session.mjs +2 -2
- package/packages/piagent-core/managed/source-revert-requests.mjs +21 -0
- package/packages/piagent-core/managed/terminal-boundary.mjs +88 -0
- package/packages/piagent-core/managed/terminal-channel.mjs +66 -0
- package/packages/piagent-core/managed/terminal-runtime.mjs +64 -0
- package/packages/piagent-core/managed/terminal-seatbelt.mjs +48 -0
- package/packages/piagent-core/managed/terminal-worker.mjs +70 -0
- package/packages/piagent-core/managed/tool-boundary.mjs +45 -35
- package/packages/piagent-core/managed/tool-freshness.mjs +47 -0
- package/packages/piagent-core/managed/tool-worker.mjs +18 -3
- package/packages/piagent-core/managed/workspace-worker-pins.mjs +26 -0
- package/packages/piagent-core/package.json +1 -1
- package/packages/piagent-core/policies/base-policy.json +2 -1
- package/packages/piagent-core/runtime/checkpoints/checkpoint-authority.ts +43 -0
- package/packages/piagent-core/runtime/checkpoints/checkpoint-refs.mjs +51 -0
- package/packages/piagent-core/runtime/checkpoints/checkpoint-store.mjs +179 -0
- package/packages/piagent-core/runtime/checkpoints/checkpoint-worker.ts +28 -0
- package/packages/piagent-core/runtime/checkpoints/git-adapter.mjs +36 -0
- package/packages/piagent-core/runtime/checkpoints/persistent-history.mjs +96 -0
- package/packages/piagent-core/runtime/checkpoints/personal-git.ts +272 -0
- package/packages/piagent-core/runtime/checkpoints/personal-runtime.ts +158 -0
- package/packages/piagent-core/runtime/checkpoints/prune.mjs +29 -0
- package/packages/piagent-core/runtime/checkpoints/restore-preview.mjs +46 -0
- package/packages/piagent-core/runtime/checkpoints/turn-checkpoints.mjs +167 -0
- package/packages/piagent-core/runtime/hooks/session-hooks.ts +19 -16
- package/packages/piagent-core/runtime/hooks/session-start-hook.ts +3 -0
- package/packages/piagent-core/runtime/hooks/tool-call-hook.ts +2 -2
- package/packages/piagent-core/runtime/inspection/diff-projection.ts +2 -1
- package/packages/piagent-core/runtime/inspection/managed-source-revert.ts +90 -0
- package/packages/piagent-core/runtime/inspection/review-state-projection.ts +19 -8
- package/packages/piagent-core/runtime/inspection/session-diff-projection.ts +47 -0
- package/packages/piagent-core/runtime/inspection/session-hunk-provenance.ts +56 -0
- package/packages/piagent-core/runtime/inspection/session-mutation-observer.ts +125 -0
- package/packages/piagent-core/runtime/inspection/source-action-contract.ts +123 -0
- package/packages/piagent-core/runtime/inspection/source-change-projection.ts +3 -9
- package/packages/piagent-core/runtime/inspection/source-mutation-projection.ts +3 -41
- package/packages/piagent-core/runtime/inspection/source-mutation-store.ts +6 -36
- package/packages/piagent-core/runtime/inspection/source-revert-projection.ts +36 -30
- package/packages/piagent-core/runtime/inspection/text-diff.ts +5 -4
- package/packages/piagent-core/runtime/inspection/workspace-worker.ts +79 -0
- package/packages/piagent-core/runtime/policy/source-index-transaction.ts +3 -10
- package/packages/piagent-core/runtime/policy/source-mutation-guard-binding.ts +27 -2
- package/packages/piagent-core/runtime/policy/source-mutation-guard.ts +42 -12
- package/packages/piagent-core/runtime/policy/source-worktree-transaction.ts +10 -5
- package/packages/piagent-core/runtime/policy/workspace-write-authority.ts +51 -0
- package/packages/piagent-core/runtime/process-owner.mjs +20 -0
- package/packages/piagent-core/runtime/process-tree-watch.mjs +46 -0
- package/packages/piagent-core/runtime/quality/model-mutation-proof.ts +8 -5
- package/packages/piagent-core/runtime/registration/permission-commands.ts +10 -2
- package/packages/piagent-core/runtime/registration/task-evidence-tools.ts +1 -1
- package/packages/piagent-core/runtime/session/workspace-member-edits.d.mts +7 -0
- package/packages/piagent-core/runtime/session/workspace-member-edits.mjs +41 -0
- package/packages/piagent-core/security/workspace-file-reader.ts +2 -1
- package/packages/piagent-webui/dist/client/assets/Alert-DXF8-XqE.js +1 -0
- package/packages/piagent-webui/dist/client/assets/Button-DZJosXQi.js +1 -0
- package/packages/piagent-webui/dist/client/assets/DialogTitle-CL-fzCnT.js +3 -0
- package/packages/piagent-webui/dist/client/assets/MarkdownMessage-k4tq1y8s.js +29 -0
- package/packages/piagent-webui/dist/client/assets/TerminalPanel-DvOPgrGI.js +2 -0
- package/packages/piagent-webui/dist/client/assets/TerminalView-B_a16moC.js +9 -0
- package/packages/piagent-webui/dist/client/assets/TerminalView-kHJ-D0s7.css +1 -0
- package/packages/piagent-webui/dist/client/assets/Typography-DMZL1kLS.js +1 -0
- package/packages/piagent-webui/dist/client/assets/WorkspaceCodeEditor-BCiYM9NA.js +2 -0
- package/packages/piagent-webui/dist/client/assets/WorkspaceExplorer-DttpQtpf.js +5 -0
- package/packages/piagent-webui/dist/client/assets/WorkspaceQuickOpen-fgXwQ46B.js +1 -0
- package/packages/piagent-webui/dist/client/assets/bootstrap-CedkNtRG.js +1 -0
- package/packages/piagent-webui/dist/client/assets/dist-B1M7IjfA.js +10 -0
- package/packages/piagent-webui/dist/client/assets/dist-BUYtGaUP.js +1 -0
- package/packages/piagent-webui/dist/client/assets/dist-C82AtDDT.js +6 -0
- package/packages/piagent-webui/dist/client/assets/dist-D9b8j6pJ.js +1 -0
- package/packages/piagent-webui/dist/client/assets/dist-DLfpWwLs.js +23 -0
- package/packages/piagent-webui/dist/client/assets/dist-DcEu_WYV.js +1 -0
- package/packages/piagent-webui/dist/client/assets/dist-_JsfplOG.js +9 -0
- package/packages/piagent-webui/dist/client/assets/dist-idN8f9Fc.js +1 -0
- package/packages/piagent-webui/dist/client/assets/dist-nvgpa2Us.js +1 -0
- package/packages/piagent-webui/dist/client/assets/index-BW8-BUMx.js +102 -0
- package/packages/piagent-webui/dist/client/assets/index-Brs_HjIo.css +1 -0
- package/packages/piagent-webui/dist/client/assets/rolldown-runtime-hePW80VL.js +1 -0
- package/packages/piagent-webui/dist/client/assets/tab-close-HE0XxoJf.js +1 -0
- package/packages/piagent-webui/dist/client/assets/ui-preferences-DiddStu0.js +124 -0
- package/packages/piagent-webui/dist/client/assets/useTheme-_ESOj7MI.js +10 -0
- package/packages/piagent-webui/dist/client/assets/workspace-api-O7auv_td.js +1 -0
- package/packages/piagent-webui/dist/client/index.html +10 -2
- package/packages/piagent-webui/extension/same-session-bridge.ts +2 -3
- package/packages/piagent-webui/extension/source-revert-controller.ts +14 -208
- package/packages/piagent-webui/gateway/checkpoint-sessions.ts +99 -0
- package/packages/piagent-webui/gateway/checkpoint-store.ts +6 -0
- package/packages/piagent-webui/gateway/company-relay.ts +5 -0
- package/packages/piagent-webui/gateway/company-terminal-cache.ts +54 -0
- package/packages/piagent-webui/gateway/company-terminal-channel.ts +117 -0
- package/packages/piagent-webui/gateway/company-terminal-relay.ts +75 -0
- package/packages/piagent-webui/gateway/gateway-protocol-service.ts +12 -0
- package/packages/piagent-webui/gateway/gateway-service.ts +48 -4
- package/packages/piagent-webui/gateway/managed-gateway.mjs +30 -3
- package/packages/piagent-webui/gateway/managed-projection.ts +5 -3
- package/packages/piagent-webui/gateway/managed-source-revert-controller.ts +45 -0
- package/packages/piagent-webui/gateway/managed-source-revert-sessions.ts +74 -0
- package/packages/piagent-webui/gateway/managed-terminal-binding.ts +30 -0
- package/packages/piagent-webui/gateway/managed-terminal-processes.ts +74 -0
- package/packages/piagent-webui/gateway/managed-terminal-sessions.ts +9 -0
- package/packages/piagent-webui/gateway/path-suggestions.ts +1 -124
- package/packages/piagent-webui/gateway/personal-terminal-binding.ts +36 -0
- package/packages/piagent-webui/gateway/personal-terminal-diagnostic.mjs +16 -0
- package/packages/piagent-webui/gateway/personal-terminal-processes.ts +119 -0
- package/packages/piagent-webui/gateway/personal-terminal-sessions.ts +22 -0
- package/packages/piagent-webui/gateway/runtime-session-controls.ts +2 -1
- package/packages/piagent-webui/gateway/session-catalog.ts +3 -2
- package/packages/piagent-webui/gateway/session-command-controller.ts +4 -4
- package/packages/piagent-webui/gateway/session-inspection-registry.ts +29 -5
- package/packages/piagent-webui/gateway/session-runtime-supervisor.ts +8 -8
- package/packages/piagent-webui/gateway/signed-session-journal.ts +77 -0
- package/packages/piagent-webui/gateway/source-revert-sessions.ts +74 -0
- package/packages/piagent-webui/gateway/source-revert-store.ts +47 -0
- package/packages/piagent-webui/gateway/terminal-command-ledger.ts +33 -0
- package/packages/piagent-webui/gateway/terminal-mentions.ts +29 -0
- package/packages/piagent-webui/gateway/terminal-output-buffer.ts +58 -0
- package/packages/piagent-webui/gateway/terminal-router.ts +12 -0
- package/packages/piagent-webui/gateway/terminal-sessions.ts +247 -0
- package/packages/piagent-webui/gateway/workspace-write-sessions.ts +69 -0
- package/packages/piagent-webui/gateway/workspace-write-store.ts +6 -0
- package/packages/piagent-webui/package.json +14 -2
- package/packages/piagent-webui/server/checkpoint-command.ts +30 -0
- package/packages/piagent-webui/server/checkpoint-controller.ts +87 -0
- package/packages/piagent-webui/server/checkpoint-routes.ts +38 -0
- package/packages/piagent-webui/server/control-command-digest.ts +5 -0
- package/packages/piagent-webui/server/core-inspection-provider.ts +16 -22
- package/packages/piagent-webui/server/gateway-websocket.ts +36 -8
- package/packages/piagent-webui/server/loopback-server.ts +9 -0
- package/packages/piagent-webui/server/read-model-provider.ts +3 -0
- package/packages/piagent-webui/server/read-only-router.ts +3 -0
- package/packages/piagent-webui/server/session-auth.ts +4 -1
- package/packages/piagent-webui/server/session-source-revert-routes.ts +54 -0
- package/packages/piagent-webui/server/source-projection-binding.ts +18 -0
- package/packages/piagent-webui/server/source-revert-controller.ts +225 -0
- package/packages/piagent-webui/server/terminal-command.ts +35 -0
- package/packages/piagent-webui/server/terminal-websocket-stream.ts +56 -0
- package/packages/piagent-webui/server/transcript-projection.ts +2 -1
- package/packages/piagent-webui/server/workspace-read-routes.ts +32 -0
- package/packages/piagent-webui/server/workspace-reader.ts +23 -0
- package/packages/piagent-webui/server/workspace-write-controller.ts +118 -0
- package/packages/piagent-webui/server/workspace-write-routes.ts +38 -0
- package/packages/piagent-webui/shared/terminal-message.ts +23 -0
- package/schemas/piagent-webui/catalog-v1.json +63 -0
- package/schemas/piagent-webui/checkpoint-v1.schema.json +466 -0
- package/schemas/piagent-webui/control-command-v1.schema.json +68 -3
- package/schemas/piagent-webui/diff-v1.schema.json +4 -0
- package/schemas/piagent-webui/gateway-protocol-v1.schema.json +123 -3
- package/schemas/piagent-webui/session-catalog-v1.schema.json +1 -1
- package/schemas/piagent-webui/session-command-v1.schema.json +1 -1
- package/schemas/piagent-webui/snapshot-v1.schema.json +2 -1
- package/schemas/piagent-webui/source-revert-v1.schema.json +22 -3
- package/schemas/piagent-webui/terminal-v1.schema.json +495 -0
- package/schemas/piagent-webui/workspace-file-v1.schema.json +79 -0
- package/schemas/piagent-webui/workspace-paths-v1.schema.json +71 -0
- package/schemas/piagent-webui/workspace-search-v1.schema.json +80 -0
- package/schemas/piagent-webui/workspace-tree-v1.schema.json +87 -0
- package/schemas/piagent-webui/workspace-write-v1.schema.json +276 -0
- package/schemas/project-profile.schema.json +8 -3
- package/scripts/company-terminal.mjs +16 -11
- package/scripts/pi-auto.sh +5 -1
- package/scripts/team-doctor.sh +8 -0
- package/packages/piagent-webui/dist/client/assets/MarkdownMessage-CfgyQQbW.js +0 -29
- package/packages/piagent-webui/dist/client/assets/index-HyzWKvDu.js +0 -234
- 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 {
|
|
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
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
327
|
-
|
|
328
|
-
|
|
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
|
|
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 (
|
|
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
|
|
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
|
|
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.#
|
|
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
|
+
}
|
|
@@ -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
|
+
}
|