terramend 0.2.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/LICENSE +661 -0
- package/README.md +145 -0
- package/dist/agents/claude.d.ts +73 -0
- package/dist/agents/claudePretoolGate.d.ts +99 -0
- package/dist/agents/gateServer.d.ts +7 -0
- package/dist/agents/index.d.ts +6 -0
- package/dist/agents/nativeFsDenies.d.ts +28 -0
- package/dist/agents/opencode.d.ts +231 -0
- package/dist/agents/opencodePlugin.d.ts +85 -0
- package/dist/agents/opencodeShared.d.ts +40 -0
- package/dist/agents/postRun.d.ts +132 -0
- package/dist/agents/reviewer.d.ts +38 -0
- package/dist/agents/sessionLabeler.d.ts +97 -0
- package/dist/agents/shared.d.ts +189 -0
- package/dist/agents/subagentModels.d.ts +19 -0
- package/dist/agents/subagentToolGates.d.ts +55 -0
- package/dist/cli.mjs +197426 -0
- package/dist/external.d.ts +227 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +196783 -0
- package/dist/internal/index.d.ts +18 -0
- package/dist/internal.js +1714 -0
- package/dist/lifecycle.d.ts +2 -0
- package/dist/main.d.ts +8 -0
- package/dist/mcp/arkConfig.d.ts +1 -0
- package/dist/mcp/checkSuite.d.ts +25 -0
- package/dist/mcp/checkout.d.ts +77 -0
- package/dist/mcp/comment.d.ts +119 -0
- package/dist/mcp/commitInfo.d.ts +9 -0
- package/dist/mcp/crosswalk.d.ts +105 -0
- package/dist/mcp/dependencies.d.ts +8 -0
- package/dist/mcp/geminiSanitizer.d.ts +28 -0
- package/dist/mcp/git.d.ts +46 -0
- package/dist/mcp/guardrails.d.ts +104 -0
- package/dist/mcp/issue.d.ts +18 -0
- package/dist/mcp/issueComments.d.ts +9 -0
- package/dist/mcp/issueEvents.d.ts +9 -0
- package/dist/mcp/issueInfo.d.ts +9 -0
- package/dist/mcp/labels.d.ts +12 -0
- package/dist/mcp/localContext.d.ts +19 -0
- package/dist/mcp/moduleExtraction.d.ts +71 -0
- package/dist/mcp/moduleTests.d.ts +104 -0
- package/dist/mcp/modules.d.ts +179 -0
- package/dist/mcp/output.d.ts +12 -0
- package/dist/mcp/pathSafety.d.ts +14 -0
- package/dist/mcp/policy.d.ts +48 -0
- package/dist/mcp/pr.d.ts +49 -0
- package/dist/mcp/prInfo.d.ts +9 -0
- package/dist/mcp/providerSchema.d.ts +50 -0
- package/dist/mcp/review.d.ts +199 -0
- package/dist/mcp/reviewComments.d.ts +178 -0
- package/dist/mcp/roots.d.ts +58 -0
- package/dist/mcp/scope.d.ts +15 -0
- package/dist/mcp/selectMode.d.ts +18 -0
- package/dist/mcp/server.d.ts +48 -0
- package/dist/mcp/shared.d.ts +47 -0
- package/dist/mcp/shell.d.ts +37 -0
- package/dist/mcp/staleFix.d.ts +51 -0
- package/dist/mcp/terraform/cost.d.ts +55 -0
- package/dist/mcp/terraform/currency.d.ts +94 -0
- package/dist/mcp/terraform/decisions.d.ts +178 -0
- package/dist/mcp/terraform/findings.d.ts +75 -0
- package/dist/mcp/terraform/plan.d.ts +157 -0
- package/dist/mcp/terraform/scanners.d.ts +131 -0
- package/dist/mcp/terraform/tools.d.ts +63 -0
- package/dist/mcp/terraform/types.d.ts +172 -0
- package/dist/mcp/terraform.d.ts +22 -0
- package/dist/mcp/terratest.d.ts +83 -0
- package/dist/mcp/upload.d.ts +6 -0
- package/dist/models.d.ts +171 -0
- package/dist/modes.d.ts +26 -0
- package/dist/prep/index.d.ts +7 -0
- package/dist/prep/installNodeDependencies.d.ts +2 -0
- package/dist/prep/installPythonDependencies.d.ts +2 -0
- package/dist/prep/types.d.ts +31 -0
- package/dist/reviewQuality.d.ts +64 -0
- package/dist/skills/terraform-best-practices/SKILL.md +369 -0
- package/dist/toolState.d.ts +135 -0
- package/dist/utils/activity.d.ts +40 -0
- package/dist/utils/agent.d.ts +20 -0
- package/dist/utils/agentHangReport.d.ts +38 -0
- package/dist/utils/apiFetch.d.ts +19 -0
- package/dist/utils/apiKeys.d.ts +41 -0
- package/dist/utils/apiUrl.d.ts +20 -0
- package/dist/utils/assets.d.ts +8 -0
- package/dist/utils/billingErrors.d.ts +85 -0
- package/dist/utils/body.d.ts +34 -0
- package/dist/utils/buildTerramendFooter.d.ts +25 -0
- package/dist/utils/byokFallback.d.ts +85 -0
- package/dist/utils/claudeSubscription.d.ts +30 -0
- package/dist/utils/cli.d.ts +10 -0
- package/dist/utils/codexHome.d.ts +29 -0
- package/dist/utils/codexOAuth.d.ts +60 -0
- package/dist/utils/diffCoverage.d.ts +63 -0
- package/dist/utils/errorReport.d.ts +17 -0
- package/dist/utils/exitHandler.d.ts +8 -0
- package/dist/utils/fixDoubleEscapedString.d.ts +1 -0
- package/dist/utils/gitAuth.d.ts +84 -0
- package/dist/utils/gitAuthServer.d.ts +24 -0
- package/dist/utils/github.d.ts +78 -0
- package/dist/utils/globals.d.ts +3 -0
- package/dist/utils/install.d.ts +60 -0
- package/dist/utils/instructions.d.ts +48 -0
- package/dist/utils/leapingComment.d.ts +11 -0
- package/dist/utils/learnings.d.ts +62 -0
- package/dist/utils/learningsTruncate.d.ts +25 -0
- package/dist/utils/lifecycle.d.ts +57 -0
- package/dist/utils/log.d.ts +111 -0
- package/dist/utils/normalizeEnv.d.ts +30 -0
- package/dist/utils/openCodeModels.d.ts +11 -0
- package/dist/utils/overrides.d.ts +40 -0
- package/dist/utils/packageManager.d.ts +49 -0
- package/dist/utils/patchWorkflowRunFields.d.ts +29 -0
- package/dist/utils/payload.d.ts +105 -0
- package/dist/utils/prSummary.d.ts +61 -0
- package/dist/utils/progressComment.d.ts +146 -0
- package/dist/utils/providerErrors.d.ts +31 -0
- package/dist/utils/rangeDiff.d.ts +51 -0
- package/dist/utils/remediationCommand.d.ts +55 -0
- package/dist/utils/retry.d.ts +13 -0
- package/dist/utils/reviewCleanup.d.ts +14 -0
- package/dist/utils/run.d.ts +9 -0
- package/dist/utils/runContext.d.ts +60 -0
- package/dist/utils/runContextData.d.ts +23 -0
- package/dist/utils/runErrorRenderer.d.ts +64 -0
- package/dist/utils/runLifecycle.d.ts +86 -0
- package/dist/utils/runStartupLog.d.ts +15 -0
- package/dist/utils/secrets.d.ts +22 -0
- package/dist/utils/setup.d.ts +90 -0
- package/dist/utils/shell.d.ts +32 -0
- package/dist/utils/skills.d.ts +10 -0
- package/dist/utils/subprocess.d.ts +80 -0
- package/dist/utils/terraformMcp.d.ts +42 -0
- package/dist/utils/time.d.ts +15 -0
- package/dist/utils/timer.d.ts +23 -0
- package/dist/utils/todoTracking.d.ts +16 -0
- package/dist/utils/token.d.ts +39 -0
- package/dist/utils/version.d.ts +2 -0
- package/dist/utils/versioning.d.ts +7 -0
- package/dist/utils/vertex.d.ts +16 -0
- package/dist/utils/workflow.d.ts +13 -0
- package/package.json +119 -0
- package/src/agents/claude.test.ts +1016 -0
- package/src/agents/claude.ts +1246 -0
- package/src/agents/claudePretoolGate.test.ts +28 -0
- package/src/agents/claudePretoolGate.ts +173 -0
- package/src/agents/gateServer.test.ts +204 -0
- package/src/agents/gateServer.ts +124 -0
- package/src/agents/index.ts +10 -0
- package/src/agents/nativeFsDenies.ts +82 -0
- package/src/agents/opencode.test.ts +1440 -0
- package/src/agents/opencode.ts +1312 -0
- package/src/agents/opencodePlugin.ts +222 -0
- package/src/agents/opencodeShared.test.ts +34 -0
- package/src/agents/opencodeShared.ts +121 -0
- package/src/agents/postRun.test.ts +549 -0
- package/src/agents/postRun.ts +535 -0
- package/src/agents/reviewer.ts +104 -0
- package/src/agents/sessionLabeler.test.ts +247 -0
- package/src/agents/sessionLabeler.ts +178 -0
- package/src/agents/shared.test.ts +76 -0
- package/src/agents/shared.ts +292 -0
- package/src/agents/subagentModels.test.ts +113 -0
- package/src/agents/subagentModels.ts +40 -0
- package/src/agents/subagentRegistration.test.ts +41 -0
- package/src/agents/subagentToolGates.ts +114 -0
- package/src/cli.test.ts +129 -0
- package/src/cli.ts +105 -0
- package/src/commands/gha.test.ts +192 -0
- package/src/commands/gha.ts +188 -0
- package/src/commands/mcp.ts +122 -0
- package/src/config.ts +1 -0
- package/src/entry.ts +7 -0
- package/src/entryPost.stdlibOnly.test.ts +109 -0
- package/src/entryPost.ts +99 -0
- package/src/external.test.ts +16 -0
- package/src/external.ts +302 -0
- package/src/index.ts +11 -0
- package/src/internal/index.ts +71 -0
- package/src/lifecycle.ts +2 -0
- package/src/main.test.ts +873 -0
- package/src/main.ts +712 -0
- package/src/mcp/__fixtures__/terramend-scratch-pr-49-review-3485940013.json +110 -0
- package/src/mcp/__fixtures__/terramend-scratch-pr-64-review-3531000326.json +14 -0
- package/src/mcp/__fixtures__/terramend-test-repo-pr-1.diff.json +67 -0
- package/src/mcp/__snapshots__/checkout.test.ts.snap +109 -0
- package/src/mcp/__snapshots__/reviewComments.test.ts.snap +71 -0
- package/src/mcp/arkConfig.ts +7 -0
- package/src/mcp/checkSuite.test.ts +245 -0
- package/src/mcp/checkSuite.ts +255 -0
- package/src/mcp/checkout.test.ts +752 -0
- package/src/mcp/checkout.ts +886 -0
- package/src/mcp/comment.test.ts +772 -0
- package/src/mcp/comment.ts +582 -0
- package/src/mcp/commitInfo.test.ts +127 -0
- package/src/mcp/commitInfo.ts +61 -0
- package/src/mcp/crosswalk.test.ts +106 -0
- package/src/mcp/crosswalk.ts +339 -0
- package/src/mcp/dependencies.test.ts +309 -0
- package/src/mcp/dependencies.ts +189 -0
- package/src/mcp/geminiSanitizer.test.ts +287 -0
- package/src/mcp/geminiSanitizer.ts +207 -0
- package/src/mcp/git.test.ts +1083 -0
- package/src/mcp/git.ts +890 -0
- package/src/mcp/guardrails.test.ts +705 -0
- package/src/mcp/guardrails.ts +465 -0
- package/src/mcp/issue.test.ts +113 -0
- package/src/mcp/issue.ts +73 -0
- package/src/mcp/issueComments.test.ts +69 -0
- package/src/mcp/issueComments.ts +48 -0
- package/src/mcp/issueEvents.test.ts +134 -0
- package/src/mcp/issueEvents.ts +100 -0
- package/src/mcp/issueInfo.test.ts +104 -0
- package/src/mcp/issueInfo.ts +72 -0
- package/src/mcp/labels.test.ts +52 -0
- package/src/mcp/labels.ts +34 -0
- package/src/mcp/localContext.ts +28 -0
- package/src/mcp/localServer.test.ts +75 -0
- package/src/mcp/localServer.ts +131 -0
- package/src/mcp/moduleExtraction.test.ts +261 -0
- package/src/mcp/moduleExtraction.ts +313 -0
- package/src/mcp/moduleTests.test.ts +269 -0
- package/src/mcp/moduleTests.ts +421 -0
- package/src/mcp/modules.test.ts +640 -0
- package/src/mcp/modules.ts +696 -0
- package/src/mcp/output.test.ts +96 -0
- package/src/mcp/output.ts +70 -0
- package/src/mcp/pathSafety.test.ts +44 -0
- package/src/mcp/pathSafety.ts +28 -0
- package/src/mcp/policy.test.ts +282 -0
- package/src/mcp/policy.ts +199 -0
- package/src/mcp/pr.test.ts +387 -0
- package/src/mcp/pr.ts +194 -0
- package/src/mcp/prInfo.test.ts +96 -0
- package/src/mcp/prInfo.ts +91 -0
- package/src/mcp/providerSchema.test.ts +85 -0
- package/src/mcp/providerSchema.ts +175 -0
- package/src/mcp/review.test.ts +936 -0
- package/src/mcp/review.ts +923 -0
- package/src/mcp/reviewComments.test.ts +549 -0
- package/src/mcp/reviewComments.ts +896 -0
- package/src/mcp/roots.test.ts +175 -0
- package/src/mcp/roots.ts +217 -0
- package/src/mcp/scope.test.ts +59 -0
- package/src/mcp/scope.ts +65 -0
- package/src/mcp/security.test.ts +720 -0
- package/src/mcp/selectMode.test.ts +210 -0
- package/src/mcp/selectMode.ts +181 -0
- package/src/mcp/server.test.ts +292 -0
- package/src/mcp/server.ts +403 -0
- package/src/mcp/shared.ts +100 -0
- package/src/mcp/shell.test.ts +520 -0
- package/src/mcp/shell.ts +505 -0
- package/src/mcp/staleFix.test.ts +237 -0
- package/src/mcp/staleFix.ts +277 -0
- package/src/mcp/terraform/cost.ts +163 -0
- package/src/mcp/terraform/currency.test.ts +338 -0
- package/src/mcp/terraform/currency.ts +336 -0
- package/src/mcp/terraform/decisions.ts +527 -0
- package/src/mcp/terraform/findings.ts +333 -0
- package/src/mcp/terraform/plan.ts +348 -0
- package/src/mcp/terraform/scanners.ts +809 -0
- package/src/mcp/terraform/tools.test.ts +1071 -0
- package/src/mcp/terraform/tools.ts +908 -0
- package/src/mcp/terraform/types.ts +305 -0
- package/src/mcp/terraform.test.ts +1957 -0
- package/src/mcp/terraform.ts +23 -0
- package/src/mcp/terratest.test.ts +105 -0
- package/src/mcp/terratest.ts +196 -0
- package/src/mcp/toolFiltering.test.ts +85 -0
- package/src/mcp/upload.test.ts +180 -0
- package/src/mcp/upload.ts +112 -0
- package/src/models.test.ts +300 -0
- package/src/models.ts +708 -0
- package/src/modes.test.ts +107 -0
- package/src/modes.ts +880 -0
- package/src/prep/index.ts +43 -0
- package/src/prep/installNodeDependencies.test.ts +298 -0
- package/src/prep/installNodeDependencies.ts +196 -0
- package/src/prep/installPythonDependencies.test.ts +268 -0
- package/src/prep/installPythonDependencies.ts +199 -0
- package/src/prep/types.ts +38 -0
- package/src/reviewQuality.test.ts +63 -0
- package/src/reviewQuality.ts +134 -0
- package/src/runCli.test.ts +214 -0
- package/src/runCli.ts +282 -0
- package/src/skills/terraform-best-practices/SKILL.md +369 -0
- package/src/toolState.test.ts +45 -0
- package/src/toolState.ts +252 -0
- package/src/utils/activity.test.ts +188 -0
- package/src/utils/activity.ts +210 -0
- package/src/utils/agent.test.ts +251 -0
- package/src/utils/agent.ts +139 -0
- package/src/utils/agentHangReport.test.ts +203 -0
- package/src/utils/agentHangReport.ts +170 -0
- package/src/utils/apiFetch.test.ts +115 -0
- package/src/utils/apiFetch.ts +62 -0
- package/src/utils/apiKeys.test.ts +344 -0
- package/src/utils/apiKeys.ts +206 -0
- package/src/utils/apiUrl.test.ts +30 -0
- package/src/utils/apiUrl.ts +59 -0
- package/src/utils/assets.test.ts +153 -0
- package/src/utils/assets.ts +107 -0
- package/src/utils/billingErrors.test.ts +121 -0
- package/src/utils/billingErrors.ts +189 -0
- package/src/utils/body.test.ts +217 -0
- package/src/utils/body.ts +168 -0
- package/src/utils/buildTerramendFooter.test.ts +38 -0
- package/src/utils/buildTerramendFooter.ts +82 -0
- package/src/utils/byokFallback.test.ts +205 -0
- package/src/utils/byokFallback.ts +128 -0
- package/src/utils/claudeSubscription.test.ts +179 -0
- package/src/utils/claudeSubscription.ts +93 -0
- package/src/utils/cli.ts +31 -0
- package/src/utils/codexHome.test.ts +190 -0
- package/src/utils/codexHome.ts +191 -0
- package/src/utils/codexOAuth.ts +147 -0
- package/src/utils/codexRefreshDetect.test.ts +85 -0
- package/src/utils/codexRefreshDetect.ts +35 -0
- package/src/utils/diffCoverage.test.ts +468 -0
- package/src/utils/diffCoverage.ts +404 -0
- package/src/utils/errorReport.test.ts +135 -0
- package/src/utils/errorReport.ts +83 -0
- package/src/utils/exitHandler.ts +35 -0
- package/src/utils/fixDoubleEscapedString.ts +9 -0
- package/src/utils/ghaCore.ts +13 -0
- package/src/utils/gitAuth.test.ts +322 -0
- package/src/utils/gitAuth.ts +263 -0
- package/src/utils/gitAuthServer.test.ts +260 -0
- package/src/utils/gitAuthServer.ts +182 -0
- package/src/utils/github.test.ts +615 -0
- package/src/utils/github.ts +538 -0
- package/src/utils/globals.ts +9 -0
- package/src/utils/humanEditCapture.test.ts +100 -0
- package/src/utils/humanEditCapture.ts +193 -0
- package/src/utils/install.test.ts +768 -0
- package/src/utils/install.ts +492 -0
- package/src/utils/instructions.test.ts +240 -0
- package/src/utils/instructions.ts +543 -0
- package/src/utils/leapingComment.test.ts +51 -0
- package/src/utils/leapingComment.ts +18 -0
- package/src/utils/learnings.test.ts +87 -0
- package/src/utils/learnings.ts +138 -0
- package/src/utils/learningsTocRender.test.ts +116 -0
- package/src/utils/learningsTruncate.test.ts +39 -0
- package/src/utils/learningsTruncate.ts +42 -0
- package/src/utils/lifecycle.test.ts +195 -0
- package/src/utils/lifecycle.ts +198 -0
- package/src/utils/log.test.ts +402 -0
- package/src/utils/log.ts +432 -0
- package/src/utils/normalizeEnv.test.ts +91 -0
- package/src/utils/normalizeEnv.ts +106 -0
- package/src/utils/openCodeModels.ts +82 -0
- package/src/utils/overrides.test.ts +89 -0
- package/src/utils/overrides.ts +98 -0
- package/src/utils/packageManager.test.ts +321 -0
- package/src/utils/packageManager.ts +257 -0
- package/src/utils/patchWorkflowRunFields.test.ts +92 -0
- package/src/utils/patchWorkflowRunFields.ts +150 -0
- package/src/utils/payload.test.ts +497 -0
- package/src/utils/payload.ts +371 -0
- package/src/utils/postApiFetch.ts +51 -0
- package/src/utils/prSummary.test.ts +224 -0
- package/src/utils/prSummary.ts +147 -0
- package/src/utils/progressComment.ts +261 -0
- package/src/utils/providerErrors.test.ts +315 -0
- package/src/utils/providerErrors.ts +172 -0
- package/src/utils/rangeDiff.test.ts +236 -0
- package/src/utils/rangeDiff.ts +182 -0
- package/src/utils/remediationCommand.test.ts +163 -0
- package/src/utils/remediationCommand.ts +119 -0
- package/src/utils/retry.test.ts +153 -0
- package/src/utils/retry.ts +58 -0
- package/src/utils/reviewCleanup.ts +106 -0
- package/src/utils/run.ts +99 -0
- package/src/utils/runContext.ts +145 -0
- package/src/utils/runContextData.ts +58 -0
- package/src/utils/runErrorRenderer.test.ts +95 -0
- package/src/utils/runErrorRenderer.ts +259 -0
- package/src/utils/runFixture.ts +76 -0
- package/src/utils/runLifecycle.ts +237 -0
- package/src/utils/runStartupLog.ts +60 -0
- package/src/utils/secrets.test.ts +103 -0
- package/src/utils/secrets.ts +177 -0
- package/src/utils/setup.test.ts +509 -0
- package/src/utils/setup.ts +352 -0
- package/src/utils/shell.ts +103 -0
- package/src/utils/skills.test.ts +46 -0
- package/src/utils/skills.ts +67 -0
- package/src/utils/subprocess.test.ts +170 -0
- package/src/utils/subprocess.ts +438 -0
- package/src/utils/terraformMcp.test.ts +63 -0
- package/src/utils/terraformMcp.ts +83 -0
- package/src/utils/time.test.ts +105 -0
- package/src/utils/time.ts +59 -0
- package/src/utils/timer.test.ts +91 -0
- package/src/utils/timer.ts +72 -0
- package/src/utils/todoTracking.test.ts +223 -0
- package/src/utils/todoTracking.ts +167 -0
- package/src/utils/token.test.ts +239 -0
- package/src/utils/token.ts +186 -0
- package/src/utils/version.ts +10 -0
- package/src/utils/versioning.test.ts +34 -0
- package/src/utils/versioning.ts +44 -0
- package/src/utils/vertex.ts +85 -0
- package/src/utils/workflow.ts +25 -0
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import type { GitAuthServer } from "#app/utils/gitAuthServer";
|
|
3
|
+
|
|
4
|
+
const execSyncMock = vi.fn();
|
|
5
|
+
const readFileSyncMock = vi.fn();
|
|
6
|
+
const realpathSyncMock = vi.fn();
|
|
7
|
+
const unlinkSyncMock = vi.fn();
|
|
8
|
+
const spawnMock = vi.fn();
|
|
9
|
+
const shellMock = vi.fn();
|
|
10
|
+
|
|
11
|
+
vi.mock("node:child_process", async (importOriginal) => ({
|
|
12
|
+
...(await importOriginal<typeof import("node:child_process")>()),
|
|
13
|
+
execSync: (...args: unknown[]) => execSyncMock(...args),
|
|
14
|
+
}));
|
|
15
|
+
|
|
16
|
+
vi.mock("node:fs", async (importOriginal) => ({
|
|
17
|
+
...(await importOriginal<typeof import("node:fs")>()),
|
|
18
|
+
readFileSync: (...args: unknown[]) => readFileSyncMock(...args),
|
|
19
|
+
realpathSync: (...args: unknown[]) => realpathSyncMock(...args),
|
|
20
|
+
unlinkSync: (...args: unknown[]) => unlinkSyncMock(...args),
|
|
21
|
+
}));
|
|
22
|
+
|
|
23
|
+
vi.mock("#app/utils/subprocess", () => ({
|
|
24
|
+
spawn: (params: unknown) => spawnMock(params),
|
|
25
|
+
}));
|
|
26
|
+
|
|
27
|
+
vi.mock("#app/utils/shell", () => ({
|
|
28
|
+
$: (...args: unknown[]) => shellMock(...args),
|
|
29
|
+
}));
|
|
30
|
+
|
|
31
|
+
vi.mock("#app/utils/secrets", () => ({
|
|
32
|
+
filterEnv: () => ({ PATH: "/usr/bin" }),
|
|
33
|
+
}));
|
|
34
|
+
|
|
35
|
+
type GitAuthModule = typeof import("#app/utils/gitAuth");
|
|
36
|
+
|
|
37
|
+
// resolveGit/setGitAuthServer mutate module-level state — each test gets a
|
|
38
|
+
// fresh module instance.
|
|
39
|
+
async function loadGitAuth(): Promise<GitAuthModule> {
|
|
40
|
+
vi.resetModules();
|
|
41
|
+
return await import("#app/utils/gitAuth");
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function makeAuthServer(): GitAuthServer {
|
|
45
|
+
return {
|
|
46
|
+
port: 45678,
|
|
47
|
+
register: vi.fn(() => "code-1234"),
|
|
48
|
+
revoke: vi.fn(),
|
|
49
|
+
writeAskpassScript: vi.fn(() => "/tmp/askpass-test.js"),
|
|
50
|
+
close: vi.fn(async () => {}),
|
|
51
|
+
[Symbol.asyncDispose]: async () => {},
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** loads a fresh module with resolveGit() done and a fake auth server installed. */
|
|
56
|
+
async function loadReadyGitAuth(): Promise<{ gitAuth: GitAuthModule; authServer: GitAuthServer }> {
|
|
57
|
+
const gitAuth = await loadGitAuth();
|
|
58
|
+
execSyncMock.mockReturnValueOnce("/usr/bin/git\n");
|
|
59
|
+
realpathSyncMock.mockReturnValueOnce("/usr/libexec/git-core/git");
|
|
60
|
+
readFileSyncMock.mockReturnValue(Buffer.from("git-binary-bytes"));
|
|
61
|
+
gitAuth.resolveGit();
|
|
62
|
+
const authServer = makeAuthServer();
|
|
63
|
+
gitAuth.setGitAuthServer(authServer);
|
|
64
|
+
return { gitAuth, authServer };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function okSpawnResult(overrides: Partial<Record<string, unknown>> = {}) {
|
|
68
|
+
return { stdout: "ok\n", stderr: "", exitCode: 0, ...overrides };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
beforeEach(() => {
|
|
72
|
+
spawnMock.mockResolvedValue(okSpawnResult());
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
afterEach(() => {
|
|
76
|
+
vi.clearAllMocks();
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
describe("resolveGit / verifyGitBinary", () => {
|
|
80
|
+
it("resolves the git path through which + realpath and fingerprints it", async () => {
|
|
81
|
+
const { gitAuth } = await loadReadyGitAuth();
|
|
82
|
+
expect(execSyncMock).toHaveBeenCalledWith("which git", { encoding: "utf-8" });
|
|
83
|
+
expect(realpathSyncMock).toHaveBeenCalledWith("/usr/bin/git");
|
|
84
|
+
|
|
85
|
+
await gitAuth.$git("fetch", ["origin"], { token: "tok" });
|
|
86
|
+
const spawnParams = spawnMock.mock.calls[0]?.[0] as { cmd: string };
|
|
87
|
+
expect(spawnParams.cmd).toBe("/usr/libexec/git-core/git");
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("$git refuses to run before resolveGit()", async () => {
|
|
91
|
+
const gitAuth = await loadGitAuth();
|
|
92
|
+
await expect(gitAuth.$git("fetch", ["origin"], { token: "tok" })).rejects.toThrow(
|
|
93
|
+
/git binary not initialized/,
|
|
94
|
+
);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("$git refuses to run when the binary hash changed since startup", async () => {
|
|
98
|
+
const { gitAuth } = await loadReadyGitAuth();
|
|
99
|
+
readFileSyncMock.mockReturnValue(Buffer.from("tampered-binary-bytes"));
|
|
100
|
+
await expect(gitAuth.$git("push", ["origin", "main"], { token: "tok" })).rejects.toThrow(
|
|
101
|
+
/git binary tampered/,
|
|
102
|
+
);
|
|
103
|
+
expect(spawnMock).not.toHaveBeenCalled();
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("$git refuses to run before setGitAuthServer()", async () => {
|
|
107
|
+
const gitAuth = await loadGitAuth();
|
|
108
|
+
execSyncMock.mockReturnValueOnce("/usr/bin/git\n");
|
|
109
|
+
realpathSyncMock.mockReturnValueOnce("/usr/bin/git");
|
|
110
|
+
readFileSyncMock.mockReturnValue(Buffer.from("git-binary-bytes"));
|
|
111
|
+
gitAuth.resolveGit();
|
|
112
|
+
await expect(gitAuth.$git("fetch", ["origin"], { token: "tok" })).rejects.toThrow(
|
|
113
|
+
/git auth server not initialized/,
|
|
114
|
+
);
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
describe("$git invocation hardening", () => {
|
|
119
|
+
it("pins core.hooksPath=/dev/null by default (security control)", async () => {
|
|
120
|
+
const { gitAuth } = await loadReadyGitAuth();
|
|
121
|
+
await gitAuth.$git("push", ["origin", "feature"], { token: "tok" });
|
|
122
|
+
|
|
123
|
+
const spawnParams = spawnMock.mock.calls[0]?.[0] as { args: string[] };
|
|
124
|
+
const args = spawnParams.args;
|
|
125
|
+
const hooksFlagIndex = args.indexOf("core.hooksPath=/dev/null");
|
|
126
|
+
expect(hooksFlagIndex).toBeGreaterThan(0);
|
|
127
|
+
expect(args[hooksFlagIndex - 1]).toBe("-c");
|
|
128
|
+
// the pin must come BEFORE the subcommand so it acts as a config override
|
|
129
|
+
expect(hooksFlagIndex).toBeLessThan(args.indexOf("push"));
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it("keeps the hooksPath pin when disableHooks is explicitly true", async () => {
|
|
133
|
+
const { gitAuth } = await loadReadyGitAuth();
|
|
134
|
+
await gitAuth.$git("push", ["origin", "feature"], { token: "tok", disableHooks: true });
|
|
135
|
+
|
|
136
|
+
const spawnParams = spawnMock.mock.calls[0]?.[0] as { args: string[] };
|
|
137
|
+
expect(spawnParams.args).toContain("core.hooksPath=/dev/null");
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it("omits the hooksPath pin only when disableHooks is explicitly false", async () => {
|
|
141
|
+
const { gitAuth } = await loadReadyGitAuth();
|
|
142
|
+
await gitAuth.$git("push", ["origin", "feature"], { token: "tok", disableHooks: false });
|
|
143
|
+
|
|
144
|
+
const spawnParams = spawnMock.mock.calls[0]?.[0] as { args: string[] };
|
|
145
|
+
expect(spawnParams.args).not.toContain("core.hooksPath=/dev/null");
|
|
146
|
+
// the other -c overrides stay in place regardless
|
|
147
|
+
expect(spawnParams.args).toContain("credential.helper=");
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it("passes the full defense-in-depth -c overrides and askpass env", async () => {
|
|
151
|
+
const { gitAuth, authServer } = await loadReadyGitAuth();
|
|
152
|
+
const result = await gitAuth.$git("fetch", ["origin", "main"], {
|
|
153
|
+
token: "tok",
|
|
154
|
+
cwd: "/work/repo",
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
expect(result).toEqual({ stdout: "ok", stderr: "" });
|
|
158
|
+
expect(authServer.register).toHaveBeenCalledWith("tok");
|
|
159
|
+
expect(authServer.writeAskpassScript).toHaveBeenCalledWith("code-1234");
|
|
160
|
+
|
|
161
|
+
const spawnParams = spawnMock.mock.calls[0]?.[0] as {
|
|
162
|
+
args: string[];
|
|
163
|
+
cwd: string;
|
|
164
|
+
env: Record<string, string>;
|
|
165
|
+
};
|
|
166
|
+
expect(spawnParams.cwd).toBe("/work/repo");
|
|
167
|
+
expect(spawnParams.args).toEqual([
|
|
168
|
+
"-c",
|
|
169
|
+
"core.fsmonitor=false",
|
|
170
|
+
"-c",
|
|
171
|
+
"credential.helper=",
|
|
172
|
+
"-c",
|
|
173
|
+
"protocol.file.allow=never",
|
|
174
|
+
"-c",
|
|
175
|
+
"core.sshCommand=ssh",
|
|
176
|
+
"-c",
|
|
177
|
+
"core.hooksPath=/dev/null",
|
|
178
|
+
"fetch",
|
|
179
|
+
"origin",
|
|
180
|
+
"main",
|
|
181
|
+
]);
|
|
182
|
+
expect(spawnParams.env.GIT_ASKPASS).toBe("/tmp/askpass-test.js");
|
|
183
|
+
expect(spawnParams.env.GIT_TERMINAL_PROMPT).toBe("0");
|
|
184
|
+
expect(spawnParams.env.GIT_CONFIG_COUNT).toBe("0");
|
|
185
|
+
expect(spawnParams.env.GIT_CONFIG_PARAMETERS).toBe("");
|
|
186
|
+
// token must never appear in the subprocess env
|
|
187
|
+
expect(Object.values(spawnParams.env)).not.toContain("tok");
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it("revokes the code and deletes the askpass script even on success", async () => {
|
|
191
|
+
const { gitAuth, authServer } = await loadReadyGitAuth();
|
|
192
|
+
await gitAuth.$git("fetch", ["origin"], { token: "tok" });
|
|
193
|
+
|
|
194
|
+
expect(authServer.revoke).toHaveBeenCalledWith("code-1234");
|
|
195
|
+
expect(unlinkSyncMock).toHaveBeenCalledWith("/tmp/askpass-test.js");
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it("revokes the code when spawn rejects, and swallows unlink failures", async () => {
|
|
199
|
+
const { gitAuth, authServer } = await loadReadyGitAuth();
|
|
200
|
+
spawnMock.mockRejectedValueOnce(new Error("spawn blew up"));
|
|
201
|
+
unlinkSyncMock.mockImplementationOnce(() => {
|
|
202
|
+
throw new Error("already gone");
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
await expect(gitAuth.$git("fetch", ["origin"], { token: "tok" })).rejects.toThrow(
|
|
206
|
+
"spawn blew up",
|
|
207
|
+
);
|
|
208
|
+
expect(authServer.revoke).toHaveBeenCalledWith("code-1234");
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
it("treats askpass-compromised stderr as a fatal auth failure", async () => {
|
|
212
|
+
const { gitAuth } = await loadReadyGitAuth();
|
|
213
|
+
spawnMock.mockResolvedValueOnce(
|
|
214
|
+
okSpawnResult({ stderr: "askpass-compromised\n", exitCode: 0 }),
|
|
215
|
+
);
|
|
216
|
+
await expect(gitAuth.$git("push", ["origin", "x"], { token: "tok" })).rejects.toThrow(
|
|
217
|
+
/askpass code was replayed after revoke/,
|
|
218
|
+
);
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
it("surfaces stderr and stdout detail on non-zero exit", async () => {
|
|
222
|
+
const { gitAuth } = await loadReadyGitAuth();
|
|
223
|
+
spawnMock.mockResolvedValueOnce(
|
|
224
|
+
okSpawnResult({ exitCode: 128, stderr: "fatal: not a repo", stdout: "smart-proto detail" }),
|
|
225
|
+
);
|
|
226
|
+
await expect(gitAuth.$git("fetch", ["origin"], { token: "tok" })).rejects.toThrow(
|
|
227
|
+
"git fetch failed (exit 128): fatal: not a repo\n--- stdout ---\nsmart-proto detail",
|
|
228
|
+
);
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
it("falls back to stdout-only detail, then to (no output)", async () => {
|
|
232
|
+
const { gitAuth } = await loadReadyGitAuth();
|
|
233
|
+
spawnMock.mockResolvedValueOnce(okSpawnResult({ exitCode: 1, stderr: "", stdout: "only out" }));
|
|
234
|
+
await expect(gitAuth.$git("fetch", ["origin"], { token: "tok" })).rejects.toThrow(
|
|
235
|
+
"git fetch failed (exit 1): only out",
|
|
236
|
+
);
|
|
237
|
+
|
|
238
|
+
spawnMock.mockResolvedValueOnce(okSpawnResult({ exitCode: 1, stderr: "", stdout: "" }));
|
|
239
|
+
await expect(gitAuth.$git("fetch", ["origin"], { token: "tok" })).rejects.toThrow(
|
|
240
|
+
"git fetch failed (exit 1): (no output)",
|
|
241
|
+
);
|
|
242
|
+
});
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
describe("$gitFetchWithDeepen", () => {
|
|
246
|
+
it("passes through on first-attempt success", async () => {
|
|
247
|
+
const { gitAuth } = await loadReadyGitAuth();
|
|
248
|
+
const result = await gitAuth.$gitFetchWithDeepen(["origin", "main"], { token: "tok" });
|
|
249
|
+
expect(result.stdout).toBe("ok");
|
|
250
|
+
expect(spawnMock).toHaveBeenCalledTimes(1);
|
|
251
|
+
expect(shellMock).not.toHaveBeenCalled();
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
it("rethrows non-shallow-unreachable errors unchanged", async () => {
|
|
255
|
+
const { gitAuth } = await loadReadyGitAuth();
|
|
256
|
+
spawnMock.mockResolvedValueOnce(okSpawnResult({ exitCode: 1, stderr: "permission denied" }));
|
|
257
|
+
await expect(gitAuth.$gitFetchWithDeepen(["origin", "main"], { token: "tok" })).rejects.toThrow(
|
|
258
|
+
/permission denied/,
|
|
259
|
+
);
|
|
260
|
+
expect(spawnMock).toHaveBeenCalledTimes(1);
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
it("stringifies non-Error rejections when checking for shallow-unreachable", async () => {
|
|
264
|
+
const { gitAuth } = await loadReadyGitAuth();
|
|
265
|
+
spawnMock.mockRejectedValueOnce("raw string failure");
|
|
266
|
+
await expect(gitAuth.$gitFetchWithDeepen(["origin"], { token: "tok" })).rejects.toBe(
|
|
267
|
+
"raw string failure",
|
|
268
|
+
);
|
|
269
|
+
expect(spawnMock).toHaveBeenCalledTimes(1);
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
it("rethrows shallow-unreachable errors when the repo is not shallow", async () => {
|
|
273
|
+
const { gitAuth } = await loadReadyGitAuth();
|
|
274
|
+
const oid = "a".repeat(40);
|
|
275
|
+
spawnMock.mockResolvedValueOnce(
|
|
276
|
+
okSpawnResult({ exitCode: 1, stderr: `fatal: Could not read ${oid}` }),
|
|
277
|
+
);
|
|
278
|
+
shellMock.mockReturnValueOnce("false\n");
|
|
279
|
+
|
|
280
|
+
await expect(gitAuth.$gitFetchWithDeepen(["origin", "main"], { token: "tok" })).rejects.toThrow(
|
|
281
|
+
/Could not read/,
|
|
282
|
+
);
|
|
283
|
+
expect(shellMock).toHaveBeenCalledWith("git", ["rev-parse", "--is-shallow-repository"], {
|
|
284
|
+
log: false,
|
|
285
|
+
});
|
|
286
|
+
expect(spawnMock).toHaveBeenCalledTimes(1);
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
it("retries once with --deepen and strips caller --depth on shallow repos", async () => {
|
|
290
|
+
const { gitAuth } = await loadReadyGitAuth();
|
|
291
|
+
const oid = "b".repeat(64);
|
|
292
|
+
spawnMock
|
|
293
|
+
.mockResolvedValueOnce(okSpawnResult({ exitCode: 1, stderr: `Could not read ${oid}` }))
|
|
294
|
+
.mockResolvedValueOnce(okSpawnResult({ stdout: "deepened" }));
|
|
295
|
+
shellMock.mockReturnValueOnce("true\n");
|
|
296
|
+
|
|
297
|
+
const result = await gitAuth.$gitFetchWithDeepen(
|
|
298
|
+
["--depth=50", "origin", "main"],
|
|
299
|
+
{ token: "tok" },
|
|
300
|
+
"checkout fetch",
|
|
301
|
+
);
|
|
302
|
+
|
|
303
|
+
expect(result.stdout).toBe("deepened");
|
|
304
|
+
const retryParams = spawnMock.mock.calls[1]?.[0] as { args: string[] };
|
|
305
|
+
expect(retryParams.args).toContain("--deepen=1000");
|
|
306
|
+
expect(retryParams.args).not.toContain("--depth=50");
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
it("also recovers from the 'remote did not send all necessary objects' wording", async () => {
|
|
310
|
+
const { gitAuth } = await loadReadyGitAuth();
|
|
311
|
+
spawnMock
|
|
312
|
+
.mockResolvedValueOnce(
|
|
313
|
+
okSpawnResult({ exitCode: 1, stderr: "remote did not send all necessary objects" }),
|
|
314
|
+
)
|
|
315
|
+
.mockResolvedValueOnce(okSpawnResult({ stdout: "deepened" }));
|
|
316
|
+
shellMock.mockReturnValueOnce("true");
|
|
317
|
+
|
|
318
|
+
const result = await gitAuth.$gitFetchWithDeepen(["origin", "main"], { token: "tok" });
|
|
319
|
+
expect(result.stdout).toBe("deepened");
|
|
320
|
+
expect(spawnMock).toHaveBeenCalledTimes(2);
|
|
321
|
+
});
|
|
322
|
+
});
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* git authentication via GIT_ASKPASS.
|
|
3
|
+
*
|
|
4
|
+
* a localhost HTTP server serves tokens via UUID codes whose lifetime is
|
|
5
|
+
* bounded by the parent $git() invocation: register() makes the code active,
|
|
6
|
+
* the script (and any sibling subprocess — e.g. git-lfs pre-push) can fetch
|
|
7
|
+
* the token any number of times, and $git()'s finally calls revoke() to
|
|
8
|
+
* close the window. each $git() call writes a unique askpass script with
|
|
9
|
+
* the server port+code baked into the file body — no secrets in subprocess
|
|
10
|
+
* env. a replay of a revoked code trips a 409 and revokes the underlying
|
|
11
|
+
* github installation token.
|
|
12
|
+
*
|
|
13
|
+
* see wiki/askpass.md for full security documentation.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { execSync } from "node:child_process";
|
|
17
|
+
import { createHash } from "node:crypto";
|
|
18
|
+
import { readFileSync, realpathSync, unlinkSync } from "node:fs";
|
|
19
|
+
import { log } from "#app/utils/cli";
|
|
20
|
+
import type { GitAuthServer } from "#app/utils/gitAuthServer";
|
|
21
|
+
import { filterEnv } from "#app/utils/secrets";
|
|
22
|
+
import { $ } from "#app/utils/shell";
|
|
23
|
+
import { spawn } from "#app/utils/subprocess";
|
|
24
|
+
|
|
25
|
+
type SafeGitSubcommand = "fetch" | "push";
|
|
26
|
+
|
|
27
|
+
type GitAuthOptions = {
|
|
28
|
+
token: string;
|
|
29
|
+
cwd?: string;
|
|
30
|
+
// when true (the default), this invocation runs with `core.hooksPath=/dev/null`
|
|
31
|
+
// so a repo-/agent-planted git hook (e.g. a `pre-push` written into
|
|
32
|
+
// `.git/hooks` or via an agent-set `core.hooksPath`) cannot fire while
|
|
33
|
+
// GIT_ASKPASS is live and read the installation token back out of the askpass
|
|
34
|
+
// script. Callers pass `false` only when the operator has granted full trust
|
|
35
|
+
// (shell: enabled), where honoring repo hooks (e.g. git-lfs pre-push upload)
|
|
36
|
+
// is intended and the agent could read the token through its shell anyway.
|
|
37
|
+
disableHooks?: boolean;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
type GitResult = {
|
|
41
|
+
stdout: string;
|
|
42
|
+
stderr: string;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
// --- git binary resolution and tamper detection ---
|
|
46
|
+
|
|
47
|
+
type GitBinaryInfo = {
|
|
48
|
+
path: string;
|
|
49
|
+
sha256: string;
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
let gitBinary: GitBinaryInfo | undefined;
|
|
53
|
+
|
|
54
|
+
function hashFile(path: string): string {
|
|
55
|
+
return createHash("sha256").update(readFileSync(path)).digest("hex");
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* resolve and fingerprint the git binary. must be called once at startup
|
|
60
|
+
* (in main()) before any agent code runs, so the path and hash reflect
|
|
61
|
+
* the untampered binary.
|
|
62
|
+
*
|
|
63
|
+
* resolves symlinks via realpath so the hash is of the actual binary.
|
|
64
|
+
* a malicious agent with sudo could replace the binary later, which is
|
|
65
|
+
* caught by verifyGitBinary() before each authenticated call.
|
|
66
|
+
*/
|
|
67
|
+
export function resolveGit(): void {
|
|
68
|
+
const whichPath = execSync("which git", { encoding: "utf-8" }).trim();
|
|
69
|
+
const resolvedPath = realpathSync(whichPath);
|
|
70
|
+
const sha256 = hashFile(resolvedPath);
|
|
71
|
+
gitBinary = { path: resolvedPath, sha256 };
|
|
72
|
+
log.debug(`» git binary: ${resolvedPath} (sha256: ${sha256.slice(0, 12)}...)`);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function verifyGitBinary(): string {
|
|
76
|
+
if (!gitBinary) {
|
|
77
|
+
throw new Error("git binary not initialized — call resolveGit() at startup");
|
|
78
|
+
}
|
|
79
|
+
const currentHash = hashFile(gitBinary.path);
|
|
80
|
+
if (currentHash !== gitBinary.sha256) {
|
|
81
|
+
throw new Error(
|
|
82
|
+
`git binary tampered: expected sha256 ${gitBinary.sha256}, got ${currentHash}. ` +
|
|
83
|
+
`path: ${gitBinary.path}`,
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
return gitBinary.path;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// --- auth server ---
|
|
90
|
+
|
|
91
|
+
let authServer: GitAuthServer | undefined;
|
|
92
|
+
|
|
93
|
+
export function setGitAuthServer(server: GitAuthServer): void {
|
|
94
|
+
authServer = server;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* execute authenticated git command via ASKPASS.
|
|
99
|
+
*
|
|
100
|
+
* subcommand is restricted to "fetch" | "push" — operations that talk to
|
|
101
|
+
* a remote and need credentials. working-tree operations (checkout, merge)
|
|
102
|
+
* use $() from shell.ts which has no token.
|
|
103
|
+
*
|
|
104
|
+
* per call: registers a code with the auth server (valid for the lifetime
|
|
105
|
+
* of this invocation), writes a unique askpass script with port+code baked
|
|
106
|
+
* in, spawns git with GIT_ASKPASS pointing to the script. on completion,
|
|
107
|
+
* revokes the code and deletes the script in finally. multiple sibling
|
|
108
|
+
* askpass calls within one invocation (e.g. git itself + git-lfs pre-push)
|
|
109
|
+
* all see a valid code; replay attempts after finally trip a 409 and the
|
|
110
|
+
* server revokes the underlying github token as a tamper signal.
|
|
111
|
+
*
|
|
112
|
+
* @example
|
|
113
|
+
* await $git("fetch", ["origin", "main"], { token });
|
|
114
|
+
* await $git("push", ["-u", "origin", "feature"], { token });
|
|
115
|
+
*/
|
|
116
|
+
export async function $git(
|
|
117
|
+
subcommand: SafeGitSubcommand,
|
|
118
|
+
args: string[],
|
|
119
|
+
options: GitAuthOptions,
|
|
120
|
+
): Promise<GitResult> {
|
|
121
|
+
const gitPath = verifyGitBinary();
|
|
122
|
+
|
|
123
|
+
if (!authServer) {
|
|
124
|
+
throw new Error("git auth server not initialized — call setGitAuthServer() at startup");
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const cwd = options.cwd ?? process.cwd();
|
|
128
|
+
|
|
129
|
+
const code = authServer.register(options.token);
|
|
130
|
+
const scriptPath = authServer.writeAskpassScript(code);
|
|
131
|
+
|
|
132
|
+
// -c flags override local .git/config — defense-in-depth against
|
|
133
|
+
// agent-set config that could spawn subprocesses before ASKPASS runs.
|
|
134
|
+
// a command-line `-c` wins over any value the agent may have written into
|
|
135
|
+
// .git/config (e.g. via the MCP `git config` tool in restricted mode), which
|
|
136
|
+
// is why hook neutralization belongs here and not only at setup.
|
|
137
|
+
const fullArgs = [
|
|
138
|
+
"-c",
|
|
139
|
+
"core.fsmonitor=false",
|
|
140
|
+
"-c",
|
|
141
|
+
"credential.helper=",
|
|
142
|
+
"-c",
|
|
143
|
+
"protocol.file.allow=never",
|
|
144
|
+
"-c",
|
|
145
|
+
"core.sshCommand=ssh",
|
|
146
|
+
// neutralize git hooks for this authenticated call unless the caller opted
|
|
147
|
+
// out (shell: enabled). a planted `pre-push`/`pre-receive` would otherwise
|
|
148
|
+
// run in the action process with GIT_ASKPASS set and could exfiltrate the
|
|
149
|
+
// token by invoking the askpass script itself.
|
|
150
|
+
...(options.disableHooks === false ? [] : ["-c", "core.hooksPath=/dev/null"]),
|
|
151
|
+
subcommand,
|
|
152
|
+
...args,
|
|
153
|
+
];
|
|
154
|
+
|
|
155
|
+
log.debug(`git ${fullArgs.join(" ")}`);
|
|
156
|
+
|
|
157
|
+
try {
|
|
158
|
+
const result = await spawn({
|
|
159
|
+
cmd: gitPath,
|
|
160
|
+
args: fullArgs,
|
|
161
|
+
cwd,
|
|
162
|
+
env: {
|
|
163
|
+
...filterEnv(),
|
|
164
|
+
GIT_ASKPASS: scriptPath,
|
|
165
|
+
GIT_TERMINAL_PROMPT: "0",
|
|
166
|
+
// blocks env-based git config injection from outer processes.
|
|
167
|
+
// GIT_CONFIG_COUNT=0 blocks the newer KEY_n/VALUE_n mechanism.
|
|
168
|
+
// GIT_CONFIG_PARAMETERS="" clears the legacy quoted-list mechanism.
|
|
169
|
+
// both are needed — they are independent systems.
|
|
170
|
+
GIT_CONFIG_COUNT: "0",
|
|
171
|
+
GIT_CONFIG_PARAMETERS: "",
|
|
172
|
+
},
|
|
173
|
+
activityTimeout: 0,
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
if (result.stderr.includes("askpass-compromised")) {
|
|
177
|
+
log.info("askpass code was replayed after revoke — token has been revoked");
|
|
178
|
+
throw new Error("git auth failed — askpass code was replayed after revoke, token revoked");
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
if (result.exitCode !== 0) {
|
|
182
|
+
const stderr = result.stderr.trim();
|
|
183
|
+
const stdout = result.stdout.trim();
|
|
184
|
+
// stderr is the primary channel for git diagnostics, but in rare cases
|
|
185
|
+
// (e.g. some HTTPS smart-protocol failures) the only useful detail is
|
|
186
|
+
// on stdout — without it the agent / operator sees an empty error.
|
|
187
|
+
// include exit code so we can distinguish e.g. signal-killed (1 with
|
|
188
|
+
// empty output) from a genuine git-level rejection.
|
|
189
|
+
const detail =
|
|
190
|
+
stderr && stdout
|
|
191
|
+
? `${stderr}\n--- stdout ---\n${stdout}`
|
|
192
|
+
: stderr || stdout || "(no output)";
|
|
193
|
+
const message = `git ${subcommand} failed (exit ${result.exitCode}): ${detail}`;
|
|
194
|
+
log.info(message);
|
|
195
|
+
throw new Error(message);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
return {
|
|
199
|
+
stdout: result.stdout.trim(),
|
|
200
|
+
stderr: result.stderr.trim(),
|
|
201
|
+
};
|
|
202
|
+
} finally {
|
|
203
|
+
authServer.revoke(code);
|
|
204
|
+
try {
|
|
205
|
+
unlinkSync(scriptPath);
|
|
206
|
+
} catch {
|
|
207
|
+
// script may already be gone (e.g. tmpdir cleanup raced us)
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* shallow-clone unreachable: when an existing local depth is too shallow for
|
|
214
|
+
* git to traverse to the requested ref's ancestry, the remote walk fails with
|
|
215
|
+
* one of these wordings (git emits the full OID via oid_to_hex, so the bound
|
|
216
|
+
* is 40 for SHA-1 or 64 for SHA-256). detecting both lets a single deepen
|
|
217
|
+
* retry recover before the error reaches the agent — see issue #564 for the
|
|
218
|
+
* original `git_fetch` precedent and #656 for the `checkout_pr` follow-up.
|
|
219
|
+
*/
|
|
220
|
+
export const SHALLOW_UNREACHABLE_PATTERNS: RegExp[] = [
|
|
221
|
+
/Could not read [a-f0-9]{40,64}/,
|
|
222
|
+
/remote did not send all necessary objects/,
|
|
223
|
+
];
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* large enough to clear the merge base on most real-world PRs without
|
|
227
|
+
* downloading the full history; matches the fallback used by
|
|
228
|
+
* `checkoutPrBranch` when the GitHub compare API is unavailable.
|
|
229
|
+
*/
|
|
230
|
+
export const DEEPEN_RETRY_DEPTH = 1000;
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* authenticated `git fetch` that recovers from shallow-unreachable errors
|
|
234
|
+
* by retrying once with `--deepen=1000`. callers pass the same args they
|
|
235
|
+
* would to `$git("fetch", ...)`; on shallow-unreachable failures in a
|
|
236
|
+
* shallow repo, the second attempt prepends `--deepen=N` and strips any
|
|
237
|
+
* caller-supplied `--depth=` (the two flags are mutually exclusive, and
|
|
238
|
+
* the caller's depth is what got us into this mess).
|
|
239
|
+
*
|
|
240
|
+
* non-shallow-unreachable errors and non-shallow repos rethrow unchanged,
|
|
241
|
+
* so this is safe to wrap any fetch without changing fast-path behavior.
|
|
242
|
+
*/
|
|
243
|
+
export async function $gitFetchWithDeepen(
|
|
244
|
+
args: string[],
|
|
245
|
+
options: GitAuthOptions,
|
|
246
|
+
label?: string,
|
|
247
|
+
): Promise<GitResult> {
|
|
248
|
+
try {
|
|
249
|
+
return await $git("fetch", args, options);
|
|
250
|
+
} catch (err) {
|
|
251
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
252
|
+
const isShallowUnreachable = SHALLOW_UNREACHABLE_PATTERNS.some((p) => p.test(msg));
|
|
253
|
+
if (!isShallowUnreachable) throw err;
|
|
254
|
+
const isShallow =
|
|
255
|
+
$("git", ["rev-parse", "--is-shallow-repository"], { log: false }).trim() === "true";
|
|
256
|
+
if (!isShallow) throw err;
|
|
257
|
+
log.info(
|
|
258
|
+
`» ${label ?? "git fetch"} hit shallow-unreachable error, retrying with --deepen=${DEEPEN_RETRY_DEPTH}`,
|
|
259
|
+
);
|
|
260
|
+
const retryArgs = args.filter((a) => !a.startsWith("--depth="));
|
|
261
|
+
return await $git("fetch", [`--deepen=${DEEPEN_RETRY_DEPTH}`, ...retryArgs], options);
|
|
262
|
+
}
|
|
263
|
+
}
|