diff --git a/src/common/types.ts b/src/common/types.ts index 270e185..92fd6f6 100644 --- a/src/common/types.ts +++ b/src/common/types.ts @@ -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; diff --git a/src/common/validation.ts b/src/common/validation.ts index 41b6560..995fbb7 100644 --- a/src/common/validation.ts +++ b/src/common/validation.ts @@ -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 { + 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(); + 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 }); diff --git a/src/mgr/result.ts b/src/mgr/result.ts index 0ed831a..672755c 100644 --- a/src/mgr/result.ts +++ b/src/mgr/result.ts @@ -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, }; } diff --git a/src/mgr/store.ts b/src/mgr/store.ts index f9f8425..57917f0 100644 --- a/src/mgr/store.ts +++ b/src/mgr/store.ts @@ -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, diff --git a/src/runner/resource-bundle.ts b/src/runner/resource-bundle.ts index f18e1b2..dc77102 100644 --- a/src/runner/resource-bundle.ts +++ b/src/runner/resource-bundle.ts @@ -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, 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 }; diff --git a/src/runner/run-once.ts b/src/runner/run-once.ts index bd9e437..63d0ae9 100644 --- a/src/runner/run-once.ts +++ b/src/runner/run-once.ts @@ -65,6 +65,7 @@ export async function runOnce(options: RunnerOnceOptions): Promise { 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 { 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 { 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 { } } +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 { await api.ackCommand(command.id); const acked = await api.getCommand(options.runId, command.id); diff --git a/src/selftest/cases/50-hwlab-manual-dispatch.ts b/src/selftest/cases/50-hwlab-manual-dispatch.ts index f188bf4..158543e 100644 --- a/src/selftest/cases/50-hwlab-manual-dispatch.ts +++ b/src/selftest/cases/50-hwlab-manual-dispatch.ts @@ -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((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: {