feat: assemble resource bundle tool aliases

This commit is contained in:
Codex
2026-06-02 08:50:21 +08:00
parent 83ab1df593
commit 9700d0600f
7 changed files with 90 additions and 8 deletions
+5
View File
@@ -53,6 +53,11 @@ export interface ResourceBundleRef extends JsonRecord {
commitId: string;
subdir?: string;
sparsePaths?: string[];
toolAliases?: Array<{
name: string;
path: string;
kind: "node-script" | "bun-script" | "sh-script" | "executable";
}>;
submodules?: false;
lfs?: false;
credentialRef?: SecretRef;
+19
View File
@@ -96,6 +96,7 @@ export function validateResourceBundleRef(value: unknown): ResourceBundleRef | n
}
result.sparsePaths = record.sparsePaths as string[];
}
if (record.toolAliases !== undefined) result.toolAliases = validateResourceToolAliases(record.toolAliases);
if (record.submodules !== undefined && record.submodules !== false) throw new AgentRunError("schema-invalid", "resourceBundleRef.submodules can only be false in v0.1", { httpStatus: 400 });
if (record.lfs !== undefined && record.lfs !== false) throw new AgentRunError("schema-invalid", "resourceBundleRef.lfs can only be false in v0.1", { httpStatus: 400 });
if (record.submodules === false) result.submodules = false;
@@ -104,6 +105,24 @@ export function validateResourceBundleRef(value: unknown): ResourceBundleRef | n
return result;
}
function validateResourceToolAliases(value: unknown): NonNullable<ResourceBundleRef["toolAliases"]> {
if (!Array.isArray(value)) throw new AgentRunError("schema-invalid", "resourceBundleRef.toolAliases must be an array", { httpStatus: 400 });
if (value.length > 16) throw new AgentRunError("schema-invalid", "resourceBundleRef.toolAliases must contain at most 16 entries", { httpStatus: 400 });
const seen = new Set<string>();
return value.map((entry, index) => {
const record = asRecord(entry, `resourceBundleRef.toolAliases[${index}]`);
const name = requiredString(record, "name");
if (!/^[a-z][a-z0-9._-]{0,62}$/u.test(name)) throw new AgentRunError("schema-invalid", `resourceBundleRef.toolAliases[${index}].name must be a lowercase command name`, { httpStatus: 400 });
if (seen.has(name)) throw new AgentRunError("schema-invalid", `resourceBundleRef.toolAliases name ${name} is duplicated`, { httpStatus: 400 });
seen.add(name);
const aliasPath = requiredString(record, "path");
if (aliasPath.startsWith("/") || aliasPath.includes("..")) throw new AgentRunError("schema-invalid", `resourceBundleRef.toolAliases[${index}].path must stay within the checkout`, { httpStatus: 400 });
const kind = requiredString(record, "kind");
if (kind !== "node-script" && kind !== "bun-script" && kind !== "sh-script" && kind !== "executable") throw new AgentRunError("schema-invalid", `resourceBundleRef.toolAliases[${index}].kind is not supported in v0.1`, { httpStatus: 400, details: { allowedKinds: ["node-script", "bun-script", "sh-script", "executable"] } });
return { name, path: aliasPath, kind };
});
}
export function validateExecutionPolicy(record: JsonRecord): ExecutionPolicy {
const timeout = record.timeoutMs;
if (typeof timeout !== "number" || !Number.isFinite(timeout) || timeout <= 0) throw new AgentRunError("schema-invalid", "executionPolicy.timeoutMs must be a positive number", { httpStatus: 400 });
+1
View File
@@ -173,6 +173,7 @@ function resourceBundleSummary(run: RunRecord, events: RunEvent[]): JsonRecord |
repoUrl: run.resourceBundleRef.repoUrl,
commitId: run.resourceBundleRef.commitId,
subdir: run.resourceBundleRef.subdir ?? null,
toolAliases: run.resourceBundleRef.toolAliases ? { count: run.resourceBundleRef.toolAliases.length, names: run.resourceBundleRef.toolAliases.map((item) => item.name), valuesPrinted: false } : { count: 0, names: [], valuesPrinted: false },
materialized: materialized as JsonValue,
};
}
+1
View File
@@ -471,6 +471,7 @@ export function summarizeResourceBundleRef(resourceBundleRef: RunRecord["resourc
commitId: resourceBundleRef.commitId,
subdir: resourceBundleRef.subdir ?? null,
sparsePathCount: resourceBundleRef.sparsePaths?.length ?? 0,
toolAliases: resourceBundleRef.toolAliases ? { count: resourceBundleRef.toolAliases.length, names: resourceBundleRef.toolAliases.map((item) => item.name), valuesPrinted: false } : { count: 0, names: [], valuesPrinted: false },
submodules: resourceBundleRef.submodules ?? false,
lfs: resourceBundleRef.lfs ?? false,
credentialRef: resourceBundleRef.credentialRef ? { name: resourceBundleRef.credentialRef.name, namespace: resourceBundleRef.credentialRef.namespace ?? null, keys: resourceBundleRef.credentialRef.keys ?? [], valuesPrinted: false } : null,
+37 -3
View File
@@ -1,5 +1,5 @@
import { spawn } from "node:child_process";
import { mkdir, writeFile } from "node:fs/promises";
import { chmod, mkdir, writeFile } from "node:fs/promises";
import path from "node:path";
import { AgentRunError } from "../common/errors.js";
import { redactText } from "../common/redaction.js";
@@ -8,6 +8,7 @@ import { stableHash } from "../common/validation.js";
export interface MaterializedResourceBundle {
workspacePath: string;
binPath?: string;
event: JsonRecord;
}
@@ -30,8 +31,10 @@ export async function materializeResourceBundle(resourceBundleRef: ResourceBundl
if (actualCommit !== resourceBundleRef.commitId) throw new AgentRunError("infra-failed", "resource bundle checkout did not land on requested commit", { httpStatus: 500, details: { expectedCommit: resourceBundleRef.commitId, actualCommit } });
const treeId = (await git(["rev-parse", "HEAD^{tree}"], checkoutPath)).stdout.trim();
const workspacePath = resolveWorkspacePath(checkoutPath, resourceBundleRef.subdir);
const toolAliases = await materializeToolAliases(checkoutPath, resourceBundleRef.toolAliases ?? [], env);
return {
workspacePath,
...(toolAliases.binPath ? { binPath: toolAliases.binPath } : {}),
event: {
phase: "resource-bundle-materialized",
kind: "git",
@@ -42,11 +45,34 @@ export async function materializeResourceBundle(resourceBundleRef: ResourceBundl
workspacePath: pathSummary(workspacePath),
subdir: resourceBundleRef.subdir ?? null,
sparsePathCount: resourceBundleRef.sparsePaths?.length ?? 0,
toolAliases: toolAliases.event,
valuesPrinted: false,
},
};
}
async function materializeToolAliases(checkoutPath: string, aliases: NonNullable<ResourceBundleRef["toolAliases"]>, env: NodeJS.ProcessEnv): Promise<{ binPath?: string; event: JsonRecord }> {
if (aliases.length === 0) return { event: { count: 0, names: [], binPath: null, valuesPrinted: false } };
const binPath = path.resolve(env.AGENTRUN_RESOURCE_BIN_PATH ?? path.join(path.dirname(checkoutPath), ".bin"));
await mkdir(binPath, { recursive: true });
const names: string[] = [];
for (const alias of aliases) {
const target = resolveBundlePath(checkoutPath, alias.path, `toolAliases.${alias.name}.path`);
const wrapper = path.join(binPath, alias.name);
await writeFile(wrapper, aliasWrapper(alias.kind, target), "utf8");
await chmod(wrapper, 0o755);
names.push(alias.name);
}
return { binPath, event: { count: names.length, names, binPath: pathSummary(binPath), valuesPrinted: false } };
}
function aliasWrapper(kind: string, target: string): string {
if (kind === "node-script") return `#!/usr/bin/env sh\nexec node ${shellArg(target)} "$@"\n`;
if (kind === "bun-script") return `#!/usr/bin/env sh\nexec bun ${shellArg(target)} "$@"\n`;
if (kind === "sh-script") return `#!/usr/bin/env sh\nexec sh ${shellArg(target)} "$@"\n`;
return `#!/usr/bin/env sh\nexec ${shellArg(target)} "$@"\n`;
}
async function git(args: string[], cwd: string, options: { allowFailure?: boolean } = {}): Promise<{ stdout: string; stderr: string }> {
const child = spawn("git", args, { cwd, stdio: ["ignore", "pipe", "pipe"] });
let stdout = "";
@@ -69,12 +95,20 @@ async function git(args: string[], cwd: string, options: { allowFailure?: boolea
function resolveWorkspacePath(checkoutPath: string, subdir: string | undefined): string {
if (!subdir) return checkoutPath;
const resolved = path.resolve(checkoutPath, subdir);
return resolveBundlePath(checkoutPath, subdir, "resourceBundleRef.subdir");
}
function resolveBundlePath(checkoutPath: string, relativePath: string, fieldName: string): string {
const resolved = path.resolve(checkoutPath, relativePath);
const root = path.resolve(checkoutPath);
if (resolved !== root && !resolved.startsWith(`${root}${path.sep}`)) throw new AgentRunError("schema-invalid", "resource bundle subdir escaped checkout", { httpStatus: 400 });
if (resolved !== root && !resolved.startsWith(`${root}${path.sep}`)) throw new AgentRunError("schema-invalid", `${fieldName} escaped checkout`, { httpStatus: 400 });
return resolved;
}
function shellArg(value: string): string {
return `'${value.replace(/'/gu, `'"'"'`)}'`;
}
function pathSummary(value: string): JsonRecord {
const parts = value.split(/[\\/]+/u).filter(Boolean);
return { absolute: path.isAbsolute(value), basename: parts.at(-1) ?? null, depth: parts.length, fingerprint: stableHash(value).slice(0, 16), valuePrinted: false };
+15 -1
View File
@@ -65,6 +65,7 @@ export async function runOnce(options: RunnerOnceOptions): Promise<JsonRecord> {
const pollIntervalMs = normalizePollIntervalMs(options.pollIntervalMs);
const commandResults: CommandExecutionResult[] = [];
let workspacePath: string | undefined;
let resourceEnv: NodeJS.ProcessEnv | undefined;
let materializationAttempted = false;
let materializationFailure: { failureKind: FailureKind; terminalStatus: TerminalStatus; message: string } | null = null;
let backendSession: BackendSession | null = null;
@@ -93,6 +94,7 @@ export async function runOnce(options: RunnerOnceOptions): Promise<JsonRecord> {
const materialized = await materializeResourceBundle(claimed.resourceBundleRef ?? null, options.env ?? process.env);
if (materialized) {
workspacePath = materialized.workspacePath;
resourceEnv = materialized.binPath ? prependPath(options.env ?? process.env, materialized.binPath) : undefined;
await api.appendEvent(options.runId, { type: "backend_status", payload: { ...materialized.event, commandId: command.id, attemptId, runnerId: runner.id } });
}
} catch (error) {
@@ -103,7 +105,7 @@ export async function runOnce(options: RunnerOnceOptions): Promise<JsonRecord> {
const result = materializationFailure
? await reportCommandFailure(api, options.runId, command.id, runner, attemptId, materializationFailure, "runner:resource-bundle")
: await executeCommand(api, options, command, runner, attemptId, workspacePath, backendSession ?? (backendSession = createBackendSession(currentRun, options)));
: await executeCommand(api, withResourceEnv(options, resourceEnv), command, runner, attemptId, workspacePath, backendSession ?? (backendSession = createBackendSession(currentRun, withResourceEnv(options, resourceEnv))));
commandResults.push(result);
if (options.oneShot === true) {
const run = await api.reportStatus(options.runId, { terminalStatus: result.terminalStatus, failureKind: result.failureKind, failureMessage: null });
@@ -130,6 +132,18 @@ export async function runOnce(options: RunnerOnceOptions): Promise<JsonRecord> {
}
}
function withResourceEnv(options: RunnerOnceOptions, resourceEnv: NodeJS.ProcessEnv | undefined): RunnerOnceOptions {
return resourceEnv ? { ...options, env: resourceEnv } : options;
}
function prependPath(env: NodeJS.ProcessEnv, binPath: string): NodeJS.ProcessEnv {
return { ...env, PATH: `${binPath}${pathDelimiter()}${env.PATH ?? process.env.PATH ?? ""}` };
}
function pathDelimiter(): string {
return process.platform === "win32" ? ";" : ":";
}
async function executeCommand(api: RunnerManagerApi, options: RunnerOnceOptions, command: CommandRecord, runner: RunnerRecord, attemptId: string, workspacePath: string | undefined, backendSession: BackendSession | null): Promise<CommandExecutionResult> {
await api.ackCommand(command.id);
const acked = await api.getCommand(options.runId, command.id);
+12 -4
View File
@@ -73,8 +73,11 @@ 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 resourceBin = 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: resourceBin }, oneShot: true });
assert.equal(runResult.terminalStatus, "completed");
const hwpod = await execFile(path.join(resourceBin, "hwpod"), ["profile", "list"]);
assert.match(hwpod.stdout, /"argv":\["profile","list"\]/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;
@@ -82,6 +85,9 @@ console.log(JSON.stringify({ apiVersion: manifest.apiVersion, kind: manifest.kin
assert.equal(resultEnvelope.reply, "fake codex stdio reply");
assert.equal(((resultEnvelope.sessionRef as JsonRecord).threadId), "thread_selftest_1");
assert.equal(((resultEnvelope.resourceBundleRef as JsonRecord).commitId), bundle.commitId);
assert.deepEqual(((resultEnvelope.resourceBundleRef as JsonRecord).toolAliases as JsonRecord).names, ["hwpod"]);
const materialized = ((resultEnvelope.resourceBundleRef as JsonRecord).materialized as JsonRecord);
assert.deepEqual(((materialized.toolAliases as JsonRecord).names), ["hwpod"]);
assertNoSecretLeak(resultEnvelope);
const resumed = await createHwlabRun(client, context, bundle, "hwlab-session-resume", "hello resumed", "hwlab-command-session-resumed");
@@ -125,7 +131,7 @@ console.log(JSON.stringify({ apiVersion: manifest.apiVersion, kind: manifest.kin
const runningResult = await running;
assert.equal(runningResult.terminalStatus, "cancelled");
return { name: "hwlab-manual-dispatch", tests: ["runner-job-idempotency", "pending-cancel", "result-envelope", "session-ref-resume", "resource-bundle-materialization", "same-run-runner-multiturn", "running-cancel"] };
return { name: "hwlab-manual-dispatch", tests: ["runner-job-idempotency", "pending-cancel", "result-envelope", "session-ref-resume", "resource-bundle-materialization", "resource-bundle-tool-alias", "same-run-runner-multiturn", "running-cancel"] };
} finally {
await new Promise<void>((resolve) => server.server.close(() => resolve()));
}
@@ -136,7 +142,9 @@ async function createLocalGitBundle(context: SelfTestContext): Promise<{ repoUrl
await mkdir(repo, { recursive: true });
await execFile("git", ["init"], { cwd: repo });
await writeFile(path.join(repo, "README.md"), "HWLAB bundle self-test\n", "utf8");
await execFile("git", ["add", "README.md"], { cwd: repo });
await mkdir(path.join(repo, "tools"), { recursive: true });
await writeFile(path.join(repo, "tools", "device-pod-cli.mjs"), "console.log(JSON.stringify({ ok: true, cli: 'hwpod-selftest', argv: process.argv.slice(2) }));\n", "utf8");
await execFile("git", ["add", "README.md", "tools/device-pod-cli.mjs"], { cwd: repo });
await execFile("git", ["-c", "user.email=selftest@example.invalid", "-c", "user.name=AgentRun SelfTest", "commit", "-m", "bundle selftest"], { cwd: repo });
const { stdout } = await execFile("git", ["rev-parse", "HEAD"], { cwd: repo });
return { repoUrl: repo, commitId: stdout.trim() };
@@ -148,7 +156,7 @@ async function createHwlabRun(client: ManagerClient, context: SelfTestContext, b
projectId: "pikasTech/HWLAB",
workspaceRef: { kind: "opaque", repo: "pikasTech/HWLAB" },
sessionRef: { sessionId, conversationId: sessionId },
resourceBundleRef: { kind: "git", repoUrl: bundle.repoUrl, commitId: bundle.commitId, submodules: false, lfs: false },
resourceBundleRef: { kind: "git", repoUrl: bundle.repoUrl, commitId: bundle.commitId, toolAliases: [{ name: "hwpod", path: "tools/device-pod-cli.mjs", kind: "node-script" }], submodules: false, lfs: false },
providerId: "G14",
backendProfile: "codex",
executionPolicy: {