fix: 支持 ResourceBundleRef workspaceFiles

This commit is contained in:
Codex
2026-06-06 17:59:05 +08:00
parent c320459deb
commit 7b3c0ea584
8 changed files with 108 additions and 10 deletions
+5
View File
@@ -79,6 +79,11 @@ export interface ResourceBundleRef extends JsonRecord {
required?: boolean;
aggregateAs?: string;
}>;
workspaceFiles?: Array<{
path: string;
content: string;
encoding?: "utf8";
}>;
submodules?: false;
lfs?: false;
credentialRef?: SecretRef;
+31
View File
@@ -100,6 +100,7 @@ export function validateResourceBundleRef(value: unknown): ResourceBundleRef | n
if (record.toolAliases !== undefined) result.toolAliases = validateResourceToolAliases(record.toolAliases);
if (record.promptRefs !== undefined) result.promptRefs = validateResourcePromptRefs(record.promptRefs);
if (record.skillRefs !== undefined) result.skillRefs = validateResourceSkillRefs(record.skillRefs);
if (record.workspaceFiles !== undefined) result.workspaceFiles = validateResourceWorkspaceFiles(record.workspaceFiles);
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;
@@ -163,6 +164,31 @@ function validateResourceSkillRefs(value: unknown): NonNullable<ResourceBundleRe
});
}
const maxWorkspaceFiles = 16;
const maxWorkspaceFileBytes = 128 * 1024;
const maxWorkspaceFilesTotalBytes = 512 * 1024;
function validateResourceWorkspaceFiles(value: unknown): NonNullable<ResourceBundleRef["workspaceFiles"]> {
if (!Array.isArray(value)) throw new AgentRunError("schema-invalid", "resourceBundleRef.workspaceFiles must be an array", { httpStatus: 400 });
if (value.length > maxWorkspaceFiles) throw new AgentRunError("schema-invalid", `resourceBundleRef.workspaceFiles must contain at most ${maxWorkspaceFiles} entries`, { httpStatus: 400 });
const seen = new Set<string>();
let totalBytes = 0;
return value.map((entry, index) => {
const record = asRecord(entry, `resourceBundleRef.workspaceFiles[${index}]`);
const filePath = validateWorkspaceRelativePath(requiredString(record, "path"), `resourceBundleRef.workspaceFiles[${index}].path`);
if (seen.has(filePath)) throw new AgentRunError("schema-invalid", `resourceBundleRef.workspaceFiles path ${filePath} is duplicated`, { httpStatus: 400 });
seen.add(filePath);
if (typeof record.content !== "string") throw new AgentRunError("schema-invalid", `resourceBundleRef.workspaceFiles[${index}].content must be a utf8 string`, { httpStatus: 400 });
const encoding = optionalString(record.encoding) ?? "utf8";
if (encoding !== "utf8") throw new AgentRunError("schema-invalid", `resourceBundleRef.workspaceFiles[${index}].encoding must be utf8`, { httpStatus: 400 });
const bytes = Buffer.byteLength(record.content, "utf8");
if (bytes > maxWorkspaceFileBytes) throw new AgentRunError("schema-invalid", `resourceBundleRef.workspaceFiles[${index}] exceeds the per-file size limit`, { httpStatus: 400, details: { path: filePath, bytes, maxWorkspaceFileBytes, valuesPrinted: false } });
totalBytes += bytes;
if (totalBytes > maxWorkspaceFilesTotalBytes) throw new AgentRunError("schema-invalid", "resourceBundleRef.workspaceFiles exceeds the total size limit", { httpStatus: 400, details: { totalBytes, maxWorkspaceFilesTotalBytes, valuesPrinted: false } });
return { path: filePath, content: record.content, encoding: "utf8" as const };
});
}
function validateResourceName(name: string, fieldName: string): string {
if (!/^[a-z][a-z0-9._-]{0,62}$/u.test(name)) throw new AgentRunError("schema-invalid", `${fieldName} must be a lowercase resource name`, { httpStatus: 400 });
return name;
@@ -173,6 +199,11 @@ function validateBundleRelativePath(relativePath: string, fieldName: string): st
return relativePath;
}
function validateWorkspaceRelativePath(relativePath: string, fieldName: string): string {
if (relativePath === "." || relativePath.startsWith("/") || relativePath.includes("..")) throw new AgentRunError("schema-invalid", `${fieldName} must stay within the workspace`, { httpStatus: 400 });
return relativePath;
}
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
@@ -176,6 +176,7 @@ function resourceBundleSummary(run: RunRecord, events: RunEvent[]): JsonRecord |
toolAliases: run.resourceBundleRef.toolAliases ? { count: run.resourceBundleRef.toolAliases.length, names: run.resourceBundleRef.toolAliases.map((item) => item.name), valuesPrinted: false } : { count: 0, names: [], valuesPrinted: false },
promptRefs: run.resourceBundleRef.promptRefs ? { count: run.resourceBundleRef.promptRefs.length, names: run.resourceBundleRef.promptRefs.map((item) => item.name), required: run.resourceBundleRef.promptRefs.filter((item) => item.required === true).map((item) => item.name), valuesPrinted: false } : { count: 0, names: [], required: [], valuesPrinted: false },
skillRefs: run.resourceBundleRef.skillRefs ? { count: run.resourceBundleRef.skillRefs.length, names: run.resourceBundleRef.skillRefs.map((item) => item.name), required: run.resourceBundleRef.skillRefs.filter((item) => item.required === true).map((item) => item.name), valuesPrinted: false } : { count: 0, names: [], required: [], valuesPrinted: false },
workspaceFiles: run.resourceBundleRef.workspaceFiles ? { count: run.resourceBundleRef.workspaceFiles.length, paths: run.resourceBundleRef.workspaceFiles.map((item) => item.path), valuesPrinted: false } : { count: 0, paths: [], valuesPrinted: false },
materialized: materialized as JsonValue,
};
}
+1
View File
@@ -737,6 +737,7 @@ export function summarizeResourceBundleRef(resourceBundleRef: RunRecord["resourc
toolAliases: resourceBundleRef.toolAliases ? { count: resourceBundleRef.toolAliases.length, names: resourceBundleRef.toolAliases.map((item) => item.name), valuesPrinted: false } : { count: 0, names: [], valuesPrinted: false },
promptRefs: resourceBundleRef.promptRefs ? { count: resourceBundleRef.promptRefs.length, names: resourceBundleRef.promptRefs.map((item) => item.name), required: resourceBundleRef.promptRefs.filter((item) => item.required === true).map((item) => item.name), valuesPrinted: false } : { count: 0, names: [], required: [], valuesPrinted: false },
skillRefs: resourceBundleRef.skillRefs ? { count: resourceBundleRef.skillRefs.length, names: resourceBundleRef.skillRefs.map((item) => item.name), required: resourceBundleRef.skillRefs.filter((item) => item.required === true).map((item) => item.name), valuesPrinted: false } : { count: 0, names: [], required: [], valuesPrinted: false },
workspaceFiles: resourceBundleRef.workspaceFiles ? { count: resourceBundleRef.workspaceFiles.length, paths: resourceBundleRef.workspaceFiles.map((item) => item.path), valuesPrinted: false } : { count: 0, paths: [], 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,
+34
View File
@@ -62,6 +62,7 @@ export async function materializeResourceBundle(resourceBundleRef: ResourceBundl
const toolAliases = await materializeToolAliases(checkoutPath, resourceBundleRef.toolAliases ?? [], env);
const skills = await materializeSkillRefs(checkoutPath, workspacePath, resourceBundleRef.skillRefs ?? []);
const prompts = await materializePromptRefs(checkoutPath, resourceBundleRef.promptRefs ?? []);
const workspaceFiles = await materializeWorkspaceFiles(workspacePath, resourceBundleRef.workspaceFiles ?? []);
const initialPrompt = assembleInitialPrompt(prompts.items, skills.items);
return {
workspacePath,
@@ -81,6 +82,7 @@ export async function materializeResourceBundle(resourceBundleRef: ResourceBundl
toolAliases: toolAliases.event,
skillRefs: skills.event,
promptRefs: prompts.event,
workspaceFiles: workspaceFiles.event,
initialPrompt: initialPrompt?.summary ?? { available: false, bytes: 0, sha256: null, promptRefCount: prompts.items.length, skillRefCount: skills.items.length, valuesPrinted: false },
valuesPrinted: false,
},
@@ -184,6 +186,31 @@ async function materializeSkillRefs(checkoutPath: string, workspacePath: string,
};
}
async function materializeWorkspaceFiles(workspacePath: string, files: NonNullable<ResourceBundleRef["workspaceFiles"]>): Promise<{ event: JsonRecord }> {
if (files.length === 0) return { event: { count: 0, materializedCount: 0, items: [], totalBytes: 0, valuesPrinted: false } };
const eventItems: JsonRecord[] = [];
let totalBytes = 0;
for (const file of files) {
const target = resolveWorkspaceFilePath(workspacePath, file.path, `workspaceFiles.${file.path}.path`);
const content = file.content;
const bytes = Buffer.byteLength(content, "utf8");
await mkdir(path.dirname(target), { recursive: true });
await writeFile(target, content, "utf8");
totalBytes += bytes;
eventItems.push({ path: file.path, encoding: file.encoding ?? "utf8", status: "materialized", bytes, sha256: sha256Text(content), valuesPrinted: false });
}
return {
event: {
count: files.length,
materializedCount: eventItems.length,
paths: eventItems.map((item) => item.path),
items: eventItems,
totalBytes,
valuesPrinted: false,
},
};
}
function assembleInitialPrompt(promptRefs: MaterializedPromptRef[], skillRefs: MaterializedSkillRef[]): InitialPromptAssembly | undefined {
if (promptRefs.length === 0 && skillRefs.length === 0) return undefined;
const sections: string[] = [
@@ -293,6 +320,13 @@ function resolveBundlePath(checkoutPath: string, relativePath: string, fieldName
return resolved;
}
function resolveWorkspaceFilePath(workspacePath: string, relativePath: string, fieldName: string): string {
const resolved = path.resolve(workspacePath, relativePath);
const root = path.resolve(workspacePath);
if (resolved === root || !resolved.startsWith(`${root}${path.sep}`)) throw new AgentRunError("schema-invalid", `${fieldName} escaped workspace`, { httpStatus: 400 });
return resolved;
}
function shellArg(value: string): string {
return `'${value.replace(/'/gu, `'"'"'`)}'`;
}
+19 -2
View File
@@ -7,11 +7,12 @@ import { startManagerServer } from "../../mgr/server.js";
import { ManagerClient } from "../../mgr/client.js";
import { MemoryAgentRunStore } from "../../mgr/store.js";
import { runOnce } from "../../runner/run-once.js";
import { stableHash } from "../../common/validation.js";
import type { JsonRecord, ResourceBundleRef } from "../../common/types.js";
import { assertNoSecretLeak, type SelfTestCase, type SelfTestContext } from "../harness.js";
const execFile = promisify(execFileCallback);
type LocalBundle = { repoUrl: string; commitId: string; toolAliases?: ResourceBundleRef["toolAliases"]; promptRefs?: ResourceBundleRef["promptRefs"]; skillRefs?: ResourceBundleRef["skillRefs"] };
type LocalBundle = { repoUrl: string; commitId: string; toolAliases?: ResourceBundleRef["toolAliases"]; promptRefs?: ResourceBundleRef["promptRefs"]; skillRefs?: ResourceBundleRef["skillRefs"]; workspaceFiles?: ResourceBundleRef["workspaceFiles"] };
const selfTest: SelfTestCase = async (context) => {
const containerfile = await readFile(path.join(context.root, "deploy/container/Containerfile"), "utf8");
@@ -105,6 +106,21 @@ console.log(JSON.stringify({ apiVersion: manifest.apiVersion, kind: manifest.kin
assert.deepEqual(((materialized.toolAliases as JsonRecord).names), ["hwpod"]);
assertNoSecretLeak(resultEnvelope);
const seededRun = await createHwlabRun(client, context, { ...bundle, workspaceFiles: [{ path: ".hwlab/hwpod-spec.yaml", content: "apiVersion: hwlab.dev/v0alpha1\nkind: Hwpod\n", encoding: "utf8" }] }, "hwlab-session-seeded", "inspect seeded spec", "hwlab-command-seeded");
const seededRoot = path.join(context.tmp, "workspaces-seeded");
const seededResult = await runOnce({ managerUrl: server.baseUrl, runId: seededRun.runId, commandId: seededRun.commandId, codexCommand: context.fakeCodexCommand, codexArgs: context.fakeCodexArgs, codexHome: context.codexHome, env: { CODEX_HOME: context.codexHome, AGENTRUN_WORKSPACE_ROOT: seededRoot }, oneShot: true }) as JsonRecord;
assert.equal(seededResult.terminalStatus, "completed");
const seededEnvelope = await client.get(`/api/v1/runs/${seededRun.runId}/commands/${seededRun.commandId}/result`) as JsonRecord;
const seededResource = seededEnvelope.resourceBundleRef as JsonRecord;
assert.deepEqual(((seededResource.workspaceFiles as JsonRecord).paths), [".hwlab/hwpod-spec.yaml"]);
const seededMaterialized = seededResource.materialized as JsonRecord;
const seededWorkspaceFiles = seededMaterialized.workspaceFiles as JsonRecord;
assert.equal(seededWorkspaceFiles.count, 1);
assert.deepEqual(seededWorkspaceFiles.paths, [".hwlab/hwpod-spec.yaml"]);
assert.equal(JSON.stringify(seededWorkspaceFiles).includes("apiVersion"), false);
const seededWorkspace = path.join(seededRoot, stableHash({ repoUrl: bundle.repoUrl, commitId: bundle.commitId }).slice(0, 16));
assert.equal(await readTextIfExists(path.join(seededWorkspace, ".hwlab", "hwpod-spec.yaml")), "apiVersion: hwlab.dev/v0alpha1\nkind: Hwpod\n");
const assemblyRun = await createHwlabRun(client, context, assemblyBundle, "hwlab-session-assembly", "list visible bundle skills without tools", "hwlab-command-assembly-1");
const assemblyInputFile = path.join(context.tmp, "fake-codex-turn-input-assembly.jsonl");
const assemblyRunner = runOnce({ managerUrl: server.baseUrl, runId: assemblyRun.runId, commandId: assemblyRun.commandId, codexCommand: context.fakeCodexCommand, codexArgs: context.fakeCodexArgs, codexHome: context.codexHome, env: { CODEX_HOME: context.codexHome, AGENTRUN_WORKSPACE_ROOT: path.join(context.tmp, "workspaces-assembly"), AGENTRUN_FAKE_CODEX_TURN_INPUT_FILE: assemblyInputFile }, idleTimeoutMs: 500, pollIntervalMs: 50 });
@@ -209,7 +225,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", "resource-bundle-tool-alias", "resource-prompt-skill-assembly", "resource-prompt-skill-required-blockers", "same-run-runner-multiturn", "running-steer", "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", "resource-bundle-workspace-files", "resource-prompt-skill-assembly", "resource-prompt-skill-required-blockers", "same-run-runner-multiturn", "running-steer", "running-cancel"] };
} finally {
await new Promise<void>((resolve) => server.server.close(() => resolve()));
}
@@ -261,6 +277,7 @@ async function createHwlabRun(client: ManagerClient, context: SelfTestContext, b
const resourceBundleRef: ResourceBundleRef = { kind: "git", repoUrl: bundle.repoUrl, commitId: bundle.commitId, toolAliases, submodules: false, lfs: false };
if (bundle.promptRefs) resourceBundleRef.promptRefs = bundle.promptRefs;
if (bundle.skillRefs) resourceBundleRef.skillRefs = bundle.skillRefs;
if (bundle.workspaceFiles) resourceBundleRef.workspaceFiles = bundle.workspaceFiles;
const run = await client.post("/api/v1/runs", {
tenantId: "hwlab",
projectId: "pikasTech/HWLAB",