Files
pikasTech-HWLAB/internal/agent/agentrun-dispatch.mjs
T
Codex 19438d6aa4 fix(v0.2): freeze HWLAB-AgentRun concept boundary for #792
P0 freezes the concept boundary between HWLAB (business session/workspace) and
AgentRun (tenant/policy/execution):

- HWLAB adapter now always uses DEFAULT_HWLAB_AGENTRUN_PROJECT_ID /
  DEFAULT_HWLAB_AGENTRUN_PROVIDER_ID / DEFAULT_HWLAB_AGENTRUN_BACKEND_PROFILE on
  AgentRun runPayload, regardless of caller options. Business projectId belongs
  in workspaceRef only, not in AgentRun projectId.

- commandPayload.projectId is now always set to the HWLAB-fixed
  DEFAULT_HWLAB_AGENTRUN_PROJECT_ID, never undefined and never derived from
  caller options.

- client agent send --session-id <SES> --conversation-id <CONV> without
  --provider-profile now looks up the explicit session record to inherit
  providerProfile / threadId / conversationId. Mismatched --conversation-id
  against the session record aborts with session_conversation_mismatch
  before dispatching to AgentRun (prevents stale session state from being
  used as a new conversation).

Contract tests added (all 4 passing):
- internal/agent/agentrun-dispatch.test.mjs: HWLAB AgentRun assembly fixes
  tenantId/projectId/providerId/backendProfile regardless of options; command
  payload does not leak business projectId into AgentRun command projectId.
- tools/hwlab-cli/client.test.ts: send --session-id + --conversation-id
  inherits explicit session providerProfile; fails with
  session_conversation_mismatch when the session points at a different
  conversation.

P2 (CLI/trace result segregation) and P3 (state machine convergence) remain
for follow-up. Tracked via #792 comment chain.

Tracked-by: pikasTech/HWLAB#792
2026-06-04 02:18:34 +08:00

433 lines
19 KiB
JavaScript

export const DEFAULT_AGENTRUN_NAMESPACE = "agentrun-v01";
export const DEFAULT_AGENTRUN_MANAGER_URL = "http://agentrun-mgr.agentrun-v01.svc.cluster.local:8080";
export const DEFAULT_HWLAB_AGENTRUN_PROJECT_ID = "pikasTech/HWLAB";
export const DEFAULT_HWLAB_AGENTRUN_PROVIDER_ID = "G14";
export const DEFAULT_HWLAB_AGENTRUN_REPO_URL = "http://git-mirror-http.devops-infra.svc.cluster.local/pikasTech/HWLAB.git";
export const DEFAULT_HWLAB_AGENTRUN_BRANCH = "v0.2";
export const DEFAULT_HWLAB_AGENTRUN_BACKEND_PROFILE = "deepseek";
export const DEFAULT_UNIDESK_MAIN_SERVER_ENV = "UNIDESK_MAIN_SERVER_IP";
export const DEFAULT_HWLAB_AGENTRUN_TOOL_ALIASES = Object.freeze([
{ name: "hwpod", path: "tools/device-pod-cli.mjs", kind: "node-script" },
{ name: "unidesk-ssh", path: "tools/unidesk-ssh.mjs", kind: "bun-script" }
]);
export const DEFAULT_HWLAB_AGENTRUN_PROMPT_REFS = Object.freeze([
{ name: "hwlab-v02-runtime", path: "internal/agent/prompts/hwlab-v02-runtime.md", inject: "thread-start", required: true }
]);
export const DEFAULT_HWLAB_AGENTRUN_SKILL_REFS = Object.freeze([
{ name: "device-pod-cli", path: "skills/device-pod-cli/SKILL.md", required: true, aggregateAs: "device-pod-cli" },
{ name: "hwlab-agent-runtime", path: "skills/hwlab-agent-runtime/SKILL.md", required: true, aggregateAs: "hwlab-agent-runtime" }
]);
export function createHwlabAgentRunAssemblySummary(options = {}) {
const env = options.env ?? process.env;
const toolAliases = normalizeResourceToolAliases(options.toolAliases ?? DEFAULT_HWLAB_AGENTRUN_TOOL_ALIASES);
const promptRefs = normalizeResourcePromptRefs(options.promptRefs ?? DEFAULT_HWLAB_AGENTRUN_PROMPT_REFS);
const skillRefs = normalizeResourceSkillRefs(options.skillRefs ?? DEFAULT_HWLAB_AGENTRUN_SKILL_REFS);
const promptAssembly = createPromptAssemblySummary(promptRefs);
const repoUrl = optionalString(options.repoUrl ?? env.HWLAB_CODE_AGENT_AGENTRUN_REPO_URL ?? env.AGENTRUN_REPO_URL) ?? DEFAULT_HWLAB_AGENTRUN_REPO_URL;
const branch = optionalString(options.branch ?? env.HWLAB_BOOT_REF ?? env.HWLAB_CODE_AGENT_AGENTRUN_BRANCH ?? env.AGENTRUN_BRANCH) ?? DEFAULT_HWLAB_AGENTRUN_BRANCH;
const commitId = optionalString(options.commitId ?? env.HWLAB_COMMIT_ID ?? env.HWLAB_GIT_SHA ?? env.HWLAB_REVISION);
const namespace = optionalString(options.namespace ?? env.AGENTRUN_RUNTIME_NAMESPACE ?? env.HWLAB_CODE_AGENT_AGENTRUN_NAMESPACE) ?? DEFAULT_AGENTRUN_NAMESPACE;
const managerUrl = optionalString(options.managerUrl ?? env.HWLAB_CODE_AGENT_AGENTRUN_MGR_URL ?? env.AGENTRUN_MANAGER_URL) ?? DEFAULT_AGENTRUN_MANAGER_URL;
return {
status: skillRefs.length > 0 ? "ready" : "degraded",
provider: "agentrun-v01",
namespace,
managerUrl,
resourceBundle: {
kind: "git",
repoUrl,
branch,
commitId,
skillsDir: ".agents/skills"
},
counts: {
toolAliases: toolAliases.length,
promptRefs: promptRefs.length,
skillRefs: skillRefs.length
},
toolAliases: toolAliases.map((item) => ({ ...item })),
promptRefs: promptRefs.map((item) => ({ ...item })),
promptAssembly,
skillRefs: skillRefs.map((item) => ({ ...item })),
runtimeEnv: {
assembledRuntime: "HWLAB_CODE_AGENT_ASSEMBLED_RUNTIME",
skillsDirs: "HWLAB_CODE_AGENT_SKILLS_DIRS"
},
valuesPrinted: false
};
}
function createPromptAssemblySummary(promptRefs) {
const refs = promptRefs.map((item) => ({
name: item.name,
path: item.path,
inject: item.inject,
stage: promptInjectStageLabel(item.inject),
required: item.required !== false,
source: "ResourceBundleRef.promptRefs"
}));
return {
status: refs.length > 0 ? "ready" : "missing",
count: refs.length,
requiredCount: refs.filter((item) => item.required).length,
injectStages: [...new Set(refs.map((item) => item.inject))],
refs,
valuesPrinted: false
};
}
function promptInjectStageLabel(inject) {
if (inject === "thread-start") return "thread-start initial prompt";
return inject;
}
export const AGENTRUN_REUSABLE_CREDENTIAL_ENV_NAMES = Object.freeze([
"AUTH_PASSWORD",
"CODEX_API_KEY",
"GH_TOKEN",
"GITHUB_TOKEN",
"OPENAI_API_KEY",
"PROVIDER_TOKEN",
"UNIDESK_AUTH_PASSWORD",
"UNIDESK_PROVIDER_TOKEN",
"UNIDESK_SSH_CLIENT_TOKEN"
]);
const backendProfiles = Object.freeze(["codex", "deepseek", "minimax-m3"]);
const providerSecretKeys = Object.freeze(["auth.json", "config.toml"]);
const reusableCredentialEnvNameSet = new Set(AGENTRUN_REUSABLE_CREDENTIAL_ENV_NAMES);
export function createGithubToolCredential({
namespace = DEFAULT_AGENTRUN_NAMESPACE,
secretName = "agentrun-v01-tool-github-pr",
secretKey = "GH_TOKEN"
} = {}) {
return {
tool: "github",
purpose: "pull-request",
secretRef: { namespace, name: secretName, keys: [secretKey] },
projection: { kind: "env", envName: secretKey, secretKey }
};
}
export function createUnideskSshToolCredential({
namespace = DEFAULT_AGENTRUN_NAMESPACE,
secretName = "agentrun-v01-tool-unidesk-ssh"
} = {}) {
return {
tool: "unidesk-ssh",
purpose: "ssh-passthrough",
secretRef: { namespace, name: secretName, keys: ["UNIDESK_SSH_CLIENT_TOKEN"] },
projection: { kind: "env", envName: "UNIDESK_SSH_CLIENT_TOKEN", secretKey: "UNIDESK_SSH_CLIENT_TOKEN" }
};
}
export function createProviderCredential({
backendProfile = DEFAULT_HWLAB_AGENTRUN_BACKEND_PROFILE,
namespace = DEFAULT_AGENTRUN_NAMESPACE,
secretName = null,
mountPath = "~/.codex"
} = {}) {
const profile = normalizeBackendProfile(backendProfile);
return {
profile,
secretRef: {
namespace,
name: secretName || `agentrun-v01-provider-${profile}`,
keys: providerSecretKeys.slice(),
mountPath
}
};
}
export function createHwlabAgentRunDispatchAssembly(options = {}) {
const env = options.env ?? process.env;
const traceId = nonEmptyString(options.traceId, "traceId");
const prompt = nonEmptyString(options.prompt, "prompt");
const commitId = fullCommitSha(options.commitId);
// Concept boundary (#792): HWLAB adapter fixes AgentRun policy fields regardless of caller.
// Business-side projectId belongs in metadata/workspaceRef, NOT AgentRun projectId.
const backendProfile = normalizeBackendProfile(DEFAULT_HWLAB_AGENTRUN_BACKEND_PROFILE);
const includeGithubToolCredential = options.includeGithubToolCredential !== false;
const includeUnideskSshToolCredential = options.includeUnideskSshToolCredential !== false;
const namespace = nonEmptyString(options.namespace ?? DEFAULT_AGENTRUN_NAMESPACE, "namespace");
const managerUrl = trimTrailingSlash(nonEmptyString(options.managerUrl ?? DEFAULT_AGENTRUN_MANAGER_URL, "managerUrl"));
const projectId = DEFAULT_HWLAB_AGENTRUN_PROJECT_ID;
const providerId = DEFAULT_HWLAB_AGENTRUN_PROVIDER_ID;
const repoUrl = nonEmptyString(options.repoUrl ?? DEFAULT_HWLAB_AGENTRUN_REPO_URL, "repoUrl");
const branch = nonEmptyString(options.branch ?? DEFAULT_HWLAB_AGENTRUN_BRANCH, "branch");
const timeoutMs = positiveInteger(options.timeoutMs ?? 600000, "timeoutMs");
const unideskMainServerIp = optionalString(options.unideskMainServerIp ?? env.UNIDESK_MAIN_SERVER_IP ?? env.UNIDESK_MAIN_SERVER_HOST);
const toolAliases = normalizeResourceToolAliases(options.toolAliases ?? DEFAULT_HWLAB_AGENTRUN_TOOL_ALIASES);
const promptRefs = normalizeResourcePromptRefs(options.promptRefs ?? DEFAULT_HWLAB_AGENTRUN_PROMPT_REFS);
const skillRefs = normalizeResourceSkillRefs(options.skillRefs ?? DEFAULT_HWLAB_AGENTRUN_SKILL_REFS);
if (includeUnideskSshToolCredential && !unideskMainServerIp) {
throw new Error("unideskMainServerIp is required when UniDesk SSH passthrough is enabled");
}
const toolCredentials = [
...(includeGithubToolCredential ? [createGithubToolCredential({ namespace })] : []),
...(includeUnideskSshToolCredential ? [createUnideskSshToolCredential({ namespace })] : []),
...normalizeToolCredentials(options.toolCredentials ?? [])
];
const transientEnv = normalizeTransientEnv(transientEnvWithUnideskMainServer(options.transientEnv ?? [], unideskMainServerIp));
const sessionRef = createSessionRef(options);
const resourceBundleRef = {
kind: "git",
repoUrl,
commitId,
...(options.subdir ? { subdir: nonEmptyString(options.subdir, "subdir") } : {}),
...(Array.isArray(options.sparsePaths) ? { sparsePaths: options.sparsePaths.map((item, index) => nonEmptyString(item, `sparsePaths[${index}]`)) } : {}),
...(toolAliases.length > 0 ? { toolAliases } : {}),
...(promptRefs.length > 0 ? { promptRefs } : {}),
...(skillRefs.length > 0 ? { skillRefs } : {}),
...(options.resourceCredentialRef ? { credentialRef: options.resourceCredentialRef } : {})
};
const runPayload = {
tenantId: "hwlab",
projectId,
workspaceRef: {
kind: "git-worktree",
repo: repoUrl,
branch
},
...(sessionRef ? { sessionRef } : { sessionRef: null }),
resourceBundleRef,
providerId,
backendProfile,
executionPolicy: {
sandbox: options.sandbox ?? "workspace-write",
approval: options.approval ?? "never",
timeoutMs,
network: options.network ?? "default",
secretScope: {
allowCredentialEcho: false,
providerCredentials: [createProviderCredential({ backendProfile, namespace })],
...(toolCredentials.length > 0 ? { toolCredentials } : {})
}
},
traceSink: options.traceSink ?? null
};
const commandPayload = {
type: "turn",
payload: {
prompt,
traceId,
projectId: DEFAULT_HWLAB_AGENTRUN_PROJECT_ID,
...(options.conversationId ? { conversationId: nonEmptyString(options.conversationId, "conversationId") } : {}),
...(options.sessionId ? { sessionId: nonEmptyString(options.sessionId, "sessionId") } : {}),
...(options.threadId ? { threadId: nonEmptyString(options.threadId, "threadId") } : {}),
providerProfile: backendProfile,
dispatchSource: "hwlab-code-agent"
},
idempotencyKey: options.commandIdempotencyKey ?? `hwlab:${traceId}:turn`
};
const runnerJobPayload = {
idempotencyKey: options.runnerJobIdempotencyKey ?? `hwlab:${traceId}:runner-job`,
...(options.attemptId ? { attemptId: nonEmptyString(options.attemptId, "attemptId") } : {}),
transientEnv
};
return {
managerUrl,
runPayload,
commandPayload,
runnerJobPayload,
boundaries: {
valuesPrinted: false,
githubCredentialSource: includeGithubToolCredential ? "toolCredentials" : null,
unideskSshCredentialSource: includeUnideskSshToolCredential ? "toolCredentials" : null,
unideskMainServerSource: unideskMainServerIp ? "transientEnv" : null,
toolAliases: toolAliases.map((item) => item.name),
promptRefs: promptRefs.map((item) => item.name),
skillRefs: skillRefs.map((item) => item.name),
transientEnvNames: transientEnv.map((item) => item.name),
forbiddenTransientEnvNames: AGENTRUN_REUSABLE_CREDENTIAL_ENV_NAMES.slice()
}
};
}
export async function dispatchHwlabAgentRun(options = {}) {
const fetchImpl = options.fetchImpl ?? globalThis.fetch;
if (typeof fetchImpl !== "function") throw new Error("dispatchHwlabAgentRun requires fetchImpl or global fetch");
const assembly = options.assembly ?? createHwlabAgentRunDispatchAssembly(options);
const managerUrl = trimTrailingSlash(options.managerUrl ?? assembly.managerUrl);
const run = await postJson(fetchImpl, managerUrl, "/api/v1/runs", assembly.runPayload);
const runId = nonEmptyString(run.id, "run.id");
const command = await postJson(fetchImpl, managerUrl, `/api/v1/runs/${encodeURIComponent(runId)}/commands`, assembly.commandPayload);
const commandId = nonEmptyString(command.id, "command.id");
const runnerJobPayload = { ...assembly.runnerJobPayload, commandId };
const runnerJob = await postJson(fetchImpl, managerUrl, `/api/v1/runs/${encodeURIComponent(runId)}/runner-jobs`, runnerJobPayload);
return {
managerUrl,
run,
command,
runnerJob,
requests: {
runPayload: assembly.runPayload,
commandPayload: assembly.commandPayload,
runnerJobPayload
},
boundaries: assembly.boundaries
};
}
function createSessionRef(options) {
const sessionId = optionalString(options.sessionId);
const conversationId = optionalString(options.conversationId);
const threadId = optionalString(options.threadId);
if (!sessionId && !conversationId && !threadId) return null;
return {
sessionId: sessionId || conversationId || threadId,
...(conversationId ? { conversationId } : {}),
...(threadId ? { threadId } : {}),
metadata: {
source: "hwlab-code-agent",
traceId: optionalString(options.traceId) ?? null
}
};
}
function normalizeTransientEnv(items) {
const seen = new Set();
return items.filter(Boolean).map((item, index) => {
const name = nonEmptyString(item.name, `transientEnv[${index}].name`);
if (!/^[A-Z_][A-Z0-9_]{0,63}$/u.test(name)) throw new Error(`transientEnv[${index}].name must be an uppercase env name`);
if (reusableCredentialEnvNameSet.has(name)) throw new Error(`transientEnv ${name} must use AgentRun tool/provider credential assembly instead`);
if (seen.has(name)) throw new Error(`transientEnv ${name} is duplicated`);
seen.add(name);
return {
name,
value: nonEmptyString(item.value, `transientEnv[${index}].value`),
sensitive: item.sensitive !== false
};
});
}
function transientEnvWithUnideskMainServer(items, unideskMainServerIp) {
if (!unideskMainServerIp) return items;
if (items.some((item) => item?.name === DEFAULT_UNIDESK_MAIN_SERVER_ENV)) return items;
return [...items, { name: DEFAULT_UNIDESK_MAIN_SERVER_ENV, value: unideskMainServerIp, sensitive: false }];
}
function normalizeToolCredentials(items) {
return items.map((item, index) => {
if (!item || typeof item !== "object") throw new Error(`toolCredentials[${index}] must be an object`);
return item;
});
}
function normalizeResourceToolAliases(items) {
if (!Array.isArray(items)) throw new Error("toolAliases must be an array");
const allowedKinds = new Set(["node-script", "bun-script", "sh-script", "executable"]);
const seen = new Set();
return items.map((item, index) => {
if (!item || typeof item !== "object") throw new Error(`toolAliases[${index}] must be an object`);
const name = nonEmptyString(item.name, `toolAliases[${index}].name`);
const aliasPath = nonEmptyString(item.path, `toolAliases[${index}].path`);
const kind = nonEmptyString(item.kind, `toolAliases[${index}].kind`);
if (!/^[a-z][a-z0-9._-]{0,62}$/u.test(name)) throw new Error(`toolAliases[${index}].name must be a lowercase command name`);
if (seen.has(name)) throw new Error(`toolAliases name ${name} is duplicated`);
if (aliasPath.startsWith("/") || aliasPath.includes("..")) throw new Error(`toolAliases[${index}].path must stay within the checkout`);
if (!allowedKinds.has(kind)) throw new Error(`toolAliases[${index}].kind is not supported`);
seen.add(name);
return { name, path: aliasPath, kind };
});
}
function normalizeResourcePromptRefs(items) {
if (!Array.isArray(items)) throw new Error("promptRefs must be an array");
const seen = new Set();
return items.map((item, index) => {
if (!item || typeof item !== "object") throw new Error(`promptRefs[${index}] must be an object`);
const name = resourceName(item.name, `promptRefs[${index}].name`);
if (seen.has(name)) throw new Error(`promptRefs name ${name} is duplicated`);
seen.add(name);
const refPath = resourcePath(item.path, `promptRefs[${index}].path`);
const inject = optionalString(item.inject) ?? "thread-start";
if (inject !== "thread-start") throw new Error(`promptRefs[${index}].inject must be thread-start`);
return { name, path: refPath, inject: "thread-start", required: item.required !== false };
});
}
function normalizeResourceSkillRefs(items) {
if (!Array.isArray(items)) throw new Error("skillRefs must be an array");
const seen = new Set();
return items.map((item, index) => {
if (!item || typeof item !== "object") throw new Error(`skillRefs[${index}] must be an object`);
const name = resourceName(item.name, `skillRefs[${index}].name`);
if (seen.has(name)) throw new Error(`skillRefs name ${name} is duplicated`);
seen.add(name);
const refPath = resourcePath(item.path, `skillRefs[${index}].path`);
if (!refPath.endsWith("SKILL.md")) throw new Error(`skillRefs[${index}].path must point to SKILL.md`);
const aggregateAs = optionalString(item.aggregateAs);
return { name, path: refPath, required: item.required !== false, ...(aggregateAs ? { aggregateAs: resourceName(aggregateAs, `skillRefs[${index}].aggregateAs`) } : {}) };
});
}
function resourceName(value, fieldName) {
const name = nonEmptyString(value, fieldName);
if (!/^[a-z][a-z0-9._-]{0,62}$/u.test(name)) throw new Error(`${fieldName} must be a lowercase resource name`);
return name;
}
function resourcePath(value, fieldName) {
const refPath = nonEmptyString(value, fieldName);
if (refPath.startsWith("/") || refPath.includes("..")) throw new Error(`${fieldName} must stay within the checkout`);
return refPath;
}
async function postJson(fetchImpl, managerUrl, path, payload) {
const response = await fetchImpl(`${managerUrl}${path}`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(payload)
});
const text = await response.text();
const body = text ? JSON.parse(text) : null;
if (!response.ok) throw new Error(`AgentRun POST ${path} failed: status=${response.status} body=${JSON.stringify(body).slice(0, 500)}`);
return unwrapAgentRunEnvelope(path, body);
}
function unwrapAgentRunEnvelope(path, body) {
if (!body || typeof body !== "object" || !("ok" in body)) return body;
if (body.ok === true) return body.data ?? null;
throw new Error(`AgentRun POST ${path} failed: envelope=${JSON.stringify(body).slice(0, 500)}`);
}
function normalizeBackendProfile(value) {
const profile = nonEmptyString(value, "backendProfile");
if (!backendProfiles.includes(profile)) throw new Error(`unsupported AgentRun backendProfile ${JSON.stringify(profile)}`);
return profile;
}
function fullCommitSha(value) {
const commitId = nonEmptyString(value, "commitId");
if (!/^[0-9a-f]{40}$/u.test(commitId)) throw new Error("commitId must be a full 40-character lowercase git commit sha");
return commitId;
}
function positiveInteger(value, fieldName) {
const parsed = Number(value);
if (!Number.isSafeInteger(parsed) || parsed <= 0) throw new Error(`${fieldName} must be a positive integer`);
return parsed;
}
function nonEmptyString(value, fieldName) {
const text = optionalString(value);
if (!text) throw new Error(`${fieldName} is required`);
return text;
}
function optionalString(value) {
return typeof value === "string" && value.trim() ? value.trim() : null;
}
function trimTrailingSlash(value) {
return nonEmptyString(value, "url").replace(/\/+$/u, "");
}