@nathapp/nax-agent 0.1.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 +24 -0
- package/LICENSE +21 -0
- package/README.md +49 -0
- package/dist/coding-tools/coding-tool-bash.d.ts +46 -0
- package/dist/coding-tools/coding-tool-bash.js +44 -0
- package/dist/coding-tools/coding-tool-extras.d.ts +35 -0
- package/dist/coding-tools/coding-tool-extras.js +56 -0
- package/dist/coding-tools/coding-tool-sandbox.d.ts +74 -0
- package/dist/coding-tools/coding-tool-sandbox.js +215 -0
- package/dist/coding-tools/coding-tool-support.d.ts +132 -0
- package/dist/coding-tools/coding-tool-support.js +159 -0
- package/dist/coding-tools/universal-coding-tools.d.ts +11 -0
- package/dist/coding-tools/universal-coding-tools.js +5 -0
- package/dist/command-interceptor/index.d.ts +55 -0
- package/dist/command-interceptor/index.js +60 -0
- package/dist/command-interceptor/shell.d.ts +36 -0
- package/dist/command-interceptor/shell.js +137 -0
- package/dist/command-safety/build.d.ts +19 -0
- package/dist/command-safety/build.js +47 -0
- package/dist/command-safety/guard.d.ts +32 -0
- package/dist/command-safety/guard.js +179 -0
- package/dist/command-safety/identifiers.d.ts +24 -0
- package/dist/command-safety/identifiers.js +20 -0
- package/dist/command-safety/index.d.ts +17 -0
- package/dist/command-safety/index.js +11 -0
- package/dist/command-safety/questions.d.ts +19 -0
- package/dist/command-safety/questions.js +60 -0
- package/dist/command-safety/row.d.ts +2 -0
- package/dist/command-safety/row.js +14 -0
- package/dist/command-safety/rule-scorer.d.ts +9 -0
- package/dist/command-safety/rule-scorer.js +391 -0
- package/dist/command-safety/shadow.d.ts +33 -0
- package/dist/command-safety/shadow.js +188 -0
- package/dist/command-safety/systemone-client.d.ts +16 -0
- package/dist/command-safety/systemone-client.js +92 -0
- package/dist/command-safety/tap.d.ts +37 -0
- package/dist/command-safety/tap.js +77 -0
- package/dist/command-safety/temp-only.d.ts +6 -0
- package/dist/command-safety/temp-only.js +215 -0
- package/dist/command-safety/tmp-write.d.ts +2 -0
- package/dist/command-safety/tmp-write.js +112 -0
- package/dist/command-safety/types.d.ts +185 -0
- package/dist/command-safety/types.js +21 -0
- package/dist/config/bash-approval.d.ts +33 -0
- package/dist/config/bash-approval.js +30 -0
- package/dist/config/catalog-overrides.d.ts +94 -0
- package/dist/config/catalog-overrides.js +7 -0
- package/dist/config/native-agent/index.d.ts +8 -0
- package/dist/config/native-agent/index.js +8 -0
- package/dist/config/schemas-sandbox.d.ts +31 -0
- package/dist/config/schemas-sandbox.js +45 -0
- package/dist/cost/core/index.d.ts +8 -0
- package/dist/cost/core/index.js +7 -0
- package/dist/cost/estimate.d.ts +34 -0
- package/dist/cost/estimate.js +65 -0
- package/dist/cost/model-spec.d.ts +28 -0
- package/dist/cost/model-spec.js +29 -0
- package/dist/cost/standard-types.d.ts +9 -0
- package/dist/cost/standard-types.js +1 -0
- package/dist/cost/usage-math.d.ts +18 -0
- package/dist/cost/usage-math.js +46 -0
- package/dist/index.d.ts +26 -0
- package/dist/index.js +21 -0
- package/dist/infra/agent-logger.d.ts +22 -0
- package/dist/infra/agent-logger.js +19 -0
- package/dist/infra/credentials-config.d.ts +20 -0
- package/dist/infra/credentials-config.js +15 -0
- package/dist/infra/errors.d.ts +9 -0
- package/dist/infra/errors.js +11 -0
- package/dist/infra/index.d.ts +7 -0
- package/dist/infra/index.js +7 -0
- package/dist/infra/nax-error.d.ts +10 -0
- package/dist/infra/nax-error.js +16 -0
- package/dist/infra/spin-breaker/hash.d.ts +2 -0
- package/dist/infra/spin-breaker/hash.js +5 -0
- package/dist/infra/spin-breaker/index.d.ts +120 -0
- package/dist/infra/spin-breaker/index.js +340 -0
- package/dist/internal/agent-output-env.d.ts +44 -0
- package/dist/internal/agent-output-env.js +57 -0
- package/dist/internal/argv-exec.d.ts +58 -0
- package/dist/internal/argv-exec.js +224 -0
- package/dist/internal/bounded-io.d.ts +24 -0
- package/dist/internal/bounded-io.js +51 -0
- package/dist/internal/command-spec/index.d.ts +29 -0
- package/dist/internal/command-spec/index.js +36 -0
- package/dist/internal/describe-value-type.d.ts +8 -0
- package/dist/internal/describe-value-type.js +14 -0
- package/dist/internal/exec-framing.d.ts +18 -0
- package/dist/internal/exec-framing.js +22 -0
- package/dist/internal/file-lock.d.ts +91 -0
- package/dist/internal/file-lock.js +212 -0
- package/dist/internal/file-size.d.ts +2 -0
- package/dist/internal/file-size.js +12 -0
- package/dist/internal/git-add.d.ts +43 -0
- package/dist/internal/git-add.js +59 -0
- package/dist/internal/git-env.d.ts +59 -0
- package/dist/internal/git-env.js +93 -0
- package/dist/internal/git-exec.d.ts +58 -0
- package/dist/internal/git-exec.js +115 -0
- package/dist/internal/path-file-lock.d.ts +23 -0
- package/dist/internal/path-file-lock.js +25 -0
- package/dist/internal/process-alive.d.ts +42 -0
- package/dist/internal/process-alive.js +56 -0
- package/dist/internal/process-kill.d.ts +27 -0
- package/dist/internal/process-kill.js +54 -0
- package/dist/internal/realpath.d.ts +29 -0
- package/dist/internal/realpath.js +52 -0
- package/dist/internal/redact.d.ts +40 -0
- package/dist/internal/redact.js +161 -0
- package/dist/internal/shell-quote.d.ts +2 -0
- package/dist/internal/shell-quote.js +4 -0
- package/dist/internal/sort.d.ts +20 -0
- package/dist/internal/sort.js +20 -0
- package/dist/internal/strip-control-chars.d.ts +19 -0
- package/dist/internal/strip-control-chars.js +38 -0
- package/dist/internal/thenable.d.ts +10 -0
- package/dist/internal/thenable.js +15 -0
- package/dist/internal.d.ts +91 -0
- package/dist/internal.js +91 -0
- package/dist/native/adapter-deps.d.ts +46 -0
- package/dist/native/adapter-deps.js +43 -0
- package/dist/native/auth-types.d.ts +70 -0
- package/dist/native/auth-types.js +13 -0
- package/dist/native/auth.d.ts +133 -0
- package/dist/native/auth.js +362 -0
- package/dist/native/client.d.ts +50 -0
- package/dist/native/client.js +174 -0
- package/dist/native/complete.d.ts +33 -0
- package/dist/native/complete.js +88 -0
- package/dist/native/credentials/chained-store.d.ts +30 -0
- package/dist/native/credentials/chained-store.js +68 -0
- package/dist/native/credentials/change-guard.d.ts +51 -0
- package/dist/native/credentials/change-guard.js +136 -0
- package/dist/native/credentials/exec-source.d.ts +50 -0
- package/dist/native/credentials/exec-source.js +222 -0
- package/dist/native/credentials/fingerprint.d.ts +26 -0
- package/dist/native/credentials/fingerprint.js +174 -0
- package/dist/native/credentials/helper-process.d.ts +79 -0
- package/dist/native/credentials/helper-process.js +259 -0
- package/dist/native/credentials/helper-protocol.d.ts +36 -0
- package/dist/native/credentials/helper-protocol.js +79 -0
- package/dist/native/credentials/index.d.ts +60 -0
- package/dist/native/credentials/index.js +184 -0
- package/dist/native/errors.d.ts +34 -0
- package/dist/native/errors.js +109 -0
- package/dist/native/index.d.ts +25 -0
- package/dist/native/index.js +23 -0
- package/dist/native/model-resolver.d.ts +57 -0
- package/dist/native/model-resolver.js +73 -0
- package/dist/native/models.d.ts +116 -0
- package/dist/native/models.js +183 -0
- package/dist/native/session/ask-human.d.ts +15 -0
- package/dist/native/session/ask-human.js +22 -0
- package/dist/native/session/compaction.d.ts +94 -0
- package/dist/native/session/compaction.js +170 -0
- package/dist/native/session/handle-invalid-tool-call.d.ts +111 -0
- package/dist/native/session/handle-invalid-tool-call.js +159 -0
- package/dist/native/session/loop-events/cache-boundary.d.ts +11 -0
- package/dist/native/session/loop-events/cache-boundary.js +29 -0
- package/dist/native/session/loop-events/external-handler.d.ts +38 -0
- package/dist/native/session/loop-events/external-handler.js +193 -0
- package/dist/native/session/loop-events/index.d.ts +16 -0
- package/dist/native/session/loop-events/index.js +15 -0
- package/dist/native/session/loop-events/payload-guard.d.ts +9 -0
- package/dist/native/session/loop-events/payload-guard.js +31 -0
- package/dist/native/session/loop-events/registry.d.ts +84 -0
- package/dist/native/session/loop-events/registry.js +305 -0
- package/dist/native/session/loop-events/types.d.ts +251 -0
- package/dist/native/session/loop-events/types.js +14 -0
- package/dist/native/session/loop-handlers.d.ts +73 -0
- package/dist/native/session/loop-handlers.js +137 -0
- package/dist/native/session/nudge.d.ts +29 -0
- package/dist/native/session/nudge.js +35 -0
- package/dist/native/session/rate-provenance.d.ts +17 -0
- package/dist/native/session/rate-provenance.js +39 -0
- package/dist/native/session/session.d.ts +139 -0
- package/dist/native/session/session.js +208 -0
- package/dist/native/session/tool-input-exemplar.d.ts +2 -0
- package/dist/native/session/tool-input-exemplar.js +66 -0
- package/dist/native/session/tool-input-validation.d.ts +15 -0
- package/dist/native/session/tool-input-validation.js +146 -0
- package/dist/native/session/tool-mapping.d.ts +18 -0
- package/dist/native/session/tool-mapping.js +27 -0
- package/dist/native/session/tool-result.d.ts +37 -0
- package/dist/native/session/tool-result.js +25 -0
- package/dist/native/session/transcript-store.d.ts +77 -0
- package/dist/native/session/transcript-store.js +200 -0
- package/dist/native/session/truncation-handler.d.ts +47 -0
- package/dist/native/session/truncation-handler.js +83 -0
- package/dist/native/session/turn-accumulator.d.ts +45 -0
- package/dist/native/session/turn-accumulator.js +67 -0
- package/dist/native/session/turn-ask-human.d.ts +23 -0
- package/dist/native/session/turn-ask-human.js +41 -0
- package/dist/native/session/turn-compaction-step.d.ts +74 -0
- package/dist/native/session/turn-compaction-step.js +128 -0
- package/dist/native/session/turn-complete-step.d.ts +77 -0
- package/dist/native/session/turn-complete-step.js +159 -0
- package/dist/native/session/turn-end-event.d.ts +3 -0
- package/dist/native/session/turn-end-event.js +17 -0
- package/dist/native/session/turn-events.d.ts +63 -0
- package/dist/native/session/turn-events.js +48 -0
- package/dist/native/session/turn-loop-round-trip.d.ts +116 -0
- package/dist/native/session/turn-loop-round-trip.js +318 -0
- package/dist/native/session/turn-loop.d.ts +23 -0
- package/dist/native/session/turn-loop.js +250 -0
- package/dist/native/session/turn-result.d.ts +39 -0
- package/dist/native/session/turn-result.js +64 -0
- package/dist/native/session/turn-retry.d.ts +120 -0
- package/dist/native/session/turn-retry.js +182 -0
- package/dist/native/session/turn-tool-batch.d.ts +61 -0
- package/dist/native/session/turn-tool-batch.js +259 -0
- package/dist/native/session/turn-types.d.ts +164 -0
- package/dist/native/session/turn-types.js +36 -0
- package/dist/native/session-adapter.d.ts +56 -0
- package/dist/native/session-adapter.js +369 -0
- package/dist/native/session-affinity.d.ts +36 -0
- package/dist/native/session-affinity.js +43 -0
- package/dist/native/tier-providers.d.ts +18 -0
- package/dist/native/tier-providers.js +20 -0
- package/dist/permissions/approval-audit.d.ts +10 -0
- package/dist/permissions/approval-audit.js +22 -0
- package/dist/permissions/approvals-link.d.ts +20 -0
- package/dist/permissions/approvals-link.js +62 -0
- package/dist/permissions/approvals-store.d.ts +168 -0
- package/dist/permissions/approvals-store.js +259 -0
- package/dist/permissions/approvals-taint.d.ts +33 -0
- package/dist/permissions/approvals-taint.js +138 -0
- package/dist/permissions/ask-chain.d.ts +69 -0
- package/dist/permissions/ask-chain.js +32 -0
- package/dist/permissions/ask.d.ts +23 -0
- package/dist/permissions/ask.js +25 -0
- package/dist/permissions/bash-lex.d.ts +59 -0
- package/dist/permissions/bash-lex.js +279 -0
- package/dist/permissions/grammar.d.ts +12 -0
- package/dist/permissions/grammar.js +24 -0
- package/dist/permissions/index.d.ts +11 -0
- package/dist/permissions/index.js +9 -0
- package/dist/permissions/secret-spans.d.ts +19 -0
- package/dist/permissions/secret-spans.js +88 -0
- package/dist/permissions/types.d.ts +85 -0
- package/dist/permissions/types.js +1 -0
- package/dist/runtime/index.d.ts +3 -0
- package/dist/runtime/index.js +2 -0
- package/dist/runtime/node-glob.d.ts +3 -0
- package/dist/runtime/node-glob.js +83 -0
- package/dist/runtime/node-runtime.d.ts +3 -0
- package/dist/runtime/node-runtime.js +126 -0
- package/dist/runtime/slot.d.ts +11 -0
- package/dist/runtime/slot.js +17 -0
- package/dist/runtime/types.d.ts +51 -0
- package/dist/runtime/types.js +1 -0
- package/dist/runtime/which.d.ts +8 -0
- package/dist/runtime/which.js +32 -0
- package/dist/sandbox/argv-quote.d.ts +11 -0
- package/dist/sandbox/argv-quote.js +13 -0
- package/dist/sandbox/defaults.d.ts +27 -0
- package/dist/sandbox/defaults.js +47 -0
- package/dist/sandbox/git-guards.d.ts +64 -0
- package/dist/sandbox/git-guards.js +161 -0
- package/dist/sandbox/index.d.ts +11 -0
- package/dist/sandbox/index.js +10 -0
- package/dist/sandbox/launcher.d.ts +37 -0
- package/dist/sandbox/launcher.js +160 -0
- package/dist/sandbox/messages.d.ts +17 -0
- package/dist/sandbox/messages.js +46 -0
- package/dist/sandbox/policy-builder.d.ts +44 -0
- package/dist/sandbox/policy-builder.js +149 -0
- package/dist/sandbox/policy-inputs.d.ts +53 -0
- package/dist/sandbox/policy-inputs.js +76 -0
- package/dist/sandbox/probe.d.ts +8 -0
- package/dist/sandbox/probe.js +66 -0
- package/dist/sandbox/registry.d.ts +17 -0
- package/dist/sandbox/registry.js +57 -0
- package/dist/sandbox/session-tmp.d.ts +20 -0
- package/dist/sandbox/session-tmp.js +95 -0
- package/dist/sandbox/srt-backend.d.ts +21 -0
- package/dist/sandbox/srt-backend.js +99 -0
- package/dist/sandbox/types.d.ts +102 -0
- package/dist/sandbox/types.js +1 -0
- package/dist/session/adapter-failure.d.ts +60 -0
- package/dist/session/adapter-failure.js +11 -0
- package/dist/session/agent-stream-events.d.ts +103 -0
- package/dist/session/agent-stream-events.js +6 -0
- package/dist/session/interaction-handler.d.ts +53 -0
- package/dist/session/interaction-handler.js +2 -0
- package/dist/session/no-op-interaction-handler.d.ts +9 -0
- package/dist/session/no-op-interaction-handler.js +11 -0
- package/dist/session/protocol-types.d.ts +12 -0
- package/dist/session/protocol-types.js +1 -0
- package/dist/session/session-types.d.ts +456 -0
- package/dist/session/session-types.js +64 -0
- package/dist/session/tool-descriptor.d.ts +23 -0
- package/dist/session/tool-descriptor.js +5 -0
- package/dist/session/turn-deadline.d.ts +17 -0
- package/dist/session/turn-deadline.js +23 -0
- package/dist/tools/ask-request.d.ts +35 -0
- package/dist/tools/ask-request.js +57 -0
- package/dist/tools/bash-cwd.d.ts +82 -0
- package/dist/tools/bash-cwd.js +55 -0
- package/dist/tools/bash.d.ts +69 -0
- package/dist/tools/bash.js +283 -0
- package/dist/tools/delete.d.ts +57 -0
- package/dist/tools/delete.js +154 -0
- package/dist/tools/denial-redirect.d.ts +42 -0
- package/dist/tools/denial-redirect.js +266 -0
- package/dist/tools/deny-paths.d.ts +35 -0
- package/dist/tools/deny-paths.js +76 -0
- package/dist/tools/edit-region.d.ts +50 -0
- package/dist/tools/edit-region.js +90 -0
- package/dist/tools/edit.d.ts +25 -0
- package/dist/tools/edit.js +114 -0
- package/dist/tools/exec-allowlist-text.d.ts +18 -0
- package/dist/tools/exec-allowlist-text.js +24 -0
- package/dist/tools/exec-guard.d.ts +69 -0
- package/dist/tools/exec-guard.js +151 -0
- package/dist/tools/git-commit.d.ts +59 -0
- package/dist/tools/git-commit.js +220 -0
- package/dist/tools/git-flags/index.d.ts +10 -0
- package/dist/tools/git-flags/index.js +10 -0
- package/dist/tools/git.d.ts +88 -0
- package/dist/tools/git.js +397 -0
- package/dist/tools/glob.d.ts +29 -0
- package/dist/tools/glob.js +153 -0
- package/dist/tools/grep.d.ts +21 -0
- package/dist/tools/grep.js +162 -0
- package/dist/tools/index.d.ts +36 -0
- package/dist/tools/index.js +28 -0
- package/dist/tools/narrow-grants.d.ts +19 -0
- package/dist/tools/narrow-grants.js +54 -0
- package/dist/tools/nax-owned-writes.d.ts +135 -0
- package/dist/tools/nax-owned-writes.js +259 -0
- package/dist/tools/package-managers-table.d.ts +146 -0
- package/dist/tools/package-managers-table.js +325 -0
- package/dist/tools/package-managers-types.d.ts +36 -0
- package/dist/tools/package-managers-types.js +6 -0
- package/dist/tools/package-managers.d.ts +85 -0
- package/dist/tools/package-managers.js +391 -0
- package/dist/tools/path-list.d.ts +38 -0
- package/dist/tools/path-list.js +62 -0
- package/dist/tools/policy-bash-raw.d.ts +19 -0
- package/dist/tools/policy-bash-raw.js +210 -0
- package/dist/tools/policy-bash.d.ts +64 -0
- package/dist/tools/policy-bash.js +232 -0
- package/dist/tools/policy-command-branch.d.ts +32 -0
- package/dist/tools/policy-command-branch.js +63 -0
- package/dist/tools/policy-input.d.ts +2 -0
- package/dist/tools/policy-input.js +10 -0
- package/dist/tools/policy-match.d.ts +75 -0
- package/dist/tools/policy-match.js +117 -0
- package/dist/tools/policy-paths-branch.d.ts +97 -0
- package/dist/tools/policy-paths-branch.js +314 -0
- package/dist/tools/policy.d.ts +65 -0
- package/dist/tools/policy.js +232 -0
- package/dist/tools/protected-paths.d.ts +26 -0
- package/dist/tools/protected-paths.js +8 -0
- package/dist/tools/provider-adapt.d.ts +4 -0
- package/dist/tools/provider-adapt.js +32 -0
- package/dist/tools/provider-advertise.d.ts +39 -0
- package/dist/tools/provider-advertise.js +50 -0
- package/dist/tools/provider-grants.d.ts +36 -0
- package/dist/tools/provider-grants.js +72 -0
- package/dist/tools/provider-sanitize.d.ts +19 -0
- package/dist/tools/provider-sanitize.js +53 -0
- package/dist/tools/provider-types.d.ts +41 -0
- package/dist/tools/provider-types.js +26 -0
- package/dist/tools/read-continuation.d.ts +147 -0
- package/dist/tools/read-continuation.js +176 -0
- package/dist/tools/read-file.d.ts +32 -0
- package/dist/tools/read-file.js +85 -0
- package/dist/tools/read.d.ts +25 -0
- package/dist/tools/read.js +182 -0
- package/dist/tools/registry.d.ts +125 -0
- package/dist/tools/registry.js +67 -0
- package/dist/tools/request-capability.d.ts +13 -0
- package/dist/tools/request-capability.js +23 -0
- package/dist/tools/run-command-exec.d.ts +14 -0
- package/dist/tools/run-command-exec.js +161 -0
- package/dist/tools/run-command.d.ts +107 -0
- package/dist/tools/run-command.js +398 -0
- package/dist/tools/runtime-calltool.d.ts +146 -0
- package/dist/tools/runtime-calltool.js +223 -0
- package/dist/tools/runtime.d.ts +149 -0
- package/dist/tools/runtime.js +345 -0
- package/dist/tools/scratchpad.d.ts +20 -0
- package/dist/tools/scratchpad.js +197 -0
- package/dist/tools/spill.d.ts +81 -0
- package/dist/tools/spill.js +251 -0
- package/dist/tools/tool-audit.d.ts +171 -0
- package/dist/tools/tool-audit.js +183 -0
- package/dist/tools/truncate.d.ts +98 -0
- package/dist/tools/truncate.js +297 -0
- package/dist/tools/types.d.ts +145 -0
- package/dist/tools/types.js +24 -0
- package/dist/tools/verb-denial-argv-hint.d.ts +35 -0
- package/dist/tools/verb-denial-argv-hint.js +40 -0
- package/dist/tools/write.d.ts +9 -0
- package/dist/tools/write.js +45 -0
- package/package.json +54 -0
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared single-file lock used by `withPathFileLock` and `withQueueFileLock`.
|
|
3
|
+
*
|
|
4
|
+
* Mutual exclusion derives from the atomicity of an exclusive create: the
|
|
5
|
+
* lock is ONE file at `<target>.lock`, and the writer whose
|
|
6
|
+
* `writeFile(path, pid, { flag: "wx" })` succeeds holds the lock. There is
|
|
7
|
+
* no candidate ordering, no filesystem birthtime, and no polling race.
|
|
8
|
+
*
|
|
9
|
+
* #1731: the previous design created timestamped candidate files and polled
|
|
10
|
+
* until it saw itself as the "oldest live" candidate. That protocol is only
|
|
11
|
+
* sound if candidate order matches creation order, which it did not: the
|
|
12
|
+
* sort key was `(stat().birthtimeMs, name.localeCompare)` — on Linux CI
|
|
13
|
+
* filesystems birthtimeMs is degenerate so order collapsed to the random
|
|
14
|
+
* uuid tail of the name, and a poll whose readdir completed before a
|
|
15
|
+
* competitor's create landed missed that competitor entirely. A
|
|
16
|
+
* later-created candidate could therefore sort before the holder, also
|
|
17
|
+
* conclude it was oldest, and enter — two writers inside the critical
|
|
18
|
+
* section, one write silently lost. An exclusive create cannot exhibit any
|
|
19
|
+
* of those failure modes: exactly one create ever succeeds.
|
|
20
|
+
*
|
|
21
|
+
* Staleness (BUG-10 / BUG-25, carried over from the candidate design):
|
|
22
|
+
* - The holder's pid is stored in the lock file. A waiter that loses the
|
|
23
|
+
* create race reads the pid; a PROVEN-dead holder (ESRCH) means the lock
|
|
24
|
+
* is abandoned and may be reclaimed.
|
|
25
|
+
* - Content that cannot be parsed (e.g. the creator crashed between the
|
|
26
|
+
* create and the pid write) is respected until it ages past
|
|
27
|
+
* `EMPTY_LOCK_EVICT_AGE_MS` — a live holder writes its pid within the
|
|
28
|
+
* same tick as the create, so old unparseable content proves abandonment.
|
|
29
|
+
* Inconclusive liveness fails closed: the waiter times out and throws
|
|
30
|
+
* rather than entering over a possible holder.
|
|
31
|
+
*
|
|
32
|
+
* Reclamation is serialized by a claim file (`<target>.lock.claim`), whose
|
|
33
|
+
* exclusive create elects a single "gravedigger" at a time. This closes the
|
|
34
|
+
* steal race: without it, two waiters that both observed the dead holder
|
|
35
|
+
* could interleave (stealer A's create landing between stealer B's
|
|
36
|
+
* re-verification and its unlink), letting B unlink A's fresh live lock and
|
|
37
|
+
* enter over it. Under the claim:
|
|
38
|
+
* - Only the claim holder may unlink the lock file, so no unlink can make
|
|
39
|
+
* the path absent during another gravedigger's verify→unlink window.
|
|
40
|
+
* - The gravedigger RE-VERIFIES abandonment after winning the claim (a
|
|
41
|
+
* stale observation must not kill a lock that a new live holder has
|
|
42
|
+
* since created) and only then unlinks.
|
|
43
|
+
* - If two gravediggers ever do overlap (only possible via the claim's own
|
|
44
|
+
* age-based steal), both re-verify the same dead file, both unlink (the
|
|
45
|
+
* second gets ENOENT), and the exclusive create on the fixed lock path
|
|
46
|
+
* still admits exactly one holder.
|
|
47
|
+
* The claim itself is held for microseconds, so it is stolen purely on age
|
|
48
|
+
* (`CLAIM_MAX_AGE_MS`): a claim older than that proves its gravedigger died
|
|
49
|
+
* mid-reclaim. A crashed gravedigger otherwise pauses reclamation until
|
|
50
|
+
* then — fail closed, never fail into someone else's live lock.
|
|
51
|
+
*
|
|
52
|
+
* Release unlinks the lock file in the `finally` block. The unlink is
|
|
53
|
+
* swallowed because release runs from the same process that created the
|
|
54
|
+
* file — a double-cleanup on retry paths must not throw. A crashed holder
|
|
55
|
+
* leaks the file, which the dead-pid reclamation above reclaims.
|
|
56
|
+
*
|
|
57
|
+
* Mixed versions: the pre-#1731 design wrote `<target>.lock.<time>.<pid>.<uuid>`
|
|
58
|
+
* candidate files, which this design deliberately does NOT scan for — they
|
|
59
|
+
* are inert leftovers here (and legacy candidate files are never GC'd). An
|
|
60
|
+
* old-version process and a new-version process must therefore not run
|
|
61
|
+
* concurrently against the same target during a one-release transition
|
|
62
|
+
* window: each would see only its own lock format and could enter
|
|
63
|
+
* concurrently. This self-heals once old-version processes exit.
|
|
64
|
+
*/
|
|
65
|
+
import { readFile, stat, unlink, writeFile } from "node:fs/promises";
|
|
66
|
+
import { NaxError } from "#src/infra/index";
|
|
67
|
+
import { isProcessAlive } from "./process-alive.js";
|
|
68
|
+
const DEFAULT_RETRY_MS = 10;
|
|
69
|
+
const DEFAULT_TIMEOUT_MS = 5_000;
|
|
70
|
+
/**
|
|
71
|
+
* A live holder writes its pid within the same tick as the create, so
|
|
72
|
+
* unparseable content older than this bound proves the creator died
|
|
73
|
+
* between creating the file and writing its pid (crash, kill -9).
|
|
74
|
+
*/
|
|
75
|
+
const EMPTY_LOCK_EVICT_AGE_MS = 10_000;
|
|
76
|
+
/**
|
|
77
|
+
* A gravedigger holds the claim file for microseconds (verify + one
|
|
78
|
+
* unlink). A claim older than this bound proves its process died before
|
|
79
|
+
* releasing it. Kept comfortably above any scheduling delay a live
|
|
80
|
+
* reclaim could take.
|
|
81
|
+
*/
|
|
82
|
+
const CLAIM_MAX_AGE_MS = 10_000;
|
|
83
|
+
export const _fileLockDeps = {
|
|
84
|
+
writeFile,
|
|
85
|
+
readFile,
|
|
86
|
+
stat,
|
|
87
|
+
unlink,
|
|
88
|
+
now: () => Date.now(),
|
|
89
|
+
sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
|
|
90
|
+
isPidAlive: isProcessAlive,
|
|
91
|
+
};
|
|
92
|
+
function unlinkQuiet(path) {
|
|
93
|
+
return _fileLockDeps.unlink(path).catch(() => { });
|
|
94
|
+
}
|
|
95
|
+
async function tryCreateExclusive(path) {
|
|
96
|
+
try {
|
|
97
|
+
await _fileLockDeps.writeFile(path, `${process.pid}\n`, { flag: "wx" });
|
|
98
|
+
return true;
|
|
99
|
+
}
|
|
100
|
+
catch (error) {
|
|
101
|
+
if (error.code === "EEXIST")
|
|
102
|
+
return false;
|
|
103
|
+
// Any other create failure (EACCES, ENOSPC, …) is environmental, not
|
|
104
|
+
// contention — fail fast instead of burning the whole timeout as a
|
|
105
|
+
// misleading "Timed out".
|
|
106
|
+
throw error;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
async function readHolderPid(path) {
|
|
110
|
+
// ENOENT/EACCES on read and empty/garbage content are deliberately
|
|
111
|
+
// conflated: both mean "no judgable holder", and both fall to the
|
|
112
|
+
// age-based reclamation bound (BUG-25 conservative rule).
|
|
113
|
+
const raw = await _fileLockDeps.readFile(path, "utf8").catch(() => null);
|
|
114
|
+
if (raw === null)
|
|
115
|
+
return null;
|
|
116
|
+
const pid = Number.parseInt(raw.trim(), 10);
|
|
117
|
+
return Number.isInteger(pid) && pid > 0 ? pid : null;
|
|
118
|
+
}
|
|
119
|
+
async function ageMs(path) {
|
|
120
|
+
const stats = await _fileLockDeps.stat(path).catch(() => null);
|
|
121
|
+
return stats ? _fileLockDeps.now() - stats.mtimeMs : null;
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* Judge whether the lock at `path` is provably abandoned: its holder pid is
|
|
125
|
+
* dead, or its content is unparseable and older than the empty-lock bound.
|
|
126
|
+
* Returns the observed pid (null when unparseable) so the reclamation step
|
|
127
|
+
* can detect a changed file before unlinking.
|
|
128
|
+
*/
|
|
129
|
+
async function judgeAbandoned(path) {
|
|
130
|
+
const pid = await readHolderPid(path);
|
|
131
|
+
if (pid !== null)
|
|
132
|
+
return { abandoned: !_fileLockDeps.isPidAlive(pid), observedPid: pid };
|
|
133
|
+
const age = await ageMs(path);
|
|
134
|
+
return { abandoned: age !== null && age >= EMPTY_LOCK_EVICT_AGE_MS, observedPid: null };
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* Elect one gravedigger via the claim file's exclusive create. A fresh
|
|
138
|
+
* claim held by someone else means reclamation is already in progress —
|
|
139
|
+
* back off. An aged claim proves its gravedigger died mid-reclaim and is
|
|
140
|
+
* stolen (age-based, so no content check is needed: a live gravedigger
|
|
141
|
+
* releases within microseconds).
|
|
142
|
+
*/
|
|
143
|
+
async function tryClaim(claimPath) {
|
|
144
|
+
if (await tryCreateExclusive(claimPath))
|
|
145
|
+
return true;
|
|
146
|
+
const age = await ageMs(claimPath);
|
|
147
|
+
if (age === null || age < CLAIM_MAX_AGE_MS)
|
|
148
|
+
return false;
|
|
149
|
+
await unlinkQuiet(claimPath);
|
|
150
|
+
return tryCreateExclusive(claimPath);
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* Reclaim `path` if it is STILL the abandoned lock that was observed — the
|
|
154
|
+
* observation can be stale (a new live holder may have been created since,
|
|
155
|
+
* or another gravedigger may have already removed the file). Runs under
|
|
156
|
+
* the claim, so no concurrent unlink can make the path flip between absent
|
|
157
|
+
* and present during this verification.
|
|
158
|
+
*/
|
|
159
|
+
async function reclaimIfStillAbandoned(path, observedPid) {
|
|
160
|
+
const pid = await readHolderPid(path);
|
|
161
|
+
if (pid !== null) {
|
|
162
|
+
if (pid === observedPid && !_fileLockDeps.isPidAlive(pid))
|
|
163
|
+
await unlinkQuiet(path);
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
if (observedPid !== null)
|
|
167
|
+
return;
|
|
168
|
+
const age = await ageMs(path);
|
|
169
|
+
if (age !== null && age >= EMPTY_LOCK_EVICT_AGE_MS)
|
|
170
|
+
await unlinkQuiet(path);
|
|
171
|
+
}
|
|
172
|
+
async function acquire(lockPath, options) {
|
|
173
|
+
const retryMs = options.retryMs ?? DEFAULT_RETRY_MS;
|
|
174
|
+
const deadline = _fileLockDeps.now() + (options.timeoutMs ?? DEFAULT_TIMEOUT_MS);
|
|
175
|
+
const claimPath = `${lockPath}.claim`;
|
|
176
|
+
for (;;) {
|
|
177
|
+
if (await tryCreateExclusive(lockPath)) {
|
|
178
|
+
return () => unlinkQuiet(lockPath);
|
|
179
|
+
}
|
|
180
|
+
if (_fileLockDeps.now() >= deadline)
|
|
181
|
+
break;
|
|
182
|
+
const { abandoned, observedPid } = await judgeAbandoned(lockPath);
|
|
183
|
+
if (abandoned && (await tryClaim(claimPath))) {
|
|
184
|
+
try {
|
|
185
|
+
await reclaimIfStillAbandoned(lockPath, observedPid);
|
|
186
|
+
}
|
|
187
|
+
finally {
|
|
188
|
+
await unlinkQuiet(claimPath);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
await _fileLockDeps.sleep(retryMs);
|
|
192
|
+
}
|
|
193
|
+
throw new NaxError(`${options.errorPrefix} Timed out acquiring ${options.lockName} lock: ${lockPath}`, "FILE_LOCK_TIMEOUT", {
|
|
194
|
+
stage: "file-lock",
|
|
195
|
+
lockName: options.lockName,
|
|
196
|
+
lockPath,
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
/**
|
|
200
|
+
* Serialize `operation` against any other process holding the lock at
|
|
201
|
+
* `lockPath`. See the module doc for the acquisition, staleness, and
|
|
202
|
+
* release semantics.
|
|
203
|
+
*/
|
|
204
|
+
export async function withFileLock(lockPath, operation, options) {
|
|
205
|
+
const release = await acquire(lockPath, options);
|
|
206
|
+
try {
|
|
207
|
+
return await operation();
|
|
208
|
+
}
|
|
209
|
+
finally {
|
|
210
|
+
await release();
|
|
211
|
+
}
|
|
212
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { statSync } from "node:fs";
|
|
2
|
+
/** Preserve missing-file size probes without suppressing other I/O errors. */
|
|
3
|
+
export function fileSizeOrZero(path) {
|
|
4
|
+
try {
|
|
5
|
+
return statSync(path).size;
|
|
6
|
+
}
|
|
7
|
+
catch (error) {
|
|
8
|
+
if (error instanceof Error && "code" in error && error.code === "ENOENT")
|
|
9
|
+
return 0;
|
|
10
|
+
throw error;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `git add` that never makes git recurse into a gitlink (#2210).
|
|
3
|
+
*
|
|
4
|
+
* For every gitlink (submodule entry) in the index, `git add` runs
|
|
5
|
+
* `git status` inside the nested repository to learn whether it is dirty,
|
|
6
|
+
* and that child git reads the nested repo's own `.git/config` and
|
|
7
|
+
* `.gitattributes`. A nested repo the agent created inside the sandbox write
|
|
8
|
+
* root can name a filter driver there (`filter.<x>.clean`), which nax's
|
|
9
|
+
* unsandboxed `git add` would then run. `add` ignores `diff.ignoreSubmodules`
|
|
10
|
+
* (it overrides submodule config), so the env hardening in `./git-env` cannot
|
|
11
|
+
* reach it. Instead, gitlinks are excluded from the `add` pathspec and
|
|
12
|
+
* restaged with `git update-index`, which records the nested HEAD without
|
|
13
|
+
* spawning git inside it. The staged result is the same as a plain `git add`.
|
|
14
|
+
*
|
|
15
|
+
* `git commit` with nothing staged prints a status, and that status honours a
|
|
16
|
+
* `submodule.<name>.ignore` from `.gitmodules` -- so it can recurse too. The
|
|
17
|
+
* commit sites therefore ask `hasStagedChanges` first and never run a commit
|
|
18
|
+
* that would only report "nothing to commit".
|
|
19
|
+
*/
|
|
20
|
+
export interface GitRunResult {
|
|
21
|
+
stdout: string;
|
|
22
|
+
stderr: string;
|
|
23
|
+
exitCode: number;
|
|
24
|
+
timedOut?: boolean;
|
|
25
|
+
}
|
|
26
|
+
/** `gitWithTimeout`'s shape; injected so this module does not import `./git` (which imports it). */
|
|
27
|
+
export type GitRunner = (args: string[], cwd: string, timeoutMs?: number) => Promise<GitRunResult>;
|
|
28
|
+
export interface GitlinkSafeAddOptions {
|
|
29
|
+
/** Flags placed before `--`, e.g. `["-A"]`. */
|
|
30
|
+
readonly flags?: readonly string[];
|
|
31
|
+
/** Pathspecs to stage; empty means the whole tree (`:/`), as a bare `git add -A` does. */
|
|
32
|
+
readonly pathspecs?: readonly string[];
|
|
33
|
+
readonly timeoutMs?: number;
|
|
34
|
+
}
|
|
35
|
+
/** Gitlink paths from `git ls-files --stage -z` output (paths as git printed them: relative to its cwd). */
|
|
36
|
+
export declare function parseGitlinks(lsFilesStageZ: string): string[];
|
|
37
|
+
export declare function gitlinkSafeAdd(git: GitRunner, cwd: string, opts: GitlinkSafeAddOptions): Promise<GitRunResult>;
|
|
38
|
+
/**
|
|
39
|
+
* Whether the index differs from HEAD: `diff --cached` compares index to HEAD
|
|
40
|
+
* only, so it never looks at the worktree or inside a gitlink. Undefined when
|
|
41
|
+
* git could not answer (fail closed: callers do not commit).
|
|
42
|
+
*/
|
|
43
|
+
export declare function hasStagedChanges(git: GitRunner, cwd: string, timeoutMs?: number): Promise<boolean | undefined>;
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `git add` that never makes git recurse into a gitlink (#2210).
|
|
3
|
+
*
|
|
4
|
+
* For every gitlink (submodule entry) in the index, `git add` runs
|
|
5
|
+
* `git status` inside the nested repository to learn whether it is dirty,
|
|
6
|
+
* and that child git reads the nested repo's own `.git/config` and
|
|
7
|
+
* `.gitattributes`. A nested repo the agent created inside the sandbox write
|
|
8
|
+
* root can name a filter driver there (`filter.<x>.clean`), which nax's
|
|
9
|
+
* unsandboxed `git add` would then run. `add` ignores `diff.ignoreSubmodules`
|
|
10
|
+
* (it overrides submodule config), so the env hardening in `./git-env` cannot
|
|
11
|
+
* reach it. Instead, gitlinks are excluded from the `add` pathspec and
|
|
12
|
+
* restaged with `git update-index`, which records the nested HEAD without
|
|
13
|
+
* spawning git inside it. The staged result is the same as a plain `git add`.
|
|
14
|
+
*
|
|
15
|
+
* `git commit` with nothing staged prints a status, and that status honours a
|
|
16
|
+
* `submodule.<name>.ignore` from `.gitmodules` -- so it can recurse too. The
|
|
17
|
+
* commit sites therefore ask `hasStagedChanges` first and never run a commit
|
|
18
|
+
* that would only report "nothing to commit".
|
|
19
|
+
*/
|
|
20
|
+
const GITLINK_MODE = "160000";
|
|
21
|
+
const WHOLE_TREE_PATHSPEC = ":/";
|
|
22
|
+
/** Gitlink paths from `git ls-files --stage -z` output (paths as git printed them: relative to its cwd). */
|
|
23
|
+
export function parseGitlinks(lsFilesStageZ) {
|
|
24
|
+
const paths = lsFilesStageZ.split("\0").flatMap((record) => {
|
|
25
|
+
const tab = record.indexOf("\t");
|
|
26
|
+
if (tab < 0 || !record.startsWith(`${GITLINK_MODE} `))
|
|
27
|
+
return [];
|
|
28
|
+
return [record.slice(tab + 1)];
|
|
29
|
+
});
|
|
30
|
+
// An unmerged gitlink has one entry per stage.
|
|
31
|
+
return [...new Set(paths)];
|
|
32
|
+
}
|
|
33
|
+
export async function gitlinkSafeAdd(git, cwd, opts) {
|
|
34
|
+
const pathspecs = opts.pathspecs !== undefined && opts.pathspecs.length > 0 ? opts.pathspecs : [WHOLE_TREE_PATHSPEC];
|
|
35
|
+
const listed = await git(["ls-files", "--stage", "-z", "--", ...pathspecs], cwd, opts.timeoutMs);
|
|
36
|
+
if (listed.exitCode !== 0 || listed.timedOut) {
|
|
37
|
+
// Fail closed: staging without knowing the gitlinks would recurse into them.
|
|
38
|
+
return { ...listed, exitCode: listed.exitCode === 0 ? 1 : listed.exitCode };
|
|
39
|
+
}
|
|
40
|
+
const gitlinks = parseGitlinks(listed.stdout);
|
|
41
|
+
const excludes = gitlinks.map((p) => `:(exclude,literal)${p}`);
|
|
42
|
+
const added = await git(["add", ...(opts.flags ?? []), "--", ...pathspecs, ...excludes], cwd, opts.timeoutMs);
|
|
43
|
+
if (added.exitCode !== 0 || gitlinks.length === 0)
|
|
44
|
+
return added;
|
|
45
|
+
return git(["update-index", "--add", "--remove", "--", ...gitlinks], cwd, opts.timeoutMs);
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Whether the index differs from HEAD: `diff --cached` compares index to HEAD
|
|
49
|
+
* only, so it never looks at the worktree or inside a gitlink. Undefined when
|
|
50
|
+
* git could not answer (fail closed: callers do not commit).
|
|
51
|
+
*/
|
|
52
|
+
export async function hasStagedChanges(git, cwd, timeoutMs) {
|
|
53
|
+
const r = await git(["diff", "--cached", "--quiet"], cwd, timeoutMs);
|
|
54
|
+
if (r.timedOut)
|
|
55
|
+
return undefined;
|
|
56
|
+
if (r.exitCode === 0)
|
|
57
|
+
return false;
|
|
58
|
+
return r.exitCode === 1 ? true : undefined;
|
|
59
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Config overrides every nax-spawned git carries (#2198, defence in depth).
|
|
3
|
+
*
|
|
4
|
+
* `core.fsmonitor` names a program git runs on every status/diff/add; were an
|
|
5
|
+
* agent ever to get a value into a config nax's git reads (a redirected
|
|
6
|
+
* commondir, a nested repo git recurses into), nax would run it unsandboxed.
|
|
7
|
+
* nax never needs an fsmonitor, so it is always off.
|
|
8
|
+
*
|
|
9
|
+
* Submodules (#2210): for every gitlink, `git status` / `diff` / `commit`
|
|
10
|
+
* run `git status` inside the nested repo to learn whether it is dirty, and
|
|
11
|
+
* that child git reads the nested repo's own config and `.gitattributes` --
|
|
12
|
+
* files an agent can write when it creates the repo inside its write root.
|
|
13
|
+
* A filter driver (`filter.<x>.clean`) named there would run unsandboxed, and
|
|
14
|
+
* unlike fsmonitor it cannot be switched off by name. `diff.ignoreSubmodules
|
|
15
|
+
* =dirty` skips that dirty check (a moved gitlink HEAD is still reported,
|
|
16
|
+
* without spawning git in it), and `submodule.recurse=false` keeps checkout /
|
|
17
|
+
* merge from recursing. Config is only a default, though: a
|
|
18
|
+
* `submodule.<name>.ignore` in the agent-writable `.gitmodules` overrides it,
|
|
19
|
+
* so status / diff also get `--ignore-submodules=dirty` on the command line
|
|
20
|
+
* (`hardenedGitArgv`), the one form that beats `.gitmodules`. `git add`
|
|
21
|
+
* ignores both, so nax stages through `gitlinkSafeAdd` (`./git-add`), which
|
|
22
|
+
* also refuses to run a commit that would only print status.
|
|
23
|
+
*
|
|
24
|
+
* Passed through GIT_CONFIG_COUNT/KEY/VALUE (git >= 2.31; older git ignores
|
|
25
|
+
* them) rather than `-c` so argv stays unchanged, and because git forwards
|
|
26
|
+
* these to the child git processes it spawns (submodules) as well.
|
|
27
|
+
*
|
|
28
|
+
* Coverage: `gitWithTimeout` applies it, and every other site in `src/` that
|
|
29
|
+
* spawns git itself passes `env: gitSpawnEnv(...)`. The generic forge and
|
|
30
|
+
* auto-pr runners harden every command they spawn, since `gh` / `glab` run git
|
|
31
|
+
* underneath. `packages/repo-tooling/scripts/check-git-spawn-env.ts` (in `lint:checks`) fails on a
|
|
32
|
+
* `["git", ...]` argv literal that is neither inside a spawn call carrying one
|
|
33
|
+
* of these helpers nor marked `// nax-git-env-allow: <reason>` (argv handed to
|
|
34
|
+
* a runner that hardens it itself). An argv held in a variable and spawned
|
|
35
|
+
* elsewhere is beyond a textual gate; keep git argv literal at the spawn.
|
|
36
|
+
*/
|
|
37
|
+
/**
|
|
38
|
+
* `base` plus the hardened entries, appended after any GIT_CONFIG_COUNT
|
|
39
|
+
* entries the caller's environment already carries. A malformed existing
|
|
40
|
+
* count is left alone: git rejects it either way, and rewriting it would
|
|
41
|
+
* change which of the caller's entries apply.
|
|
42
|
+
*/
|
|
43
|
+
export declare function hardenedGitEnv(base: Readonly<Record<string, string | undefined>>): Record<string, string | undefined>;
|
|
44
|
+
/**
|
|
45
|
+
* The environment for a git nax spawns directly: `process.env`, then the
|
|
46
|
+
* caller's `overlay` (e.g. GIT_INDEX_FILE), then the hardened entries. Pass
|
|
47
|
+
* the overlay here rather than spreading the result, so a GIT_CONFIG_COUNT
|
|
48
|
+
* the overlay sets is appended to instead of overwritten.
|
|
49
|
+
*/
|
|
50
|
+
export declare function gitSpawnEnv(overlay?: Readonly<Record<string, string | undefined>>): Record<string, string | undefined>;
|
|
51
|
+
export declare const IGNORE_DIRTY_SUBMODULES_FLAG = "--ignore-submodules=dirty";
|
|
52
|
+
/**
|
|
53
|
+
* `argv` (argv[0] is the git program) with `--ignore-submodules=dirty` placed
|
|
54
|
+
* right after a `status` / `diff` subcommand. Unlike the config key, the flag
|
|
55
|
+
* overrides a `submodule.<name>.ignore` from `.gitmodules`. A later explicit
|
|
56
|
+
* `--ignore-submodules` in `argv` still wins (git takes the last), which the
|
|
57
|
+
* agent-facing git tool cannot supply: it refuses every `-`-leading element.
|
|
58
|
+
*/
|
|
59
|
+
export declare function hardenedGitArgv(argv: readonly string[]): string[];
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Config overrides every nax-spawned git carries (#2198, defence in depth).
|
|
3
|
+
*
|
|
4
|
+
* `core.fsmonitor` names a program git runs on every status/diff/add; were an
|
|
5
|
+
* agent ever to get a value into a config nax's git reads (a redirected
|
|
6
|
+
* commondir, a nested repo git recurses into), nax would run it unsandboxed.
|
|
7
|
+
* nax never needs an fsmonitor, so it is always off.
|
|
8
|
+
*
|
|
9
|
+
* Submodules (#2210): for every gitlink, `git status` / `diff` / `commit`
|
|
10
|
+
* run `git status` inside the nested repo to learn whether it is dirty, and
|
|
11
|
+
* that child git reads the nested repo's own config and `.gitattributes` --
|
|
12
|
+
* files an agent can write when it creates the repo inside its write root.
|
|
13
|
+
* A filter driver (`filter.<x>.clean`) named there would run unsandboxed, and
|
|
14
|
+
* unlike fsmonitor it cannot be switched off by name. `diff.ignoreSubmodules
|
|
15
|
+
* =dirty` skips that dirty check (a moved gitlink HEAD is still reported,
|
|
16
|
+
* without spawning git in it), and `submodule.recurse=false` keeps checkout /
|
|
17
|
+
* merge from recursing. Config is only a default, though: a
|
|
18
|
+
* `submodule.<name>.ignore` in the agent-writable `.gitmodules` overrides it,
|
|
19
|
+
* so status / diff also get `--ignore-submodules=dirty` on the command line
|
|
20
|
+
* (`hardenedGitArgv`), the one form that beats `.gitmodules`. `git add`
|
|
21
|
+
* ignores both, so nax stages through `gitlinkSafeAdd` (`./git-add`), which
|
|
22
|
+
* also refuses to run a commit that would only print status.
|
|
23
|
+
*
|
|
24
|
+
* Passed through GIT_CONFIG_COUNT/KEY/VALUE (git >= 2.31; older git ignores
|
|
25
|
+
* them) rather than `-c` so argv stays unchanged, and because git forwards
|
|
26
|
+
* these to the child git processes it spawns (submodules) as well.
|
|
27
|
+
*
|
|
28
|
+
* Coverage: `gitWithTimeout` applies it, and every other site in `src/` that
|
|
29
|
+
* spawns git itself passes `env: gitSpawnEnv(...)`. The generic forge and
|
|
30
|
+
* auto-pr runners harden every command they spawn, since `gh` / `glab` run git
|
|
31
|
+
* underneath. `packages/repo-tooling/scripts/check-git-spawn-env.ts` (in `lint:checks`) fails on a
|
|
32
|
+
* `["git", ...]` argv literal that is neither inside a spawn call carrying one
|
|
33
|
+
* of these helpers nor marked `// nax-git-env-allow: <reason>` (argv handed to
|
|
34
|
+
* a runner that hardens it itself). An argv held in a variable and spawned
|
|
35
|
+
* elsewhere is beyond a textual gate; keep git argv literal at the spawn.
|
|
36
|
+
*/
|
|
37
|
+
const HARDENED_GIT_CONFIG = [
|
|
38
|
+
["core.fsmonitor", "false"],
|
|
39
|
+
["diff.ignoreSubmodules", "dirty"],
|
|
40
|
+
["submodule.recurse", "false"],
|
|
41
|
+
];
|
|
42
|
+
const NON_NEGATIVE_INT = /^\d+$/;
|
|
43
|
+
/**
|
|
44
|
+
* `base` plus the hardened entries, appended after any GIT_CONFIG_COUNT
|
|
45
|
+
* entries the caller's environment already carries. A malformed existing
|
|
46
|
+
* count is left alone: git rejects it either way, and rewriting it would
|
|
47
|
+
* change which of the caller's entries apply.
|
|
48
|
+
*/
|
|
49
|
+
export function hardenedGitEnv(base) {
|
|
50
|
+
const existing = base.GIT_CONFIG_COUNT;
|
|
51
|
+
if (existing !== undefined && existing !== "" && !NON_NEGATIVE_INT.test(existing))
|
|
52
|
+
return { ...base };
|
|
53
|
+
const start = existing === undefined || existing === "" ? 0 : Number.parseInt(existing, 10);
|
|
54
|
+
const env = { ...base };
|
|
55
|
+
HARDENED_GIT_CONFIG.forEach(([key, value], i) => {
|
|
56
|
+
env[`GIT_CONFIG_KEY_${start + i}`] = key;
|
|
57
|
+
env[`GIT_CONFIG_VALUE_${start + i}`] = value;
|
|
58
|
+
});
|
|
59
|
+
env.GIT_CONFIG_COUNT = String(start + HARDENED_GIT_CONFIG.length);
|
|
60
|
+
return env;
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* The environment for a git nax spawns directly: `process.env`, then the
|
|
64
|
+
* caller's `overlay` (e.g. GIT_INDEX_FILE), then the hardened entries. Pass
|
|
65
|
+
* the overlay here rather than spreading the result, so a GIT_CONFIG_COUNT
|
|
66
|
+
* the overlay sets is appended to instead of overwritten.
|
|
67
|
+
*/
|
|
68
|
+
export function gitSpawnEnv(overlay) {
|
|
69
|
+
return hardenedGitEnv(overlay === undefined ? process.env : { ...process.env, ...overlay });
|
|
70
|
+
}
|
|
71
|
+
/** Subcommands that dirty-check gitlinks, and so run git inside them unless told not to (#2210). */
|
|
72
|
+
const SUBMODULE_DIRTY_CHECK_VERBS = new Set(["status", "diff"]);
|
|
73
|
+
/** Global options that consume the next argument (`-C <dir>`, `-c <k=v>`). */
|
|
74
|
+
const GLOBAL_OPTIONS_WITH_VALUE = new Set(["-C", "-c"]);
|
|
75
|
+
export const IGNORE_DIRTY_SUBMODULES_FLAG = "--ignore-submodules=dirty";
|
|
76
|
+
/**
|
|
77
|
+
* `argv` (argv[0] is the git program) with `--ignore-submodules=dirty` placed
|
|
78
|
+
* right after a `status` / `diff` subcommand. Unlike the config key, the flag
|
|
79
|
+
* overrides a `submodule.<name>.ignore` from `.gitmodules`. A later explicit
|
|
80
|
+
* `--ignore-submodules` in `argv` still wins (git takes the last), which the
|
|
81
|
+
* agent-facing git tool cannot supply: it refuses every `-`-leading element.
|
|
82
|
+
*/
|
|
83
|
+
export function hardenedGitArgv(argv) {
|
|
84
|
+
const out = [...argv];
|
|
85
|
+
let i = 1;
|
|
86
|
+
while (i < out.length && out[i].startsWith("-")) {
|
|
87
|
+
i += GLOBAL_OPTIONS_WITH_VALUE.has(out[i]) ? 2 : 1;
|
|
88
|
+
}
|
|
89
|
+
const verb = out[i];
|
|
90
|
+
if (verb !== undefined && SUBMODULE_DIRTY_CHECK_VERBS.has(verb))
|
|
91
|
+
out.splice(i + 1, 0, IGNORE_DIRTY_SUBMODULES_FLAG);
|
|
92
|
+
return out;
|
|
93
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Generic git subprocess execution, shared by nax and the move set. `_gitDeps`
|
|
3
|
+
* is defined here once; `utils/git.ts` re-exports the same object (spec section
|
|
4
|
+
* 4.3), so a test patch through either path affects both halves.
|
|
5
|
+
*/
|
|
6
|
+
import { getSafeLogger } from "#src/infra/index";
|
|
7
|
+
/**
|
|
8
|
+
* Default timeout for git subprocess calls.
|
|
9
|
+
* Prevents git from hanging indefinitely on locked repos or network mounts.
|
|
10
|
+
*/
|
|
11
|
+
export declare const GIT_TIMEOUT_MS = 10000;
|
|
12
|
+
/**
|
|
13
|
+
* Injectable dependencies for git subprocess calls — allows tests to intercept
|
|
14
|
+
* Bun.spawn without mock.module().
|
|
15
|
+
*
|
|
16
|
+
* `gitTimeoutMs` and `timeoutRetryGitTimeoutMs` are injectable so the hang-path
|
|
17
|
+
* tests can assert the SIGKILL contract without burning the full production
|
|
18
|
+
* timeout in wall-clock.
|
|
19
|
+
*
|
|
20
|
+
* @internal
|
|
21
|
+
*/
|
|
22
|
+
export declare const _gitDeps: {
|
|
23
|
+
spawn: (cmd: readonly string[], opts: import("#src/runtime/index").AgentSpawnOptions) => import("#src/runtime/index").AgentSpawnResult;
|
|
24
|
+
getSafeLogger: typeof getSafeLogger;
|
|
25
|
+
gitTimeoutMs: number;
|
|
26
|
+
timeoutRetryGitTimeoutMs: number;
|
|
27
|
+
};
|
|
28
|
+
/**
|
|
29
|
+
* Return the absolute path of the git repository root for the given workdir.
|
|
30
|
+
* Returns null if workdir is not inside a git repo or the command fails.
|
|
31
|
+
*/
|
|
32
|
+
export declare function getGitRoot(workdir: string): Promise<string | null>;
|
|
33
|
+
/**
|
|
34
|
+
* Spawn a git command with a hard timeout.
|
|
35
|
+
*
|
|
36
|
+
* Kills the process with SIGKILL after GIT_TIMEOUT_MS if it hasn't exited.
|
|
37
|
+
* Returns empty stdout and exit code 1 on timeout.
|
|
38
|
+
*
|
|
39
|
+
* `timedOut` is ADDITIVE and OMITTED on every non-timeout return -- every
|
|
40
|
+
* existing caller that destructures `{ stdout, stderr, exitCode }` or asserts
|
|
41
|
+
* on that exact shape is unaffected. It exists because `exitCode === 1` is
|
|
42
|
+
* ambiguous on its own: a timeout collapses to exit code 1 (see below), which
|
|
43
|
+
* is indistinguishable from a real "git said no" exit 1 unless a caller can
|
|
44
|
+
* also see `timedOut`. A caller that treats "not exit 0" as a real, trustworthy
|
|
45
|
+
* answer (e.g. `git check-ignore`'s "not ignored") must check `timedOut` first
|
|
46
|
+
* -- see `partitionNaxOwnedPaths` in `src/tools/git-commit.ts`.
|
|
47
|
+
*
|
|
48
|
+
* @internal
|
|
49
|
+
*/
|
|
50
|
+
export declare function gitWithTimeout(args: string[], workdir: string, timeoutMs?: number, maxBytes?: number,
|
|
51
|
+
/** Full argv INCLUDING argv[0]. Callers that must control argv[0] pass this;
|
|
52
|
+
* everyone else gets ["git", ...args] as before. */
|
|
53
|
+
argvOverride?: readonly string[]): Promise<{
|
|
54
|
+
stdout: string;
|
|
55
|
+
stderr: string;
|
|
56
|
+
exitCode: number;
|
|
57
|
+
timedOut?: boolean;
|
|
58
|
+
}>;
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Generic git subprocess execution, shared by nax and the move set. `_gitDeps`
|
|
3
|
+
* is defined here once; `utils/git.ts` re-exports the same object (spec section
|
|
4
|
+
* 4.3), so a test patch through either path affects both halves.
|
|
5
|
+
*/
|
|
6
|
+
import { getSafeLogger } from "#src/infra/index";
|
|
7
|
+
import { runtimeSpawn } from "#src/runtime/index";
|
|
8
|
+
import { drainBounded } from "./bounded-io.js";
|
|
9
|
+
import { hardenedGitArgv, hardenedGitEnv } from "./git-env.js";
|
|
10
|
+
/**
|
|
11
|
+
* Default timeout for git subprocess calls.
|
|
12
|
+
* Prevents git from hanging indefinitely on locked repos or network mounts.
|
|
13
|
+
*/
|
|
14
|
+
export const GIT_TIMEOUT_MS = 10_000;
|
|
15
|
+
/**
|
|
16
|
+
* Timeout for the git subprocesses captureWorkingTreeChanges spawns during
|
|
17
|
+
* timeout-retry recovery. Scoped separately from GIT_TIMEOUT_MS so it doesn't
|
|
18
|
+
* shrink the timeout for unrelated callers (captureOutputFiles, findMergeBase,
|
|
19
|
+
* etc.) — a hung git here must not stall the already-timed-out agent turn.
|
|
20
|
+
*/
|
|
21
|
+
const TIMEOUT_RETRY_GIT_TIMEOUT_MS = 3_000;
|
|
22
|
+
/**
|
|
23
|
+
* Injectable dependencies for git subprocess calls — allows tests to intercept
|
|
24
|
+
* Bun.spawn without mock.module().
|
|
25
|
+
*
|
|
26
|
+
* `gitTimeoutMs` and `timeoutRetryGitTimeoutMs` are injectable so the hang-path
|
|
27
|
+
* tests can assert the SIGKILL contract without burning the full production
|
|
28
|
+
* timeout in wall-clock.
|
|
29
|
+
*
|
|
30
|
+
* @internal
|
|
31
|
+
*/
|
|
32
|
+
export const _gitDeps = {
|
|
33
|
+
spawn: runtimeSpawn,
|
|
34
|
+
getSafeLogger,
|
|
35
|
+
gitTimeoutMs: GIT_TIMEOUT_MS,
|
|
36
|
+
timeoutRetryGitTimeoutMs: TIMEOUT_RETRY_GIT_TIMEOUT_MS,
|
|
37
|
+
};
|
|
38
|
+
/**
|
|
39
|
+
* Return the absolute path of the git repository root for the given workdir.
|
|
40
|
+
* Returns null if workdir is not inside a git repo or the command fails.
|
|
41
|
+
*/
|
|
42
|
+
export async function getGitRoot(workdir) {
|
|
43
|
+
try {
|
|
44
|
+
const { stdout, exitCode } = await gitWithTimeout(["rev-parse", "--show-toplevel"], workdir);
|
|
45
|
+
if (exitCode !== 0)
|
|
46
|
+
return null;
|
|
47
|
+
const trimmed = stdout.trim();
|
|
48
|
+
return trimmed || null;
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Spawn a git command with a hard timeout.
|
|
56
|
+
*
|
|
57
|
+
* Kills the process with SIGKILL after GIT_TIMEOUT_MS if it hasn't exited.
|
|
58
|
+
* Returns empty stdout and exit code 1 on timeout.
|
|
59
|
+
*
|
|
60
|
+
* `timedOut` is ADDITIVE and OMITTED on every non-timeout return -- every
|
|
61
|
+
* existing caller that destructures `{ stdout, stderr, exitCode }` or asserts
|
|
62
|
+
* on that exact shape is unaffected. It exists because `exitCode === 1` is
|
|
63
|
+
* ambiguous on its own: a timeout collapses to exit code 1 (see below), which
|
|
64
|
+
* is indistinguishable from a real "git said no" exit 1 unless a caller can
|
|
65
|
+
* also see `timedOut`. A caller that treats "not exit 0" as a real, trustworthy
|
|
66
|
+
* answer (e.g. `git check-ignore`'s "not ignored") must check `timedOut` first
|
|
67
|
+
* -- see `partitionNaxOwnedPaths` in `src/tools/git-commit.ts`.
|
|
68
|
+
*
|
|
69
|
+
* @internal
|
|
70
|
+
*/
|
|
71
|
+
export async function gitWithTimeout(args, workdir, timeoutMs = _gitDeps.gitTimeoutMs, maxBytes,
|
|
72
|
+
/** Full argv INCLUDING argv[0]. Callers that must control argv[0] pass this;
|
|
73
|
+
* everyone else gets ["git", ...args] as before. */
|
|
74
|
+
argvOverride) {
|
|
75
|
+
const proc = _gitDeps.spawn(hardenedGitArgv(argvOverride ?? ["git", ...args]), {
|
|
76
|
+
cwd: workdir,
|
|
77
|
+
env: hardenedGitEnv(process.env),
|
|
78
|
+
stdout: "pipe",
|
|
79
|
+
stderr: "pipe",
|
|
80
|
+
});
|
|
81
|
+
let timedOut = false;
|
|
82
|
+
const timerId = setTimeout(() => {
|
|
83
|
+
timedOut = true;
|
|
84
|
+
try {
|
|
85
|
+
proc.kill("SIGKILL");
|
|
86
|
+
}
|
|
87
|
+
catch {
|
|
88
|
+
// Process may have already exited
|
|
89
|
+
}
|
|
90
|
+
}, timeoutMs);
|
|
91
|
+
// Drain stdout/stderr concurrently with awaiting exit — a process that fills
|
|
92
|
+
// either pipe's OS buffer (>64KB) before being read would otherwise block on
|
|
93
|
+
// the write and never reach `exited`, defeating the timeout's own SIGKILL.
|
|
94
|
+
// `.catch()` is attached eagerly: on the timeout path below we return without
|
|
95
|
+
// awaiting these, and an unawaited rejection (a SIGKILLed process can error its
|
|
96
|
+
// pipes) would surface as an unhandled rejection and take the process down.
|
|
97
|
+
// `maxBytes` bounds the WORK, not just the answer: the timeout caps wall
|
|
98
|
+
// clock, so without it `git log -p` on a large repository can accumulate for
|
|
99
|
+
// as long as the timeout allows. Optional, and unbounded when absent, so the
|
|
100
|
+
// callers that read a single ref keep exactly their current behaviour.
|
|
101
|
+
const drain = (stream) => (maxBytes === undefined ? new Response(stream).text() : drainBounded(stream, maxBytes)).catch(() => "");
|
|
102
|
+
const stdoutPromise = drain(proc.stdout);
|
|
103
|
+
const stderrPromise = drain(proc.stderr);
|
|
104
|
+
const exitCode = await proc.exited;
|
|
105
|
+
clearTimeout(timerId);
|
|
106
|
+
if (timedOut) {
|
|
107
|
+
// Don't await the drain promises here — a SIGKILL'd process's pipes may
|
|
108
|
+
// never close in test mocks (and are irrelevant either way since the
|
|
109
|
+
// output is discarded), so awaiting them could re-introduce a hang on
|
|
110
|
+
// the very path this timeout exists to bound.
|
|
111
|
+
return { stdout: "", stderr: "", exitCode: 1, timedOut: true };
|
|
112
|
+
}
|
|
113
|
+
const [stdout, stderr] = await Promise.all([stdoutPromise, stderrPromise]);
|
|
114
|
+
return { stdout, stderr, exitCode };
|
|
115
|
+
}
|