fix: 修复 runner Codex shell 工具环境

This commit is contained in:
Codex
2026-06-09 01:06:19 +08:00
parent 437822e5fd
commit aeb49460b7
9 changed files with 92 additions and 15 deletions
+12 -3
View File
@@ -248,7 +248,7 @@ async function sessionTurn(args: ParsedArgs, positionalSessionId: string | null)
copyOptionalFlag(args, runnerBody, "attempt-id", "attemptId");
copyOptionalFlag(args, runnerBody, "runner-id", "runnerId");
copyOptionalFlag(args, runnerBody, "source-commit", "sourceCommit");
copyOptionalFlag(args, runnerBody, "runner-manager-url", "managerUrl");
copyRunnerManagerUrlFlag(args, runnerBody);
copyOptionalFlag(args, runnerBody, "service-account-name", "serviceAccountName");
const runnerIdempotencyKey = optionalFlag(args, "runner-idempotency-key");
if (runnerIdempotencyKey) runnerBody.idempotencyKey = runnerIdempotencyKey;
@@ -314,7 +314,7 @@ async function dispatchQueueTask(args: ParsedArgs, taskId: string): Promise<Json
copy("attempt-id", "attemptId");
copy("runner-id", "runnerId");
copy("source-commit", "sourceCommit");
copy("runner-manager-url", "managerUrl");
copyRunnerManagerUrlFlag(args, body);
copy("service-account-name", "serviceAccountName");
return client(args).post(`/api/v1/queue/tasks/${encodeURIComponent(taskId)}/dispatch`, body);
}
@@ -346,7 +346,7 @@ async function renderRunnerJob(args: ParsedArgs): Promise<JsonRecord> {
if (attemptId) body.attemptId = attemptId;
if (runnerId) body.runnerId = runnerId;
if (sourceCommit) body.sourceCommit = sourceCommit;
if (runnerManagerUrl) body.managerUrl = runnerManagerUrl;
if (runnerManagerUrl) body.managerUrl = resolveRunnerManagerUrlFlag(args, runnerManagerUrl);
if (idempotencyKey) body.idempotencyKey = idempotencyKey;
return await client(args).post(`/api/v1/runs/${encodeURIComponent(runId)}/runner-jobs`, body) as JsonRecord;
}
@@ -754,6 +754,15 @@ function copyOptionalFlag(args: ParsedArgs, target: JsonRecord, flagName: string
if (value) target[key] = value;
}
function copyRunnerManagerUrlFlag(args: ParsedArgs, target: JsonRecord): void {
const value = optionalFlag(args, "runner-manager-url");
if (value) target.managerUrl = resolveRunnerManagerUrlFlag(args, value);
}
function resolveRunnerManagerUrlFlag(args: ParsedArgs, value: string): string {
return value === "auto" ? managerUrl(args) : value;
}
function readerQuery(args: ParsedArgs): string {
const readerId = optionalFlag(args, "reader-id");
return readerId ? `?readerId=${encodeURIComponent(readerId)}` : "";
+9 -1
View File
@@ -46,12 +46,15 @@ export function createBackendSession(run: RunRecord, options: BackendAdapterOpti
export function backendTurnOptions(run: RunRecord, command: CommandRecord, options: BackendAdapterOptions = {}): CodexStdioTurnOptions {
const prompt = typeof command.payload.prompt === "string" ? command.payload.prompt : JSON.stringify(command.payload);
const sandboxOverride = codexShellSandboxOverride(options.env ?? process.env);
const turnOptions: CodexStdioTurnOptions = {
backendProfile: run.backendProfile,
prompt,
cwd: options.workspacePath ?? (typeof run.workspaceRef.path === "string" ? run.workspaceRef.path : process.cwd()),
approvalPolicy: run.executionPolicy.approval,
sandbox: run.executionPolicy.sandbox,
sandbox: sandboxOverride ?? run.executionPolicy.sandbox,
requestedSandbox: run.executionPolicy.sandbox,
sandboxOverrideSource: sandboxOverride ? "AGENTRUN_CODEX_SHELL_SANDBOX" : null,
timeoutMs: run.executionPolicy.timeoutMs,
};
if (typeof command.payload.model === "string") turnOptions.model = command.payload.model;
@@ -67,3 +70,8 @@ export function backendTurnOptions(run: RunRecord, command: CommandRecord, optio
if (options.onActiveTurn) turnOptions.onActiveTurn = options.onActiveTurn;
return turnOptions;
}
function codexShellSandboxOverride(env: NodeJS.ProcessEnv): string | null {
const value = env.AGENTRUN_CODEX_SHELL_SANDBOX?.trim();
return value && value.length > 0 ? value : null;
}
+8
View File
@@ -45,6 +45,8 @@ export interface CodexStdioTurnOptions {
threadId?: string;
approvalPolicy: string;
sandbox: string;
requestedSandbox?: string;
sandboxOverrideSource?: string | null;
timeoutMs: number;
command?: string;
args?: string[];
@@ -1065,6 +1067,12 @@ function backendMetadata(options: CodexStdioTurnOptions): JsonRecord {
backendKind: spec?.backendKind ?? "codex-app-server-stdio",
protocol: spec?.protocol ?? codexProtocol,
transport: spec?.transport ?? "stdio",
sandbox: {
requested: options.requestedSandbox ?? options.sandbox,
effective: options.sandbox,
overrideSource: options.sandboxOverrideSource ?? null,
valuesPrinted: false,
},
};
}
+7
View File
@@ -4,6 +4,7 @@ import { backendProfileSpec } from "../common/backend-profiles.js";
const defaultBootRepoUrl = "http://git-mirror-http.devops-infra.svc.cluster.local/pikasTech/agentrun.git";
const defaultResourceBinPath = "/usr/local/bin";
const defaultCodexShellSandbox = "danger-full-access";
export interface RunnerJobRenderOptions {
run: RunRecord;
@@ -177,6 +178,7 @@ function runnerEnv(options: RunnerJobRenderOptions, context: { namespace: string
{ name: "AGENTRUN_RUNNER_ID", value: context.runnerId },
{ name: "AGENTRUN_BACKEND_PROFILE", value: options.run.backendProfile },
{ name: "AGENTRUN_EXECUTION_POLICY_JSON", value: JSON.stringify(options.run.executionPolicy) },
{ name: "AGENTRUN_CODEX_SHELL_SANDBOX", value: codexShellSandbox(options.run.executionPolicy) },
{ name: "AGENTRUN_SESSION_REF_JSON", value: JSON.stringify(options.run.sessionRef ?? null) },
{ name: "AGENTRUN_RESOURCE_BUNDLE_JSON", value: JSON.stringify(options.run.resourceBundleRef ?? null) },
{ name: "AGENTRUN_WORKSPACE_ROOT", value: "/home/agentrun/workspaces" },
@@ -205,6 +207,11 @@ function runnerEnv(options: RunnerJobRenderOptions, context: { namespace: string
];
}
function codexShellSandbox(policy: ExecutionPolicy): string {
if (policy.sandbox === "workspace-write") return defaultCodexShellSandbox;
return policy.sandbox;
}
function toolCredentialEnvVars(items: ToolCredentialProjection[]): JsonRecord[] {
return items.map((item) => ({
name: item.envName,
+29 -7
View File
@@ -90,7 +90,7 @@ export async function materializeResourceBundle(resourceBundleRef: ResourceBundl
};
const defaultCheckout = await checkoutFor(defaultSource);
const materializedBundles = await materializeGitBundles(workspacePath, resourceBundleRef, defaultSource, defaultCheckout, checkoutFor);
const tools = await prepareGitBundleTools(workspacePath);
const tools = await prepareGitBundleTools(workspacePath, env);
const skills = await discoverGitBundleSkills(workspacePath);
const prompts = await materializePromptRefs(defaultCheckout.checkoutPath, resourceBundleRef.promptRefs ?? []);
const initialPrompt = assembleInitialPrompt(prompts.items, skills.items);
@@ -193,28 +193,50 @@ function optionalNonEmpty(value: unknown): string | undefined {
return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined;
}
async function prepareGitBundleTools(workspacePath: string): Promise<{ binPath?: string; event: JsonRecord }> {
const binPath = path.join(workspacePath, "tools");
async function prepareGitBundleTools(workspacePath: string, env: NodeJS.ProcessEnv): Promise<{ binPath?: string; event: JsonRecord }> {
const sourceBinPath = path.join(workspacePath, "tools");
const installedBinPath = optionalNonEmpty(env.AGENTRUN_RESOURCE_BIN_PATH);
const runtimeBinPath = installedBinPath ?? sourceBinPath;
let entries;
try {
entries = await readdir(binPath, { withFileTypes: true });
entries = await readdir(sourceBinPath, { withFileTypes: true });
} catch (error) {
if (error && typeof error === "object" && "code" in error && (error as { code?: unknown }).code === "ENOENT") return { event: { count: 0, names: [], binPath: null, valuesPrinted: false } };
if (error && typeof error === "object" && "code" in error && (error as { code?: unknown }).code === "ENOENT") return { event: { count: 0, names: [], binPath: null, sourceBinPath: null, installedBinPath: null, installed: false, valuesPrinted: false } };
throw error;
}
const names: string[] = [];
const items: JsonRecord[] = [];
if (installedBinPath) await mkdir(installedBinPath, { recursive: true });
for (const entry of entries) {
if (!entry.isFile()) continue;
const filePath = path.join(binPath, entry.name);
const filePath = path.join(sourceBinPath, entry.name);
const text = await readFile(filePath, "utf8");
const firstLine = text.split(/\r?\n/u, 1)[0] ?? "";
if (!firstLine.startsWith("#!")) continue;
await chmod(filePath, 0o755);
if (installedBinPath) {
const targetPath = path.join(installedBinPath, entry.name);
if (targetPath !== filePath) {
await cp(filePath, targetPath, { force: true, dereference: false });
await chmod(targetPath, 0o755);
}
}
names.push(entry.name);
items.push({ name: entry.name, sha256: sha256Text(text), bytes: Buffer.byteLength(text, "utf8"), shebang: firstLine.slice(0, 80), valuesPrinted: false });
}
return { binPath, event: { count: names.length, names, items, binPath: pathSummary(binPath), valuesPrinted: false } };
return {
...(names.length > 0 ? { binPath: runtimeBinPath } : {}),
event: {
count: names.length,
names,
items,
binPath: names.length > 0 ? pathSummary(runtimeBinPath) : null,
sourceBinPath: pathSummary(sourceBinPath),
installedBinPath: installedBinPath ? pathSummary(installedBinPath) : null,
installed: Boolean(installedBinPath && names.length > 0),
valuesPrinted: false,
},
};
}
async function materializePromptRefs(checkoutPath: string, refs: NonNullable<ResourceBundleRef["promptRefs"]>): Promise<{ items: MaterializedPromptRef[]; event: JsonRecord }> {
+2 -1
View File
@@ -42,6 +42,7 @@ const selfTest: SelfTestCase = async (context) => {
assertRunnerJobUsesWritableCodexHome(rendered.manifest as JsonRecord, context.codexHome, "codex-0", "/var/run/agentrun/secrets/codex-0");
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");
assert.equal(runnerEnvValue(rendered.manifest as JsonRecord, "AGENTRUN_CODEX_SHELL_SANDBOX"), "danger-full-access");
assert.equal(runnerEnvValue(rendered.manifest as JsonRecord, "HWLAB_API_KEY"), "REDACTED");
assert.deepEqual((((rendered.transientEnv as JsonRecord).names) as string[]), ["HWLAB_API_KEY"]);
assertNoSecretLeak(rendered);
@@ -227,7 +228,7 @@ console.log(JSON.stringify({ apiVersion: manifest.apiVersion, kind: manifest.kin
assert.equal(envMap.get("AGENTRUN_SESSION_PVC_NAMESPACE"), "agentrun-v01");
assert.equal(envMap.get("AGENTRUN_SESSION_PVC_MOUNT_PATH"), "/home/agentrun/.codex-codex/sessions");
assert.equal(envMap.get("AGENTRUN_CODEX_ROLLOUT_SUBDIR"), "sessions");
return { name: "runner-k8s-job", tests: ["runner-k8s-job-dry-run", "runner-k8s-job-deepseek-profile-dry-run", "runner-k8s-job-minimax-m3-profile-dry-run", "runner-k8s-job-dsflash-go-profile-dry-run", "runner-k8s-job-dsflash-go-legacy-secretref-normalized", "runner-k8s-job-create-api", "runner-k8s-job-retention-ttl", "runner-job-transient-env", "runner-job-tool-credential-env", "runner-job-unidesk-ssh-tool-credential-env", "runner-job-unidesk-ssh-transient-env-denied", "runner-k8s-job-session-pvc-volume-and-env"] };
return { name: "runner-k8s-job", tests: ["runner-k8s-job-dry-run", "runner-k8s-job-codex-shell-sandbox-env", "runner-k8s-job-deepseek-profile-dry-run", "runner-k8s-job-minimax-m3-profile-dry-run", "runner-k8s-job-dsflash-go-profile-dry-run", "runner-k8s-job-dsflash-go-legacy-secretref-normalized", "runner-k8s-job-create-api", "runner-k8s-job-retention-ttl", "runner-job-transient-env", "runner-job-tool-credential-env", "runner-job-unidesk-ssh-tool-credential-env", "runner-job-unidesk-ssh-transient-env-denied", "runner-k8s-job-session-pvc-volume-and-env"] };
} finally {
await new Promise<void>((resolve) => server.server.close(() => resolve()));
}
+10 -1
View File
@@ -27,6 +27,15 @@ const selfTest: SelfTestCase = async (context) => {
const finalCommand = await client.get(`/api/v1/runs/${happy.runId}/commands/${happy.commandId}`) as { state?: string };
assert.equal(finalCommand.state, "completed");
const sandboxOverride = await createRunWithCommand(client, context, "hello sandbox override", "selftest-sandbox-override", 15_000);
const sandboxOverrideResult = await runOnce({ managerUrl: server.baseUrl, runId: sandboxOverride.runId, codexCommand: context.fakeCodexCommand, codexArgs: context.fakeCodexArgs, codexHome: context.codexHome, env: { CODEX_HOME: context.codexHome, AGENTRUN_CODEX_SHELL_SANDBOX: "danger-full-access", AGENTRUN_FAKE_CODEX_MODE: "require-danger-sandbox" }, oneShot: true });
assert.equal(sandboxOverrideResult.terminalStatus, "completed");
const sandboxEvents = await client.get(`/api/v1/runs/${sandboxOverride.runId}/events?afterSeq=0&limit=100`) as { items?: Array<{ type: string; payload: JsonRecord }> };
const sandboxStarting = sandboxEvents.items?.find((event) => event.type === "backend_status" && event.payload.phase === "codex-app-server-starting");
assert.equal(((sandboxStarting?.payload.sandbox as JsonRecord | undefined)?.requested), "workspace-write");
assert.equal(((sandboxStarting?.payload.sandbox as JsonRecord | undefined)?.effective), "danger-full-access");
assert.equal(((sandboxStarting?.payload.sandbox as JsonRecord | undefined)?.overrideSource), "AGENTRUN_CODEX_SHELL_SANDBOX");
await runLeaseConflictRecoveryCase({ client, managerUrl: server.baseUrl, context });
const projectedHome = path.join(context.tmp, "runtime-codex-home");
@@ -230,7 +239,7 @@ const selfTest: SelfTestCase = async (context) => {
await runSessionStorageSubdirCase({ client, managerUrl: server.baseUrl, context });
await runSessionStorageNoSecretLeakCase({ client, managerUrl: server.baseUrl, context });
return { name: "codex-stdio", tests: ["runner-lease-heartbeat", "runner-lease-conflict-recovery", "codex-stdio-fake-turn", "codex-stdio-projected-writable-home", "codex-stdio-deepseek-profile-fake-turn", "codex-stdio-dsflash-go-profile-fake-turn", "codex-stdio-dsflash-go-config-metadata", "codex-stdio-minimax-m3-profile-fake-turn", "codex-stdio-deepseek-missing-secret-no-fallback", "codex-stdio-minimax-m3-missing-secret-no-fallback", "codex-stdio-config-model-authoritative", "codex-stdio-explicit-model-forwarded", "codex-stdio-final-agent-message-only", "codex-stdio-web-search-progress", "codex-stdio-stale-thread-resume-failed", "codex-stdio-live-tool-events", "codex-stdio-noisy-reasoning-suppression", "codex-stdio-missing-turn-result", "codex-stdio-provider-auth-failed", "codex-stdio-provider-rate-limited", "codex-stdio-provider-invalid-tool-call", "codex-stdio-provider-compact-unsupported", "codex-stdio-provider-503-rpc-error", "codex-stdio-provider-503-terminal", "codex-stdio-provider-503-retry-event", "codex-stdio-invalid-json", "codex-stdio-timeout", "codex-stdio-idle-timeout-progress-refresh", "codex-stdio-command-failure-keeps-run-open", "codex-stdio-secret-unavailable", "codex-stdio-spawn-failure"] };
return { name: "codex-stdio", tests: ["runner-lease-heartbeat", "runner-lease-conflict-recovery", "codex-stdio-fake-turn", "codex-stdio-k8s-sandbox-override", "codex-stdio-projected-writable-home", "codex-stdio-deepseek-profile-fake-turn", "codex-stdio-dsflash-go-profile-fake-turn", "codex-stdio-dsflash-go-config-metadata", "codex-stdio-minimax-m3-profile-fake-turn", "codex-stdio-deepseek-missing-secret-no-fallback", "codex-stdio-minimax-m3-missing-secret-no-fallback", "codex-stdio-config-model-authoritative", "codex-stdio-explicit-model-forwarded", "codex-stdio-final-agent-message-only", "codex-stdio-web-search-progress", "codex-stdio-stale-thread-resume-failed", "codex-stdio-live-tool-events", "codex-stdio-noisy-reasoning-suppression", "codex-stdio-missing-turn-result", "codex-stdio-provider-auth-failed", "codex-stdio-provider-rate-limited", "codex-stdio-provider-invalid-tool-call", "codex-stdio-provider-compact-unsupported", "codex-stdio-provider-503-rpc-error", "codex-stdio-provider-503-terminal", "codex-stdio-provider-503-retry-event", "codex-stdio-invalid-json", "codex-stdio-timeout", "codex-stdio-idle-timeout-progress-refresh", "codex-stdio-command-failure-keeps-run-open", "codex-stdio-secret-unavailable", "codex-stdio-spawn-failure"] };
} finally {
await new Promise<void>((resolve) => server.server.close(() => resolve()));
}
@@ -1,7 +1,7 @@
import assert from "node:assert/strict";
import { execFile as execFileCallback } from "node:child_process";
import { promisify } from "node:util";
import { chmod, mkdir, readFile, writeFile } from "node:fs/promises";
import { access, chmod, mkdir, readFile, writeFile } from "node:fs/promises";
import path from "node:path";
import { startManagerServer } from "../../mgr/server.js";
import { ManagerClient } from "../../mgr/client.js";
@@ -79,8 +79,12 @@ console.log(JSON.stringify({ apiVersion: manifest.apiVersion, kind: manifest.kin
);
const sessionRun = await createHwlabRun(client, context, bundle, "hwlab-session-resume", "hello session", "hwlab-command-session");
const runResult = await runOnce({ managerUrl: server.baseUrl, runId: sessionRun.runId, codexCommand: context.fakeCodexCommand, codexArgs: context.fakeCodexArgs, codexHome: context.codexHome, env: { CODEX_HOME: context.codexHome, AGENTRUN_WORKSPACE_ROOT: path.join(context.tmp, "workspaces") }, oneShot: true });
const resourceBinPath = path.join(context.tmp, "resource-bin");
const runResult = await runOnce({ managerUrl: server.baseUrl, runId: sessionRun.runId, codexCommand: context.fakeCodexCommand, codexArgs: context.fakeCodexArgs, codexHome: context.codexHome, env: { CODEX_HOME: context.codexHome, AGENTRUN_WORKSPACE_ROOT: path.join(context.tmp, "workspaces"), AGENTRUN_RESOURCE_BIN_PATH: resourceBinPath }, oneShot: true });
assert.equal(runResult.terminalStatus, "completed");
await access(path.join(resourceBinPath, "hwpod"));
const resourceBinExec = await execFile(path.join(resourceBinPath, "hwpod"), ["--selftest"]);
assert.match(resourceBinExec.stdout, /hwpod-selftest/u);
const session = await store.getSession("hwlab-session-resume");
assert.equal(session?.threadId, "thread_selftest_1");
const resultEnvelope = await client.get(`/api/v1/runs/${sessionRun.runId}/commands/${sessionRun.commandId}/result`) as JsonRecord;
@@ -93,6 +97,7 @@ console.log(JSON.stringify({ apiVersion: manifest.apiVersion, kind: manifest.kin
assert.deepEqual(resultBundleTargets, ["tools", ".agents/skills"]);
const materialized = ((resultEnvelope.resourceBundleRef as JsonRecord).materialized as JsonRecord);
assert.deepEqual(((materialized.tools as JsonRecord).names), ["hwpod"]);
assert.equal(((materialized.tools as JsonRecord).installed), true);
assert.deepEqual(((materialized.skillDirs as JsonRecord).names), ["hwpod-cli", "hwpod-ctl"]);
assertNoSecretLeak(resultEnvelope);
+8
View File
@@ -23,6 +23,10 @@ for await (const line of rl) {
}
if (message.method === "thread/start") {
observedThreadModel = Object.hasOwn(message.params ?? {}, "model");
if (mode === "require-danger-sandbox" && message.params?.sandbox !== "danger-full-access") {
respond(message.id, null, { code: -32000, message: `thread/start expected danger-full-access sandbox, got ${String(message.params?.sandbox ?? "missing")}` });
continue;
}
if (mode === "reject-unexpected-model" && observedThreadModel) {
respond(message.id, null, { code: -32000, message: "thread/start unexpectedly included model" });
continue;
@@ -39,6 +43,10 @@ for await (const line of rl) {
}
if (message.method === "thread/resume") {
observedThreadModel = Object.hasOwn(message.params ?? {}, "model");
if (mode === "require-danger-sandbox" && message.params?.sandbox !== "danger-full-access") {
respond(message.id, null, { code: -32000, message: `thread/resume expected danger-full-access sandbox, got ${String(message.params?.sandbox ?? "missing")}` });
continue;
}
if (mode === "resume-no-rollout") {
respond(message.id, null, { code: -32000, message: `no rollout found for thread id ${String(message.params?.threadId ?? "unknown")}` });
continue;