diff --git a/src/common/git-transport.ts b/src/common/git-transport.ts index 4bcc56f..5f79d4d 100644 --- a/src/common/git-transport.ts +++ b/src/common/git-transport.ts @@ -11,30 +11,78 @@ export const defaultGitDirectHosts = Object.freeze([ "registry.npmmirror.com", ]); -export function runnerGitTransportEnvVars(): JsonRecord[] { - return [ - { name: "GIT_TERMINAL_PROMPT", value: "0" }, - { name: "GIT_HTTP_VERSION", value: defaultGitHttpVersion }, - { name: "GIT_HTTP_LOW_SPEED_LIMIT", value: String(defaultGitLowSpeedLimitBytes) }, - { name: "GIT_HTTP_LOW_SPEED_TIME", value: String(defaultGitLowSpeedTimeSeconds) }, - { name: "AGENTRUN_GIT_DEFAULT_TIMEOUT_MS", value: String(defaultGitOperationTimeoutMs) }, - { name: "AGENTRUN_GIT_CONNECT_TIMEOUT_SECONDS", value: String(defaultGitConnectTimeoutSeconds) }, - { name: "AGENTRUN_GIT_HTTP_VERSION", value: defaultGitHttpVersion }, - { name: "AGENTRUN_GIT_DIRECT_HOSTS", value: defaultGitDirectHosts.join(",") }, - { name: "AGENTRUN_GIT_CREDENTIAL_HELPER", value: "gh-auth-setup-git" }, - ]; +export interface GitTransportOptions { + sshOnly?: boolean; + scope?: "runner" | "backend-command" | "resource-bundle"; } -export function gitTransportSummary(): JsonRecord { +export function runnerGitTransportEnvVars(options: GitTransportOptions = {}): JsonRecord[] { + return Object.entries(gitTransportEnv(options)).map(([name, value]) => ({ name, value })); +} + +export function gitTransportEnv(options: GitTransportOptions = {}): Record { + const base = { + GIT_TERMINAL_PROMPT: "0", + GIT_HTTP_VERSION: defaultGitHttpVersion, + GIT_HTTP_LOW_SPEED_LIMIT: String(defaultGitLowSpeedLimitBytes), + GIT_HTTP_LOW_SPEED_TIME: String(defaultGitLowSpeedTimeSeconds), + AGENTRUN_GIT_DEFAULT_TIMEOUT_MS: String(defaultGitOperationTimeoutMs), + AGENTRUN_GIT_CONNECT_TIMEOUT_SECONDS: String(defaultGitConnectTimeoutSeconds), + AGENTRUN_GIT_HTTP_VERSION: defaultGitHttpVersion, + AGENTRUN_GIT_DIRECT_HOSTS: defaultGitDirectHosts.join(","), + AGENTRUN_GIT_CREDENTIAL_HELPER: options.sshOnly ? "disabled" : "gh-auth-setup-git", + }; + if (!options.sshOnly) return base; return { + ...base, + GIT_ALLOW_PROTOCOL: "ssh", + GIT_CONFIG_COUNT: "3", + GIT_CONFIG_KEY_0: "credential.helper", + GIT_CONFIG_VALUE_0: "", + GIT_CONFIG_KEY_1: "protocol.allow", + GIT_CONFIG_VALUE_1: "never", + GIT_CONFIG_KEY_2: "protocol.ssh.allow", + GIT_CONFIG_VALUE_2: "always", + GIT_ASKPASS: "/bin/false", + SSH_ASKPASS: "/bin/false", + SSH_ASKPASS_REQUIRE: "never", + }; +} + +export function gitTransportSummary(options: GitTransportOptions = {}): JsonRecord { + return { + scope: options.scope ?? "runner", terminalPrompt: false, defaultTimeoutMs: defaultGitOperationTimeoutMs, connectTimeoutSeconds: defaultGitConnectTimeoutSeconds, httpVersion: defaultGitHttpVersion, lowSpeedLimitBytes: defaultGitLowSpeedLimitBytes, lowSpeedTimeSeconds: defaultGitLowSpeedTimeSeconds, - credentialHelper: "gh-auth-setup-git", + credentialHelper: options.sshOnly ? null : "gh-auth-setup-git", + allowedProtocols: options.sshOnly ? ["ssh"] : null, + protocolFence: options.sshOnly ? "backend-command-scope" : null, directHosts: [...defaultGitDirectHosts], valuesPrinted: false, }; } + +export function githubSshCommand(mountPathValue: string, configDeclared: boolean): string { + const mountPath = mountPathValue.replace(/\/+$/u, ""); + const configPath = configDeclared ? `${mountPath}/config` : "/dev/null"; + return [ + "ssh", + "-F", configPath, + "-i", `${mountPath}/id_ed25519`, + "-o", "BatchMode=yes", + "-o", "IdentitiesOnly=yes", + "-o", "IdentityAgent=none", + "-o", "PasswordAuthentication=no", + "-o", "KbdInteractiveAuthentication=no", + "-o", "StrictHostKeyChecking=yes", + "-o", `UserKnownHostsFile=${mountPath}/known_hosts`, + ].map(shellQuote).join(" "); +} + +function shellQuote(value: string): string { + return `'${value.replace(/'/gu, `'"'"'`)}'`; +} diff --git a/src/common/validation.ts b/src/common/validation.ts index 8ea0075..1cc0312 100644 --- a/src/common/validation.ts +++ b/src/common/validation.ts @@ -267,7 +267,7 @@ function validateToolCredentials(value: unknown): NonNullable 8) throw new AgentRunError("schema-invalid", "toolCredentials must contain at most 8 entries", { httpStatus: 400 }); const seen = new Set(); - return value.map((credential, index) => { + const result = value.map((credential, index) => { const item = asRecord(credential, `toolCredentials[${index}]`); const tool = requiredString(item, "tool"); if (!allowedToolCredentials.includes(tool as (typeof allowedToolCredentials)[number])) throw new AgentRunError("schema-invalid", `tool credential ${tool} is not supported in v0.1`, { httpStatus: 400, details: { allowedTools: [...allowedToolCredentials] } }); @@ -278,11 +278,16 @@ function validateToolCredentials(value: unknown): NonNullable item.tool === "github" && item.purpose === "github-ssh").length > 1) { + throw new AgentRunError("schema-invalid", "toolCredentials must contain at most one github-ssh credential authority", { httpStatus: 400 }); + } + return result; } function validateToolCredentialProjection(tool: string, projection: JsonRecord, keys: string[], index: number): NonNullable[number]["projection"] { @@ -317,6 +322,19 @@ function validateUnideskSshProjection(envName: string, secretKey: string, keys: } } +function validateGithubSshProjection(projection: NonNullable[number]["projection"], keys: string[], index: number): void { + if (projection.kind !== "volume") { + throw new AgentRunError("schema-invalid", `toolCredentials[${index}] github-ssh must use a volume projection`, { httpStatus: 400 }); + } + const missingKeys = ["id_ed25519", "known_hosts"].filter((key) => !keys.includes(key)); + if (missingKeys.length > 0) { + throw new AgentRunError("schema-invalid", `toolCredentials[${index}] github-ssh secretRef.keys must include id_ed25519 and known_hosts`, { + httpStatus: 400, + details: { missingKeys, valuesPrinted: false }, + }); + } +} + export function validateEnvName(name: string, fieldName: string): void { if (!/^[A-Z_][A-Z0-9_]{0,63}$/u.test(name)) throw new AgentRunError("schema-invalid", `${fieldName} must be an uppercase env name`, { httpStatus: 400 }); } diff --git a/src/mgr/kubernetes-runner-job.ts b/src/mgr/kubernetes-runner-job.ts index a31476a..3826b8e 100644 --- a/src/mgr/kubernetes-runner-job.ts +++ b/src/mgr/kubernetes-runner-job.ts @@ -309,7 +309,8 @@ async function createKubernetesRunnerJobUnderFence(options: { store: AgentRunSto runnerApiKeySecretRef: { ...options.defaults.runnerApiKeySecretRef, valuesPrinted: false }, secretRefs: render.secretRefs.map((item) => ({ profile: item.profile, name: item.secretRef.name, namespace: item.secretRef.namespace ?? render.namespace, keys: item.secretRef.keys ?? [], mountPath: item.runtimeMountPath, projectionPath: item.projectionMountPath, writableCopy: true, valuesPrinted: false })), toolCredentials: summarizeToolCredentials(render.toolCredentials, render.namespace), - gitTransport: gitTransportSummary(), + gitTransport: gitTransportSummary({ scope: "runner" }), + backendGitTransport: hasGithubSshCredential(run.executionPolicy) ? gitTransportSummary({ sshOnly: true, scope: "backend-command" }) : null, transientEnv: summarizeTransientEnv(transientEnv), transientEnvSecret: transientEnvSecretResponse, sessionPvc: sessionPvcSummary, @@ -367,7 +368,8 @@ async function createKubernetesRunnerJobUnderFence(options: { store: AgentRunSto transientEnv: summarizeTransientEnv(transientEnv), transientEnvSecret: transientEnvSecretResponse, envImage, - gitTransport: gitTransportSummary(), + gitTransport: gitTransportSummary({ scope: "runner" }), + backendGitTransport: hasGithubSshCredential(run.executionPolicy) ? gitTransportSummary({ sshOnly: true, scope: "backend-command" }) : null, workReady: staticWorkReadyCapabilitySummary(), retention: preCreateRetention, toolCredentials: summarizeToolCredentials(render.toolCredentials, render.namespace), @@ -399,6 +401,10 @@ function transientEnvField(value: unknown): RunnerTransientEnv[] { }); } +function hasGithubSshCredential(policy: ExecutionPolicy): boolean { + return (policy.secretScope.toolCredentials ?? []).some((item) => item.tool === "github" && item.purpose === "github-ssh" && item.projection.kind === "volume"); +} + function assembleToolContextTransientEnv(policy: ExecutionPolicy, provided: RunnerTransientEnv[], defaults: RunnerJobDefaults): RunnerTransientEnv[] { if (!usesUnideskSsh(policy)) return provided; if (provided.some((item) => unideskSshEndpointEnvNameSet.has(item.name))) return provided; diff --git a/src/runner/github-ssh-credential.ts b/src/runner/github-ssh-credential.ts new file mode 100644 index 0000000..87909d0 --- /dev/null +++ b/src/runner/github-ssh-credential.ts @@ -0,0 +1,115 @@ +import { stat } from "node:fs/promises"; +import path from "node:path"; +import { AgentRunError } from "../common/errors.js"; +import { githubSshCommand, gitTransportEnv, gitTransportSummary } from "../common/git-transport.js"; +import type { ExecutionPolicy, JsonRecord } from "../common/types.js"; +import { stableHash } from "../common/validation.js"; + +const requiredGithubSshKeys = ["id_ed25519", "known_hosts"] as const; + +type ToolCredential = NonNullable[number]; +type GithubSshCredential = ToolCredential & { projection: { kind: "volume"; mountPath: string } }; + +export interface PreparedGithubSshCredential { + env: NodeJS.ProcessEnv; + summary: JsonRecord | null; +} + +export async function prepareGithubSshCredentialEnvironment(policy: ExecutionPolicy, sourceEnv: NodeJS.ProcessEnv): Promise { + const credentials = (policy.secretScope.toolCredentials ?? []).filter((credential) => credential.tool === "github" && credential.purpose === "github-ssh"); + if (credentials.length === 0) return { env: { ...sourceEnv }, summary: null }; + if (credentials.length !== 1) { + throw new AgentRunError("schema-invalid", "github-ssh tool credential must have exactly one durable volume projection", { + httpStatus: 400, + details: { count: credentials.length, valuesPrinted: false }, + }); + } + + const rawCredential = credentials[0]; + const rawFingerprint = credentialFingerprint(rawCredential); + const reference = secretRefSummary(rawCredential); + if (rawCredential.projection.kind !== "volume") { + throw new AgentRunError("secret-unavailable", "declared github-ssh tool credential volume projection is unavailable", { + httpStatus: 503, + details: { reference, presence: { projection: false, keys: {} }, fingerprint: rawFingerprint, valuesPrinted: false }, + }); + } + const credential = rawCredential as GithubSshCredential; + const declaredKeys = [...new Set(credential.secretRef.keys ?? [])]; + const inspectedKeys = [...new Set([...requiredGithubSshKeys, ...declaredKeys])]; + const fingerprint = rawFingerprint; + const projectionPresent = await isDirectory(credential.projection.mountPath); + const keyPresence: Record = {}; + for (const key of inspectedKeys) { + keyPresence[key] = declaredKeys.includes(key) && projectionPresent && await isFile(path.join(credential.projection.mountPath, key)); + } + const presence = { projection: projectionPresent, keys: keyPresence } satisfies JsonRecord; + const available = projectionPresent && inspectedKeys.every((key) => keyPresence[key] === true); + if (!available) { + throw new AgentRunError("secret-unavailable", "declared github-ssh tool credential projection is unavailable or incomplete", { + httpStatus: 503, + details: { reference, presence, fingerprint, valuesPrinted: false }, + }); + } + + const mountPath = credential.projection.mountPath.replace(/\/+$/u, ""); + const gitSshCommand = githubSshCommand(mountPath, declaredKeys.includes("config")); + const env: NodeJS.ProcessEnv = { + ...sourceEnv, + ...gitTransportEnv({ sshOnly: true }), + HOME: path.basename(mountPath) === ".ssh" ? path.dirname(mountPath) : sourceEnv.HOME ?? "/home/agentrun", + GIT_SSH_COMMAND: gitSshCommand, + GIT_SSH_VARIANT: "ssh", + }; + delete env.GIT_SSH; + delete env.SSH_AUTH_SOCK; + return { + env, + summary: { + reference, + presence, + fingerprint, + transport: gitTransportSummary({ sshOnly: true, scope: "backend-command" }), + valuesPrinted: false, + }, + }; +} + +function credentialFingerprint(credential: ToolCredential): string { + return stableHash({ + tool: credential.tool, + purpose: credential.purpose ?? null, + secretRef: { + name: credential.secretRef.name, + namespace: credential.secretRef.namespace ?? null, + keys: credential.secretRef.keys ?? [], + }, + projection: credential.projection, + }); +} + +function secretRefSummary(credential: ToolCredential): JsonRecord { + return { + kind: "SecretRef", + name: credential.secretRef.name, + namespace: credential.secretRef.namespace ?? null, + keys: credential.secretRef.keys ?? [], + valuesPrinted: false, + }; +} + +async function isDirectory(target: string): Promise { + try { + return (await stat(target)).isDirectory(); + } catch { + return false; + } +} + +async function isFile(target: string): Promise { + try { + return (await stat(target)).isFile(); + } catch { + return false; + } +} diff --git a/src/runner/k8s-job.ts b/src/runner/k8s-job.ts index c7b60d2..4cef17c 100644 --- a/src/runner/k8s-job.ts +++ b/src/runner/k8s-job.ts @@ -152,7 +152,8 @@ export function renderRunnerJobDryRun(options: RunnerJobRenderOptions): JsonReco secretRefs: render.secretRefs.map((item) => ({ profile: item.profile, name: item.secretRef.name, namespace: item.secretRef.namespace ?? render.namespace, keys: item.secretRef.keys ?? [], mountPath: item.runtimeMountPath, projectionPath: item.projectionMountPath, writableCopy: true, valuesPrinted: false })), toolCredentials: summarizeToolCredentials(render.toolCredentials, render.namespace), resourceCredential: summarizeResourceCredential(render.resourceCredential, render.namespace), - gitTransport: gitTransportSummary(), + gitTransport: gitTransportSummary({ scope: "runner" }), + backendGitTransport: hasGithubSshCredential(render.toolCredentials) ? gitTransportSummary({ sshOnly: true, scope: "backend-command" }) : null, transientEnv: summarizeTransientEnv(options.transientEnv ?? []), workReady: staticWorkReadyCapabilitySummary(), retention: { @@ -320,7 +321,7 @@ function runnerEnv(options: RunnerJobRenderOptions, context: { namespace: string { name: "AGENTRUN_SESSION_WORKSPACE_PATH", value: context.sessionPvc.workspacePath ?? `${context.sessionPvc.mountPath}/agentrun-workspace` }, ] : []), ...runnerEgressProxyEnvVars(), - ...runnerGitTransportEnvVars(), + ...runnerGitTransportEnvVars({ scope: "runner" }), ...toolCredentialEnvVars(context.toolCredentials), ...transientEnvVars(options.transientEnv ?? []), ]); @@ -377,8 +378,7 @@ function codexShellSandbox(policy: ExecutionPolicy): string { } function toolCredentialEnvVars(items: ToolCredentialProjection[]): JsonRecord[] { - return [ - ...items.filter((item): item is ToolCredentialEnvProjection => item.kind === "env").map((item) => ({ + return items.filter((item): item is ToolCredentialEnvProjection => item.kind === "env").map((item) => ({ name: item.envName, valueFrom: { secretKeyRef: { @@ -386,26 +386,11 @@ function toolCredentialEnvVars(items: ToolCredentialProjection[]): JsonRecord[] key: item.secretKey, }, }, - })), - ...githubSshCommandEnvVars(items), - ]; + })); } -function githubSshCommandEnvVars(items: ToolCredentialProjection[]): JsonRecord[] { - const credential = items.find((item): item is ToolCredentialVolumeProjection => item.kind === "volume" && item.tool === "github" && item.purpose === "github-ssh"); - if (!credential) return []; - const mountPath = credential.mountPath.replace(/\/+$/u, ""); - return [{ - name: "GIT_SSH_COMMAND", - value: [ - "ssh", - "-F", `${mountPath}/config`, - "-i", `${mountPath}/id_ed25519`, - "-o", "IdentitiesOnly=yes", - "-o", "StrictHostKeyChecking=yes", - "-o", `UserKnownHostsFile=${mountPath}/known_hosts`, - ].join(" "), - }]; +function hasGithubSshCredential(items: ToolCredentialProjection[]): boolean { + return items.some((item) => item.kind === "volume" && item.tool === "github" && item.purpose === "github-ssh"); } function toolCredentialVolumeMounts(items: ToolCredentialProjection[]): JsonRecord[] { diff --git a/src/runner/resource-bundle.ts b/src/runner/resource-bundle.ts index 30896d5..9c8d008 100644 --- a/src/runner/resource-bundle.ts +++ b/src/runner/resource-bundle.ts @@ -221,7 +221,7 @@ export async function materializeResourceBundle(resourceBundleRef: ResourceBundl workspaceLifecycle, resolutionAuthority: provenance.resolutionAuthority, primaryWorkspace, - gitTransport: gitTransportSummary(), + gitTransport: gitTransportSummary({ scope: "resource-bundle" }), bundles: { count: materializedBundles.length, items: materializedBundles.map((item) => ({ ...item, valuesPrinted: false })), diff --git a/src/runner/run-once.ts b/src/runner/run-once.ts index 5059605..a0372ce 100644 --- a/src/runner/run-once.ts +++ b/src/runner/run-once.ts @@ -7,6 +7,7 @@ import type { BackendEvent, BackendProfile, CommandRecord, FailureKind, InitialP import { AgentRunError } from "../common/errors.js"; import { smokeBundledWorkReadyCapabilities, smokeImageWorkReadyCapabilities } from "../common/work-ready.js"; import { stableHash } from "../common/validation.js"; +import { prepareGithubSshCredentialEnvironment } from "./github-ssh-credential.js"; export interface RunnerOnceOptions extends BackendAdapterOptions { managerUrl: string; @@ -257,17 +258,25 @@ async function executeCommand(api: RunnerManagerApi, options: RunnerOnceOptions, const acked = await api.getCommand(options.runId, command.id); if (acked.state === "cancelled") return await reportCancelled(api, options.runId, command.id, runner, attemptId, "command cancelled before backend start", runnerLog, options.runnerJobId); await assertNotCancelled(api, options.runId, command.id); - await api.appendEvent(options.runId, { type: "backend_status", payload: { phase: "backend-turn-started", commandId: command.id, attemptId, runnerId: runner.id, backendProfile: options.backendProfile ?? null, workspaceReady: Boolean(workspacePath) } }); - await runnerLog.write("command.started", { runId: options.runId, commandId: command.id, runnerId: runner.id, runnerJobId: options.runnerJobId ?? null, attemptId, backendProfile: options.backendProfile ?? null, valuesPrinted: false }); const abortController = new AbortController(); - const stopCancelWatch = watchCancellation(api, options.runId, command.id, abortController, { attemptId, runnerId: runner.id, ...(options.runnerJobId ? { runnerJobId: options.runnerJobId } : {}), runnerLog }); + let stopCancelWatch: () => void = () => undefined; const backendProgress = startBackendProgress(); + let backendStarted = false; let stopSteerWatch: (() => void) | undefined; const terminalOutboxEvents: BackendEvent[] = []; try { const latestRun = await api.getRun(options.runId); + const preparedCredential = await prepareGithubSshCredentialEnvironment(latestRun.executionPolicy, options.env ?? process.env); + if (preparedCredential.summary) { + await api.appendEvent(options.runId, { type: "backend_status", payload: { phase: "github-ssh-tool-credential-ready", commandId: command.id, attemptId, runnerId: runner.id, githubSsh: preparedCredential.summary, valuesPrinted: false } }); + } + await api.appendEvent(options.runId, { type: "backend_status", payload: { phase: "backend-turn-started", commandId: command.id, attemptId, runnerId: runner.id, backendProfile: latestRun.backendProfile, workspaceReady: Boolean(workspacePath) } }); + backendStarted = true; + await runnerLog.write("command.started", { runId: options.runId, commandId: command.id, runnerId: runner.id, runnerJobId: options.runnerJobId ?? null, attemptId, backendProfile: latestRun.backendProfile, valuesPrinted: false }); + stopCancelWatch = watchCancellation(api, options.runId, command.id, abortController, { attemptId, runnerId: runner.id, ...(options.runnerJobId ? { runnerJobId: options.runnerJobId } : {}), runnerLog }); const backendOptions = { ...options, + env: preparedCredential.env, ...(workspacePath ? { workspacePath } : {}), abortSignal: abortController.signal, otelContext: { @@ -297,7 +306,7 @@ async function executeCommand(api: RunnerManagerApi, options: RunnerOnceOptions, }; }, }; - const retryPolicy = backendRetryPolicy(latestRun.executionPolicy, options.env ?? process.env, options); + const retryPolicy = backendRetryPolicy(latestRun.executionPolicy, preparedCredential.env, options); let backendAttempt = 1; let result = backendSession ? await backendSession.runTurn(latestRun, command, backendOptions) : await runBackendTurn(latestRun, command, backendOptions); while (shouldRetryBackendTurn(result, retryPolicy, backendAttempt)) { @@ -353,12 +362,12 @@ async function executeCommand(api: RunnerManagerApi, options: RunnerOnceOptions, } catch (error) { if (error instanceof TerminalOutboxReportError) throw error; const failureKind = failureKindFromError(error); - const failure = { failureKind, terminalStatus: terminalStatusForFailure(failureKind), message: errorMessage(error) }; - return await reportCommandFailure(api, options.runId, command.id, runner, attemptId, failure, "runner:execute", runnerLog, { ...(options.runnerJobId ? { runnerJobId: options.runnerJobId } : {}), events: terminalOutboxEvents }); + const failure = { failureKind, terminalStatus: terminalStatusForFailure(failureKind), message: errorMessage(error), details: failureDetailsFromError(error) }; + return await reportCommandFailure(api, options.runId, command.id, runner, attemptId, failure, "runner:execute", runnerLog, { terminalRun: failureKind === "secret-unavailable", ...(options.runnerJobId ? { runnerJobId: options.runnerJobId } : {}), events: terminalOutboxEvents }); } finally { stopSteerWatch?.(); const progressSummary = backendProgress.stop(); - await appendBestEffort(api, options.runId, { type: "backend_status", payload: { phase: "backend-turn-finished", commandId: command.id, attemptId, runnerId: runner.id, ...progressSummary } }); + if (backendStarted) await appendBestEffort(api, options.runId, { type: "backend_status", payload: { phase: "backend-turn-finished", commandId: command.id, attemptId, runnerId: runner.id, ...progressSummary } }); stopCancelWatch(); } } diff --git a/src/selftest/cases/20-runner-k8s-job.ts b/src/selftest/cases/20-runner-k8s-job.ts index 085d1f1..8a0116d 100644 --- a/src/selftest/cases/20-runner-k8s-job.ts +++ b/src/selftest/cases/20-runner-k8s-job.ts @@ -56,7 +56,7 @@ const selfTest: SelfTestCase = async (context) => { assertRunnerJobUsesToolCredential(rendered, "GH_TOKEN", "agentrun-v01-tool-github-pr", "GH_TOKEN"); assertRunnerJobUsesToolCredential(rendered, "UNIDESK_SSH_CLIENT_TOKEN", "agentrun-v01-tool-unidesk-ssh", "UNIDESK_SSH_CLIENT_TOKEN"); assertRunnerJobUsesToolCredentialVolume(rendered, "agentrun-v01-tool-github-ssh", "/home/agentrun/.ssh", ["id_ed25519", "known_hosts", "config"]); - assertRunnerJobUsesGithubSshCommand(rendered.manifest as JsonRecord, "/home/agentrun/.ssh"); + assertRunnerJobDefersGithubSshToBackend(rendered.manifest as JsonRecord, rendered.backendGitTransport as JsonRecord); assertRunnerJobUsesG14EgressProxy(rendered.manifest as JsonRecord); assertRunnerJobUsesBoundedGitTransport(rendered); assert.equal(runnerEnvValue(rendered.manifest as JsonRecord, "AGENTRUN_CODEX_SHELL_SANDBOX"), "danger-full-access"); @@ -274,7 +274,7 @@ process.exit(1); assertRunnerJobUsesToolCredential({ manifest, toolCredentials: (created as JsonRecord).toolCredentials } as JsonRecord, "GH_TOKEN", "agentrun-v01-tool-github-pr", "GH_TOKEN"); assertRunnerJobUsesToolCredential({ manifest, toolCredentials: (created as JsonRecord).toolCredentials } as JsonRecord, "UNIDESK_SSH_CLIENT_TOKEN", "agentrun-v01-tool-unidesk-ssh", "UNIDESK_SSH_CLIENT_TOKEN"); assertRunnerJobUsesToolCredentialVolume({ manifest, toolCredentials: (created as JsonRecord).toolCredentials } as JsonRecord, "agentrun-v01-tool-github-ssh", "/home/agentrun/.ssh", ["id_ed25519", "known_hosts", "config"]); - assertRunnerJobUsesGithubSshCommand(manifest, "/home/agentrun/.ssh"); + assertRunnerJobDefersGithubSshToBackend(manifest, (created as JsonRecord).backendGitTransport as JsonRecord); assertNoSecretLeak(created); const defaultEndpointJobItem = await createRunWithCommand(jobClient, { ...context, toolCredentials: unideskSshToolCredentials }, "job create unidesk ssh default endpoint", "selftest-job-create-unidesk-ssh-default-endpoint", 15_000); const defaultEndpointCreated = await jobClient.post(`/api/v1/runs/${defaultEndpointJobItem.runId}/runner-jobs`, { @@ -628,6 +628,8 @@ function assertRunnerJobUsesBoundedGitTransport(rendered: JsonRecord): void { assert.equal(runnerEnvValue(manifest, "GIT_HTTP_LOW_SPEED_TIME"), "15"); assert.equal(runnerEnvValue(manifest, "AGENTRUN_GIT_DEFAULT_TIMEOUT_MS"), "60000"); assert.equal(runnerEnvValue(manifest, "AGENTRUN_GIT_CREDENTIAL_HELPER"), "gh-auth-setup-git"); + assert.equal(runnerEnvValue(manifest, "GIT_ALLOW_PROTOCOL"), undefined, "runner/resource materialization must retain generic HTTP mirror transport"); + assert.equal(runnerEnvValue(manifest, "GIT_CONFIG_COUNT"), undefined); const directHosts = String(runnerEnvValue(manifest, "AGENTRUN_GIT_DIRECT_HOSTS")); assert.equal(directHosts.includes("github.com"), false, "GitHub HTTPS transport should use the runner egress proxy by default"); assert.equal(directHosts.includes("codeload.github.com"), false, "codeload downloads should use the runner egress proxy by default"); @@ -637,15 +639,19 @@ function assertRunnerJobUsesBoundedGitTransport(rendered: JsonRecord): void { assert.equal(summary.terminalPrompt, false); assert.equal(summary.defaultTimeoutMs, 60_000); assert.equal(summary.httpVersion, "HTTP/1.1"); + assert.equal(summary.scope, "runner"); assert.equal(summary.credentialHelper, "gh-auth-setup-git"); + assert.equal(summary.allowedProtocols, null); + assert.equal(summary.protocolFence, null); } -function assertRunnerJobUsesGithubSshCommand(manifest: JsonRecord, mountPath: string): void { - const value = String(runnerEnvValue(manifest, "GIT_SSH_COMMAND")); - assert.ok(value.includes(`-F ${mountPath}/config`), "GIT_SSH_COMMAND must use the mounted SSH config"); - assert.ok(value.includes(`-i ${mountPath}/id_ed25519`), "GIT_SSH_COMMAND must use the mounted identity by absolute path"); - assert.ok(value.includes(`UserKnownHostsFile=${mountPath}/known_hosts`), "GIT_SSH_COMMAND must use mounted known_hosts by absolute path"); - assert.ok(value.includes("StrictHostKeyChecking=yes"), "GIT_SSH_COMMAND must keep strict host key checking enabled"); +function assertRunnerJobDefersGithubSshToBackend(manifest: JsonRecord, backendGitTransport: JsonRecord): void { + assert.equal(runnerEnvValue(manifest, "GIT_SSH_COMMAND"), undefined, "resource materialization must not inherit backend-only SSH transport"); + assert.equal(runnerEnvValue(manifest, "GIT_SSH_VARIANT"), undefined); + assert.equal(backendGitTransport.scope, "backend-command"); + assert.equal(backendGitTransport.credentialHelper, null); + assert.deepEqual(backendGitTransport.allowedProtocols, ["ssh"]); + assert.equal(backendGitTransport.protocolFence, "backend-command-scope"); } function assertRunnerJobUsesToolCredential(rendered: JsonRecord, envName: string, secretName: string, secretKey: string): void { diff --git a/src/selftest/cases/80-github-ssh-transport.ts b/src/selftest/cases/80-github-ssh-transport.ts new file mode 100644 index 0000000..c3927b1 --- /dev/null +++ b/src/selftest/cases/80-github-ssh-transport.ts @@ -0,0 +1,378 @@ +import assert from "node:assert/strict"; +import { execFile as execFileCallback, spawn } from "node:child_process"; +import { randomUUID } from "node:crypto"; +import { access, chmod, mkdir, readFile, rm, writeFile } from "node:fs/promises"; +import { createServer, type Server } from "node:http"; +import path from "node:path"; +import { promisify } from "node:util"; +import { gitTransportEnv } from "../../common/git-transport.js"; +import { startManagerServer } from "../../mgr/server.js"; +import { ManagerClient } from "../../mgr/client.js"; +import { MemoryAgentRunStore } from "../../mgr/store.js"; +import { materializeResourceBundle } from "../../runner/resource-bundle.js"; +import { runOnce } from "../../runner/run-once.js"; +import type { JsonRecord, ResourceBundleRef, WorkspaceRef } from "../../common/types.js"; +import { assertNoSecretLeak, createRunWithCommand, type SelfTestCase } from "../harness.js"; + +const execFile = promisify(execFileCallback); + +const selfTest: SelfTestCase = async (context) => { + const server = await startManagerServer({ + port: 0, + host: "127.0.0.1", + sourceCommit: "self-test", + store: new MemoryAgentRunStore(), + runnerJobDefaults: { + namespace: "agentrun-selftest", + managerUrl: "http://agentrun-mgr.agentrun-selftest.svc.cluster.local:8080", + runnerApiKeySecretRef: { name: "agentrun-selftest-api-key", key: "HWLAB_API_KEY" }, + image: "selftest.invalid/agentrun@sha256:1111111111111111111111111111111111111111111111111111111111111111", + serviceAccountName: "agentrun-selftest-runner", + jobNamePrefix: "agentrun-selftest-runner", + lane: "selftest", + }, + runnerDispatcherOptions: { enabled: false }, + runnerReconcilerOptions: { enabled: false }, + }); + const projectionRoot = path.join("/home/agentrun", `github-ssh-selftest-${process.pid}-${randomUUID()}`); + try { + const client = new ManagerClient(server.baseUrl); + await runInternalHttpMirrorMaterializationCase(context); + await runInheritedCommandEnvironmentCase({ client, managerUrl: server.baseUrl, context, projectionRoot }); + await runMissingProjectionCase({ client, managerUrl: server.baseUrl, context, projectionRoot }); + await runMissingKnownHostsCase({ client, managerUrl: server.baseUrl, context, projectionRoot }); + await assert.rejects( + () => createRunWithCommand(client, { + ...context, + toolCredentials: githubSshToolCredentials(path.join(projectionRoot, "invalid-declaration", ".ssh"), ["id_ed25519"]), + }, "invalid github ssh declaration", "selftest-github-ssh-invalid-declaration", 15_000), + (error) => error instanceof Error && error.message.includes("must include id_ed25519 and known_hosts"), + ); + return { + name: "github-ssh-transport", + tests: [ + "github-ssh-runner-keeps-internal-http-mirror-materialization", + "github-ssh-real-app-server-git-child-env", + "github-ssh-https-token-fallback-fenced", + "github-ssh-missing-projection-fails-before-backend", + "github-ssh-missing-key-fails-before-backend", + "github-ssh-declaration-contract", + ], + }; + } finally { + await rm(projectionRoot, { recursive: true, force: true }); + await new Promise((resolve) => server.server.close(() => resolve())); + } +}; + +async function runInternalHttpMirrorMaterializationCase(context: Parameters[1] & { tmp: string }): Promise { + const sourceRepo = path.join(context.tmp, "github-ssh-mirror-source"); + const mirrorRoot = path.join(context.tmp, "github-ssh-http-mirror"); + const bareRepo = path.join(mirrorRoot, "pikasTech", "agentrun-mirror-probe.git"); + await mkdir(sourceRepo, { recursive: true }); + await execFile("git", ["init"], { cwd: sourceRepo }); + await writeFile(path.join(sourceRepo, "probe.txt"), "internal http mirror remains available\n", "utf8"); + await execFile("git", ["add", "probe.txt"], { cwd: sourceRepo }); + await execFile("git", ["-c", "user.name=AgentRun Mirror SelfTest", "-c", "user.email=mirror@example.invalid", "commit", "-m", "mirror probe"], { cwd: sourceRepo }); + const branch = (await execFile("git", ["branch", "--show-current"], { cwd: sourceRepo })).stdout.trim() || "master"; + await mkdir(path.dirname(bareRepo), { recursive: true }); + await execFile("git", ["clone", "--bare", sourceRepo, bareRepo]); + const mirrorServer = await startGitHttpBackend(mirrorRoot); + try { + const address = mirrorServer.address(); + assert.ok(address && typeof address === "object"); + const mirrorBaseUrl = `http://127.0.0.1:${address.port}`; + const workspacePath = path.join(context.tmp, "github-ssh-http-mirror-workspace"); + const resourceBundleRef: ResourceBundleRef = { + kind: "gitbundle", + repoUrl: "git@github.com:pikasTech/agentrun-mirror-probe.git", + ref: branch, + bundles: [{ name: "mirror-probe", subpath: "probe.txt", targetPath: "mirror-probe.txt" }], + }; + const workspaceRef: WorkspaceRef = { kind: "opaque", path: "." }; + const materialized = await withGenericRunnerGitEnvironment(async () => await materializeResourceBundle(resourceBundleRef, workspaceRef, { + AGENTRUN_GIT_MIRROR_BASE_URL: mirrorBaseUrl, + AGENTRUN_WORKSPACE_ROOT: path.join(context.tmp, "github-ssh-http-mirror-assembly"), + AGENTRUN_WORKSPACE_PATH: workspacePath, + AGENTRUN_RUN_ID: "run_github_ssh_http_mirror_selftest", + })); + assert.ok(materialized); + assert.equal(materialized.event.mirrorUsed, true); + assert.equal(String(materialized.event.fetchRepoUrl).startsWith(`${mirrorBaseUrl}/`), true); + assert.equal(((materialized.event.gitTransport as JsonRecord).scope), "resource-bundle"); + assert.equal(await readFile(path.join(workspacePath, "mirror-probe.txt"), "utf8"), "internal http mirror remains available\n"); + } finally { + await new Promise((resolve) => mirrorServer.close(() => resolve())); + } +} + +async function runInheritedCommandEnvironmentCase(options: { client: ManagerClient; managerUrl: string; context: Parameters[1] & { tmp: string; fakeCodexCommand: string; fakeCodexArgs: string[]; codexHome: string }; projectionRoot: string }): Promise { + const home = path.join(options.projectionRoot, "ready"); + const mountPath = path.join(home, ".ssh"); + await mkdir(mountPath, { recursive: true }); + await Promise.all([ + writeFile(path.join(home, ".gitconfig"), "[user]\n name = AgentRun Artificer\n email = artificer@example.invalid\n", "utf8"), + writeFile(path.join(mountPath, "id_ed25519"), "selftest-private-key-placeholder\n", { mode: 0o400 }), + writeFile(path.join(mountPath, "known_hosts"), "selftest-known-host-placeholder\n", { mode: 0o400 }), + writeFile(path.join(mountPath, "config"), "Host github.com\n User git\n", { mode: 0o400 }), + ]); + const fakeBin = path.join(options.context.tmp, "github-ssh-fake-bin"); + const sshCaptureFile = path.join(options.context.tmp, "github-ssh-child-argv.txt"); + const appServerProbeFile = path.join(options.context.tmp, "github-ssh-app-server-probe.json"); + await mkdir(fakeBin, { recursive: true }); + const fakeSsh = path.join(fakeBin, "ssh"); + await writeFile(fakeSsh, "#!/bin/sh\nprintf '%s\\n' \"$*\" >> \"$AGENTRUN_FAKE_SSH_CAPTURE_FILE\"\nexit 1\n", "utf8"); + await chmod(fakeSsh, 0o755); + + const item = await createRunWithCommand(options.client, { + ...options.context, + toolCredentials: githubSshToolCredentials(mountPath), + }, "github ssh command environment", "selftest-github-ssh-command-environment", 15_000); + const result = await runOnce({ + managerUrl: options.managerUrl, + runId: item.runId, + commandId: item.commandId, + codexCommand: options.context.fakeCodexCommand, + codexArgs: options.context.fakeCodexArgs, + codexHome: options.context.codexHome, + env: { + CODEX_HOME: options.context.codexHome, + HOME: "/root", + PATH: `${fakeBin}:${process.env.PATH ?? ""}`, + GH_TOKEN: "test-token-material", + GIT_ALLOW_PROTOCOL: "https", + GIT_SSH_COMMAND: "ssh -o StrictHostKeyChecking=no", + AGENTRUN_GIT_CREDENTIAL_HELPER: "gh-auth-setup-git", + AGENTRUN_FAKE_CODEX_MODE: "github-ssh-transport-probe", + AGENTRUN_FAKE_CODEX_GIT_PROBE_FILE: appServerProbeFile, + AGENTRUN_FAKE_SSH_CAPTURE_FILE: sshCaptureFile, + }, + oneShot: true, + }) as JsonRecord; + assert.equal(result.terminalStatus, "completed"); + assert.equal(result.failureKind, null); + + const probe = JSON.parse(await readFile(appServerProbeFile, "utf8")) as JsonRecord; + assert.equal(probe.home, home, "backend app-server HOME must align with the declared .ssh projection"); + assert.equal(probe.gitSshCommandPresent, true); + assert.equal(probe.gitSshVariant, "ssh"); + assert.equal(probe.allowedProtocols, "ssh"); + assert.equal(probe.credentialHelperMode, "disabled"); + assert.equal(probe.gitConfigGlobalOverridden, false, "backend fence must preserve global git identity configuration"); + assert.equal(probe.gitConfigNoSystem, false, "backend fence must not discard system git configuration"); + assert.equal(probe.ghTokenPresent, true, "GH_TOKEN remains available to gh API/PR operations"); + assert.equal(probe.sshAuthSockPresent, false, "undeclared SSH agent fallback must be removed"); + assert.equal(probe.legacyGitSshPresent, false, "legacy GIT_SSH override must be removed"); + assert.equal(probe.httpsBlocked, true, "real downstream git must reject HTTPS before network/auth fallback"); + assert.equal(probe.credentialHelperDisabled, true); + assert.equal(probe.gitUserName, "AgentRun Artificer"); + assert.equal(probe.gitUserEmail, "artificer@example.invalid"); + const sshArgv = await readFile(sshCaptureFile, "utf8"); + assert.ok(sshArgv.includes(path.join(mountPath, "id_ed25519")), "real downstream git must use the declared identity path"); + assert.ok(sshArgv.includes(`UserKnownHostsFile=${path.join(mountPath, "known_hosts")}`), "real downstream git must use the declared known_hosts path"); + assert.ok(sshArgv.includes("StrictHostKeyChecking=yes")); + assert.equal(sshArgv.includes("test-token-material"), false); + + const events = await runEvents(options.client, item.runId); + const readyIndex = events.findIndex((event) => event.type === "backend_status" && payload(event).phase === "github-ssh-tool-credential-ready"); + const startedIndex = events.findIndex((event) => event.type === "backend_status" && payload(event).phase === "backend-turn-started"); + assert.ok(readyIndex >= 0 && startedIndex > readyIndex, "credential preflight must complete before backend-turn-started"); + const ready = payload(events[readyIndex]).githubSsh as JsonRecord; + const readyText = JSON.stringify(ready); + assert.equal(readyText.includes(mountPath), false, "credential readiness event must not disclose projection paths"); + assert.equal(((ready.reference as JsonRecord).kind), "SecretRef"); + assert.equal(((ready.reference as JsonRecord).name), "agentrun-v01-tool-github-ssh"); + assert.equal(((ready.presence as JsonRecord).projection), true); + assert.equal(typeof ready.fingerprint, "string"); + assertNoSecretLeak(probe); + assertNoSecretLeak(events); +} + +async function runMissingProjectionCase(options: { client: ManagerClient; managerUrl: string; context: Parameters[1] & { tmp: string; fakeCodexCommand: string; fakeCodexArgs: string[]; codexHome: string }; projectionRoot: string }): Promise { + const missingMount = path.join(options.projectionRoot, "missing-projection", ".ssh"); + await assertCredentialUnavailableBeforeBackend(options, missingMount, "selftest-github-ssh-missing-projection", (details) => { + const presence = details.presence as JsonRecord; + assert.equal(presence.projection, false); + }); +} + +async function runMissingKnownHostsCase(options: { client: ManagerClient; managerUrl: string; context: Parameters[1] & { tmp: string; fakeCodexCommand: string; fakeCodexArgs: string[]; codexHome: string }; projectionRoot: string }): Promise { + const missingKnownHosts = path.join(options.projectionRoot, "missing-known-hosts", ".ssh"); + await mkdir(missingKnownHosts, { recursive: true }); + await Promise.all([ + writeFile(path.join(missingKnownHosts, "id_ed25519"), "selftest-private-key-placeholder\n", { mode: 0o400 }), + writeFile(path.join(missingKnownHosts, "config"), "Host github.com\n User git\n", { mode: 0o400 }), + ]); + await assertCredentialUnavailableBeforeBackend(options, missingKnownHosts, "selftest-github-ssh-missing-known-hosts", (details) => { + const presence = details.presence as JsonRecord; + const keys = presence.keys as JsonRecord; + assert.equal(presence.projection, true); + assert.equal(keys.id_ed25519, true); + assert.equal(keys.known_hosts, false); + assert.equal(keys.config, true); + }); +} + +async function assertCredentialUnavailableBeforeBackend( + options: { client: ManagerClient; managerUrl: string; context: Parameters[1] & { tmp: string; fakeCodexCommand: string; fakeCodexArgs: string[]; codexHome: string } }, + mountPath: string, + idempotencyKey: string, + assertDetails: (details: JsonRecord) => void, +): Promise { + const startFile = path.join(options.context.tmp, `${idempotencyKey}-backend-started.txt`); + const item = await createRunWithCommand(options.client, { + ...options.context, + toolCredentials: githubSshToolCredentials(mountPath), + }, idempotencyKey, idempotencyKey, 15_000); + const result = await runOnce({ + managerUrl: options.managerUrl, + runId: item.runId, + commandId: item.commandId, + codexCommand: options.context.fakeCodexCommand, + codexArgs: options.context.fakeCodexArgs, + codexHome: options.context.codexHome, + env: { + CODEX_HOME: options.context.codexHome, + GH_TOKEN: "test-token-material", + AGENTRUN_FAKE_CODEX_START_FILE: startFile, + }, + oneShot: true, + }) as JsonRecord; + assert.equal(result.terminalStatus, "blocked"); + assert.equal(result.failureKind, "secret-unavailable"); + await assert.rejects(() => access(startFile), "fake app-server process must not start when credential preflight fails"); + const events = await runEvents(options.client, item.runId); + assert.equal(events.some((event) => event.type === "backend_status" && payload(event).phase === "backend-turn-started"), false); + const failureEvent = events.find((event) => event.type === "error" && payload(event).failureKind === "secret-unavailable"); + assert.ok(failureEvent, "typed secret-unavailable event must be durable"); + const details = payload(failureEvent).details as JsonRecord; + assert.equal(((details.reference as JsonRecord).kind), "SecretRef"); + assert.equal(((details.reference as JsonRecord).name), "agentrun-v01-tool-github-ssh"); + assert.equal(typeof details.fingerprint, "string"); + assertDetails(details); + assert.equal(JSON.stringify(details).includes(mountPath), false, "failure details must not disclose projection paths or Secret values"); + assertNoSecretLeak(events); +} + +function githubSshToolCredentials(mountPath: string, keys = ["id_ed25519", "known_hosts", "config"]): JsonRecord[] { + return [{ + tool: "github", + purpose: "github-ssh", + secretRef: { name: "agentrun-v01-tool-github-ssh", keys }, + projection: { kind: "volume", mountPath }, + }]; +} + +async function runEvents(client: ManagerClient, runId: string): Promise> { + const response = await client.get(`/api/v1/runs/${runId}/events?afterSeq=0&limit=100`) as { items?: Array<{ type: string; payload: unknown }> }; + return response.items ?? []; +} + +function payload(event: { payload: unknown }): JsonRecord { + return event.payload as JsonRecord; +} + +async function withGenericRunnerGitEnvironment(operation: () => Promise): Promise { + const generic = gitTransportEnv({ scope: "runner" }); + const backendOnlyKeys = [ + "GIT_ALLOW_PROTOCOL", + "GIT_CONFIG_COUNT", + "GIT_CONFIG_KEY_0", + "GIT_CONFIG_VALUE_0", + "GIT_CONFIG_KEY_1", + "GIT_CONFIG_VALUE_1", + "GIT_CONFIG_KEY_2", + "GIT_CONFIG_VALUE_2", + "GIT_ASKPASS", + "SSH_ASKPASS", + "SSH_ASKPASS_REQUIRE", + "GIT_SSH", + "GIT_SSH_COMMAND", + "GIT_SSH_VARIANT", + ]; + const keys = [...new Set([...Object.keys(generic), ...backendOnlyKeys])]; + const previous = new Map(keys.map((key) => [key, process.env[key]])); + try { + Object.assign(process.env, generic); + for (const key of backendOnlyKeys) delete process.env[key]; + return await operation(); + } finally { + for (const [key, value] of previous) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + } +} + +async function startGitHttpBackend(projectRoot: string): Promise { + const server = createServer((request, response) => { + const requestUrl = new URL(request.url ?? "/", "http://127.0.0.1"); + const child = spawn("git", ["http-backend"], { + env: { + ...process.env, + GIT_PROJECT_ROOT: projectRoot, + GIT_HTTP_EXPORT_ALL: "1", + PATH_INFO: requestUrl.pathname, + QUERY_STRING: requestUrl.search.slice(1), + REQUEST_METHOD: request.method ?? "GET", + CONTENT_TYPE: String(request.headers["content-type"] ?? ""), + CONTENT_LENGTH: String(request.headers["content-length"] ?? ""), + SERVER_PROTOCOL: `HTTP/${request.httpVersion}`, + SERVER_NAME: "127.0.0.1", + SERVER_PORT: "0", + REMOTE_ADDR: request.socket.remoteAddress ?? "127.0.0.1", + }, + stdio: ["pipe", "pipe", "pipe"], + }); + let headerBuffer = Buffer.alloc(0); + let headersSent = false; + child.stderr.resume(); + child.stdout.on("data", (chunk: Buffer) => { + if (headersSent) { + response.write(chunk); + return; + } + headerBuffer = Buffer.concat([headerBuffer, chunk]); + const separator = headerBuffer.indexOf("\r\n\r\n"); + const fallbackSeparator = separator < 0 ? headerBuffer.indexOf("\n\n") : -1; + const headerEnd = separator >= 0 ? separator : fallbackSeparator; + if (headerEnd < 0) return; + const separatorBytes = separator >= 0 ? 4 : 2; + const headerText = headerBuffer.subarray(0, headerEnd).toString("utf8"); + const headers: Record = {}; + let statusCode = 200; + for (const line of headerText.split(/\r?\n/u)) { + const colon = line.indexOf(":"); + if (colon < 0) continue; + const name = line.slice(0, colon).trim(); + const value = line.slice(colon + 1).trim(); + if (name.toLowerCase() === "status") statusCode = Number.parseInt(value, 10) || 500; + else headers[name] = value; + } + response.writeHead(statusCode, headers); + headersSent = true; + const body = headerBuffer.subarray(headerEnd + separatorBytes); + if (body.length > 0) response.write(body); + headerBuffer = Buffer.alloc(0); + }); + child.on("error", () => { + if (!headersSent) response.writeHead(500); + response.end(); + }); + child.on("close", (code) => { + if (!headersSent) response.writeHead(code === 0 ? 200 : 500); + response.end(); + }); + request.pipe(child.stdin); + }); + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", () => { + server.off("error", reject); + resolve(); + }); + }); + return server; +} + +export default selfTest; diff --git a/src/selftest/fake-codex-app-server.ts b/src/selftest/fake-codex-app-server.ts index f03c111..dadaf5b 100644 --- a/src/selftest/fake-codex-app-server.ts +++ b/src/selftest/fake-codex-app-server.ts @@ -1,5 +1,6 @@ import * as readline from "node:readline"; -import { appendFileSync } from "node:fs"; +import { spawnSync } from "node:child_process"; +import { appendFileSync, writeFileSync } from "node:fs"; const rl = readline.createInterface({ input: process.stdin, crlfDelay: Infinity }); const mode = process.env.AGENTRUN_FAKE_CODEX_MODE ?? "success"; @@ -265,6 +266,20 @@ for await (const line of rl) { respond(message.id, { turn }); continue; } + if (mode === "github-ssh-transport-probe") { + turnCounter += 1; + const probe = runGithubSshTransportProbe(); + const completed = probe.httpsBlocked === true && probe.credentialHelperDisabled === true && probe.gitSshCommandPresent === true && probe.ghTokenPresent === true && probe.gitUserName === "AgentRun Artificer" && probe.gitUserEmail === "artificer@example.invalid"; + const turn = completed + ? { id: `turn_selftest_${turnCounter}`, status: "completed" } + : { id: `turn_selftest_${turnCounter}`, status: "failed", error: { message: "github-ssh transport probe failed" } }; + notify("turn/started", { turn: { id: turn.id, status: "running" } }); + notify("item/started", { item: { id: "github_ssh_transport_probe", type: "commandExecution", command: "git transport probe", status: "running" } }); + notify("item/completed", { item: { id: "github_ssh_transport_probe", type: "commandExecution", command: "git transport probe", status: completed ? "completed" : "failed", exitCode: completed ? 0 : 1 } }); + notify("turn/completed", { turn }); + respond(message.id, { turn }); + continue; + } if (mode === "slow-tool-events") { turnCounter += 1; const turn = { id: `turn_selftest_${turnCounter}`, status: "completed" }; @@ -410,3 +425,33 @@ function steerText(input: unknown): string { return typeof text === "string" ? [text] : []; }).join(""); } + +function runGithubSshTransportProbe(): Record { + const sshProbe = spawnSync("git", ["ls-remote", "git@github.com:pikasTech/agentrun-transport-probe.git"], { env: process.env, encoding: "utf8", timeout: 5_000 }); + const httpsProbe = spawnSync("git", ["ls-remote", "https://github.com/pikasTech/agentrun.git"], { env: process.env, encoding: "utf8", timeout: 5_000 }); + const credentialHelper = spawnSync("git", ["config", "--get-all", "credential.helper"], { env: process.env, encoding: "utf8", timeout: 5_000 }); + const gitUserName = spawnSync("git", ["config", "--get", "user.name"], { env: process.env, encoding: "utf8", timeout: 5_000 }); + const gitUserEmail = spawnSync("git", ["config", "--get", "user.email"], { env: process.env, encoding: "utf8", timeout: 5_000 }); + const payload = { + home: process.env.HOME ?? null, + gitSshCommandPresent: Boolean(process.env.GIT_SSH_COMMAND), + gitSshVariant: process.env.GIT_SSH_VARIANT ?? null, + allowedProtocols: process.env.GIT_ALLOW_PROTOCOL ?? null, + credentialHelperMode: process.env.AGENTRUN_GIT_CREDENTIAL_HELPER ?? null, + gitConfigGlobalOverridden: Boolean(process.env.GIT_CONFIG_GLOBAL), + gitConfigNoSystem: Boolean(process.env.GIT_CONFIG_NOSYSTEM), + ghTokenPresent: Boolean(process.env.GH_TOKEN), + sshAuthSockPresent: Boolean(process.env.SSH_AUTH_SOCK), + legacyGitSshPresent: Boolean(process.env.GIT_SSH), + sshGitExitCode: sshProbe.status, + httpsGitExitCode: httpsProbe.status, + httpsBlocked: /transport ['"]https['"] not allowed/iu.test(String(httpsProbe.stderr ?? "")), + credentialHelperDisabled: String(credentialHelper.stdout ?? "").trim().length === 0, + gitUserName: String(gitUserName.stdout ?? "").trim() || null, + gitUserEmail: String(gitUserEmail.stdout ?? "").trim() || null, + valuesPrinted: false, + }; + const outputFile = process.env.AGENTRUN_FAKE_CODEX_GIT_PROBE_FILE; + if (outputFile) writeFileSync(outputFile, JSON.stringify(payload), "utf8"); + return payload; +}