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,260 @@
|
|
|
1
|
+
import { existsSync, mkdtempSync, readFileSync } from "node:fs";
|
|
2
|
+
import { createServer, type Server } from "node:http";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
6
|
+
import { type GitAuthServer, startGitAuthServer } from "#app/utils/gitAuthServer";
|
|
7
|
+
|
|
8
|
+
let server: GitAuthServer | undefined;
|
|
9
|
+
|
|
10
|
+
afterEach(async () => {
|
|
11
|
+
if (server) {
|
|
12
|
+
await server.close();
|
|
13
|
+
server = undefined;
|
|
14
|
+
}
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
function makeTmpdir(): string {
|
|
18
|
+
return mkdtempSync(join(tmpdir(), "askpass-test-"));
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
describe("git auth server lifecycle", () => {
|
|
22
|
+
it("starts and listens on a port", async () => {
|
|
23
|
+
const tmp = makeTmpdir();
|
|
24
|
+
server = await startGitAuthServer(tmp);
|
|
25
|
+
expect(server.port).toBeGreaterThan(0);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("closes cleanly", async () => {
|
|
29
|
+
const tmp = makeTmpdir();
|
|
30
|
+
server = await startGitAuthServer(tmp);
|
|
31
|
+
const port = server.port;
|
|
32
|
+
await server.close();
|
|
33
|
+
server = undefined;
|
|
34
|
+
|
|
35
|
+
// port should no longer accept connections
|
|
36
|
+
const err = await fetch(`http://127.0.0.1:${port}/test`).catch((e) => e);
|
|
37
|
+
expect(err).toBeInstanceOf(Error);
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
describe("token delivery", () => {
|
|
42
|
+
it("returns token on first request with valid code", async () => {
|
|
43
|
+
const tmp = makeTmpdir();
|
|
44
|
+
server = await startGitAuthServer(tmp);
|
|
45
|
+
const code = server.register("ghs_test_token_12345");
|
|
46
|
+
|
|
47
|
+
const res = await fetch(`http://127.0.0.1:${server.port}/${code}`);
|
|
48
|
+
expect(res.status).toBe(200);
|
|
49
|
+
const body = await res.text();
|
|
50
|
+
expect(body).toBe("ghs_test_token_12345");
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("returns 404 for unknown code", async () => {
|
|
54
|
+
const tmp = makeTmpdir();
|
|
55
|
+
server = await startGitAuthServer(tmp);
|
|
56
|
+
|
|
57
|
+
const res = await fetch(`http://127.0.0.1:${server.port}/nonexistent-code`);
|
|
58
|
+
expect(res.status).toBe(404);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("returns 400 for empty code", async () => {
|
|
62
|
+
const tmp = makeTmpdir();
|
|
63
|
+
server = await startGitAuthServer(tmp);
|
|
64
|
+
|
|
65
|
+
const res = await fetch(`http://127.0.0.1:${server.port}/`);
|
|
66
|
+
expect(res.status).toBe(400);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("returns 405 for non-GET methods", async () => {
|
|
70
|
+
const tmp = makeTmpdir();
|
|
71
|
+
server = await startGitAuthServer(tmp);
|
|
72
|
+
const code = server.register("token");
|
|
73
|
+
|
|
74
|
+
const res = await fetch(`http://127.0.0.1:${server.port}/${code}`, { method: "POST" });
|
|
75
|
+
expect(res.status).toBe(405);
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
describe("code lifecycle (tamper detection)", () => {
|
|
80
|
+
it("returns the token on repeated use while the code is active", async () => {
|
|
81
|
+
// a single $git() call can produce multiple legitimate askpass requests:
|
|
82
|
+
// git itself (username + password), git-lfs pre-push hook, custom hooks.
|
|
83
|
+
// they must all succeed until $git()'s finally calls revoke().
|
|
84
|
+
const tmp = makeTmpdir();
|
|
85
|
+
server = await startGitAuthServer(tmp);
|
|
86
|
+
const code = server.register("ghs_active_test");
|
|
87
|
+
|
|
88
|
+
for (let i = 0; i < 5; i++) {
|
|
89
|
+
const res = await fetch(`http://127.0.0.1:${server.port}/${code}`);
|
|
90
|
+
expect(res.status).toBe(200);
|
|
91
|
+
expect(await res.text()).toBe("ghs_active_test");
|
|
92
|
+
}
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("returns 409 after revoke (replay-after-call trap)", async () => {
|
|
96
|
+
const tmp = makeTmpdir();
|
|
97
|
+
server = await startGitAuthServer(tmp);
|
|
98
|
+
const code = server.register("ghs_tamper_test");
|
|
99
|
+
|
|
100
|
+
const first = await fetch(`http://127.0.0.1:${server.port}/${code}`);
|
|
101
|
+
expect(first.status).toBe(200);
|
|
102
|
+
|
|
103
|
+
server.revoke(code);
|
|
104
|
+
|
|
105
|
+
const replay = await fetch(`http://127.0.0.1:${server.port}/${code}`);
|
|
106
|
+
expect(replay.status).toBe(409);
|
|
107
|
+
expect(await replay.text()).toBe("compromised");
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("revoke() on an unknown code is a no-op", async () => {
|
|
111
|
+
const tmp = makeTmpdir();
|
|
112
|
+
const local = await startGitAuthServer(tmp);
|
|
113
|
+
server = local;
|
|
114
|
+
expect(() => local.revoke("nonexistent")).not.toThrow();
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("each register() call produces an independent code", async () => {
|
|
118
|
+
const tmp = makeTmpdir();
|
|
119
|
+
server = await startGitAuthServer(tmp);
|
|
120
|
+
const code1 = server.register("token-a");
|
|
121
|
+
const code2 = server.register("token-b");
|
|
122
|
+
|
|
123
|
+
expect(code1).not.toBe(code2);
|
|
124
|
+
|
|
125
|
+
const res1 = await fetch(`http://127.0.0.1:${server.port}/${code1}`);
|
|
126
|
+
expect(await res1.text()).toBe("token-a");
|
|
127
|
+
|
|
128
|
+
const res2 = await fetch(`http://127.0.0.1:${server.port}/${code2}`);
|
|
129
|
+
expect(await res2.text()).toBe("token-b");
|
|
130
|
+
});
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
describe("askpass script generation", () => {
|
|
134
|
+
it("writes an executable script file", async () => {
|
|
135
|
+
const tmp = makeTmpdir();
|
|
136
|
+
server = await startGitAuthServer(tmp);
|
|
137
|
+
const code = server.register("ghs_script_test");
|
|
138
|
+
const scriptPath = server.writeAskpassScript(code);
|
|
139
|
+
|
|
140
|
+
expect(existsSync(scriptPath)).toBe(true);
|
|
141
|
+
expect(scriptPath.startsWith(tmp)).toBe(true);
|
|
142
|
+
|
|
143
|
+
const content = readFileSync(scriptPath, "utf-8");
|
|
144
|
+
expect(content).toContain("#!/usr/bin/env node");
|
|
145
|
+
expect(content).toContain(String(server.port));
|
|
146
|
+
expect(content).toContain(code);
|
|
147
|
+
// token should NOT be in the script — only port and code
|
|
148
|
+
expect(content).not.toContain("ghs_script_test");
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it("script handles Username prompt locally (no server call)", async () => {
|
|
152
|
+
const tmp = makeTmpdir();
|
|
153
|
+
server = await startGitAuthServer(tmp);
|
|
154
|
+
const code = server.register("ghs_username_test");
|
|
155
|
+
const scriptPath = server.writeAskpassScript(code);
|
|
156
|
+
const content = readFileSync(scriptPath, "utf-8");
|
|
157
|
+
|
|
158
|
+
// script checks for /^Username/i and returns "x-access-token" without HTTP
|
|
159
|
+
expect(content).toContain("Username");
|
|
160
|
+
expect(content).toContain("x-access-token");
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it("each writeAskpassScript call produces a distinct script file", async () => {
|
|
164
|
+
const tmp = makeTmpdir();
|
|
165
|
+
server = await startGitAuthServer(tmp);
|
|
166
|
+
const code = server.register("ghs_distinct_test");
|
|
167
|
+
|
|
168
|
+
const first = server.writeAskpassScript(code);
|
|
169
|
+
const second = server.writeAskpassScript(code);
|
|
170
|
+
expect(first).not.toBe(second);
|
|
171
|
+
expect(existsSync(first)).toBe(true);
|
|
172
|
+
expect(existsSync(second)).toBe(true);
|
|
173
|
+
});
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
describe("revoked-entry trap window", () => {
|
|
177
|
+
afterEach(() => {
|
|
178
|
+
vi.useRealTimers();
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it("forgets a revoked code after the trap window, replay then 404s instead of 409", async () => {
|
|
182
|
+
const tmp = makeTmpdir();
|
|
183
|
+
server = await startGitAuthServer(tmp);
|
|
184
|
+
const code = server.register("ghs_trap_expiry_test");
|
|
185
|
+
|
|
186
|
+
// fake timers only around revoke() so the cleanup setTimeout is controllable;
|
|
187
|
+
// the actual HTTP round-trips below run on real timers.
|
|
188
|
+
vi.useFakeTimers();
|
|
189
|
+
server.revoke(code);
|
|
190
|
+
vi.advanceTimersByTime(60_001);
|
|
191
|
+
vi.useRealTimers();
|
|
192
|
+
|
|
193
|
+
const replay = await fetch(`http://127.0.0.1:${server.port}/${code}`);
|
|
194
|
+
expect(replay.status).toBe(404);
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it("close() clears pending trap timers and shuts the listener", async () => {
|
|
198
|
+
const tmp = makeTmpdir();
|
|
199
|
+
const local = await startGitAuthServer(tmp);
|
|
200
|
+
const code = local.register("ghs_close_with_pending_trap");
|
|
201
|
+
local.revoke(code);
|
|
202
|
+
|
|
203
|
+
// must not hang or throw with a revoked entry's timer still pending
|
|
204
|
+
await local.close();
|
|
205
|
+
|
|
206
|
+
const err = await fetch(`http://127.0.0.1:${local.port}/${code}`).catch((e: unknown) => e);
|
|
207
|
+
expect(err).toBeInstanceOf(Error);
|
|
208
|
+
});
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
describe("github token revocation on replay", () => {
|
|
212
|
+
let apiServer: Server | undefined;
|
|
213
|
+
|
|
214
|
+
afterEach(async () => {
|
|
215
|
+
vi.unstubAllEnvs();
|
|
216
|
+
if (apiServer) {
|
|
217
|
+
const closing = apiServer;
|
|
218
|
+
apiServer = undefined;
|
|
219
|
+
await new Promise<void>((resolve) => closing.close(() => resolve()));
|
|
220
|
+
}
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
it("DELETEs the installation token at GITHUB_API_URL when a revoked code is replayed", async () => {
|
|
224
|
+
const received = Promise.withResolvers<{ method: string; url: string; auth: string }>();
|
|
225
|
+
apiServer = createServer((req, res) => {
|
|
226
|
+
received.resolve({
|
|
227
|
+
method: req.method ?? "",
|
|
228
|
+
url: req.url ?? "",
|
|
229
|
+
auth: req.headers.authorization ?? "",
|
|
230
|
+
});
|
|
231
|
+
res.writeHead(204).end();
|
|
232
|
+
});
|
|
233
|
+
await new Promise<void>((resolve) => {
|
|
234
|
+
const listener = apiServer;
|
|
235
|
+
if (!listener) throw new Error("api server not constructed");
|
|
236
|
+
listener.listen(0, "127.0.0.1", () => resolve());
|
|
237
|
+
});
|
|
238
|
+
const addr = apiServer.address();
|
|
239
|
+
if (!addr || typeof addr === "string") throw new Error("api server failed to bind");
|
|
240
|
+
vi.stubEnv("GITHUB_API_URL", `http://127.0.0.1:${addr.port}`);
|
|
241
|
+
|
|
242
|
+
const tmp = makeTmpdir();
|
|
243
|
+
server = await startGitAuthServer(tmp);
|
|
244
|
+
const code = server.register("ghs_revoke_forward_test");
|
|
245
|
+
server.revoke(code);
|
|
246
|
+
|
|
247
|
+
const replay = await fetch(`http://127.0.0.1:${server.port}/${code}`);
|
|
248
|
+
expect(replay.status).toBe(409);
|
|
249
|
+
|
|
250
|
+
// the tamper-trap revocation is fire-and-forget; await its arrival
|
|
251
|
+
const revocation = await received.promise;
|
|
252
|
+
expect(revocation.method).toBe("DELETE");
|
|
253
|
+
expect(revocation.url).toBe("/installation/token");
|
|
254
|
+
expect(revocation.auth).toBe("Bearer ghs_revoke_forward_test");
|
|
255
|
+
|
|
256
|
+
// the trapped entry is consumed — a second replay is an opaque 404
|
|
257
|
+
const second = await fetch(`http://127.0.0.1:${server.port}/${code}`);
|
|
258
|
+
expect(second.status).toBe(404);
|
|
259
|
+
});
|
|
260
|
+
});
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ASKPASS-based git authentication server.
|
|
3
|
+
*
|
|
4
|
+
* serves tokens via a localhost HTTP server with per-$git()-call UUID codes.
|
|
5
|
+
* each $git() call gets a unique askpass script with the port+code baked in.
|
|
6
|
+
* the token never appears in subprocess env — only the script file path.
|
|
7
|
+
*
|
|
8
|
+
* lifetime: the code is valid for as long as the $git() invocation is
|
|
9
|
+
* running. multiple askpass calls within one invocation (e.g. git's own
|
|
10
|
+
* fetch/push + a git-lfs pre-push hook that also authenticates) all
|
|
11
|
+
* succeed. $git() calls revoke(code) in finally; subsequent requests for
|
|
12
|
+
* a revoked code trigger immediate token revocation via the GitHub API
|
|
13
|
+
* as a tamper-evidence precaution (an agent replaying the code after the
|
|
14
|
+
* legitimate window has closed is the realistic attack we still catch).
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { randomUUID } from "node:crypto";
|
|
18
|
+
import { writeFileSync } from "node:fs";
|
|
19
|
+
import { createServer } from "node:http";
|
|
20
|
+
import { join } from "node:path";
|
|
21
|
+
import { log } from "#app/utils/cli";
|
|
22
|
+
|
|
23
|
+
type CodeState = "active" | "revoked";
|
|
24
|
+
|
|
25
|
+
type CodeEntry = {
|
|
26
|
+
token: string;
|
|
27
|
+
state: CodeState;
|
|
28
|
+
// only present once the entry is revoked — bounds the replay-trap window.
|
|
29
|
+
// active entries have no timer because $git() can take arbitrarily long
|
|
30
|
+
// (large LFS pushes, slow networks, `activityTimeout: 0` on the spawn);
|
|
31
|
+
// any wall-clock TTL here would re-introduce the original LFS bug at
|
|
32
|
+
// a different boundary. revoke() is the only way out for an active code.
|
|
33
|
+
timeout?: NodeJS.Timeout;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
const REVOKED_TRAP_MS = 60_000;
|
|
37
|
+
|
|
38
|
+
export type GitAuthServer = {
|
|
39
|
+
port: number;
|
|
40
|
+
register: (token: string) => string;
|
|
41
|
+
revoke: (code: string) => void;
|
|
42
|
+
writeAskpassScript: (code: string) => string;
|
|
43
|
+
close: () => Promise<void>;
|
|
44
|
+
[Symbol.asyncDispose]: () => Promise<void>;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
function revokeGitHubToken(token: string): void {
|
|
48
|
+
// honor GITHUB_API_URL so the tamper-trap revocation also works on GitHub
|
|
49
|
+
// Enterprise Server (where the API host is not api.github.com); falls back to
|
|
50
|
+
// public GitHub otherwise. Mirrors src/utils/token.ts.
|
|
51
|
+
const apiBase = process.env.GITHUB_API_URL || "https://api.github.com";
|
|
52
|
+
fetch(`${apiBase}/installation/token`, {
|
|
53
|
+
method: "DELETE",
|
|
54
|
+
headers: {
|
|
55
|
+
Authorization: `Bearer ${token}`,
|
|
56
|
+
Accept: "application/vnd.github+json",
|
|
57
|
+
"User-Agent": "terramend",
|
|
58
|
+
},
|
|
59
|
+
}).then(
|
|
60
|
+
(r) => log.info(`token revocation response: ${r.status}`),
|
|
61
|
+
() => log.warning("token revocation request failed"),
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export async function startGitAuthServer(tmpdir: string): Promise<GitAuthServer> {
|
|
66
|
+
const codes = new Map<string, CodeEntry>();
|
|
67
|
+
|
|
68
|
+
const server = createServer((req, res) => {
|
|
69
|
+
if (req.method !== "GET") {
|
|
70
|
+
res.writeHead(405).end();
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const code = req.url?.slice(1);
|
|
75
|
+
if (!code) {
|
|
76
|
+
res.writeHead(400).end();
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const entry = codes.get(code);
|
|
81
|
+
if (!entry) {
|
|
82
|
+
res.writeHead(404).end();
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (entry.state === "active") {
|
|
87
|
+
// legitimate caller (git, git-lfs, or any subprocess of the running
|
|
88
|
+
// $git() call). hand back the token without consuming the code —
|
|
89
|
+
// revoke() in $git's finally is what closes the window.
|
|
90
|
+
res.writeHead(200, { "Content-Type": "text/plain" });
|
|
91
|
+
res.end(entry.token);
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// request for a revoked code — the $git() window has closed, so this
|
|
96
|
+
// is an agent replaying the code. revoke the token as a precaution.
|
|
97
|
+
log.info("askpass code used after revoke — revoking token");
|
|
98
|
+
revokeGitHubToken(entry.token);
|
|
99
|
+
if (entry.timeout) clearTimeout(entry.timeout);
|
|
100
|
+
codes.delete(code);
|
|
101
|
+
res.writeHead(409, { "Content-Type": "text/plain" });
|
|
102
|
+
res.end("compromised");
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
await new Promise<void>((resolve, reject) => {
|
|
106
|
+
server.on("error", reject);
|
|
107
|
+
server.listen(0, "127.0.0.1", () => resolve());
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
const rawAddr = server.address();
|
|
111
|
+
if (!rawAddr || typeof rawAddr === "string") {
|
|
112
|
+
throw new Error("git auth server failed to bind");
|
|
113
|
+
}
|
|
114
|
+
const port = rawAddr.port;
|
|
115
|
+
|
|
116
|
+
log.debug(`git auth server listening on 127.0.0.1:${port}`);
|
|
117
|
+
|
|
118
|
+
function register(token: string): string {
|
|
119
|
+
const code = randomUUID();
|
|
120
|
+
codes.set(code, { token, state: "active" });
|
|
121
|
+
return code;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function revoke(code: string): void {
|
|
125
|
+
const entry = codes.get(code);
|
|
126
|
+
if (!entry) return;
|
|
127
|
+
entry.state = "revoked";
|
|
128
|
+
// keep the entry around briefly so a replay attempt trips the trap
|
|
129
|
+
// (token revocation) instead of returning an opaque 404.
|
|
130
|
+
entry.timeout = setTimeout(() => codes.delete(code), REVOKED_TRAP_MS);
|
|
131
|
+
entry.timeout.unref();
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function writeAskpassScript(code: string): string {
|
|
135
|
+
const scriptId = randomUUID();
|
|
136
|
+
const scriptName = `askpass-${scriptId}.js`;
|
|
137
|
+
const scriptPath = join(tmpdir, scriptName);
|
|
138
|
+
|
|
139
|
+
// standalone node script — no project dependencies.
|
|
140
|
+
// git invokes this once per credential prompt — separate process spawn
|
|
141
|
+
// per prompt: one for "Username for ...", one for "Password for ...".
|
|
142
|
+
// sibling subprocesses (git-lfs pre-push, custom auth-bound hooks)
|
|
143
|
+
// invoke it independently for their own auth, also one spawn per prompt.
|
|
144
|
+
// all succeed as long as the parent $git() is still running, which is
|
|
145
|
+
// why neither the script nor the code is single-use. cleanup happens
|
|
146
|
+
// in $git()'s finally.
|
|
147
|
+
// 409 = code was already revoked by $git()'s finally (replay attempt).
|
|
148
|
+
const content = [
|
|
149
|
+
`#!/usr/bin/env node`,
|
|
150
|
+
`var a=process.argv[2]||"";`,
|
|
151
|
+
`if(/^Username/i.test(a)){process.stdout.write("x-access-token\\n")}`,
|
|
152
|
+
`else{var h=require("http");`,
|
|
153
|
+
`h.get("http://127.0.0.1:${port}/${code}",function(r){`,
|
|
154
|
+
`if(r.statusCode===409){process.stderr.write("askpass-compromised\\n");process.exit(1)}`,
|
|
155
|
+
`if(r.statusCode!==200){process.exit(1)}`,
|
|
156
|
+
`var d="";r.on("data",function(c){d+=c});`,
|
|
157
|
+
`r.on("end",function(){process.stdout.write(d+"\\n")})`,
|
|
158
|
+
`}).on("error",function(){process.exit(1)})}`,
|
|
159
|
+
].join("\n");
|
|
160
|
+
|
|
161
|
+
writeFileSync(scriptPath, content, { mode: 0o700 });
|
|
162
|
+
return scriptPath;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
async function close(): Promise<void> {
|
|
166
|
+
for (const entry of codes.values()) {
|
|
167
|
+
if (entry.timeout) clearTimeout(entry.timeout);
|
|
168
|
+
}
|
|
169
|
+
codes.clear();
|
|
170
|
+
await new Promise<void>((resolve) => server.close(() => resolve()));
|
|
171
|
+
log.debug("git auth server closed");
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
return {
|
|
175
|
+
port,
|
|
176
|
+
register,
|
|
177
|
+
revoke,
|
|
178
|
+
writeAskpassScript,
|
|
179
|
+
close,
|
|
180
|
+
[Symbol.asyncDispose]: close,
|
|
181
|
+
};
|
|
182
|
+
}
|