Merge pull request #315 from pikasTech/fix/313-artificer-github-ssh

fix: 收紧 Artificer GitHub SSH 传输边界
This commit is contained in:
Lyon
2026-07-12 14:30:24 +08:00
committed by GitHub
10 changed files with 666 additions and 56 deletions
+62 -14
View File
@@ -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<string, string> {
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, `'"'"'`)}'`;
}
+19 -1
View File
@@ -267,7 +267,7 @@ function validateToolCredentials(value: unknown): NonNullable<ExecutionPolicy["s
if (!Array.isArray(value)) throw new AgentRunError("schema-invalid", "toolCredentials must be an array", { httpStatus: 400 });
if (value.length > 8) throw new AgentRunError("schema-invalid", "toolCredentials must contain at most 8 entries", { httpStatus: 400 });
const seen = new Set<string>();
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<ExecutionPolicy["s
const projection = asRecord(item.projection, `toolCredentials[${index}].projection`);
const kind = requiredString(projection, "kind");
const normalizedProjection = validateToolCredentialProjection(tool, projection, keys, index);
if (tool === "github" && purpose === "github-ssh") validateGithubSshProjection(normalizedProjection, keys, index);
const identity = `${tool}:${purpose ?? ""}:${kind}:${normalizedProjection.kind === "env" ? normalizedProjection.envName : normalizedProjection.mountPath}`;
if (seen.has(identity)) throw new AgentRunError("schema-invalid", `tool credential projection ${identity} is duplicated`, { httpStatus: 400 });
seen.add(identity);
return { tool, ...(purpose ? { purpose } : {}), secretRef, projection: normalizedProjection };
});
if (result.filter((item) => 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<ExecutionPolicy["secretScope"]["toolCredentials"]>[number]["projection"] {
@@ -317,6 +322,19 @@ function validateUnideskSshProjection(envName: string, secretKey: string, keys:
}
}
function validateGithubSshProjection(projection: NonNullable<ExecutionPolicy["secretScope"]["toolCredentials"]>[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 });
}
+8 -2
View File
@@ -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;
+115
View File
@@ -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<ExecutionPolicy["secretScope"]["toolCredentials"]>[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<PreparedGithubSshCredential> {
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<string, boolean> = {};
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<boolean> {
try {
return (await stat(target)).isDirectory();
} catch {
return false;
}
}
async function isFile(target: string): Promise<boolean> {
try {
return (await stat(target)).isFile();
} catch {
return false;
}
}
+7 -22
View File
@@ -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[] {
+1 -1
View File
@@ -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 })),
+16 -7
View File
@@ -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();
}
}
+14 -8
View File
@@ -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 {
@@ -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<void>((resolve) => server.server.close(() => resolve()));
}
};
async function runInternalHttpMirrorMaterializationCase(context: Parameters<typeof createRunWithCommand>[1] & { tmp: string }): Promise<void> {
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<void>((resolve) => mirrorServer.close(() => resolve()));
}
}
async function runInheritedCommandEnvironmentCase(options: { client: ManagerClient; managerUrl: string; context: Parameters<typeof createRunWithCommand>[1] & { tmp: string; fakeCodexCommand: string; fakeCodexArgs: string[]; codexHome: string }; projectionRoot: string }): Promise<void> {
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<typeof createRunWithCommand>[1] & { tmp: string; fakeCodexCommand: string; fakeCodexArgs: string[]; codexHome: string }; projectionRoot: string }): Promise<void> {
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<typeof createRunWithCommand>[1] & { tmp: string; fakeCodexCommand: string; fakeCodexArgs: string[]; codexHome: string }; projectionRoot: string }): Promise<void> {
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<typeof createRunWithCommand>[1] & { tmp: string; fakeCodexCommand: string; fakeCodexArgs: string[]; codexHome: string } },
mountPath: string,
idempotencyKey: string,
assertDetails: (details: JsonRecord) => void,
): Promise<void> {
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<Array<{ type: string; payload: unknown }>> {
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<T>(operation: () => Promise<T>): Promise<T> {
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<Server> {
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<string, string> = {};
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<void>((resolve, reject) => {
server.once("error", reject);
server.listen(0, "127.0.0.1", () => {
server.off("error", reject);
resolve();
});
});
return server;
}
export default selfTest;
+46 -1
View File
@@ -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<string, unknown> {
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;
}