From 19438d6aa4a02a18561b66b8739e183e5e186d6e Mon Sep 17 00:00:00 2001 From: Codex Date: Thu, 4 Jun 2026 02:18:34 +0800 Subject: [PATCH] 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 --conversation-id 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 --- internal/agent/agentrun-dispatch.mjs | 10 +- internal/agent/agentrun-dispatch.test.mjs | 40 ++++ tools/hwlab-cli/client.test.ts | 213 ++++++++++++++++++++-- tools/src/hwlab-cli-lib.ts | 14 +- 4 files changed, 253 insertions(+), 24 deletions(-) diff --git a/internal/agent/agentrun-dispatch.mjs b/internal/agent/agentrun-dispatch.mjs index eb59e07a..7c22b8cd 100644 --- a/internal/agent/agentrun-dispatch.mjs +++ b/internal/agent/agentrun-dispatch.mjs @@ -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") } : {}), diff --git a/internal/agent/agentrun-dispatch.test.mjs b/internal/agent/agentrun-dispatch.test.mjs index d967e2ad..085befc5 100644 --- a/internal/agent/agentrun-dispatch.test.mjs +++ b/internal/agent/agentrun-dispatch.test.mjs @@ -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"); +}); diff --git a/tools/hwlab-cli/client.test.ts b/tools/hwlab-cli/client.test.ts index 99e0dcf2..f5da56b0 100644 --- a/tools/hwlab-cli/client.test.ts +++ b/tools/hwlab-cli/client.test.ts @@ -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); +}); diff --git a/tools/src/hwlab-cli-lib.ts b/tools/src/hwlab-cli-lib.ts index 9cacffd8..04c49b15 100644 --- a/tools/src/hwlab-cli-lib.ts +++ b/tools/src/hwlab-cli-lib.ts @@ -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 --conversation-id 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 --message TEXT" + ] + }); + } } const explicitSessionConversationId = text(explicitSessionRecord?.conversationId); const allowWorkspaceSession = !requestedConversationId || requestedConversationId === workspaceConversationId;