Merge pull request #315 from pikasTech/fix/313-artificer-github-ssh
fix: 收紧 Artificer GitHub SSH 传输边界
This commit is contained in:
+62
-14
@@ -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, `'"'"'`)}'`;
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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[] {
|
||||
|
||||
@@ -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
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user