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
This commit is contained in:
Codex
2026-06-04 02:18:34 +08:00
parent b23ba1cb82
commit 19438d6aa4
4 changed files with 253 additions and 24 deletions
+6 -4
View File
@@ -146,13 +146,15 @@ export function createHwlabAgentRunDispatchAssembly(options = {}) {
const traceId = nonEmptyString(options.traceId, "traceId");
const prompt = nonEmptyString(options.prompt, "prompt");
const commitId = fullCommitSha(options.commitId);
const backendProfile = normalizeBackendProfile(options.backendProfile ?? DEFAULT_HWLAB_AGENTRUN_BACKEND_PROFILE);
// 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 = nonEmptyString(options.projectId ?? DEFAULT_HWLAB_AGENTRUN_PROJECT_ID, "projectId");
const providerId = nonEmptyString(options.providerId ?? DEFAULT_HWLAB_AGENTRUN_PROVIDER_ID, "providerId");
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");
@@ -214,7 +216,7 @@ export function createHwlabAgentRunDispatchAssembly(options = {}) {
payload: {
prompt,
traceId,
projectId,
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") } : {}),
+40
View File
@@ -176,3 +176,43 @@ function jsonResponse(body, { status = 200 } = {}) {
text: async () => JSON.stringify(body)
};
}
test("HWLAB AgentRun assembly fixes tenantId/projectId/providerId/backendProfile regardless of options (#792)", () => {
// Even if caller passes contradicting projectId / providerId / backendProfile,
// the assembly must use HWLAB-fixed values to keep AgentRun tenant/policy boundaries
const assembly = createHwlabAgentRunDispatchAssembly({
traceId: "trc_hwlab_agentrun_concept_boundary",
prompt: "concept boundary",
commitId,
unideskMainServerIp: "https://unidesk.example.test",
projectId: "pikasTech/SomeOtherProject",
providerId: "OtherProvider",
backendProfile: "codex"
});
// runPayload is the AgentRun tenant/policy payload
assert.equal(assembly.runPayload.tenantId, "hwlab");
assert.equal(assembly.runPayload.projectId, "pikasTech/HWLAB");
assert.equal(assembly.runPayload.providerId, "G14");
assert.equal(assembly.runPayload.backendProfile, "deepseek");
// workspaceRef is fixed to v0.2 lane
assert.equal(assembly.runPayload.workspaceRef.kind, "git-worktree");
assert.equal(assembly.runPayload.workspaceRef.repo, "http://git-mirror-http.devops-infra.svc.cluster.local/pikasTech/HWLAB.git");
assert.equal(assembly.runPayload.workspaceRef.branch, "v0.2");
// resourceBundleRef.commitId must be the 40-char sha
assert.equal(assembly.runPayload.resourceBundleRef.commitId, commitId);
assert.equal(assembly.runPayload.resourceBundleRef.commitId.length, 40);
});
test("HWLAB AgentRun assembly command payload does not leak business projectId into AgentRun command projectId (#792)", () => {
// Business-side projectId belongs in workspaceRef only, not in AgentRun command.projectId.
const assembly = createHwlabAgentRunDispatchAssembly({
traceId: "trc_hwlab_agentrun_command_project",
prompt: "do not leak business project",
commitId,
unideskMainServerIp: "https://unidesk.example.test"
});
// commandPayload.projectId must equal runPayload.projectId (HWLAB-fixed), NOT any business project
assert.equal(assembly.commandPayload.payload.projectId, assembly.runPayload.projectId);
assert.equal(assembly.commandPayload.payload.projectId, "pikasTech/HWLAB");
assert.equal(assembly.runPayload.projectId, "pikasTech/HWLAB");
});
+194 -19
View File
@@ -758,6 +758,20 @@ test("hwlab-cli client agent send saves completed workspace response for new con
}
}), { status: 200 });
}
if (String(url).endsWith("/v1/agent/sessions/ses_new_workspace")) {
return new Response(JSON.stringify({
ok: true,
status: "found",
session: {
sessionId: "ses_new_workspace",
conversationId: "cnv_new_workspace",
threadId: "thread-new-workspace",
status: "idle",
usable: true,
providerProfile: "deepseek"
}
}), { status: 200 });
}
if (String(url).endsWith("/v1/agent/chat")) {
return new Response(JSON.stringify({ accepted: true, status: "running", traceId: "trc_new_workspace", resultUrl: "/v1/agent/chat/result/trc_new_workspace" }), { status: 202 });
}
@@ -790,11 +804,11 @@ test("hwlab-cli client agent send saves completed workspace response for new con
});
assert.equal(result.exitCode, 0);
assert.equal(calls[1].url, "http://web.test/v1/agent/chat");
assert.equal(calls[1].body.conversationId, "cnv_new_workspace");
assert.equal(calls[1].body.sessionId, "ses_new_workspace");
assert.equal(Object.hasOwn(calls[1].body, "threadId"), false);
assert.equal(calls[3].url, "http://web.test/v1/workbench/workspace/wsp_new_workspace");
assert.equal(calls[2].url, "http://web.test/v1/agent/chat");
assert.equal(calls[2].body.conversationId, "cnv_new_workspace");
assert.equal(calls[2].body.sessionId, "ses_new_workspace");
assert.equal(Object.hasOwn(calls[2].body, "threadId"), true);
assert.equal(calls[4].url, "http://web.test/v1/workbench/workspace/wsp_new_workspace");
assert.equal(result.payload.workspace.selectedConversationId, "cnv_new_workspace");
assert.equal(result.payload.workspace.selectedAgentSessionId, "ses_new_workspace");
assert.equal(result.payload.workspace.threadId, "thread-new-workspace");
@@ -872,6 +886,20 @@ test("hwlab-cli client agent send restores workspace when completed patch omits
}
}), { status: 200 });
}
if (String(url).endsWith("/v1/agent/sessions/ses_restore_workspace")) {
return new Response(JSON.stringify({
ok: true,
status: "found",
session: {
sessionId: "ses_restore_workspace",
conversationId: "cnv_restore_workspace",
threadId: "thread-restore-workspace",
status: "idle",
usable: true,
providerProfile: "deepseek"
}
}), { status: 200 });
}
if (String(url).endsWith("/v1/agent/chat")) {
return new Response(JSON.stringify({ accepted: true, status: "running", traceId: "trc_restore_workspace", resultUrl: "/v1/agent/chat/result/trc_restore_workspace" }), { status: 202 });
}
@@ -904,10 +932,10 @@ test("hwlab-cli client agent send restores workspace when completed patch omits
});
assert.equal(result.exitCode, 0);
assert.equal(calls[1].url, "http://web.test/v1/agent/chat");
assert.equal(calls[1].body.sessionId, "ses_restore_workspace");
assert.equal(Object.hasOwn(calls[1].body, "threadId"), false);
assert.equal(calls[3].url, "http://web.test/v1/workbench/workspace/wsp_restore_workspace");
assert.equal(calls[2].url, "http://web.test/v1/agent/chat");
assert.equal(calls[2].body.sessionId, "ses_restore_workspace");
assert.equal(Object.hasOwn(calls[2].body, "threadId"), true);
assert.equal(calls[4].url, "http://web.test/v1/workbench/workspace/wsp_restore_workspace");
assert.equal(result.payload.workspace.selectedConversationId, "cnv_restore_workspace");
assert.equal(result.payload.workspace.selectedAgentSessionId, "ses_restore_workspace");
assert.equal(result.payload.workspace.threadId, "thread-restore-workspace");
@@ -970,6 +998,34 @@ test("hwlab-cli client agent send output uses completed result when restore is s
}
}), { status: 200 });
}
if (String(url).endsWith("/v1/agent/sessions/ses_stale_restore")) {
return new Response(JSON.stringify({
ok: true,
status: "found",
session: {
sessionId: "ses_stale_restore",
conversationId: "cnv_stale_restore",
threadId: "thread-stale-restore",
status: "idle",
usable: true,
providerProfile: "deepseek"
}
}), { status: 200 });
}
if (String(url).endsWith("/v1/agent/sessions/ses_web_continue")) {
return new Response(JSON.stringify({
ok: true,
status: "found",
session: {
sessionId: "ses_web_continue",
conversationId: "cnv_web_continue",
threadId: "thread-web-continue",
status: "idle",
usable: true,
providerProfile: "deepseek"
}
}), { status: 200 });
}
if (String(url).endsWith("/v1/agent/chat")) {
return new Response(JSON.stringify({ accepted: true, status: "running", traceId: "trc_stale_restore", resultUrl: "/v1/agent/chat/result/trc_stale_restore" }), { status: 202 });
}
@@ -985,18 +1041,18 @@ test("hwlab-cli client agent send output uses completed result when restore is s
});
assert.equal(result.exitCode, 0);
assert.equal(calls[4].url, "http://web.test/v1/workbench/workspace?projectId=prj_device_pod_workbench");
assert.equal(calls[5].url, "http://web.test/v1/workbench/workspace?projectId=prj_device_pod_workbench");
assert.equal(result.payload.workspace.selectedConversationId, "cnv_stale_restore");
assert.equal(result.payload.workspace.selectedAgentSessionId, "ses_stale_restore");
assert.equal(Object.hasOwn(result.payload.workspace, "threadId"), false);
assert.equal(Object.hasOwn(result.payload.workspace, "threadId"), true);
assert.equal(result.payload.workspace.selectedConversation.conversationId, "cnv_stale_restore");
assert.equal(result.payload.workspace.selectedConversation.sessionId, "ses_stale_restore");
assert.equal(Object.hasOwn(result.payload.workspace.selectedConversation, "threadId"), false);
assert.equal(Object.hasOwn(result.payload.workspace.selectedConversation, "threadId"), true);
const session = JSON.parse(await readFile(path.join(cwd, ".state/hwlab-cli/session.json"), "utf8"));
assert.equal(session.workspace.selectedConversationId, "cnv_stale_restore");
assert.equal(session.workspace.selectedAgentSessionId, "ses_stale_restore");
assert.equal(Object.hasOwn(session.workspace, "threadId"), false);
assert.equal(Object.hasOwn(session.workspace, "threadId"), true);
});
test("hwlab-cli client agent send preserves Web continuation fields", async () => {
@@ -1038,12 +1094,12 @@ test("hwlab-cli client agent send preserves Web continuation fields", async () =
assert.equal(result.exitCode, 0);
assert.equal(calls[0].url, "http://web.test/v1/workbench/workspace?projectId=prj_device_pod_workbench");
assert.equal(calls[1].url, "http://web.test/v1/agent/chat");
assert.equal(calls[1].body.message, "看看有什么device-pod能用?");
assert.equal(calls[1].body.conversationId, "cnv_web_continue");
assert.equal(calls[1].body.sessionId, "ses_web_continue");
assert.equal(calls[1].body.threadId, "019e8078-db67-7750-a5d9-1a99f3abd445");
assert.equal(calls[1].body.retryOf, "trc_previous");
assert.equal(calls[2].url, "http://web.test/v1/agent/chat");
assert.equal(calls[2].body.message, "看看有什么device-pod能用?");
assert.equal(calls[2].body.conversationId, "cnv_web_continue");
assert.equal(calls[2].body.sessionId, "ses_web_continue");
assert.equal(calls[2].body.threadId, "019e8078-db67-7750-a5d9-1a99f3abd445");
assert.equal(calls[2].body.retryOf, "trc_previous");
assert.equal(result.payload.continuation.webEquivalent, true);
assert.equal(result.payload.continuation.threadId, "019e8078-db67-7750-a5d9-1a99f3abd445");
assert.equal(result.payload.continuation.retryOf, "trc_previous");
@@ -1796,3 +1852,122 @@ test("hwlab-cli client request keeps command visibility on transport failure", a
assert.equal(result.payload.request.transportError.code, "request_failed");
assert.equal(result.payload.body.error.code, "request_failed");
});
test("hwlab-cli client agent send with --session-id and --conversation-id still inherits explicit session providerProfile (#792)", async () => {
const calls: any[] = [];
const result = await runHwlabCli([
"client",
"agent",
"send",
"--base-url",
"http://web.test",
"--cookie",
"hwlab_session=session-a",
"--message",
"send with explicit session + conversation",
"--trace-id",
"trc_explicit_session_with_conv",
"--session-id",
"ses_explicit",
"--conversation-id",
"cnv_explicit"
], {
fetchImpl: async (url, init) => {
calls.push({ url: String(url), init, body: init?.body ? JSON.parse(String(init.body)) : null });
if (String(url).endsWith("/v1/workbench/workspace?projectId=prj_device_pod_workbench")) {
return new Response(JSON.stringify({
ok: true,
workspace: {
workspaceId: "wsp_other",
revision: 4,
providerProfile: "deepseek",
workspace: { sessionStatus: "idle", providerProfile: "deepseek" }
}
}), { status: 200 });
}
if (String(url).endsWith("/v1/agent/sessions/ses_explicit")) {
return new Response(JSON.stringify({
ok: true,
status: "found",
session: {
sessionId: "ses_explicit",
conversationId: "cnv_explicit",
threadId: "thread-explicit",
status: "idle",
usable: true,
providerProfile: "minimax-m3"
}
}), { status: 200 });
}
if (String(url).endsWith("/v1/agent/chat")) {
return new Response(JSON.stringify({ accepted: true, status: "running", traceId: "trc_explicit_session_with_conv", resultUrl: "/v1/agent/chat/result/trc_explicit_session_with_conv" }), { status: 202 });
}
return new Response(JSON.stringify({ status: "completed", traceId: "trc_explicit_session_with_conv", conversationId: "cnv_explicit", sessionId: "ses_explicit", reply: { role: "assistant", content: "ok" } }), { status: 200 });
},
sleep: async () => {}
});
assert.equal(result.exitCode, 0);
const sessionLookup = calls.find((c) => String(c.url).endsWith("/v1/agent/sessions/ses_explicit"));
assert.ok(sessionLookup, "CLI must look up explicit session when --session-id is given alongside --conversation-id (without --provider-profile)");
const chatCall = calls.find((c) => String(c.url).endsWith("/v1/agent/chat"));
assert.ok(chatCall);
assert.equal(chatCall.body.providerProfile, "minimax-m3");
assert.equal(chatCall.body.sessionId, "ses_explicit");
assert.equal(chatCall.body.conversationId, "cnv_explicit");
assert.equal(chatCall.body.threadId, "thread-explicit");
});
test("hwlab-cli client agent send fails when explicit session conversationId does not match requested conversationId (#792)", async () => {
const calls: any[] = [];
const result = await runHwlabCli([
"client",
"agent",
"send",
"--base-url",
"http://web.test",
"--cookie",
"hwlab_session=session-a",
"--message",
"wrong conversation",
"--trace-id",
"trc_mismatch",
"--session-id",
"ses_a",
"--conversation-id",
"cnv_b"
], {
fetchImpl: async (url, init) => {
calls.push({ url: String(url), init, body: init?.body ? JSON.parse(String(init.body)) : null });
if (String(url).endsWith("/v1/workbench/workspace?projectId=prj_device_pod_workbench")) {
return new Response(JSON.stringify({ ok: true, workspace: { workspaceId: "wsp_test", revision: 1, workspace: {} } }), { status: 200 });
}
if (String(url).endsWith("/v1/agent/sessions/ses_a")) {
return new Response(JSON.stringify({
ok: true,
status: "found",
session: {
sessionId: "ses_a",
conversationId: "cnv_a",
threadId: "thread-a",
status: "idle",
usable: true,
providerProfile: "minimax-m3"
}
}), { status: 200 });
}
if (String(url).endsWith("/v1/agent/chat")) {
return new Response(JSON.stringify({ accepted: true }), { status: 202 });
}
return new Response(JSON.stringify({ status: "completed" }), { status: 200 });
}
});
assert.equal(result.exitCode, 1);
assert.equal(result.payload.error.code, "session_conversation_mismatch");
assert.equal(result.payload.error.details.sessionId, "ses_a");
assert.equal(result.payload.error.details.requestedConversationId, "cnv_b");
assert.equal(result.payload.error.details.sessionConversationId, "cnv_a");
const chatCall = calls.find((c) => String(c.url).endsWith("/v1/agent/chat"));
assert.equal(chatCall, undefined);
});
+13 -1
View File
@@ -1306,12 +1306,24 @@ async function agentSend(context: any) {
const workspaceConversationId = text(workspaceState?.selectedConversationId);
const explicitSessionId = text(parsed.sessionId);
let explicitSessionRecord = null;
if (explicitSessionId && !requestedConversationId) {
if (explicitSessionId && !text(parsed.providerProfile)) {
const lookup = await fetchAgentSessionForSend(context, explicitSessionId);
if (!isHttpSuccess(lookup.response)) {
return responsePayload("client.agent.send", lookup.response, context, { route: route("GET", `/v1/agent/sessions/${encodeURIComponent(explicitSessionId)}`), traceId, sessionId: explicitSessionId, replay: replay.summary });
}
explicitSessionRecord = lookup.session;
if (requestedConversationId && text(explicitSessionRecord?.conversationId) && text(explicitSessionRecord.conversationId) !== requestedConversationId) {
throw cliError("session_conversation_mismatch", "client agent send --session-id <SESSION> --conversation-id <CONV> points at a session whose conversationId does not match; re-create the session for the conversation or pass --provider-profile to keep the user-specified profile without inheriting the session identity", {
traceId,
sessionId: explicitSessionId,
requestedConversationId,
sessionConversationId: text(explicitSessionRecord.conversationId),
nextCommands: [
`hwlab-cli client agent session create --conversation-id ${requestedConversationId}`,
"hwlab-cli client agent send --session-id <SESSION_ID> --message TEXT"
]
});
}
}
const explicitSessionConversationId = text(explicitSessionRecord?.conversationId);
const allowWorkspaceSession = !requestedConversationId || requestedConversationId === workspaceConversationId;