@gajae-code/coding-agent 0.14.2 → 0.15.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +120 -1
- package/dist/types/async/job-manager.d.ts +4 -1
- package/dist/types/autoresearch/dashboard.d.ts +29 -4
- package/dist/types/autoresearch/data-context.d.ts +25 -0
- package/dist/types/autoresearch/git.d.ts +65 -7
- package/dist/types/autoresearch/harness.d.ts +58 -0
- package/dist/types/autoresearch/prompts.d.ts +51 -0
- package/dist/types/autoresearch/runs.d.ts +173 -0
- package/dist/types/autoresearch/types.d.ts +9 -139
- package/dist/types/capability/types.d.ts +20 -0
- package/dist/types/cli/accounts-cli.d.ts +1 -0
- package/dist/types/commands/{rlm.d.ts → autoresearch.d.ts} +2 -4
- package/dist/types/config/autorouting-contract.d.ts +146 -0
- package/dist/types/config/autorouting-generator.d.ts +40 -0
- package/dist/types/config/autorouting-tier-map.d.ts +16844 -0
- package/dist/types/config/autorouting.d.ts +35 -0
- package/dist/types/config/model-discovery-manager.d.ts +8 -1
- package/dist/types/config/model-registry.d.ts +30 -1
- package/dist/types/config/provider-selection-policy.d.ts +23 -0
- package/dist/types/config/settings-schema.d.ts +61 -3
- package/dist/types/config/settings.d.ts +3 -0
- package/dist/types/coordinator/contract.d.ts +1 -1
- package/dist/types/coordinator-mcp/durability.d.ts +29 -0
- package/dist/types/coordinator-mcp/event-webhook.d.ts +50 -0
- package/dist/types/coordinator-mcp/question-state.d.ts +1 -1
- package/dist/types/coordinator-mcp/server.d.ts +14 -0
- package/dist/types/crash/index-store.d.ts +3 -0
- package/dist/types/crash/record-loader.d.ts +2 -0
- package/dist/types/crash/upstream/relay.d.ts +5 -3
- package/dist/types/cursor.d.ts +12 -0
- package/dist/types/defaults/gjc-defaults.d.ts +35 -1
- package/dist/types/defaults/gjc-skills.generated.d.ts +1 -1
- package/dist/types/discovery/helpers.d.ts +1 -1
- package/dist/types/edit/index.d.ts +2 -1
- package/dist/types/eval/py/executor.d.ts +2 -0
- package/dist/types/extensibility/extensions/loader.d.ts +32 -0
- package/dist/types/extensibility/gjc-plugins/types.d.ts +1 -1
- package/dist/types/extensibility/skills.d.ts +2 -0
- package/dist/types/gjc-runtime/autoresearch-runtime.d.ts +194 -0
- package/dist/types/gjc-runtime/gc-runtime.d.ts +1 -1
- package/dist/types/gjc-runtime/psmux-detect.d.ts +1 -1
- package/dist/types/gjc-runtime/python-transcript.d.ts +19 -0
- package/dist/types/gjc-runtime/ralplan-runtime.d.ts +2 -8
- package/dist/types/gjc-runtime/session-layout.d.ts +6 -3
- package/dist/types/gjc-runtime/state-renderer.d.ts +0 -22
- package/dist/types/gjc-runtime/state-schema.d.ts +7 -7
- package/dist/types/gjc-runtime/tmux-common.d.ts +2 -2
- package/dist/types/gjc-runtime/ultragoal-change-set.d.ts +38 -1
- package/dist/types/gjc-runtime/ultragoal-runtime.d.ts +2 -24
- package/dist/types/gjc-runtime/ultragoal-validation-policy.d.ts +73 -0
- package/dist/types/gjc-runtime/workflow-recovery-projection.d.ts +100 -0
- package/dist/types/hooks/skill-keywords.d.ts +1 -1
- package/dist/types/main.d.ts +0 -10
- package/dist/types/modes/acp/acp-agent.d.ts +13 -6
- package/dist/types/modes/components/model-selector.d.ts +14 -0
- package/dist/types/modes/components/skill-hud/render.d.ts +6 -1
- package/dist/types/modes/components/smart-routing-panel.d.ts +82 -0
- package/dist/types/modes/controllers/selector-controller.d.ts +14 -0
- package/dist/types/modes/interactive-mode.d.ts +3 -0
- package/dist/types/modes/types.d.ts +9 -0
- package/dist/types/rlm/artifacts.d.ts +4 -4
- package/dist/types/runtime/optional-runtime-services.d.ts +2 -1
- package/dist/types/runtime/workspace-tree-service.d.ts +5 -1
- package/dist/types/runtime-mcp/config.d.ts +6 -0
- package/dist/types/runtime-mcp/manager.d.ts +10 -0
- package/dist/types/sdk/acp/adapter.d.ts +0 -4
- package/dist/types/sdk/broker/broker.d.ts +1 -1
- package/dist/types/sdk/broker/lifecycle.d.ts +57 -2
- package/dist/types/sdk/broker/session-index.d.ts +15 -0
- package/dist/types/sdk/bus/chat-daemon-control.d.ts +6 -0
- package/dist/types/sdk/bus/chat-daemon-runtime.d.ts +4 -0
- package/dist/types/sdk/bus/index.d.ts +8 -0
- package/dist/types/sdk/bus/kind-aware-reconciliation.d.ts +2 -0
- package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +12 -2
- package/dist/types/sdk/client/client.d.ts +39 -4
- package/dist/types/sdk/host/host.d.ts +1 -0
- package/dist/types/sdk/host/internal-autorouting-state.d.ts +3 -0
- package/dist/types/sdk/host/model-pin.d.ts +29 -0
- package/dist/types/sdk/host/session-runtime.d.ts +2 -1
- package/dist/types/sdk/lifecycle-session.d.ts +13 -0
- package/dist/types/sdk/router/session-router.d.ts +27 -1
- package/dist/types/sdk/session.d.ts +11 -1
- package/dist/types/session/agent-session.d.ts +36 -6
- package/dist/types/session/artifacts.d.ts +16 -1
- package/dist/types/session/internal/managed-session-storage.d.ts +7 -0
- package/dist/types/session/session-manager.d.ts +84 -2
- package/dist/types/session/session-staging-paths.d.ts +3 -0
- package/dist/types/skill-state/canonical-skills.d.ts +1 -1
- package/dist/types/skill-state/workflow-hud.d.ts +12 -21
- package/dist/types/skill-state/workflow-mutation-guard.d.ts +1 -0
- package/dist/types/slash-commands/helpers/autorouting-status.d.ts +8 -0
- package/dist/types/system-prompt.d.ts +23 -0
- package/dist/types/task/executor.d.ts +35 -4
- package/dist/types/task/index.d.ts +17 -3
- package/dist/types/task/receipt.d.ts +3 -1
- package/dist/types/task/types.d.ts +144 -0
- package/dist/types/tools/atomic-file-write.d.ts +72 -0
- package/dist/types/tools/descriptors.d.ts +1 -0
- package/dist/types/tools/index.d.ts +36 -1
- package/dist/types/tools/move-session.d.ts +40 -0
- package/dist/types/tools/python.d.ts +12 -0
- package/dist/types/tools/resource-gc.d.ts +0 -3
- package/dist/types/tools/search.d.ts +8 -1
- package/dist/types/tools/skill.d.ts +9 -4
- package/dist/types/tools/subagent.d.ts +33 -3
- package/dist/types/utils/xml-escape.d.ts +2 -0
- package/dist/types/workflow/workflow-intent-diff.d.ts +2 -2
- package/dist/types/workflow/workflow-intent-report-consumer.d.ts +1 -1
- package/package.json +11 -12
- package/scripts/build-sdk-package-smoke.ts +7 -5
- package/scripts/check-autorouting-tier-map.ts +183 -0
- package/scripts/generate-sdk-operation-inventory.ts +12 -0
- package/scripts/generate-tool-catalog.ts +40 -5
- package/src/async/job-manager.ts +5 -2
- package/src/autoresearch/dashboard.ts +134 -314
- package/src/autoresearch/data-context.ts +35 -0
- package/src/autoresearch/git.ts +186 -26
- package/src/autoresearch/{helpers.ts → harness.ts} +65 -40
- package/src/autoresearch/{prompt-setup.md → prompts/prompt-setup.md} +7 -7
- package/src/autoresearch/prompts/prompt.md +103 -0
- package/src/autoresearch/prompts.ts +126 -0
- package/src/autoresearch/runs.ts +708 -0
- package/src/autoresearch/types.ts +9 -150
- package/src/capability/index.ts +4 -4
- package/src/capability/types.ts +20 -0
- package/src/cli/accounts-cli.ts +21 -5
- package/src/cli/config-cli.ts +25 -1
- package/src/cli/fast-help.ts +1 -2
- package/src/cli/list-models.ts +5 -8
- package/src/cli/setup-cli.ts +8 -0
- package/src/cli.ts +2 -21
- package/src/commands/autoresearch.ts +33 -0
- package/src/commands/coordinator.ts +15 -0
- package/src/commands/launch.ts +2 -4
- package/src/commands/quick-lane.ts +1 -1
- package/src/commands/sdk.ts +24 -0
- package/src/commands/state.ts +1 -1
- package/src/config/autorouting-contract.ts +474 -0
- package/src/config/autorouting-generator.ts +238 -0
- package/src/config/autorouting-tier-map.ts +6108 -0
- package/src/config/autorouting.ts +120 -0
- package/src/config/model-discovery-manager.ts +42 -11
- package/src/config/model-registry.ts +348 -68
- package/src/config/provider-selection-policy.ts +60 -9
- package/src/config/settings-schema.ts +195 -6
- package/src/config/settings.ts +62 -1
- package/src/config.ts +2 -3
- package/src/coordinator/contract.ts +0 -1
- package/src/coordinator-mcp/codex-handoff.ts +68 -42
- package/src/coordinator-mcp/durability.ts +200 -0
- package/src/coordinator-mcp/event-webhook.ts +278 -0
- package/src/coordinator-mcp/question-state.ts +109 -46
- package/src/coordinator-mcp/server.ts +932 -504
- package/src/crash/index-store.ts +126 -22
- package/src/crash/record-loader.ts +7 -0
- package/src/crash/upstream/relay.ts +82 -34
- package/src/cursor.ts +84 -1
- package/src/debug/index.ts +26 -0
- package/src/defaults/gjc/skills/autoresearch/SKILL.md +116 -0
- package/src/defaults/gjc/skills/autoresearch/auto-critic.md +46 -0
- package/src/defaults/gjc/skills/autoresearch/auto-iterate.md +42 -0
- package/src/defaults/gjc/skills/deep-interview/SKILL.md +17 -37
- package/src/defaults/gjc/skills/ralplan/SKILL.md +21 -18
- package/src/defaults/gjc/skills/ultragoal/SKILL.md +6 -17
- package/src/defaults/gjc/skills/ultragoal/ai-slop-cleaner.md +2 -2
- package/src/defaults/gjc-defaults.ts +89 -2
- package/src/defaults/gjc-skills.generated.ts +24 -17
- package/src/discovery/builtin.ts +18 -5
- package/src/discovery/helpers.ts +6 -6
- package/src/edit/index.ts +5 -2
- package/src/eval/py/executor.ts +8 -0
- package/src/extensibility/extensions/loader.ts +135 -13
- package/src/extensibility/extensions/wrapper.ts +1 -2
- package/src/extensibility/gjc-plugins/schema.ts +1 -1
- package/src/extensibility/runtime-skill-discovery.ts +2 -2
- package/src/extensibility/skill-management.ts +2 -3
- package/src/extensibility/skills.ts +16 -8
- package/src/gjc-runtime/autoresearch-runtime.ts +1509 -0
- package/src/gjc-runtime/gc-render.ts +0 -1
- package/src/gjc-runtime/gc-runtime.ts +1 -11
- package/src/gjc-runtime/launch-tmux.ts +3 -3
- package/src/gjc-runtime/psmux-detect.ts +2 -2
- package/src/gjc-runtime/python-transcript.ts +58 -0
- package/src/gjc-runtime/ralplan-runtime.ts +6 -18
- package/src/gjc-runtime/session-layout.ts +18 -7
- package/src/gjc-runtime/session-state-sidecar.ts +58 -9
- package/src/gjc-runtime/state-renderer.ts +0 -38
- package/src/gjc-runtime/state-runtime.ts +43 -22
- package/src/gjc-runtime/state-schema.ts +1 -1
- package/src/gjc-runtime/tmux-common.ts +7 -7
- package/src/gjc-runtime/ultragoal-change-set.ts +232 -11
- package/src/gjc-runtime/ultragoal-runtime.ts +309 -114
- package/src/gjc-runtime/ultragoal-validation-policy.ts +247 -0
- package/src/gjc-runtime/workflow-command-ref.ts +15 -11
- package/src/gjc-runtime/workflow-manifest.generated.json +413 -532
- package/src/gjc-runtime/workflow-manifest.ts +43 -83
- package/src/gjc-runtime/workflow-recovery-projection.ts +616 -0
- package/src/hooks/skill-keywords.ts +3 -3
- package/src/hooks/skill-state.ts +2 -2
- package/src/internal-urls/docs-index.generated.ts +26 -24
- package/src/lsp/index.ts +25 -5
- package/src/main.ts +2 -27
- package/src/modes/acp/acp-agent.ts +200 -75
- package/src/modes/components/model-selector.ts +216 -13
- package/src/modes/components/oauth-selector.ts +5 -5
- package/src/modes/components/skill-hud/render.ts +173 -35
- package/src/modes/components/smart-routing-panel.ts +531 -0
- package/src/modes/components/tool-status-header.ts +6 -3
- package/src/modes/controllers/command-controller.ts +17 -7
- package/src/modes/controllers/input-controller.ts +44 -9
- package/src/modes/controllers/runtime-mcp-command-controller.ts +4 -0
- package/src/modes/controllers/selector-controller.ts +298 -65
- package/src/modes/interactive-mode.ts +8 -1
- package/src/modes/shared/agent-wire/workflow-gate-types.ts +1 -1
- package/src/modes/types.ts +9 -1
- package/src/prompts/agents/planner.md +1 -1
- package/src/prompts/system/system-prompt.md +1 -1
- package/src/prompts/system/volatile-project-context.md +2 -1
- package/src/prompts/tools/move-session.md +15 -0
- package/src/prompts/tools/python.md +12 -0
- package/src/prompts/tools/skill-discovery.md +1 -1
- package/src/prompts/tools/skill.md +2 -2
- package/src/prompts/tools/task-summary.md +1 -0
- package/src/prompts/tools/task.md +6 -0
- package/src/rlm/artifacts.ts +12 -10
- package/src/runtime/optional-runtime-services.ts +3 -2
- package/src/runtime/workspace-tree-service.ts +11 -3
- package/src/runtime-mcp/config.ts +8 -1
- package/src/runtime-mcp/manager.ts +33 -0
- package/src/sdk/acp/adapter.ts +48 -1
- package/src/sdk/broker/broker.ts +30 -2
- package/src/sdk/broker/discovery.ts +217 -2
- package/src/sdk/broker/lifecycle.ts +385 -59
- package/src/sdk/broker/session-index.ts +243 -48
- package/src/sdk/broker/transport.ts +1 -0
- package/src/sdk/bus/chat-daemon-control.ts +8 -2
- package/src/sdk/bus/chat-daemon-runtime.ts +5 -0
- package/src/sdk/bus/index.ts +208 -11
- package/src/sdk/bus/kind-aware-reconciliation.ts +16 -0
- package/src/sdk/bus/reconciliation-store.ts +27 -5
- package/src/sdk/bus/telegram-daemon-contract.ts +12 -2
- package/src/sdk/client/client.ts +204 -22
- package/src/sdk/host/host.ts +16 -0
- package/src/sdk/host/internal-autorouting-state.ts +13 -0
- package/src/sdk/host/model-pin.ts +101 -0
- package/src/sdk/host/reverse-leases.ts +2 -1
- package/src/sdk/host/session-runtime.test.ts +224 -1
- package/src/sdk/host/session-runtime.ts +125 -37
- package/src/sdk/lifecycle/service.ts +3 -1
- package/src/sdk/lifecycle-session.ts +42 -1
- package/src/sdk/protocol/operation-inventory.generated.json +66 -0
- package/src/sdk/router/session-router.ts +375 -36
- package/src/sdk/session.ts +547 -47
- package/src/session/account-inventory.ts +1 -1
- package/src/session/agent-session.ts +765 -189
- package/src/session/artifacts.ts +234 -4
- package/src/session/internal/managed-session-storage.ts +91 -4
- package/src/session/provider-safety-stop-hint.ts +3 -2
- package/src/session/session-manager.ts +775 -16
- package/src/session/session-staging-paths.ts +8 -0
- package/src/setup/hermes/templates/operator-instructions.v1.md +0 -1
- package/src/setup/host-plugin-setup.ts +2 -2
- package/src/skill-state/active-state.ts +2 -2
- package/src/skill-state/canonical-skills.ts +1 -1
- package/src/skill-state/initial-phase.ts +1 -1
- package/src/skill-state/workflow-hud.ts +41 -41
- package/src/skill-state/workflow-mutation-guard.ts +26 -7
- package/src/slash-commands/builtin-registry.ts +77 -0
- package/src/slash-commands/helpers/autorouting-status.ts +55 -0
- package/src/system-prompt.ts +102 -5
- package/src/task/discovery.ts +2 -3
- package/src/task/executor.ts +617 -43
- package/src/task/index.ts +262 -15
- package/src/task/receipt.ts +30 -0
- package/src/task/render.ts +11 -1
- package/src/task/types.ts +316 -0
- package/src/tools/atomic-file-write.ts +594 -0
- package/src/tools/bisect.ts +3 -2
- package/src/tools/descriptors.test.ts +3 -0
- package/src/tools/descriptors.ts +24 -0
- package/src/tools/image-gen.ts +5 -1
- package/src/tools/index.ts +62 -5
- package/src/tools/move-session.ts +107 -0
- package/src/tools/python.ts +205 -0
- package/src/tools/read.ts +88 -15
- package/src/tools/renderers.ts +2 -0
- package/src/tools/resource-gc.ts +8 -177
- package/src/tools/search.ts +30 -6
- package/src/tools/skill-discovery.ts +1 -0
- package/src/tools/skill.ts +37 -7
- package/src/tools/subagent-render.ts +119 -13
- package/src/tools/subagent.ts +91 -74
- package/src/tools/tool-catalog.generated.ts +64 -2
- package/src/tools/write.ts +16 -12
- package/src/utils/xml-escape.ts +14 -0
- package/src/web/search/providers/codex.ts +2 -2
- package/src/workflow/workflow-intent-diff.ts +13 -12
- package/src/workflow/workflow-intent-report-consumer.ts +6 -6
- package/dist/types/autoresearch/helpers.d.ts +0 -24
- package/dist/types/autoresearch/index.d.ts +0 -2
- package/dist/types/autoresearch/state.d.ts +0 -17
- package/dist/types/autoresearch/storage.d.ts +0 -142
- package/dist/types/autoresearch/tools/init-experiment.d.ts +0 -31
- package/dist/types/autoresearch/tools/log-experiment.d.ts +0 -23
- package/dist/types/autoresearch/tools/run-experiment.d.ts +0 -8
- package/dist/types/autoresearch/tools/update-notes.d.ts +0 -12
- package/dist/types/commands/team.d.ts +0 -24
- package/dist/types/gjc-runtime/team-gc.d.ts +0 -7
- package/dist/types/gjc-runtime/team-launch.d.ts +0 -61
- package/dist/types/gjc-runtime/team-notify.d.ts +0 -45
- package/dist/types/gjc-runtime/team-runtime.d.ts +0 -336
- package/dist/types/gjc-runtime/team-store.d.ts +0 -168
- package/dist/types/gjc-runtime/team-worker-heartbeat.d.ts +0 -43
- package/dist/types/gjc-runtime/team-worker-memory-guard.d.ts +0 -92
- package/dist/types/gjc-runtime/team-workers.d.ts +0 -83
- package/dist/types/rlm/index.d.ts +0 -47
- package/dist/types/rlm/preset.d.ts +0 -23
- package/dist/types/rlm/python-tool.d.ts +0 -16
- package/src/autoresearch/command-resume.md +0 -14
- package/src/autoresearch/index.ts +0 -536
- package/src/autoresearch/prompt.md +0 -103
- package/src/autoresearch/resume-message.md +0 -10
- package/src/autoresearch/state.ts +0 -273
- package/src/autoresearch/storage.ts +0 -699
- package/src/autoresearch/tools/init-experiment.ts +0 -272
- package/src/autoresearch/tools/log-experiment.ts +0 -524
- package/src/autoresearch/tools/run-experiment.ts +0 -475
- package/src/autoresearch/tools/update-notes.ts +0 -109
- package/src/commands/rlm.ts +0 -19
- package/src/commands/team.ts +0 -238
- package/src/defaults/gjc/skills/deep-interview/auto-research-greenfield.md +0 -42
- package/src/defaults/gjc/skills/team/SKILL.md +0 -467
- package/src/gjc-runtime/team-gc.ts +0 -49
- package/src/gjc-runtime/team-launch.ts +0 -404
- package/src/gjc-runtime/team-notify.ts +0 -326
- package/src/gjc-runtime/team-runtime.ts +0 -5923
- package/src/gjc-runtime/team-store.ts +0 -878
- package/src/gjc-runtime/team-worker-heartbeat.ts +0 -149
- package/src/gjc-runtime/team-worker-memory-guard.ts +0 -363
- package/src/gjc-runtime/team-workers.ts +0 -1123
- package/src/prompts/system/rlm-report-command.md +0 -1
- package/src/prompts/system/rlm-research.md +0 -23
- package/src/rlm/index.ts +0 -365
- package/src/rlm/preset.ts +0 -76
- package/src/rlm/python-tool.ts +0 -68
|
@@ -0,0 +1,594 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fail-atomic user-file writes for the write/edit/LSP writethrough path.
|
|
3
|
+
*
|
|
4
|
+
* The guarantee is visibility, not crash durability: a failed write never
|
|
5
|
+
* publishes partial or truncated bytes, and the destination is left byte-
|
|
6
|
+
* identical. It is deliberately NOT crash-atomic -- the parent directory is
|
|
7
|
+
* never fsynced, so a rename can be lost across a system crash.
|
|
8
|
+
*
|
|
9
|
+
* Publication is last-writer-wins, not conditional. Identity is revalidated
|
|
10
|
+
* immediately before the rename, which rejects a destination that was replaced
|
|
11
|
+
* or retargeted while staging, but `rename(2)` commits against the pathname:
|
|
12
|
+
* a writer that publishes a successor inside the window between that check and
|
|
13
|
+
* the rename is overwritten. Closing that window needs an OS conditional-
|
|
14
|
+
* replace primitive; the exchange-based one available here was removed because
|
|
15
|
+
* it validated only after committing and leaked protocol debris into user
|
|
16
|
+
* directories on every successful write. `write` is a last-writer-wins tool by
|
|
17
|
+
* contract, so this is the documented behavior rather than a silent race.
|
|
18
|
+
*
|
|
19
|
+
* `Bun.write` truncates the destination then copies bytes. A permission or IO
|
|
20
|
+
* failure after that truncate leaves a 0-byte target even though the tool
|
|
21
|
+
* reported an error. Stage to a sibling temp, then rename over the destination
|
|
22
|
+
* so a failed attempt never publishes a truncated file. Directory fsync is
|
|
23
|
+
* intentionally omitted: Windows reports `EPERM` for it (#4457) and user-file
|
|
24
|
+
* publication does not need that durability barrier.
|
|
25
|
+
*
|
|
26
|
+
* Destination symlinks are followed: the referent is replaced, the link stays.
|
|
27
|
+
* The referent is re-resolved immediately before publication so a retargeted
|
|
28
|
+
* link cannot silently repoint the write, and when the lexical destination
|
|
29
|
+
* sits inside a session-scoped `gjc-local` root the resolved referent and its
|
|
30
|
+
* parent must remain inside that root (a link there must not redirect the
|
|
31
|
+
* write out of the trust boundary).
|
|
32
|
+
*
|
|
33
|
+
* Staging uses exclusive create (`wx`) so a colliding leftover temp is never
|
|
34
|
+
* truncated or unlinked; only the temp this call created is cleaned on failure.
|
|
35
|
+
* Existing-file mode and ownership are re-applied after staging so a process
|
|
36
|
+
* umask or replacement inode never changes the target's identity. Hard-linked
|
|
37
|
+
* targets are rejected because replacement would split their link group, and
|
|
38
|
+
* target identity is revalidated before the final same-directory rename. The
|
|
39
|
+
* staged bytes are synced before publication; directory fsync is intentionally not
|
|
40
|
+
* promised because Windows reports EPERM for it (#4457).
|
|
41
|
+
*/
|
|
42
|
+
|
|
43
|
+
import * as fs from "node:fs/promises";
|
|
44
|
+
import * as os from "node:os";
|
|
45
|
+
import * as path from "node:path";
|
|
46
|
+
import { hasFsCode, isEacces, isEisdir, isEnoent, isFsError } from "@gajae-code/utils";
|
|
47
|
+
|
|
48
|
+
const TEMP_CREATE_ATTEMPTS = 8;
|
|
49
|
+
const DEFAULT_FILE_MODE = 0o666;
|
|
50
|
+
const WINDOWS_RENAME_BACKOFF_MS = [10, 25, 50, 100, 200] as const;
|
|
51
|
+
const WINDOWS_SHARING_VIOLATION_CODES = new Set(["EPERM", "EACCES", "EBUSY"]);
|
|
52
|
+
|
|
53
|
+
export type FileWritePublicationState = "not_published" | "published" | "unknown";
|
|
54
|
+
|
|
55
|
+
interface ExistingFileMetadata {
|
|
56
|
+
mode: number;
|
|
57
|
+
uid: number;
|
|
58
|
+
gid: number;
|
|
59
|
+
nlink: number;
|
|
60
|
+
dev: number;
|
|
61
|
+
ino: number;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
interface ResolvedPublishPath {
|
|
65
|
+
publishPath: string;
|
|
66
|
+
existing?: ExistingFileMetadata;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export class FileWriteNotPublishedError extends Error {
|
|
70
|
+
readonly dest: string;
|
|
71
|
+
readonly destUnchanged: boolean;
|
|
72
|
+
readonly publicationState: FileWritePublicationState;
|
|
73
|
+
override readonly cause: unknown;
|
|
74
|
+
constructor(
|
|
75
|
+
dest: string,
|
|
76
|
+
cause: unknown,
|
|
77
|
+
options: { destUnchanged?: boolean; publicationState?: FileWritePublicationState } = {},
|
|
78
|
+
) {
|
|
79
|
+
const destUnchanged = options.destUnchanged ?? true;
|
|
80
|
+
super(formatFileWriteError(cause, dest, { destUnchanged }));
|
|
81
|
+
this.name = "FileWriteNotPublishedError";
|
|
82
|
+
this.dest = dest;
|
|
83
|
+
this.destUnchanged = destUnchanged;
|
|
84
|
+
this.publicationState = options.publicationState ?? (destUnchanged ? "not_published" : "unknown");
|
|
85
|
+
this.cause = cause;
|
|
86
|
+
if (isFsError(cause)) {
|
|
87
|
+
(this as Error & { code?: string }).code = cause.code;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function isFileWritePermissionError(error: unknown): boolean {
|
|
93
|
+
return isEacces(error) || hasFsCode(error, "EPERM") || hasFsCode(error, "EROFS");
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function formatFileWriteError(error: unknown, dest: string, options: { destUnchanged?: boolean } = {}): string {
|
|
97
|
+
if (error instanceof FileWriteNotPublishedError && options.destUnchanged !== false) return error.message;
|
|
98
|
+
if (isEisdir(error)) {
|
|
99
|
+
return `Cannot write '${dest}': path is a directory.`;
|
|
100
|
+
}
|
|
101
|
+
if (isFileWritePermissionError(error)) {
|
|
102
|
+
const code = isFsError(error) ? error.code : "EPERM";
|
|
103
|
+
const unchanged = options.destUnchanged
|
|
104
|
+
? " The original file was left unchanged."
|
|
105
|
+
: " The destination may already have been replaced if a formatter published earlier in this write.";
|
|
106
|
+
return `Permission denied writing '${dest}' (${code}).${unchanged} Check directory write bits, file immutability, and any sandbox policy. Do not retry the same path through the shell tool.`;
|
|
107
|
+
}
|
|
108
|
+
return error instanceof Error ? error.message : String(error);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export interface WriteFileAtomicallyOptions {
|
|
112
|
+
/**
|
|
113
|
+
* Trusted root that a resolved symlink referent and its parent must not
|
|
114
|
+
* leave. When omitted, the helper still enforces the session-scoped
|
|
115
|
+
* `gjc-local` boundary implied by a lexical destination under
|
|
116
|
+
* `<tmpdir>/gjc-local/<session-id>`.
|
|
117
|
+
*/
|
|
118
|
+
trustBoundary?: string;
|
|
119
|
+
/** Platform override used by deterministic retry tests. */
|
|
120
|
+
platform?: NodeJS.Platform;
|
|
121
|
+
/** Sleep seam used by deterministic retry tests. */
|
|
122
|
+
sleep?: (delayMs: number) => Promise<void>;
|
|
123
|
+
/** Test seam invoked after the fallback handle is opened and before mutation. */
|
|
124
|
+
beforeInPlaceMutation?: () => Promise<void>;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function tempPathFor(dest: string): string {
|
|
128
|
+
const unique = `${process.pid}.${Date.now().toString(36)}.${Math.random().toString(36).slice(2, 8)}`;
|
|
129
|
+
return path.join(path.dirname(dest), `.${path.basename(dest)}.${unique}.tmp`);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function eisdir(dest: string): Error & { code: string } {
|
|
133
|
+
const error = new Error(`EISDIR: illegal operation on a directory, write '${dest}'`) as Error & {
|
|
134
|
+
code: string;
|
|
135
|
+
};
|
|
136
|
+
error.code = "EISDIR";
|
|
137
|
+
return error;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
async function resolvePublishPath(dest: string, depth = 0): Promise<ResolvedPublishPath> {
|
|
141
|
+
if (depth > 40) {
|
|
142
|
+
throw new Error(`ELOOP: too many symbolic links, write '${dest}'`);
|
|
143
|
+
}
|
|
144
|
+
try {
|
|
145
|
+
const lst = await fs.lstat(dest);
|
|
146
|
+
if (lst.isDirectory()) throw eisdir(dest);
|
|
147
|
+
if (lst.isSymbolicLink()) {
|
|
148
|
+
const target = await fs.readlink(dest);
|
|
149
|
+
return resolvePublishPath(path.resolve(path.dirname(dest), target), depth + 1);
|
|
150
|
+
}
|
|
151
|
+
return {
|
|
152
|
+
publishPath: dest,
|
|
153
|
+
existing: {
|
|
154
|
+
mode: lst.mode,
|
|
155
|
+
uid: lst.uid,
|
|
156
|
+
gid: lst.gid,
|
|
157
|
+
nlink: lst.nlink,
|
|
158
|
+
dev: lst.dev,
|
|
159
|
+
ino: lst.ino,
|
|
160
|
+
},
|
|
161
|
+
};
|
|
162
|
+
} catch (error) {
|
|
163
|
+
if (!isEnoent(error)) throw error;
|
|
164
|
+
return { publishPath: dest };
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function pathIsWithin(target: string, root: string): boolean {
|
|
169
|
+
return target === root || target.startsWith(`${root}${path.sep}`);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
async function realpathOrSelf(p: string): Promise<string> {
|
|
173
|
+
try {
|
|
174
|
+
return await fs.realpath(p);
|
|
175
|
+
} catch (error) {
|
|
176
|
+
if (!isEnoent(error)) throw error;
|
|
177
|
+
return path.resolve(p);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Derive the session-scoped `local://` trust boundary from a lexical write
|
|
183
|
+
* destination. Session roots live at `<tmpdir>/gjc-local/<session-id>`, and a
|
|
184
|
+
* symlink placed inside one must not be able to redirect a write out of it.
|
|
185
|
+
*/
|
|
186
|
+
function sessionLocalRootFor(lexicalDest: string): string | undefined {
|
|
187
|
+
const resolvedDest = path.resolve(lexicalDest);
|
|
188
|
+
const localParent = path.join(os.tmpdir(), "gjc-local");
|
|
189
|
+
if (!pathIsWithin(resolvedDest, localParent)) return undefined;
|
|
190
|
+
const rest = resolvedDest.slice(localParent.length + path.sep.length);
|
|
191
|
+
const sessionSegment = rest.split(path.sep, 1)[0] ?? "";
|
|
192
|
+
if (sessionSegment.length === 0) return undefined;
|
|
193
|
+
return path.join(localParent, sessionSegment);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Reject publication when the resolved referent's real parent (and therefore
|
|
198
|
+
* the referent itself) leaves the trust boundary. The boundary root is
|
|
199
|
+
* realpathed so a symlinked `gjc-local` root cannot smuggle a write out.
|
|
200
|
+
*
|
|
201
|
+
* This must run before any directory is created. A dangling symlink inside a
|
|
202
|
+
* trusted root resolves to a path outside it, so creating parents first would
|
|
203
|
+
* let an attacker-selected directory tree be materialized outside the sandbox
|
|
204
|
+
* before publication is refused. Missing ancestors therefore resolve
|
|
205
|
+
* lexically (`realpathOrSelf` tolerates ENOENT) and are still boundary-checked.
|
|
206
|
+
*/
|
|
207
|
+
async function assertWithinTrustBoundary(publishPath: string, trustBoundary: string): Promise<void> {
|
|
208
|
+
const boundary = path.resolve(trustBoundary);
|
|
209
|
+
const realBoundary = await realpathOrSelf(boundary);
|
|
210
|
+
const realParent = await realpathOrSelf(path.dirname(publishPath));
|
|
211
|
+
if (!pathIsWithin(realParent, realBoundary)) {
|
|
212
|
+
throw new Error(`write target '${publishPath}' resolves outside trust boundary '${trustBoundary}'`);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Identity of the publication parent directory. A realpath string alone cannot
|
|
218
|
+
* detect a parent that was unlinked and replaced by a different directory at
|
|
219
|
+
* the same path: the string still matches while the rename would publish into
|
|
220
|
+
* the replacement. Pin device and inode instead.
|
|
221
|
+
*/
|
|
222
|
+
interface ParentIdentity {
|
|
223
|
+
realpath: string;
|
|
224
|
+
dev: number;
|
|
225
|
+
ino: number;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
async function captureParentIdentity(publishPath: string): Promise<ParentIdentity> {
|
|
229
|
+
const parent = path.dirname(publishPath);
|
|
230
|
+
const realpath = await realpathOrSelf(parent);
|
|
231
|
+
const stat = await fs.stat(parent);
|
|
232
|
+
return { realpath, dev: stat.dev, ino: stat.ino };
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Rename replaces the referent without consulting its file permissions, so a
|
|
237
|
+
* writable parent could otherwise overwrite a read-only or ACL-denied target in
|
|
238
|
+
* a way a direct write would not. Probe effective write authorization the way a
|
|
239
|
+
* direct write would: open the existing referent for append (requires write
|
|
240
|
+
* permission and mutates nothing). Native Windows read-only attributes surface
|
|
241
|
+
* as EPERM/EACCES here just like POSIX immutable/`0444` targets.
|
|
242
|
+
*/
|
|
243
|
+
async function assertExistingTargetWritable(publishPath: string): Promise<void> {
|
|
244
|
+
const handle = await fs.open(publishPath, "a");
|
|
245
|
+
await handle.close();
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function sameFileIdentity(left: ExistingFileMetadata, right: ExistingFileMetadata): boolean {
|
|
249
|
+
return (
|
|
250
|
+
left.dev === right.dev &&
|
|
251
|
+
left.ino === right.ino &&
|
|
252
|
+
left.nlink === right.nlink &&
|
|
253
|
+
left.mode === right.mode &&
|
|
254
|
+
left.uid === right.uid &&
|
|
255
|
+
left.gid === right.gid
|
|
256
|
+
);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function metadataFromStat(stat: {
|
|
260
|
+
mode: number;
|
|
261
|
+
uid: number;
|
|
262
|
+
gid: number;
|
|
263
|
+
nlink: number;
|
|
264
|
+
dev: number;
|
|
265
|
+
ino: number;
|
|
266
|
+
}): ExistingFileMetadata {
|
|
267
|
+
return {
|
|
268
|
+
mode: stat.mode,
|
|
269
|
+
uid: stat.uid,
|
|
270
|
+
gid: stat.gid,
|
|
271
|
+
nlink: stat.nlink,
|
|
272
|
+
dev: stat.dev,
|
|
273
|
+
ino: stat.ino,
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
async function readWholeFileAtPositionZero(handle: fs.FileHandle): Promise<Uint8Array> {
|
|
278
|
+
const stat = await handle.stat();
|
|
279
|
+
const bytes = new Uint8Array(stat.size);
|
|
280
|
+
let read = 0;
|
|
281
|
+
while (read < bytes.byteLength) {
|
|
282
|
+
const result = await handle.read(bytes, read, bytes.byteLength - read, read);
|
|
283
|
+
if (result.bytesRead === 0) throw new Error(`in-place read stalled at ${read} of ${bytes.byteLength} bytes`);
|
|
284
|
+
read += result.bytesRead;
|
|
285
|
+
}
|
|
286
|
+
return bytes;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
async function preserveExistingMetadata(tmp: string, existing: ExistingFileMetadata): Promise<void> {
|
|
290
|
+
const staged = await fs.stat(tmp);
|
|
291
|
+
if (staged.uid !== existing.uid || staged.gid !== existing.gid) {
|
|
292
|
+
await fs.chown(tmp, existing.uid, existing.gid);
|
|
293
|
+
}
|
|
294
|
+
await fs.chmod(tmp, existing.mode);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
async function cleanupOwnedTemp(tmp: string, cause: unknown): Promise<void> {
|
|
298
|
+
try {
|
|
299
|
+
await fs.unlink(tmp);
|
|
300
|
+
} catch (cleanupError) {
|
|
301
|
+
if (isEnoent(cleanupError)) return;
|
|
302
|
+
throw new AggregateError([cause, cleanupError], `Failed to clean up staging file '${tmp}'.`);
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function isWindowsSharingViolation(error: unknown): boolean {
|
|
307
|
+
return isFsError(error) && WINDOWS_SHARING_VIOLATION_CODES.has(error.code);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
async function renameIntoPlace(
|
|
311
|
+
from: string,
|
|
312
|
+
to: string,
|
|
313
|
+
platform: NodeJS.Platform,
|
|
314
|
+
sleep: (delayMs: number) => Promise<void>,
|
|
315
|
+
): Promise<void> {
|
|
316
|
+
try {
|
|
317
|
+
await fs.rename(from, to);
|
|
318
|
+
return;
|
|
319
|
+
} catch (error) {
|
|
320
|
+
if (platform !== "win32" || !isWindowsSharingViolation(error)) throw error;
|
|
321
|
+
let lastError: unknown = error;
|
|
322
|
+
for (const delay of WINDOWS_RENAME_BACKOFF_MS) {
|
|
323
|
+
await sleep(delay);
|
|
324
|
+
try {
|
|
325
|
+
await fs.rename(from, to);
|
|
326
|
+
return;
|
|
327
|
+
} catch (retryError) {
|
|
328
|
+
lastError = retryError;
|
|
329
|
+
if (!isWindowsSharingViolation(retryError)) throw retryError;
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
throw lastError;
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* Windows permits another process to share writes while denying delete/rename.
|
|
338
|
+
* In that narrow case preserve the existing inode and perform a rollback-capable
|
|
339
|
+
* in-place replacement. Hard-linked files never enter this path: they are
|
|
340
|
+
* rejected before staging because a failed write cannot safely preserve every
|
|
341
|
+
* alias with a pathname replacement contract.
|
|
342
|
+
*/
|
|
343
|
+
/**
|
|
344
|
+
* Write `bytes` as the file's entire leading content starting at absolute
|
|
345
|
+
* position 0, looping until every byte is accepted. `FileHandle.writeFile()`
|
|
346
|
+
* writes from the handle's current position, which makes it unsafe for a
|
|
347
|
+
* rollback that must reproduce the original bytes exactly.
|
|
348
|
+
*/
|
|
349
|
+
async function writeWholeFileAtPositionZero(handle: fs.FileHandle, bytes: Uint8Array): Promise<void> {
|
|
350
|
+
let written = 0;
|
|
351
|
+
while (written < bytes.byteLength) {
|
|
352
|
+
const result = await handle.write(bytes, written, bytes.byteLength - written, written);
|
|
353
|
+
if (result.bytesWritten === 0)
|
|
354
|
+
throw new Error(`in-place write stalled at ${written} of ${bytes.byteLength} bytes`);
|
|
355
|
+
written += result.bytesWritten;
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
async function replaceInPlaceAfterSharingViolation(
|
|
360
|
+
dest: string,
|
|
361
|
+
tmp: string,
|
|
362
|
+
platform: NodeJS.Platform,
|
|
363
|
+
expectedExisting: ExistingFileMetadata,
|
|
364
|
+
beforeMutation?: () => Promise<void>,
|
|
365
|
+
): Promise<void> {
|
|
366
|
+
if (platform !== "win32") throw new Error("in-place sharing fallback is Windows-only");
|
|
367
|
+
// This fallback mutates the destination by pathname instead of publishing a
|
|
368
|
+
// staged inode, so it must re-establish that the pathname still names the
|
|
369
|
+
// file we were authorized to replace. Rename retries and their backoff give a
|
|
370
|
+
// concurrent writer time to substitute a different inode; overwriting that
|
|
371
|
+
// one in place would be an unauthorized mutation with no rollback source.
|
|
372
|
+
// Open the pathname first, then bind all reads and writes to that handle. A
|
|
373
|
+
// pathname read followed by a later open can read one inode and mutate its
|
|
374
|
+
// successor. The handle identity and pathname identity are both checked
|
|
375
|
+
// immediately before the first mutation; a race therefore fails closed and
|
|
376
|
+
// never writes the successor.
|
|
377
|
+
const replacement = new Uint8Array(await Bun.file(tmp).arrayBuffer());
|
|
378
|
+
const handle = await fs.open(dest, "r+");
|
|
379
|
+
let original: Uint8Array | undefined;
|
|
380
|
+
let failure: unknown;
|
|
381
|
+
let committed = false;
|
|
382
|
+
let mutationStarted = false;
|
|
383
|
+
try {
|
|
384
|
+
try {
|
|
385
|
+
const opened = metadataFromStat(await handle.stat());
|
|
386
|
+
if (!sameFileIdentity(opened, expectedExisting)) {
|
|
387
|
+
throw new FileWriteNotPublishedError(
|
|
388
|
+
dest,
|
|
389
|
+
new Error(`destination '${dest}' changed before the in-place fallback was opened`),
|
|
390
|
+
{ destUnchanged: true, publicationState: "not_published" },
|
|
391
|
+
);
|
|
392
|
+
}
|
|
393
|
+
original = await readWholeFileAtPositionZero(handle);
|
|
394
|
+
if (beforeMutation) await beforeMutation();
|
|
395
|
+
const current = (await resolvePublishPath(dest)).existing;
|
|
396
|
+
const bound = metadataFromStat(await handle.stat());
|
|
397
|
+
if (
|
|
398
|
+
current === undefined ||
|
|
399
|
+
!sameFileIdentity(current, expectedExisting) ||
|
|
400
|
+
!sameFileIdentity(bound, expectedExisting)
|
|
401
|
+
) {
|
|
402
|
+
throw new FileWriteNotPublishedError(
|
|
403
|
+
dest,
|
|
404
|
+
new Error(
|
|
405
|
+
`destination '${dest}' was replaced before the in-place fallback mutation; refusing to overwrite a different file`,
|
|
406
|
+
),
|
|
407
|
+
{ destUnchanged: true, publicationState: "not_published" },
|
|
408
|
+
);
|
|
409
|
+
}
|
|
410
|
+
// Write at an explicit absolute position: handle.writeFile() appends from
|
|
411
|
+
// the handle's current offset, so a retry or rollback after a partial
|
|
412
|
+
// write would otherwise land mid-file and interleave bytes.
|
|
413
|
+
mutationStarted = true;
|
|
414
|
+
await writeWholeFileAtPositionZero(handle, replacement);
|
|
415
|
+
await handle.sync();
|
|
416
|
+
await handle.truncate(replacement.byteLength);
|
|
417
|
+
await handle.sync();
|
|
418
|
+
committed = true;
|
|
419
|
+
} catch (error) {
|
|
420
|
+
if (original !== undefined && mutationStarted) {
|
|
421
|
+
try {
|
|
422
|
+
await writeWholeFileAtPositionZero(handle, original);
|
|
423
|
+
await handle.truncate(original.byteLength);
|
|
424
|
+
await handle.sync();
|
|
425
|
+
} catch (rollbackError) {
|
|
426
|
+
failure = new FileWriteNotPublishedError(
|
|
427
|
+
dest,
|
|
428
|
+
new AggregateError([error, rollbackError], "In-place write rollback failed."),
|
|
429
|
+
{ destUnchanged: false, publicationState: "unknown" },
|
|
430
|
+
);
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
if (failure === undefined) failure = error;
|
|
434
|
+
}
|
|
435
|
+
} finally {
|
|
436
|
+
try {
|
|
437
|
+
await handle.close();
|
|
438
|
+
} catch (closeError) {
|
|
439
|
+
if (committed) {
|
|
440
|
+
failure = new FileWriteNotPublishedError(dest, closeError, {
|
|
441
|
+
destUnchanged: false,
|
|
442
|
+
publicationState: "published",
|
|
443
|
+
});
|
|
444
|
+
} else if (failure === undefined) {
|
|
445
|
+
failure = closeError;
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
if (failure !== undefined) {
|
|
450
|
+
if (failure instanceof FileWriteNotPublishedError) throw failure;
|
|
451
|
+
throw new FileWriteNotPublishedError(dest, failure, {
|
|
452
|
+
destUnchanged: !committed,
|
|
453
|
+
publicationState: committed ? "published" : "not_published",
|
|
454
|
+
});
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
/**
|
|
459
|
+
* Revalidate the resolved destination immediately before publication so a
|
|
460
|
+
* symlink retargeted while staging cannot silently repoint the write at a
|
|
461
|
+
* different file.
|
|
462
|
+
*/
|
|
463
|
+
async function assertPublishTargetStillIntended(
|
|
464
|
+
dest: string,
|
|
465
|
+
publishPath: string,
|
|
466
|
+
trustBoundary: string | undefined,
|
|
467
|
+
expectedExisting: ExistingFileMetadata | undefined,
|
|
468
|
+
expectedParent: ParentIdentity,
|
|
469
|
+
): Promise<void> {
|
|
470
|
+
const after = await resolvePublishPath(dest);
|
|
471
|
+
if (after.publishPath !== publishPath) {
|
|
472
|
+
throw new Error(`destination '${dest}' was retargeted while staging; refusing to overwrite a different file`);
|
|
473
|
+
}
|
|
474
|
+
const currentParent = await captureParentIdentity(publishPath);
|
|
475
|
+
if (
|
|
476
|
+
currentParent.realpath !== expectedParent.realpath ||
|
|
477
|
+
currentParent.dev !== expectedParent.dev ||
|
|
478
|
+
currentParent.ino !== expectedParent.ino
|
|
479
|
+
) {
|
|
480
|
+
throw new Error(
|
|
481
|
+
`destination '${dest}' parent was retargeted while staging; refusing to overwrite a different file`,
|
|
482
|
+
);
|
|
483
|
+
}
|
|
484
|
+
if (expectedExisting === undefined) {
|
|
485
|
+
if (after.existing !== undefined) {
|
|
486
|
+
throw new Error(`destination '${dest}' appeared while staging; refusing to overwrite a different file`);
|
|
487
|
+
}
|
|
488
|
+
} else if (after.existing === undefined || !sameFileIdentity(after.existing, expectedExisting)) {
|
|
489
|
+
throw new Error(`destination '${dest}' was replaced while staging; refusing to overwrite a different file`);
|
|
490
|
+
}
|
|
491
|
+
if (trustBoundary !== undefined) {
|
|
492
|
+
await assertWithinTrustBoundary(after.publishPath, trustBoundary);
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
export async function writeFileAtomically(
|
|
497
|
+
dest: string,
|
|
498
|
+
content: string | Uint8Array,
|
|
499
|
+
options: WriteFileAtomicallyOptions = {},
|
|
500
|
+
): Promise<void> {
|
|
501
|
+
let publishPath = dest;
|
|
502
|
+
try {
|
|
503
|
+
const platform = options.platform ?? process.platform;
|
|
504
|
+
const sleep = options.sleep ?? (async (delayMs: number): Promise<void> => await Bun.sleep(delayMs));
|
|
505
|
+
const trustBoundary = options.trustBoundary ?? sessionLocalRootFor(dest);
|
|
506
|
+
const resolved = await resolvePublishPath(dest);
|
|
507
|
+
publishPath = resolved.publishPath;
|
|
508
|
+
// Boundary first, then create parents. A dangling symlink inside a trusted
|
|
509
|
+
// root resolves outside it, so creating directories before this check would
|
|
510
|
+
// materialize an attacker-selected tree outside the sandbox and only then
|
|
511
|
+
// refuse to publish.
|
|
512
|
+
if (trustBoundary !== undefined) {
|
|
513
|
+
await assertWithinTrustBoundary(publishPath, trustBoundary);
|
|
514
|
+
}
|
|
515
|
+
await fs.mkdir(path.dirname(publishPath), { recursive: true });
|
|
516
|
+
// Re-check after creation: `mkdir -p` follows existing symlinked ancestors,
|
|
517
|
+
// so the post-creation parent is the one publication must be bound to.
|
|
518
|
+
if (trustBoundary !== undefined) {
|
|
519
|
+
await assertWithinTrustBoundary(publishPath, trustBoundary);
|
|
520
|
+
}
|
|
521
|
+
const expectedParent = await captureParentIdentity(publishPath);
|
|
522
|
+
const existing = resolved.existing;
|
|
523
|
+
if (existing !== undefined && existing.nlink > 1) {
|
|
524
|
+
throw new Error(
|
|
525
|
+
`Cannot atomically replace hard-linked file '${dest}': replacement would split its link group.`,
|
|
526
|
+
);
|
|
527
|
+
}
|
|
528
|
+
if (existing !== undefined) {
|
|
529
|
+
await assertExistingTargetWritable(publishPath);
|
|
530
|
+
}
|
|
531
|
+
let lastError: unknown;
|
|
532
|
+
for (let attempt = 0; attempt < TEMP_CREATE_ATTEMPTS; attempt++) {
|
|
533
|
+
const tmp = tempPathFor(publishPath);
|
|
534
|
+
let owned = false;
|
|
535
|
+
try {
|
|
536
|
+
const handle = await fs.open(tmp, "wx", existing?.mode ?? DEFAULT_FILE_MODE);
|
|
537
|
+
owned = true;
|
|
538
|
+
try {
|
|
539
|
+
await handle.writeFile(content);
|
|
540
|
+
await handle.sync();
|
|
541
|
+
} finally {
|
|
542
|
+
await handle.close();
|
|
543
|
+
}
|
|
544
|
+
if (existing !== undefined) {
|
|
545
|
+
await preserveExistingMetadata(tmp, existing);
|
|
546
|
+
}
|
|
547
|
+
await assertPublishTargetStillIntended(dest, publishPath, trustBoundary, existing, expectedParent);
|
|
548
|
+
// The staged file lives beside the destination, so rename is one
|
|
549
|
+
// same-directory atomic publication. A failed rename leaves the
|
|
550
|
+
// destination untouched and the owned staging file is cleaned below.
|
|
551
|
+
try {
|
|
552
|
+
await renameIntoPlace(tmp, publishPath, platform, sleep);
|
|
553
|
+
} catch (error) {
|
|
554
|
+
if (existing !== undefined && platform === "win32" && isWindowsSharingViolation(error)) {
|
|
555
|
+
await replaceInPlaceAfterSharingViolation(
|
|
556
|
+
publishPath,
|
|
557
|
+
tmp,
|
|
558
|
+
platform,
|
|
559
|
+
existing,
|
|
560
|
+
options.beforeInPlaceMutation,
|
|
561
|
+
);
|
|
562
|
+
try {
|
|
563
|
+
await fs.unlink(tmp);
|
|
564
|
+
} catch (cleanupError) {
|
|
565
|
+
owned = false;
|
|
566
|
+
throw new FileWriteNotPublishedError(
|
|
567
|
+
dest,
|
|
568
|
+
new AggregateError([error, cleanupError], "Published bytes could not be cleaned up."),
|
|
569
|
+
{ destUnchanged: false, publicationState: "published" },
|
|
570
|
+
);
|
|
571
|
+
}
|
|
572
|
+
owned = false;
|
|
573
|
+
return;
|
|
574
|
+
}
|
|
575
|
+
throw error;
|
|
576
|
+
}
|
|
577
|
+
owned = false;
|
|
578
|
+
return;
|
|
579
|
+
} catch (error) {
|
|
580
|
+
lastError = error;
|
|
581
|
+
// A temp that never got created was a genuine pre-existing
|
|
582
|
+
// collision file: leave it alone and try a fresh sibling name.
|
|
583
|
+
if (hasFsCode(error, "EEXIST") && !owned) continue;
|
|
584
|
+
// Any failure after we exclusively created the temp must not leak it.
|
|
585
|
+
if (owned) await cleanupOwnedTemp(tmp, error);
|
|
586
|
+
throw error;
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
throw lastError;
|
|
590
|
+
} catch (error) {
|
|
591
|
+
if (error instanceof FileWriteNotPublishedError) throw error;
|
|
592
|
+
throw new FileWriteNotPublishedError(dest, error);
|
|
593
|
+
}
|
|
594
|
+
}
|
package/src/tools/bisect.ts
CHANGED
|
@@ -89,7 +89,7 @@ export interface BisectToolDetails {
|
|
|
89
89
|
reason?: string;
|
|
90
90
|
}
|
|
91
91
|
|
|
92
|
-
const FIRST_BAD_RE =
|
|
92
|
+
const FIRST_BAD_RE = /^\s*([0-9a-f]{7,40})\s+is the first (?:bad|'bad') commit\b/m;
|
|
93
93
|
const ONLY_SKIPPED_RE = /only '?skip'?ped commits left to test/i;
|
|
94
94
|
|
|
95
95
|
/** Parse the culprit SHA from `git bisect good|bad` output, or null when not yet converged. */
|
|
@@ -266,7 +266,7 @@ export class BisectTool implements AgentTool<typeof bisectSchema, BisectToolDeta
|
|
|
266
266
|
);
|
|
267
267
|
}
|
|
268
268
|
|
|
269
|
-
let outcome: BisectOutcome;
|
|
269
|
+
let outcome: BisectOutcome | undefined;
|
|
270
270
|
// Defensive reset in case a previous aborted bisect left state behind; a
|
|
271
271
|
// no-op when not bisecting (reset never throws).
|
|
272
272
|
await git.bisect.reset(cwd, signal);
|
|
@@ -318,6 +318,7 @@ export class BisectTool implements AgentTool<typeof bisectSchema, BisectToolDeta
|
|
|
318
318
|
await git.reset(cwd, { hard: true }).catch(() => {});
|
|
319
319
|
await git.bisect.reset(cwd);
|
|
320
320
|
}
|
|
321
|
+
if (!outcome) throw new ToolError("git bisect ended without producing a result.");
|
|
321
322
|
|
|
322
323
|
// Never claim a clean restore we did not actually achieve.
|
|
323
324
|
const restoreLine = await describeRestore(cwd);
|
|
@@ -56,6 +56,7 @@ function makeSession(overrides: Record<string, unknown> = {}): any {
|
|
|
56
56
|
requireYieldTool: false,
|
|
57
57
|
enableLsp: true,
|
|
58
58
|
taskDepth: 0,
|
|
59
|
+
rescopeSessionCwd: async () => ({ from: process.cwd(), to: process.cwd() }),
|
|
59
60
|
getSessionFile: () => null,
|
|
60
61
|
getSessionSpawns: () => null,
|
|
61
62
|
};
|
|
@@ -99,6 +100,7 @@ describe("tool descriptor compatibility gate", () => {
|
|
|
99
100
|
"debug",
|
|
100
101
|
"bisect",
|
|
101
102
|
"eval",
|
|
103
|
+
"python",
|
|
102
104
|
"calc",
|
|
103
105
|
"ssh",
|
|
104
106
|
"github",
|
|
@@ -124,6 +126,7 @@ describe("tool descriptor compatibility gate", () => {
|
|
|
124
126
|
"write",
|
|
125
127
|
"skill",
|
|
126
128
|
"goal",
|
|
129
|
+
"move_session",
|
|
127
130
|
];
|
|
128
131
|
expect(Object.keys(BUILTIN_TOOLS)).toEqual(expectedBuiltin);
|
|
129
132
|
expect(Object.keys(BUILTIN_TOOL_DESCRIPTORS)).toEqual(expectedBuiltin);
|