eb5bada0d1
Tracks pikasTech/HWLAB#773. PR #765 fixed selector confusion but did not touch cloud-api evidence propagation. This change closes the real root cause and adds the read-only evidence selectors that #760 follow-up called for. cloud-api (internal/cloud/access-control.ts): - DEVICE_JOB_INTENTS adds workspace.evidence and debug.evidence. - DEVICE_JOB_READ_ONLY_SUB_ACTIONS = { status, output, wait, cancel, evidence } and DEVICE_JOB_ACTIONABLE_INTENTS = { workspace.build, debug.download } make the mutating-intent / sub-action matrix explicit. - _deviceJobRequiresReason(intent, args, reason) returns false when the caller already provided reason OR when the actionable mutating intent is paired with a read-only sub-action. debug.reset and other non-actionable mutating intents still always require reason. - executorOutputPayload now also surfaces output.summary, nestedOutput.summary, evidence.text, evidence.logTail and evidence.summary as body.output.text, so a dispatcher that includes the host logTail / buildSummary in its result becomes visible to the Code Agent without a separate bootsharp dance. device-pod executor (cmd/hwlab-device-pod/main.ts): - gatewayDispatchText also walks result.evidence.{text,logTail, summary}, dispatch.message (only when dispatchStatus=completed), dispatch.summary, result.summary and dispatch.buildSummary before falling back to JSON.stringify. - deviceHostArgs maps workspace.evidence / debug.evidence to the host device-host-cli evidence subcommands. device-host-cli (skills/device-pod-cli/assets/device-host-cli.mjs): - new readJobEvidence(kindPrefix, requestedId, { tail, full, target }) reads the most recent (or specified) keil-build / keil-download job log file and returns it as keil-build.evidence / keil-download.evidence. tail defaults to 200, --full for entire log. Missing job returns ok=false but does not throw. - workspace build evidence and debug-probe download evidence now wired in main() and help text. device-pod-cli (tools/src/device-pod-cli-lib.ts): - selectorCheatSheet adds the evidence row and clarifies that build/download status/output/wait/cancel/evidence are read-only and do NOT need --reason. build/download start still require --reason. usage examples now include the two evidence selectors. - failed-fast selectors are unchanged; existing 26 tests still pass. SKILL.md (skills/device-pod-cli/SKILL.md): - Selector Cheat Sheet adds workspace.evidence and debug.evidence rows. - #773 follow-up note: --reason is now required only for mutating sub-actions, not for the read-only ones. cloud-api tests (internal/cloud/access-control.test.ts): - workspace.evidence and debug.evidence must be in DEVICE_JOB_INTENTS. - _deviceJobRequiresReason signature and the read-only / actionable branch table. - executorOutputPayload must look at evidence and summary fields in addition to text. 3 new tests; pre-existing 4 workbench failures are unrelated to this change and reproduce on the unchanged v0.2 base. Verification (host-side, G14 /root/hwlab-v02, source=0cf9a8c6): - bun test tools/device-pod-cli.test.ts → 26/26 pass. - bun test internal/cloud/access-control.test.ts → 23 pass, 4 pre-existing workbench failures (unchanged on v0.2 base). - bun test cmd/hwlab-device-pod/main.test.ts → 7/7 pass. - node --check skills/device-pod-cli/assets/device-host-cli.mjs → syntax OK. Hot probe: live v0.2 cloud-api at 74.48.78.17:19667 confirmed job_devicepod_804c5db4... (workspace.build) returned text="", bytes=0, output={} before this change. After the executor text-extraction update and the new evidence selector, a follow-up workspace.build start + build evidence will surface the host logTail / buildSummary in body.output.text without requiring bootsharp + host file fallback. Tracked-by: pikasTech/HWLAB#773
2207 lines
102 KiB
TypeScript
2207 lines
102 KiB
TypeScript
import assert from "node:assert/strict";
|
|
import { mkdtemp } from "node:fs/promises";
|
|
import { createServer } from "node:http";
|
|
import { test } from "bun:test";
|
|
|
|
import { createAccessController } from "./access-control.ts";
|
|
import { createCodeAgentTraceStore } from "./code-agent-trace-store.ts";
|
|
import { createCloudApiServer } from "./server.ts";
|
|
|
|
const INTERNAL_TOKEN = "test-internal-token";
|
|
|
|
test("Postgres workspace update query preserves parameter numbering", async () => {
|
|
const calls = [];
|
|
const accessController = createAccessController({
|
|
now: () => "2026-06-01T00:00:00.000Z",
|
|
runtimeStore: {
|
|
query: async (sql, params = []) => {
|
|
calls.push({ sql, params });
|
|
if (sql.startsWith("CREATE ") || sql.startsWith("ALTER ")) return { rows: [] };
|
|
if (sql.startsWith("SELECT * FROM account_workspaces")) {
|
|
return { rows: [{
|
|
id: "wsp_pg_param_guard",
|
|
owner_user_id: "usr_pg_param_guard",
|
|
project_id: "prj_device_pod_workbench",
|
|
name: "Default Workbench",
|
|
status: "active",
|
|
is_default: true,
|
|
selected_conversation_id: null,
|
|
selected_agent_session_id: null,
|
|
selected_device_pod_id: null,
|
|
active_trace_id: null,
|
|
provider_profile: null,
|
|
workspace_json: "{}",
|
|
revision: 1,
|
|
updated_by_session_id: null,
|
|
updated_by_client: null,
|
|
created_at: "2026-06-01T00:00:00.000Z",
|
|
updated_at: "2026-06-01T00:00:00.000Z"
|
|
}] };
|
|
}
|
|
if (sql.startsWith("UPDATE account_workspaces")) return { rows: [] };
|
|
return { rows: [] };
|
|
}
|
|
}
|
|
});
|
|
await accessController.store.updateWorkspace({
|
|
workspaceId: "wsp_pg_param_guard",
|
|
ownerUserId: "usr_pg_param_guard",
|
|
actorRole: "user",
|
|
providerProfile: "deepseek",
|
|
patch: { issue655AcceptanceMarker: "postgres-param-guard" },
|
|
updatedByClient: "test-suite"
|
|
});
|
|
|
|
const update = calls.find((call) => call.sql.startsWith("UPDATE account_workspaces"));
|
|
assert.ok(update);
|
|
assert.match(update.sql, /created_at=\$16, updated_at=\$17/u);
|
|
assert.match(update.sql, /\$18::text = 'admin'/u);
|
|
assert.equal(update.params.length, 18);
|
|
assert.equal(update.params[15], "2026-06-01T00:00:00.000Z");
|
|
assert.equal(update.params[16], "2026-06-01T00:00:00.000Z");
|
|
assert.equal(update.params[17], "user");
|
|
});
|
|
|
|
test("workbench workspace is account-scoped, persistent, and permits independent Code Agent turns", async () => {
|
|
const server = createCloudApiServer({
|
|
env: {
|
|
HWLAB_ACCESS_CONTROL_REQUIRED: "1",
|
|
HWLAB_BOOTSTRAP_ADMIN_USERNAME: "admin",
|
|
HWLAB_BOOTSTRAP_ADMIN_PASSWORD: "admin-pass"
|
|
},
|
|
now: () => "2026-06-01T00:00:00.000Z"
|
|
});
|
|
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
|
|
|
|
try {
|
|
const { port } = server.address();
|
|
const adminLogin = await postJson(port, "/auth/login", { username: "admin", password: "admin-pass" });
|
|
const adminCookie = adminLogin.cookie;
|
|
const alice = await postJson(port, "/v1/admin/users", { username: "alice-ws", password: "alice-pass" }, adminCookie);
|
|
const bob = await postJson(port, "/v1/admin/users", { username: "bob-ws", password: "bob-pass" }, adminCookie);
|
|
assert.equal(alice.status, 201);
|
|
assert.equal(bob.status, 201);
|
|
|
|
const aliceLogin = await postJson(port, "/auth/login", { username: "alice-ws", password: "alice-pass" });
|
|
const bobLogin = await postJson(port, "/auth/login", { username: "bob-ws", password: "bob-pass" });
|
|
const workspace = await getJson(port, "/v1/workbench/workspace?projectId=prj_device_pod_workbench", aliceLogin.cookie);
|
|
assert.equal(workspace.status, 200);
|
|
assert.equal(workspace.body.contractVersion, "workbench-workspace-v1");
|
|
assert.match(workspace.body.workspace.workspaceId, /^wsp_/u);
|
|
assert.equal(workspace.body.workspace.revision, 1);
|
|
assert.equal(workspace.body.workspace.selectedConversationId, null);
|
|
|
|
const conversation = await putJson(port, "/v1/agent/conversations/cnv_issue655_shared", {
|
|
projectId: "prj_device_pod_workbench",
|
|
sessionId: "ses_issue655_shared",
|
|
threadId: "thread-issue-655",
|
|
sessionStatus: "running",
|
|
lastTraceId: "trc_issue655_previous",
|
|
messages: [{ role: "agent", text: "persist me", status: "running", traceId: "trc_issue655_active" }]
|
|
}, aliceLogin.cookie);
|
|
assert.equal(conversation.status, 200);
|
|
assert.equal(conversation.body.conversation.conversationId, "cnv_issue655_shared");
|
|
|
|
const update = await patchJson(port, `/v1/workbench/workspace/${workspace.body.workspace.workspaceId}`, {
|
|
projectId: "prj_device_pod_workbench",
|
|
expectedRevision: 1,
|
|
selectedConversationId: "cnv_issue655_shared",
|
|
selectedAgentSessionId: "ses_issue655_shared",
|
|
activeTraceId: "trc_issue655_active",
|
|
providerProfile: "deepseek",
|
|
sessionStatus: "running",
|
|
threadId: "thread-issue-655",
|
|
messages: [{ role: "agent", status: "running", traceId: "trc_issue655_active" }],
|
|
updatedByClient: "test-suite"
|
|
}, aliceLogin.cookie);
|
|
assert.equal(update.status, 200);
|
|
assert.equal(update.body.workspace.revision, 2);
|
|
assert.equal(update.body.workspace.selectedConversationId, "cnv_issue655_shared");
|
|
assert.equal(update.body.workspace.selectedConversation.conversationId, "cnv_issue655_shared");
|
|
assert.equal(update.body.workspace.activeTraceId, "trc_issue655_active");
|
|
assert.equal(update.body.workspace.secretMaterialStored, false);
|
|
|
|
const stale = await patchJson(port, `/v1/workbench/workspace/${workspace.body.workspace.workspaceId}`, {
|
|
expectedRevision: 1,
|
|
selectedConversationId: "cnv_issue655_shared",
|
|
updatedByClient: "test-suite"
|
|
}, aliceLogin.cookie);
|
|
assert.equal(stale.status, 409);
|
|
assert.equal(stale.body.error.code, "workspace_revision_conflict");
|
|
assert.equal(stale.body.workspace.revision, 2);
|
|
|
|
const bobRead = await getJson(port, `/v1/workbench/workspace/${workspace.body.workspace.workspaceId}/events?afterRevision=0`, bobLogin.cookie);
|
|
assert.equal(bobRead.status, 404);
|
|
assert.equal(bobRead.body.error.code, "workbench_workspace_not_found");
|
|
|
|
const aliceRelogin = await postJson(port, "/auth/login", { username: "alice-ws", password: "alice-pass" });
|
|
const restored = await getJson(port, "/v1/workbench/workspace?projectId=prj_device_pod_workbench", aliceRelogin.cookie);
|
|
assert.equal(restored.status, 200);
|
|
assert.equal(restored.body.workspace.workspaceId, workspace.body.workspace.workspaceId);
|
|
assert.equal(restored.body.workspace.selectedConversationId, "cnv_issue655_shared");
|
|
assert.equal(restored.body.workspace.revision, 2);
|
|
|
|
const events = await getJson(port, `/v1/workbench/workspace/${workspace.body.workspace.workspaceId}/events?afterRevision=1`, aliceRelogin.cookie);
|
|
assert.equal(events.status, 200);
|
|
assert.equal(events.body.status, "changed");
|
|
assert.equal(events.body.latestRevision, 2);
|
|
|
|
const parallel = await postJson(port, "/v1/agent/chat", {
|
|
message: "should run beside active workspace turn",
|
|
traceId: "trc_issue717_parallel",
|
|
conversationId: "cnv_issue717_parallel",
|
|
workspaceId: workspace.body.workspace.workspaceId,
|
|
expectedWorkspaceRevision: 2,
|
|
shortConnection: true
|
|
}, aliceRelogin.cookie, { prefer: "respond-async", "x-trace-id": "trc_issue717_parallel" });
|
|
assert.equal(parallel.status, 202);
|
|
assert.equal(parallel.body.status, "running");
|
|
assert.equal(parallel.body.traceId, "trc_issue717_parallel");
|
|
assert.equal(parallel.body.conversationId, "cnv_issue717_parallel");
|
|
assert.equal(parallel.body.sessionId, null);
|
|
assert.equal(parallel.body.workspaceId, workspace.body.workspace.workspaceId);
|
|
assert.ok(parallel.body.workspaceRevision > 2);
|
|
|
|
const afterParallel = await getJson(port, "/v1/workbench/workspace?projectId=prj_device_pod_workbench", aliceRelogin.cookie);
|
|
assert.equal(afterParallel.status, 200);
|
|
assert.equal(afterParallel.body.workspace.activeTraceId, "trc_issue717_parallel");
|
|
assert.equal(afterParallel.body.workspace.selectedConversationId, "cnv_issue717_parallel");
|
|
assert.equal(afterParallel.body.workspace.selectedAgentSessionId, null);
|
|
assert.equal(afterParallel.body.workspace.workspace.selectedAgentSessionId, undefined);
|
|
assert.equal(afterParallel.body.workspace.workspace.previousActiveTraceId, "trc_issue655_active");
|
|
assert.equal(afterParallel.body.workspace.workspace.workspaceRevisionConflict, false);
|
|
|
|
const reset = await postJson(port, `/v1/workbench/workspace/${workspace.body.workspace.workspaceId}/reset`, {
|
|
confirm: true,
|
|
updatedByClient: "test-suite"
|
|
}, aliceRelogin.cookie);
|
|
assert.equal(reset.status, 200);
|
|
assert.equal(reset.body.reset, true);
|
|
assert.equal(reset.body.workspace.activeTraceId, null);
|
|
assert.equal(reset.body.workspace.selectedConversationId, null);
|
|
} finally {
|
|
await new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve())));
|
|
}
|
|
});
|
|
|
|
test("workbench workspace permits a new turn after AgentRun active trace reaches terminal status", async () => {
|
|
const agentRunCalls = [];
|
|
const agentSessions = new Map();
|
|
const agentRunServer = createServer(async (request, response) => {
|
|
const url = new URL(request.url || "/", "http://127.0.0.1");
|
|
agentRunCalls.push({ method: request.method, path: url.pathname, search: url.search });
|
|
const send = (data) => {
|
|
response.writeHead(200, { "content-type": "application/json" });
|
|
response.end(`${JSON.stringify({ ok: true, data })}\n`);
|
|
};
|
|
if (request.method === "GET" && url.pathname === "/api/v1/runs/run_workspace_done/events") {
|
|
return send({ items: [
|
|
{ id: "evt_done", runId: "run_workspace_done", seq: 1, type: "terminal_status", payload: { commandId: "cmd_workspace_done", terminalStatus: "completed" }, createdAt: "2026-06-01T00:00:01.000Z" }
|
|
] });
|
|
}
|
|
if (request.method === "GET" && url.pathname === "/api/v1/runs/run_workspace_done/commands/cmd_workspace_done/result") {
|
|
return send({
|
|
runId: "run_workspace_done",
|
|
commandId: "cmd_workspace_done",
|
|
status: "completed",
|
|
runStatus: "completed",
|
|
commandState: "completed",
|
|
terminalStatus: "completed",
|
|
completed: true,
|
|
reply: "previous turn completed",
|
|
lastSeq: 1,
|
|
sessionRef: { sessionId: "ses_issue655_shared", conversationId: "cnv_issue655_shared", threadId: "thread-issue-655" }
|
|
});
|
|
}
|
|
if (request.method === "POST" && url.pathname === "/api/v1/runs") {
|
|
return send({ id: "run_workspace_next", status: "pending", backendProfile: "deepseek", sessionRef: { sessionId: "ses_issue655_shared", conversationId: "cnv_issue655_shared" } });
|
|
}
|
|
if (request.method === "POST" && url.pathname === "/api/v1/runs/run_workspace_next/commands") {
|
|
return send({ id: "cmd_workspace_next", runId: "run_workspace_next", state: "pending", type: "turn", seq: 1 });
|
|
}
|
|
if (request.method === "POST" && url.pathname === "/api/v1/runs/run_workspace_next/runner-jobs") {
|
|
return send({
|
|
action: "create-kubernetes-job",
|
|
runId: "run_workspace_next",
|
|
commandId: "cmd_workspace_next",
|
|
attemptId: "attempt_workspace_next",
|
|
runnerId: "runner_workspace_next",
|
|
namespace: "agentrun-v01",
|
|
jobName: "agentrun-v01-runner-workspace-next"
|
|
});
|
|
}
|
|
response.writeHead(404, { "content-type": "application/json" });
|
|
response.end(`${JSON.stringify({ ok: false, message: `unexpected ${request.method} ${url.pathname}` })}\n`);
|
|
});
|
|
await new Promise((resolve) => agentRunServer.listen(0, "127.0.0.1", resolve));
|
|
|
|
const agentRunPort = agentRunServer.address().port;
|
|
const accessController = createAccessController({
|
|
env: {
|
|
HWLAB_ACCESS_CONTROL_REQUIRED: "1",
|
|
HWLAB_BOOTSTRAP_ADMIN_USERNAME: "admin",
|
|
HWLAB_BOOTSTRAP_ADMIN_PASSWORD: "admin-pass"
|
|
},
|
|
now: () => "2026-06-01T00:00:00.000Z"
|
|
});
|
|
accessController.getAgentSessionByTraceId = async (traceId) => agentSessions.get(traceId) ?? null;
|
|
const server = createCloudApiServer({
|
|
env: {
|
|
HWLAB_ACCESS_CONTROL_REQUIRED: "1",
|
|
HWLAB_BOOTSTRAP_ADMIN_USERNAME: "admin",
|
|
HWLAB_BOOTSTRAP_ADMIN_PASSWORD: "admin-pass",
|
|
HWLAB_CODE_AGENT_ADAPTER: "agentrun-v01",
|
|
AGENTRUN_MGR_URL: `http://127.0.0.1:${agentRunPort}`,
|
|
HWLAB_CODE_AGENT_AGENTRUN_ALLOW_NON_K3S_URL: "1",
|
|
HWLAB_CODE_AGENT_AGENTRUN_SOURCE_COMMIT: "0123456789abcdef0123456789abcdef01234567",
|
|
HWLAB_CODE_AGENT_DEFAULT_PROVIDER_PROFILE: "deepseek"
|
|
},
|
|
accessController,
|
|
now: () => "2026-06-01T00:00:00.000Z"
|
|
});
|
|
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
|
|
|
|
try {
|
|
const { port } = server.address();
|
|
const adminLogin = await postJson(port, "/auth/login", { username: "admin", password: "admin-pass" });
|
|
const alice = await postJson(port, "/v1/admin/users", { username: "alice-ws-terminal", password: "alice-pass" }, adminLogin.cookie);
|
|
assert.equal(alice.status, 201);
|
|
const aliceLogin = await postJson(port, "/auth/login", { username: "alice-ws-terminal", password: "alice-pass" });
|
|
const workspace = await getJson(port, "/v1/workbench/workspace?projectId=prj_device_pod_workbench", aliceLogin.cookie);
|
|
assert.equal(workspace.status, 200);
|
|
const conversation = await putJson(port, "/v1/agent/conversations/cnv_issue655_shared", {
|
|
projectId: "prj_device_pod_workbench",
|
|
sessionId: "ses_issue655_shared",
|
|
threadId: "thread-issue-655",
|
|
sessionStatus: "active",
|
|
lastTraceId: "trc_issue655_previous",
|
|
messages: [{ role: "agent", text: "previous completed turn", traceId: "trc_issue655_previous" }]
|
|
}, aliceLogin.cookie);
|
|
assert.equal(conversation.status, 200);
|
|
|
|
const previousPayload = {
|
|
messageId: "msg_workspace_done",
|
|
agentRun: {
|
|
runId: "run_workspace_done",
|
|
commandId: "cmd_workspace_done",
|
|
backendProfile: "deepseek",
|
|
managerUrl: `http://127.0.0.1:${agentRunPort}`,
|
|
lastSeq: 0,
|
|
sessionId: "ses_issue655_shared",
|
|
conversationId: "cnv_issue655_shared",
|
|
threadId: "thread-issue-655"
|
|
}
|
|
};
|
|
agentSessions.set("trc_issue655_active_done", {
|
|
id: "ses_issue655_shared",
|
|
ownerUserId: alice.body.user.id,
|
|
conversationId: "cnv_issue655_shared",
|
|
threadId: "thread-issue-655",
|
|
status: "active",
|
|
session: previousPayload,
|
|
updatedAt: "2026-06-01T00:00:00.000Z"
|
|
});
|
|
|
|
const update = await patchJson(port, `/v1/workbench/workspace/${workspace.body.workspace.workspaceId}`, {
|
|
expectedRevision: 1,
|
|
selectedConversationId: "cnv_issue655_shared",
|
|
selectedAgentSessionId: "ses_issue655_shared",
|
|
activeTraceId: "trc_issue655_active_done",
|
|
providerProfile: "deepseek",
|
|
sessionStatus: "running",
|
|
updatedByClient: "test-suite"
|
|
}, aliceLogin.cookie);
|
|
assert.equal(update.status, 200);
|
|
assert.equal(update.body.workspace.activeTraceId, "trc_issue655_active_done");
|
|
|
|
const next = await postJson(port, "/v1/agent/chat", {
|
|
message: "new turn after completed active trace",
|
|
traceId: "trc_issue655_after_done",
|
|
conversationId: "cnv_issue655_shared",
|
|
sessionId: "ses_issue655_shared",
|
|
workspaceId: workspace.body.workspace.workspaceId,
|
|
expectedWorkspaceRevision: 2,
|
|
shortConnection: true
|
|
}, aliceLogin.cookie, { prefer: "respond-async", "x-trace-id": "trc_issue655_after_done" });
|
|
assert.equal(next.status, 202);
|
|
assert.equal(next.body.status, "running");
|
|
assert.equal(next.body.traceId, "trc_issue655_after_done");
|
|
await waitForCondition(() => agentRunCalls.some((call) => call.method === "POST" && call.path === "/api/v1/runs"));
|
|
assert.equal(agentRunCalls.some((call) => call.path === "/api/v1/runs/run_workspace_done/commands/cmd_workspace_done/result"), false);
|
|
|
|
const restored = await getJson(port, "/v1/workbench/workspace?projectId=prj_device_pod_workbench", aliceLogin.cookie);
|
|
assert.equal(restored.body.workspace.activeTraceId, "trc_issue655_after_done");
|
|
assert.equal(restored.body.workspace.workspace.sessionStatus, "running");
|
|
} finally {
|
|
await new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve())));
|
|
await new Promise((resolve, reject) => agentRunServer.close((error) => (error ? reject(error) : resolve())));
|
|
}
|
|
});
|
|
|
|
test("workbench workspace ignores stale active trace when selected conversation already failed", async () => {
|
|
const workspaceDir = await mkdtemp("/tmp/hwlab-stale-active-");
|
|
const server = createCloudApiServer({
|
|
env: {
|
|
PATH: process.env.PATH,
|
|
HWLAB_ACCESS_CONTROL_REQUIRED: "1",
|
|
HWLAB_BOOTSTRAP_ADMIN_USERNAME: "admin",
|
|
HWLAB_BOOTSTRAP_ADMIN_PASSWORD: "admin-pass",
|
|
HWLAB_CODE_AGENT_PROVIDER: "codex-stdio",
|
|
HWLAB_CODE_AGENT_CODEX_WORKSPACE: workspaceDir,
|
|
HWLAB_CODE_AGENT_CODEX_SANDBOX: "danger-full-access",
|
|
OPENAI_API_KEY: "test-openai-key-material"
|
|
},
|
|
codexStdioManager: {
|
|
describe: () => ({ available: true, ready: true, workspace: workspaceDir, sandbox: "danger-full-access" }),
|
|
probe: async () => ({ available: true, ready: true, workspace: workspaceDir, sandbox: "danger-full-access" }),
|
|
chat: async (params = {}) => ({
|
|
status: "completed",
|
|
traceId: params.traceId,
|
|
conversationId: params.conversationId,
|
|
sessionId: params.sessionId,
|
|
reply: { role: "assistant", content: "ok" },
|
|
runnerTrace: { traceId: params.traceId, status: "completed", events: [], eventCount: 0 },
|
|
session: { sessionId: params.sessionId, conversationId: params.conversationId, status: "idle" }
|
|
}),
|
|
cancel() {},
|
|
reapIdle() {}
|
|
}
|
|
});
|
|
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
|
|
|
|
try {
|
|
const { port } = server.address();
|
|
const adminLogin = await postJson(port, "/auth/login", { username: "admin", password: "admin-pass" });
|
|
const workspace = await getJson(port, "/v1/workbench/workspace?projectId=prj_device_pod_workbench", adminLogin.cookie);
|
|
const conversation = await putJson(port, "/v1/agent/conversations/cnv_stale_active_failed", {
|
|
projectId: "prj_device_pod_workbench",
|
|
sessionId: "ses_stale_active_failed",
|
|
threadId: "thread-stale-active",
|
|
status: "failed",
|
|
sessionStatus: "failed",
|
|
lastTraceId: "trc_stale_active_missing",
|
|
messages: [{ role: "agent", text: "failed", status: "failed", traceId: "trc_stale_active_missing" }]
|
|
}, adminLogin.cookie);
|
|
assert.equal(conversation.status, 200);
|
|
|
|
const update = await patchJson(port, `/v1/workbench/workspace/${workspace.body.workspace.workspaceId}`, {
|
|
expectedRevision: 1,
|
|
selectedConversationId: "cnv_stale_active_failed",
|
|
selectedAgentSessionId: "ses_stale_active_failed",
|
|
activeTraceId: "trc_stale_active_missing",
|
|
sessionStatus: "running",
|
|
updatedByClient: "test-suite"
|
|
}, adminLogin.cookie);
|
|
assert.equal(update.status, 200);
|
|
|
|
const next = await postJson(port, "/v1/agent/chat", {
|
|
message: "new turn after stale active trace",
|
|
traceId: "trc_stale_active_next",
|
|
conversationId: "cnv_stale_active_failed",
|
|
sessionId: "ses_stale_active_failed",
|
|
workspaceId: workspace.body.workspace.workspaceId,
|
|
expectedWorkspaceRevision: 2,
|
|
shortConnection: true
|
|
}, adminLogin.cookie, { prefer: "respond-async", "x-trace-id": "trc_stale_active_next" });
|
|
|
|
assert.equal(next.status, 202);
|
|
assert.equal(next.body.traceId, "trc_stale_active_next");
|
|
|
|
const restored = await getJson(port, "/v1/workbench/workspace?projectId=prj_device_pod_workbench", adminLogin.cookie);
|
|
assert.equal(restored.body.workspace.activeTraceId, "trc_stale_active_next");
|
|
} finally {
|
|
await new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve())));
|
|
}
|
|
});
|
|
|
|
test("workbench workspace releases orphan cancel-blocked active trace on new turn", async () => {
|
|
const traceStore = createCodeAgentTraceStore();
|
|
traceStore.append("trc_issue664_orphan_active", {
|
|
type: "cancel",
|
|
stage: "cancel",
|
|
status: "blocked",
|
|
label: "cancel:not_cancelable",
|
|
message: "Cancel request did not include a bound Codex stdio sessionId.",
|
|
errorCode: "cancel_session_missing",
|
|
waitingFor: "session-binding"
|
|
});
|
|
const server = createCloudApiServer({
|
|
env: {
|
|
HWLAB_ACCESS_CONTROL_REQUIRED: "1",
|
|
HWLAB_BOOTSTRAP_ADMIN_USERNAME: "admin",
|
|
HWLAB_BOOTSTRAP_ADMIN_PASSWORD: "admin-pass",
|
|
HWLAB_CODE_AGENT_ADAPTER: "agentrun-v01",
|
|
AGENTRUN_MGR_URL: "http://127.0.0.1:9",
|
|
HWLAB_CODE_AGENT_AGENTRUN_ALLOW_NON_K3S_URL: "1",
|
|
HWLAB_CODE_AGENT_DEFAULT_PROVIDER_PROFILE: "deepseek"
|
|
},
|
|
traceStore,
|
|
now: () => "2026-06-01T00:00:00.000Z"
|
|
});
|
|
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
|
|
|
|
try {
|
|
const { port } = server.address();
|
|
const adminLogin = await postJson(port, "/auth/login", { username: "admin", password: "admin-pass" });
|
|
const alice = await postJson(port, "/v1/admin/users", { username: "alice-ws-orphan", password: "alice-pass" }, adminLogin.cookie);
|
|
assert.equal(alice.status, 201);
|
|
const aliceLogin = await postJson(port, "/auth/login", { username: "alice-ws-orphan", password: "alice-pass" });
|
|
const workspace = await getJson(port, "/v1/workbench/workspace?projectId=prj_device_pod_workbench", aliceLogin.cookie);
|
|
assert.equal(workspace.status, 200);
|
|
|
|
const update = await patchJson(port, `/v1/workbench/workspace/${workspace.body.workspace.workspaceId}`, {
|
|
expectedRevision: 1,
|
|
activeTraceId: "trc_issue664_orphan_active",
|
|
providerProfile: "deepseek",
|
|
sessionStatus: "running",
|
|
updatedByClient: "test-suite"
|
|
}, aliceLogin.cookie);
|
|
assert.equal(update.status, 200);
|
|
assert.equal(update.body.workspace.activeTraceId, "trc_issue664_orphan_active");
|
|
|
|
const next = await postJson(port, "/v1/agent/chat", {
|
|
message: "new turn should replace orphan active trace",
|
|
traceId: "trc_issue664_after_orphan",
|
|
conversationId: "cnv_issue664_after_orphan",
|
|
workspaceId: workspace.body.workspace.workspaceId,
|
|
expectedWorkspaceRevision: 2,
|
|
shortConnection: true
|
|
}, aliceLogin.cookie, { prefer: "respond-async", "x-trace-id": "trc_issue664_after_orphan" });
|
|
assert.equal(next.status, 202);
|
|
assert.equal(next.body.status, "running");
|
|
assert.equal(next.body.traceId, "trc_issue664_after_orphan");
|
|
|
|
const restored = await getJson(port, "/v1/workbench/workspace?projectId=prj_device_pod_workbench", aliceLogin.cookie);
|
|
assert.equal(restored.body.workspace.activeTraceId, "trc_issue664_after_orphan");
|
|
assert.equal(restored.body.workspace.workspace.sessionStatus, "running");
|
|
} finally {
|
|
await new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve())));
|
|
}
|
|
});
|
|
|
|
test("workbench workspace permits continuation when stale active trace has idle active conversation", async () => {
|
|
const workspaceDir = await mkdtemp("/tmp/hwlab-stale-active-idle-");
|
|
const server = createCloudApiServer({
|
|
env: {
|
|
PATH: process.env.PATH,
|
|
HWLAB_ACCESS_CONTROL_REQUIRED: "1",
|
|
HWLAB_BOOTSTRAP_ADMIN_USERNAME: "admin",
|
|
HWLAB_BOOTSTRAP_ADMIN_PASSWORD: "admin-pass",
|
|
HWLAB_CODE_AGENT_PROVIDER: "codex-stdio",
|
|
HWLAB_CODE_AGENT_CODEX_WORKSPACE: workspaceDir,
|
|
HWLAB_CODE_AGENT_CODEX_SANDBOX: "danger-full-access",
|
|
OPENAI_API_KEY: "test-openai-key-material"
|
|
},
|
|
codexStdioManager: {
|
|
describe: () => ({ available: true, ready: true, workspace: workspaceDir, sandbox: "danger-full-access" }),
|
|
probe: async () => ({ available: true, ready: true, workspace: workspaceDir, sandbox: "danger-full-access" }),
|
|
chat: async (params = {}) => ({
|
|
status: "completed",
|
|
traceId: params.traceId,
|
|
conversationId: params.conversationId,
|
|
sessionId: params.sessionId,
|
|
threadId: params.threadId,
|
|
reply: { role: "assistant", content: "ok" },
|
|
runnerTrace: { traceId: params.traceId, status: "completed", events: [], eventCount: 0 },
|
|
session: { sessionId: params.sessionId, conversationId: params.conversationId, threadId: params.threadId, status: "idle" }
|
|
}),
|
|
cancel() {},
|
|
reapIdle() {}
|
|
}
|
|
});
|
|
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
|
|
|
|
try {
|
|
const { port } = server.address();
|
|
const adminLogin = await postJson(port, "/auth/login", { username: "admin", password: "admin-pass" });
|
|
const workspace = await getJson(port, "/v1/workbench/workspace?projectId=prj_device_pod_workbench", adminLogin.cookie);
|
|
const conversation = await putJson(port, "/v1/agent/conversations/cnv_stale_active_idle", {
|
|
projectId: "prj_device_pod_workbench",
|
|
sessionId: "ses_stale_active_idle",
|
|
threadId: "thread-stale-active-idle",
|
|
status: "active",
|
|
sessionStatus: "active",
|
|
lastTraceId: "trc_stale_active_idle_missing",
|
|
messages: [{ role: "agent", text: "previous completed turn", status: "completed", traceId: "trc_stale_active_idle_missing" }]
|
|
}, adminLogin.cookie);
|
|
assert.equal(conversation.status, 200);
|
|
|
|
const update = await patchJson(port, `/v1/workbench/workspace/${workspace.body.workspace.workspaceId}`, {
|
|
expectedRevision: 1,
|
|
selectedConversationId: "cnv_stale_active_idle",
|
|
selectedAgentSessionId: "ses_stale_active_idle",
|
|
activeTraceId: "trc_stale_active_idle_missing",
|
|
sessionStatus: "running",
|
|
updatedByClient: "test-suite"
|
|
}, adminLogin.cookie);
|
|
assert.equal(update.status, 200);
|
|
|
|
const next = await postJson(port, "/v1/agent/chat", {
|
|
message: "new turn after stale active idle trace",
|
|
traceId: "trc_stale_active_idle_next",
|
|
conversationId: "cnv_stale_active_idle",
|
|
sessionId: "ses_stale_active_idle",
|
|
threadId: "thread-stale-active-idle",
|
|
workspaceId: workspace.body.workspace.workspaceId,
|
|
expectedWorkspaceRevision: 2,
|
|
shortConnection: true
|
|
}, adminLogin.cookie, { prefer: "respond-async", "x-trace-id": "trc_stale_active_idle_next" });
|
|
|
|
assert.equal(next.status, 202);
|
|
assert.equal(next.body.traceId, "trc_stale_active_idle_next");
|
|
assert.equal(next.body.conversationId, "cnv_stale_active_idle");
|
|
assert.equal(next.body.sessionId, "ses_stale_active_idle");
|
|
|
|
const restored = await getJson(port, "/v1/workbench/workspace?projectId=prj_device_pod_workbench", adminLogin.cookie);
|
|
assert.equal(restored.body.workspace.activeTraceId, "trc_stale_active_idle_next");
|
|
} finally {
|
|
await new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve())));
|
|
}
|
|
});
|
|
|
|
test("workbench workspace status clears completed AgentRun active trace on read", async () => {
|
|
const agentRunCalls = [];
|
|
const agentSessions = new Map();
|
|
const agentRunServer = createServer(async (request, response) => {
|
|
const url = new URL(request.url || "/", "http://127.0.0.1");
|
|
agentRunCalls.push({ method: request.method, path: url.pathname, search: url.search });
|
|
const send = (data) => {
|
|
response.writeHead(200, { "content-type": "application/json" });
|
|
response.end(`${JSON.stringify({ ok: true, data })}\n`);
|
|
};
|
|
if (request.method === "GET" && url.pathname === "/api/v1/runs/run_workspace_status/events") {
|
|
return send({ items: [
|
|
{ id: "evt_status_done", runId: "run_workspace_status", seq: 1, type: "terminal_status", payload: { commandId: "cmd_workspace_status", terminalStatus: "completed" }, createdAt: "2026-06-01T00:00:01.000Z" }
|
|
] });
|
|
}
|
|
if (request.method === "GET" && url.pathname === "/api/v1/runs/run_workspace_status/commands/cmd_workspace_status/result") {
|
|
return send({
|
|
runId: "run_workspace_status",
|
|
commandId: "cmd_workspace_status",
|
|
status: "completed",
|
|
runStatus: "completed",
|
|
commandState: "completed",
|
|
terminalStatus: "completed",
|
|
completed: true,
|
|
reply: "previous turn completed",
|
|
lastSeq: 1,
|
|
sessionRef: { sessionId: "ses_issue664_status", conversationId: "cnv_issue664_status", threadId: "thread-issue-664" }
|
|
});
|
|
}
|
|
response.writeHead(404, { "content-type": "application/json" });
|
|
response.end(`${JSON.stringify({ ok: false, message: `unexpected ${request.method} ${url.pathname}` })}\n`);
|
|
});
|
|
await new Promise((resolve) => agentRunServer.listen(0, "127.0.0.1", resolve));
|
|
|
|
const agentRunPort = agentRunServer.address().port;
|
|
const accessController = createAccessController({
|
|
env: {
|
|
HWLAB_ACCESS_CONTROL_REQUIRED: "1",
|
|
HWLAB_BOOTSTRAP_ADMIN_USERNAME: "admin",
|
|
HWLAB_BOOTSTRAP_ADMIN_PASSWORD: "admin-pass"
|
|
},
|
|
now: () => "2026-06-01T00:00:00.000Z"
|
|
});
|
|
accessController.getAgentSessionByTraceId = async (traceId) => agentSessions.get(traceId) ?? null;
|
|
const server = createCloudApiServer({
|
|
env: {
|
|
HWLAB_ACCESS_CONTROL_REQUIRED: "1",
|
|
HWLAB_BOOTSTRAP_ADMIN_USERNAME: "admin",
|
|
HWLAB_BOOTSTRAP_ADMIN_PASSWORD: "admin-pass",
|
|
HWLAB_CODE_AGENT_ADAPTER: "agentrun-v01",
|
|
AGENTRUN_MGR_URL: `http://127.0.0.1:${agentRunPort}`,
|
|
HWLAB_CODE_AGENT_AGENTRUN_ALLOW_NON_K3S_URL: "1",
|
|
HWLAB_CODE_AGENT_DEFAULT_PROVIDER_PROFILE: "deepseek"
|
|
},
|
|
accessController,
|
|
now: () => "2026-06-01T00:00:00.000Z"
|
|
});
|
|
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
|
|
|
|
try {
|
|
const { port } = server.address();
|
|
const adminLogin = await postJson(port, "/auth/login", { username: "admin", password: "admin-pass" });
|
|
const alice = await postJson(port, "/v1/admin/users", { username: "alice-ws-status", password: "alice-pass" }, adminLogin.cookie);
|
|
assert.equal(alice.status, 201);
|
|
const aliceLogin = await postJson(port, "/auth/login", { username: "alice-ws-status", password: "alice-pass" });
|
|
const workspace = await getJson(port, "/v1/workbench/workspace?projectId=prj_device_pod_workbench", aliceLogin.cookie);
|
|
assert.equal(workspace.status, 200);
|
|
const conversation = await putJson(port, "/v1/agent/conversations/cnv_issue664_status", {
|
|
projectId: "prj_device_pod_workbench",
|
|
sessionId: "ses_issue664_status",
|
|
threadId: "thread-issue-664",
|
|
sessionStatus: "active",
|
|
lastTraceId: "trc_issue664_previous",
|
|
messages: [{ role: "agent", text: "previous turn still running", status: "running", traceId: "trc_issue664_status_done" }]
|
|
}, aliceLogin.cookie);
|
|
assert.equal(conversation.status, 200);
|
|
|
|
const previousPayload = {
|
|
messageId: "msg_workspace_status_done",
|
|
agentRun: {
|
|
runId: "run_workspace_status",
|
|
commandId: "cmd_workspace_status",
|
|
backendProfile: "deepseek",
|
|
managerUrl: `http://127.0.0.1:${agentRunPort}`,
|
|
lastSeq: 0,
|
|
sessionId: "ses_issue664_status",
|
|
conversationId: "cnv_issue664_status",
|
|
threadId: "thread-issue-664"
|
|
}
|
|
};
|
|
agentSessions.set("trc_issue664_status_done", {
|
|
id: "ses_issue664_status",
|
|
ownerUserId: alice.body.user.id,
|
|
conversationId: "cnv_issue664_status",
|
|
threadId: "thread-issue-664",
|
|
status: "active",
|
|
session: previousPayload,
|
|
updatedAt: "2026-06-01T00:00:00.000Z"
|
|
});
|
|
|
|
const update = await patchJson(port, `/v1/workbench/workspace/${workspace.body.workspace.workspaceId}`, {
|
|
expectedRevision: 1,
|
|
selectedConversationId: "cnv_issue664_status",
|
|
selectedAgentSessionId: "ses_issue664_status",
|
|
activeTraceId: "trc_issue664_status_done",
|
|
providerProfile: "deepseek",
|
|
sessionStatus: "running",
|
|
updatedByClient: "test-suite"
|
|
}, aliceLogin.cookie);
|
|
assert.equal(update.status, 200);
|
|
assert.equal(update.body.workspace.activeTraceId, "trc_issue664_status_done");
|
|
|
|
const restored = await getJson(port, "/v1/workbench/workspace?projectId=prj_device_pod_workbench", aliceLogin.cookie);
|
|
assert.equal(restored.status, 200);
|
|
assert.equal(restored.body.workspace.activeTraceId, null);
|
|
assert.equal(restored.body.workspace.workspace.lastTraceId, "trc_issue664_status_done");
|
|
assert.equal(restored.body.workspace.workspace.sessionStatus, "idle");
|
|
assert.equal(restored.body.workspace.selectedConversation.status, "idle");
|
|
assert.equal(restored.body.workspace.selectedConversation.lastTraceId, "trc_issue664_status_done");
|
|
assert.equal(restored.body.workspace.selectedConversation.messages.length, 1);
|
|
assert.equal(restored.body.workspace.selectedConversation.messages[0].status, "idle");
|
|
assert.equal(restored.body.workspace.selectedConversation.messages[0].text, "previous turn completed");
|
|
assert.equal(restored.body.workspace.revision, 3);
|
|
assert.ok(agentRunCalls.some((call) => call.path === "/api/v1/runs/run_workspace_status/commands/cmd_workspace_status/result"));
|
|
} finally {
|
|
await new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve())));
|
|
await new Promise((resolve, reject) => agentRunServer.close((error) => (error ? reject(error) : resolve())));
|
|
}
|
|
});
|
|
|
|
test("workbench workspace clears stale continuation after AgentRun thread resume failure", async () => {
|
|
const staleThreadId = "019e0000-0000-7000-8000-000000000195";
|
|
const agentRunCalls = [];
|
|
const createRunInputs = [];
|
|
const agentSessions = new Map();
|
|
const agentRunServer = createServer(async (request, response) => {
|
|
const url = new URL(request.url || "/", "http://127.0.0.1");
|
|
agentRunCalls.push({ method: request.method, path: url.pathname, search: url.search });
|
|
const send = (data) => {
|
|
response.writeHead(200, { "content-type": "application/json" });
|
|
response.end(`${JSON.stringify({ ok: true, data })}\n`);
|
|
};
|
|
if (request.method === "GET" && url.pathname === "/api/v1/runs/run_workspace_thread_resume_failed/events") {
|
|
return send({ items: [
|
|
{ id: "evt_thread_resume_failed", runId: "run_workspace_thread_resume_failed", seq: 1, type: "terminal_status", payload: { commandId: "cmd_workspace_thread_resume_failed", terminalStatus: "failed" }, createdAt: "2026-06-01T00:00:01.000Z" }
|
|
] });
|
|
}
|
|
if (request.method === "GET" && url.pathname === "/api/v1/runs/run_workspace_thread_resume_failed/commands/cmd_workspace_thread_resume_failed/result") {
|
|
return send({
|
|
runId: "run_workspace_thread_resume_failed",
|
|
commandId: "cmd_workspace_thread_resume_failed",
|
|
status: "failed",
|
|
runStatus: "claimed",
|
|
commandState: "failed",
|
|
terminalStatus: "failed",
|
|
completed: false,
|
|
failureKind: "thread-resume-failed",
|
|
failureMessage: `codex app-server thread/resume failed for existing thread: no rollout found for thread id ${staleThreadId}`,
|
|
lastSeq: 1,
|
|
sessionRef: { sessionId: "ses_issue195_stale", conversationId: "cnv_issue195_stale", threadId: staleThreadId }
|
|
});
|
|
}
|
|
if (request.method === "POST" && url.pathname === "/api/v1/runs") {
|
|
const body = await requestJson(request);
|
|
createRunInputs.push(body);
|
|
return send({ id: "run_workspace_after_stale", status: "pending", backendProfile: "deepseek", sessionRef: { sessionId: body.sessionRef?.sessionId } });
|
|
}
|
|
if (request.method === "POST" && url.pathname === "/api/v1/runs/run_workspace_after_stale/commands") {
|
|
return send({ id: "cmd_workspace_after_stale", runId: "run_workspace_after_stale", state: "pending", type: "turn", seq: 1 });
|
|
}
|
|
if (request.method === "POST" && url.pathname === "/api/v1/runs/run_workspace_after_stale/runner-jobs") {
|
|
return send({
|
|
action: "create-kubernetes-job",
|
|
runId: "run_workspace_after_stale",
|
|
commandId: "cmd_workspace_after_stale",
|
|
attemptId: "attempt_workspace_after_stale",
|
|
runnerId: "runner_workspace_after_stale",
|
|
namespace: "agentrun-v01",
|
|
jobName: "agentrun-v01-runner-workspace-after-stale"
|
|
});
|
|
}
|
|
response.writeHead(404, { "content-type": "application/json" });
|
|
response.end(`${JSON.stringify({ ok: false, message: `unexpected ${request.method} ${url.pathname}` })}\n`);
|
|
});
|
|
await new Promise((resolve) => agentRunServer.listen(0, "127.0.0.1", resolve));
|
|
|
|
const agentRunPort = agentRunServer.address().port;
|
|
const accessController = createAccessController({
|
|
env: {
|
|
HWLAB_ACCESS_CONTROL_REQUIRED: "1",
|
|
HWLAB_BOOTSTRAP_ADMIN_USERNAME: "admin",
|
|
HWLAB_BOOTSTRAP_ADMIN_PASSWORD: "admin-pass"
|
|
},
|
|
now: () => "2026-06-01T00:00:00.000Z"
|
|
});
|
|
accessController.getAgentSessionByTraceId = async (traceId) => agentSessions.get(traceId) ?? null;
|
|
const server = createCloudApiServer({
|
|
env: {
|
|
HWLAB_ACCESS_CONTROL_REQUIRED: "1",
|
|
HWLAB_BOOTSTRAP_ADMIN_USERNAME: "admin",
|
|
HWLAB_BOOTSTRAP_ADMIN_PASSWORD: "admin-pass",
|
|
HWLAB_CODE_AGENT_ADAPTER: "agentrun-v01",
|
|
AGENTRUN_MGR_URL: `http://127.0.0.1:${agentRunPort}`,
|
|
HWLAB_CODE_AGENT_AGENTRUN_ALLOW_NON_K3S_URL: "1",
|
|
HWLAB_CODE_AGENT_AGENTRUN_SOURCE_COMMIT: "0123456789abcdef0123456789abcdef01234567",
|
|
HWLAB_CODE_AGENT_DEFAULT_PROVIDER_PROFILE: "deepseek"
|
|
},
|
|
accessController,
|
|
now: () => "2026-06-01T00:00:00.000Z"
|
|
});
|
|
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
|
|
|
|
try {
|
|
const { port } = server.address();
|
|
const adminLogin = await postJson(port, "/auth/login", { username: "admin", password: "admin-pass" });
|
|
const alice = await postJson(port, "/v1/admin/users", { username: "alice-ws-thread-resume", password: "alice-pass" }, adminLogin.cookie);
|
|
assert.equal(alice.status, 201);
|
|
const aliceLogin = await postJson(port, "/auth/login", { username: "alice-ws-thread-resume", password: "alice-pass" });
|
|
const workspace = await getJson(port, "/v1/workbench/workspace?projectId=prj_device_pod_workbench", aliceLogin.cookie);
|
|
assert.equal(workspace.status, 200);
|
|
const conversation = await putJson(port, "/v1/agent/conversations/cnv_issue195_stale", {
|
|
projectId: "prj_device_pod_workbench",
|
|
sessionId: "ses_issue195_stale",
|
|
threadId: staleThreadId,
|
|
sessionStatus: "active",
|
|
lastTraceId: "trc_issue195_thread_resume_failed",
|
|
messages: [{ role: "agent", text: "stale continuation still running", status: "running", traceId: "trc_issue195_thread_resume_failed" }]
|
|
}, aliceLogin.cookie);
|
|
assert.equal(conversation.status, 200);
|
|
|
|
agentSessions.set("trc_issue195_thread_resume_failed", {
|
|
id: "ses_issue195_stale",
|
|
ownerUserId: alice.body.user.id,
|
|
conversationId: "cnv_issue195_stale",
|
|
threadId: staleThreadId,
|
|
status: "active",
|
|
session: {
|
|
messageId: "msg_issue195_thread_resume_failed",
|
|
agentRun: {
|
|
runId: "run_workspace_thread_resume_failed",
|
|
commandId: "cmd_workspace_thread_resume_failed",
|
|
backendProfile: "deepseek",
|
|
managerUrl: `http://127.0.0.1:${agentRunPort}`,
|
|
lastSeq: 0,
|
|
sessionId: "ses_issue195_stale",
|
|
conversationId: "cnv_issue195_stale",
|
|
threadId: staleThreadId
|
|
}
|
|
},
|
|
updatedAt: "2026-06-01T00:00:00.000Z"
|
|
});
|
|
|
|
const update = await patchJson(port, `/v1/workbench/workspace/${workspace.body.workspace.workspaceId}`, {
|
|
expectedRevision: 1,
|
|
selectedConversationId: "cnv_issue195_stale",
|
|
selectedAgentSessionId: "ses_issue195_stale",
|
|
activeTraceId: "trc_issue195_thread_resume_failed",
|
|
providerProfile: "deepseek",
|
|
sessionStatus: "running",
|
|
updatedByClient: "test-suite"
|
|
}, aliceLogin.cookie);
|
|
assert.equal(update.status, 200);
|
|
assert.equal(update.body.workspace.selectedConversationId, "cnv_issue195_stale");
|
|
assert.equal(update.body.workspace.activeTraceId, "trc_issue195_thread_resume_failed");
|
|
|
|
const restored = await getJson(port, "/v1/workbench/workspace?projectId=prj_device_pod_workbench", aliceLogin.cookie);
|
|
assert.equal(restored.status, 200);
|
|
assert.equal(restored.body.workspace.activeTraceId, null);
|
|
assert.equal(restored.body.workspace.selectedConversationId, null);
|
|
assert.equal(restored.body.workspace.selectedAgentSessionId, null);
|
|
assert.equal(restored.body.workspace.selectedConversation, null);
|
|
assert.equal(restored.body.workspace.workspace.sessionStatus, "failed");
|
|
assert.equal(restored.body.workspace.workspace.lastTraceId, "trc_issue195_thread_resume_failed");
|
|
assert.equal(restored.body.workspace.workspace.staleContinuationCleared, true);
|
|
assert.equal(restored.body.workspace.workspace.staleContinuationReason, "thread-resume-failed");
|
|
assert.equal(restored.body.workspace.workspace.staleConversationId, "cnv_issue195_stale");
|
|
assert.equal(restored.body.workspace.workspace.staleAgentSessionId, "ses_issue195_stale");
|
|
assert.equal(restored.body.workspace.workspace.staleThreadId, staleThreadId);
|
|
assert.equal(restored.body.workspace.workspace.recoveryAction, "new-session-on-next-turn");
|
|
|
|
const failedConversation = await getJson(port, "/v1/agent/conversations/cnv_issue195_stale", aliceLogin.cookie);
|
|
assert.equal(failedConversation.status, 200);
|
|
assert.equal(failedConversation.body.conversation.status, "failed");
|
|
assert.equal(failedConversation.body.conversation.threadId, staleThreadId);
|
|
|
|
const next = await postJson(port, "/v1/agent/chat", {
|
|
message: "new turn after stale continuation was cleared",
|
|
traceId: "trc_issue195_after_stale",
|
|
workspaceId: workspace.body.workspace.workspaceId,
|
|
expectedWorkspaceRevision: restored.body.workspace.revision,
|
|
shortConnection: true
|
|
}, aliceLogin.cookie, { prefer: "respond-async", "x-trace-id": "trc_issue195_after_stale" });
|
|
assert.equal(next.status, 202);
|
|
assert.equal(next.body.status, "running");
|
|
assert.equal(next.body.traceId, "trc_issue195_after_stale");
|
|
await waitForCondition(() => createRunInputs.length === 1);
|
|
assert.equal(createRunInputs.length, 1);
|
|
assert.notEqual(createRunInputs[0].sessionRef?.threadId, staleThreadId);
|
|
assert.equal(createRunInputs[0].sessionRef?.threadId, undefined);
|
|
assert.equal(createRunInputs[0].sessionRef?.conversationId, undefined);
|
|
|
|
const afterNext = await getJson(port, "/v1/workbench/workspace?projectId=prj_device_pod_workbench", aliceLogin.cookie);
|
|
assert.equal(afterNext.body.workspace.activeTraceId, "trc_issue195_after_stale");
|
|
assert.ok(agentRunCalls.some((call) => call.path === "/api/v1/runs/run_workspace_thread_resume_failed/commands/cmd_workspace_thread_resume_failed/result"));
|
|
} finally {
|
|
await new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve())));
|
|
await new Promise((resolve, reject) => agentRunServer.close((error) => (error ? reject(error) : resolve())));
|
|
}
|
|
});
|
|
|
|
test("workbench workspace status repairs terminal selected conversation after active trace was cleared", async () => {
|
|
const agentRunCalls = [];
|
|
const agentSessions = new Map();
|
|
const agentRunServer = createServer(async (request, response) => {
|
|
const url = new URL(request.url || "/", "http://127.0.0.1");
|
|
agentRunCalls.push({ method: request.method, path: url.pathname, search: url.search });
|
|
const send = (data) => {
|
|
response.writeHead(200, { "content-type": "application/json" });
|
|
response.end(`${JSON.stringify({ ok: true, data })}\n`);
|
|
};
|
|
if (request.method === "GET" && url.pathname === "/api/v1/runs/run_workspace_repair/events") {
|
|
return send({ items: [] });
|
|
}
|
|
if (request.method === "GET" && url.pathname === "/api/v1/runs/run_workspace_repair/commands/cmd_workspace_repair/result") {
|
|
return send({
|
|
runId: "run_workspace_repair",
|
|
commandId: "cmd_workspace_repair",
|
|
status: "completed",
|
|
runStatus: "completed",
|
|
commandState: "completed",
|
|
terminalStatus: "completed",
|
|
completed: true,
|
|
reply: "repair completed",
|
|
lastSeq: 2,
|
|
sessionRef: { sessionId: "ses_issue664_repair", conversationId: "cnv_issue664_repair", threadId: "thread-issue-664-repair" }
|
|
});
|
|
}
|
|
response.writeHead(404, { "content-type": "application/json" });
|
|
response.end(`${JSON.stringify({ ok: false, message: `unexpected ${request.method} ${url.pathname}` })}\n`);
|
|
});
|
|
await new Promise((resolve) => agentRunServer.listen(0, "127.0.0.1", resolve));
|
|
|
|
const agentRunPort = agentRunServer.address().port;
|
|
const accessController = createAccessController({
|
|
env: {
|
|
HWLAB_ACCESS_CONTROL_REQUIRED: "1",
|
|
HWLAB_BOOTSTRAP_ADMIN_USERNAME: "admin",
|
|
HWLAB_BOOTSTRAP_ADMIN_PASSWORD: "admin-pass"
|
|
},
|
|
now: () => "2026-06-01T00:00:00.000Z"
|
|
});
|
|
accessController.getAgentSessionByTraceId = async (traceId) => agentSessions.get(traceId) ?? null;
|
|
const server = createCloudApiServer({
|
|
env: {
|
|
HWLAB_ACCESS_CONTROL_REQUIRED: "1",
|
|
HWLAB_BOOTSTRAP_ADMIN_USERNAME: "admin",
|
|
HWLAB_BOOTSTRAP_ADMIN_PASSWORD: "admin-pass",
|
|
HWLAB_CODE_AGENT_ADAPTER: "agentrun-v01",
|
|
AGENTRUN_MGR_URL: `http://127.0.0.1:${agentRunPort}`,
|
|
HWLAB_CODE_AGENT_AGENTRUN_ALLOW_NON_K3S_URL: "1",
|
|
HWLAB_CODE_AGENT_DEFAULT_PROVIDER_PROFILE: "deepseek"
|
|
},
|
|
accessController,
|
|
now: () => "2026-06-01T00:00:00.000Z"
|
|
});
|
|
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
|
|
|
|
try {
|
|
const { port } = server.address();
|
|
const adminLogin = await postJson(port, "/auth/login", { username: "admin", password: "admin-pass" });
|
|
const alice = await postJson(port, "/v1/admin/users", { username: "alice-ws-repair", password: "alice-pass" }, adminLogin.cookie);
|
|
assert.equal(alice.status, 201);
|
|
const aliceLogin = await postJson(port, "/auth/login", { username: "alice-ws-repair", password: "alice-pass" });
|
|
const workspace = await getJson(port, "/v1/workbench/workspace?projectId=prj_device_pod_workbench", aliceLogin.cookie);
|
|
assert.equal(workspace.status, 200);
|
|
const conversation = await putJson(port, "/v1/agent/conversations/cnv_issue664_repair", {
|
|
projectId: "prj_device_pod_workbench",
|
|
sessionId: "ses_issue664_repair",
|
|
threadId: "thread-issue-664-repair",
|
|
sessionStatus: "running",
|
|
lastTraceId: "trc_issue664_repair_done",
|
|
messages: []
|
|
}, aliceLogin.cookie);
|
|
assert.equal(conversation.status, 200);
|
|
|
|
agentSessions.set("trc_issue664_repair_done", {
|
|
id: "ses_issue664_repair",
|
|
ownerUserId: alice.body.user.id,
|
|
conversationId: "cnv_issue664_repair",
|
|
threadId: "thread-issue-664-repair",
|
|
status: "active",
|
|
session: {
|
|
messageId: "msg_workspace_repair_done",
|
|
agentRun: {
|
|
runId: "run_workspace_repair",
|
|
commandId: "cmd_workspace_repair",
|
|
backendProfile: "deepseek",
|
|
managerUrl: `http://127.0.0.1:${agentRunPort}`,
|
|
lastSeq: 0,
|
|
sessionId: "ses_issue664_repair",
|
|
conversationId: "cnv_issue664_repair",
|
|
threadId: "thread-issue-664-repair"
|
|
}
|
|
},
|
|
updatedAt: "2026-06-01T00:00:00.000Z"
|
|
});
|
|
|
|
const update = await patchJson(port, `/v1/workbench/workspace/${workspace.body.workspace.workspaceId}`, {
|
|
expectedRevision: 1,
|
|
selectedConversationId: "cnv_issue664_repair",
|
|
selectedAgentSessionId: "ses_issue664_repair",
|
|
activeTraceId: null,
|
|
providerProfile: "deepseek",
|
|
sessionStatus: "idle",
|
|
lastTraceId: "trc_issue664_repair_done",
|
|
updatedByClient: "test-suite"
|
|
}, aliceLogin.cookie);
|
|
assert.equal(update.status, 200);
|
|
assert.equal(update.body.workspace.activeTraceId, null);
|
|
assert.equal(update.body.workspace.selectedConversation.status, "running");
|
|
assert.equal(update.body.workspace.selectedConversation.messages.length, 0);
|
|
|
|
const restored = await getJson(port, "/v1/workbench/workspace?projectId=prj_device_pod_workbench", aliceLogin.cookie);
|
|
assert.equal(restored.status, 200);
|
|
assert.equal(restored.body.workspace.activeTraceId, null);
|
|
assert.equal(restored.body.workspace.workspace.lastTraceId, "trc_issue664_repair_done");
|
|
assert.equal(restored.body.workspace.workspace.sessionStatus, "idle");
|
|
assert.equal(restored.body.workspace.selectedConversation.status, "idle");
|
|
assert.equal(restored.body.workspace.selectedConversation.lastTraceId, "trc_issue664_repair_done");
|
|
assert.equal(restored.body.workspace.selectedConversation.messages.length, 1);
|
|
assert.equal(restored.body.workspace.selectedConversation.messages[0].status, "idle");
|
|
assert.equal(restored.body.workspace.selectedConversation.messages[0].text, "repair completed");
|
|
assert.ok(agentRunCalls.some((call) => call.path === "/api/v1/runs/run_workspace_repair/commands/cmd_workspace_repair/result"));
|
|
} finally {
|
|
await new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve())));
|
|
await new Promise((resolve, reject) => agentRunServer.close((error) => (error ? reject(error) : resolve())));
|
|
}
|
|
});
|
|
|
|
test("cloud api access control grants visible device pods and requires device-pod executor", async () => {
|
|
let directGatewayDispatches = 0;
|
|
const gatewayRegistry = {
|
|
isOnline: () => true,
|
|
enqueue: async () => {
|
|
directGatewayDispatches += 1;
|
|
throw new Error("cloud-api must not bypass hwlab-device-pod executor");
|
|
},
|
|
describe: () => ({ sessions: [] })
|
|
};
|
|
const server = createCloudApiServer({
|
|
env: {
|
|
HWLAB_ACCESS_CONTROL_REQUIRED: "1",
|
|
HWLAB_BOOTSTRAP_ADMIN_USERNAME: "admin",
|
|
HWLAB_BOOTSTRAP_ADMIN_PASSWORD: "admin-pass"
|
|
},
|
|
gatewayRegistry,
|
|
now: () => "2026-05-28T00:00:00.000Z"
|
|
});
|
|
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
|
|
|
|
try {
|
|
const { port } = server.address();
|
|
const adminLogin = await postJson(port, "/auth/login", { username: "admin", password: "admin-pass" });
|
|
assert.equal(adminLogin.status, 200);
|
|
assert.equal(adminLogin.body.actor.role, "admin");
|
|
const adminCookie = adminLogin.cookie;
|
|
|
|
const userCreate = await postJson(port, "/v1/admin/users", {
|
|
username: "alice",
|
|
password: "alice-pass",
|
|
displayName: "Alice"
|
|
}, adminCookie);
|
|
assert.equal(userCreate.status, 201);
|
|
assert.equal(userCreate.body.user.username, "alice");
|
|
assert.equal(JSON.stringify(userCreate.body).includes("alice-pass"), false);
|
|
const bobCreate = await postJson(port, "/v1/admin/users", {
|
|
username: "bob",
|
|
password: "bob-pass",
|
|
displayName: "Bob"
|
|
}, adminCookie);
|
|
assert.equal(bobCreate.status, 201);
|
|
|
|
const podCreate = await postJson(port, "/v1/admin/device-pods", {
|
|
devicePodId: "device-pod-71-freq",
|
|
name: "71-FREQ",
|
|
profile: {
|
|
schemaVersion: 1,
|
|
devicePodId: "device-pod-local-spoof",
|
|
target: { id: "target-71-freq" },
|
|
projectWorkspace: { projectPath: "FirmWare/MDK-ARM/app.uvprojx", targetName: "app" },
|
|
route: {
|
|
gatewaySessionId: "gws_missing",
|
|
resourceId: "res_windows_host",
|
|
capabilityId: "cap_device_host_cli",
|
|
hostCli: "node tools/device-host-cli.mjs"
|
|
}
|
|
}
|
|
}, adminCookie);
|
|
assert.equal(podCreate.status, 201);
|
|
assert.equal(podCreate.body.devicePod.devicePodId, "device-pod-71-freq");
|
|
assert.notEqual(podCreate.body.devicePod.profileHash, "sha256:a-local-profile-hash");
|
|
assert.equal(podCreate.body.devicePod.profile.route.gatewaySessionId, "redacted");
|
|
assert.equal(JSON.stringify(podCreate.body).includes("gws_missing"), false);
|
|
|
|
const secretProfile = await postJson(port, "/v1/admin/device-pods", {
|
|
devicePodId: "device-pod-secret",
|
|
profile: {
|
|
schemaVersion: 1,
|
|
target: { id: "target-secret" },
|
|
route: {
|
|
gatewaySessionId: "gws_secret",
|
|
cloudToken: "should-not-be-stored"
|
|
}
|
|
}
|
|
}, adminCookie);
|
|
assert.equal(secretProfile.status, 400);
|
|
assert.equal(secretProfile.body.error.code, "device_pod_profile_secret_forbidden");
|
|
assert.equal(JSON.stringify(secretProfile.body).includes("should-not-be-stored"), false);
|
|
|
|
const emptyLogin = await postJson(port, "/auth/login", { username: "alice", password: "alice-pass" });
|
|
assert.equal(emptyLogin.status, 200);
|
|
const aliceCookie = emptyLogin.cookie;
|
|
const emptyList = await getJson(port, "/v1/device-pods", aliceCookie);
|
|
assert.equal(emptyList.status, 200);
|
|
assert.deepEqual(emptyList.body.devicePods, []);
|
|
|
|
const grant = await postJson(port, "/v1/admin/device-pod-grants", {
|
|
devicePodId: "device-pod-71-freq",
|
|
userId: userCreate.body.user.id
|
|
}, adminCookie);
|
|
assert.equal(grant.status, 201);
|
|
const bobGrant = await postJson(port, "/v1/admin/device-pod-grants", {
|
|
devicePodId: "device-pod-71-freq",
|
|
userId: bobCreate.body.user.id
|
|
}, adminCookie);
|
|
assert.equal(bobGrant.status, 201);
|
|
|
|
const visible = await getJson(port, "/v1/device-pods", aliceCookie);
|
|
assert.equal(visible.status, 200);
|
|
assert.equal(visible.body.contractVersion, "device-pod-authority-v1");
|
|
assert.equal(visible.body.source.kind, "CLOUD_API_PROFILE_AUTHORITY");
|
|
assert.equal(visible.body.source.fake, false);
|
|
assert.equal(visible.body.devicePods.length, 1);
|
|
assert.equal(visible.body.devicePods[0].devicePodId, "device-pod-71-freq");
|
|
assert.match(visible.body.devicePods[0].profileHash, /^sha256:/u);
|
|
|
|
const status = await getJson(port, "/v1/device-pods/device-pod-71-freq/status", aliceCookie);
|
|
assert.equal(status.status, 200);
|
|
assert.equal(status.body.devicePodId, "device-pod-71-freq");
|
|
assert.equal(status.body.targetId, "target-71-freq");
|
|
assert.equal(status.body.profileHash, visible.body.devicePods[0].profileHash);
|
|
assert.match(status.body.traceId, /^trc_devicepod_/u);
|
|
assert.match(status.body.operationId, /^op_devicepod_/u);
|
|
assert.equal(status.body.status, "ok");
|
|
assert.equal(status.body.freshness.stale, false);
|
|
assert.equal(status.body.blocker, null);
|
|
assert.equal(status.body.truncation.truncated, false);
|
|
assert.equal(status.body.output.summary, "device-pod status ok");
|
|
|
|
const job = await postJson(port, "/v1/device-pods/device-pod-71-freq/jobs", {
|
|
intent: "workspace.ls",
|
|
args: { path: "." }
|
|
}, aliceCookie);
|
|
assert.equal(job.status, 409);
|
|
assert.equal(job.body.status, "blocked");
|
|
assert.equal(job.body.blocker.code, "device_pod_executor_unavailable");
|
|
assert.equal(job.body.blocker.summary, "HWLAB_DEVICE_POD_URL is not configured; cloud-api will not bypass hwlab-device-pod executor");
|
|
assert.equal(job.body.devicePodId, "device-pod-71-freq");
|
|
assert.equal(job.body.profileHash, visible.body.devicePods[0].profileHash);
|
|
assert.equal(directGatewayDispatches, 0);
|
|
|
|
const mutatingWithReason = await postJson(port, "/v1/device-pods/device-pod-71-freq/jobs", {
|
|
intent: "debug.reset",
|
|
reason: "reset smoke"
|
|
}, aliceCookie);
|
|
assert.equal(mutatingWithReason.status, 409);
|
|
assert.equal(mutatingWithReason.body.status, "blocked");
|
|
assert.equal(mutatingWithReason.body.devicePodId, "device-pod-71-freq");
|
|
assert.equal(mutatingWithReason.body.profileHash, visible.body.devicePods[0].profileHash);
|
|
assert.equal(mutatingWithReason.body.blocker.code, "device_pod_executor_unavailable");
|
|
assert.equal(mutatingWithReason.body.freshness.stale, true);
|
|
const blockedJob = await getJson(port, `/v1/device-pods/device-pod-71-freq/jobs/${mutatingWithReason.body.job.id}`, aliceCookie);
|
|
assert.equal(blockedJob.status, 200);
|
|
assert.equal(blockedJob.body.job.id, mutatingWithReason.body.job.id);
|
|
assert.equal(blockedJob.body.status, "blocked");
|
|
assert.equal(blockedJob.body.blocker.code, "device_pod_executor_unavailable");
|
|
const blockedOutput = await getJson(port, `/v1/device-pods/device-pod-71-freq/jobs/${mutatingWithReason.body.job.id}/output`, aliceCookie);
|
|
assert.equal(blockedOutput.status, 200);
|
|
assert.equal(blockedOutput.body.truncation.truncated, false);
|
|
assert.match(blockedOutput.body.output.error, /HWLAB_DEVICE_POD_URL is not configured/u);
|
|
|
|
const mutatingWithoutReason = await postJson(port, "/v1/device-pods/device-pod-71-freq/jobs", {
|
|
intent: "debug.reset"
|
|
}, aliceCookie);
|
|
assert.equal(mutatingWithoutReason.status, 400);
|
|
assert.equal(mutatingWithoutReason.body.error.code, "device_job_reason_required");
|
|
assert.equal(mutatingWithoutReason.body.status, "blocked");
|
|
assert.equal(mutatingWithoutReason.body.devicePodId, "device-pod-71-freq");
|
|
assert.equal(mutatingWithoutReason.body.profileHash, visible.body.devicePods[0].profileHash);
|
|
assert.equal(mutatingWithoutReason.body.blocker.code, "device_job_reason_required");
|
|
assert.equal(mutatingWithoutReason.body.freshness.stale, true);
|
|
|
|
const bobLogin = await postJson(port, "/auth/login", { username: "bob", password: "bob-pass" });
|
|
assert.equal(bobLogin.status, 200);
|
|
const bobListWithGrant = await getJson(port, "/v1/device-pods", bobLogin.cookie);
|
|
assert.equal(bobListWithGrant.status, 200);
|
|
assert.equal(bobListWithGrant.body.devicePods.length, 1);
|
|
|
|
const privatePodCreate = await postJson(port, "/v1/admin/device-pods", {
|
|
devicePodId: "device-pod-private",
|
|
profile: { schemaVersion: 1, target: { id: "target-private" }, route: { gatewaySessionId: "gws_private" } }
|
|
}, adminCookie);
|
|
assert.equal(privatePodCreate.status, 201);
|
|
const bobPrivateStatus = await getJson(port, "/v1/device-pods/device-pod-private/status", bobLogin.cookie);
|
|
assert.equal(bobPrivateStatus.status, 403);
|
|
assert.equal(bobPrivateStatus.body.error.code, "device_pod_forbidden");
|
|
const bobMissingStatus = await getJson(port, "/v1/device-pods/device-pod-missing/status", bobLogin.cookie);
|
|
assert.equal(bobMissingStatus.status, 404);
|
|
assert.equal(bobMissingStatus.body.error.code, "device_pod_not_found");
|
|
|
|
const events = await getJson(port, "/v1/device-pods/device-pod-71-freq/events", aliceCookie);
|
|
assert.equal(events.status, 200);
|
|
assert.equal(events.body.events[0].blocker.code, "device_pod_executor_unavailable");
|
|
|
|
const unsupported = await postJson(port, "/v1/device-pods/device-pod-71-freq/jobs", {
|
|
intent: "debug.erase-all",
|
|
args: {}
|
|
}, aliceCookie);
|
|
assert.equal(unsupported.status, 400);
|
|
assert.equal(unsupported.body.error.code, "unsupported_device_job_intent");
|
|
|
|
const revoke = await fetch(`http://127.0.0.1:${port}/v1/admin/device-pod-grants/device-pod-71-freq/${bobCreate.body.user.id}`, {
|
|
method: "DELETE",
|
|
headers: { cookie: adminCookie }
|
|
});
|
|
assert.equal(revoke.status, 200);
|
|
const revoked = await revoke.json();
|
|
assert.equal(revoked.devicePodId, "device-pod-71-freq");
|
|
assert.equal(revoked.userId, bobCreate.body.user.id);
|
|
|
|
const afterRevoke = await getJson(port, "/v1/device-pods", bobLogin.cookie);
|
|
assert.equal(afterRevoke.status, 200);
|
|
assert.deepEqual(afterRevoke.body.devicePods, []);
|
|
} finally {
|
|
await new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve())));
|
|
}
|
|
});
|
|
|
|
test("cloud api stores and restores Code Agent conversations by authenticated account", async () => {
|
|
const server = createCloudApiServer({
|
|
env: {
|
|
HWLAB_ACCESS_CONTROL_REQUIRED: "1",
|
|
HWLAB_BOOTSTRAP_ADMIN_USERNAME: "admin",
|
|
HWLAB_BOOTSTRAP_ADMIN_PASSWORD: "admin-pass"
|
|
},
|
|
now: () => "2026-05-31T07:30:00.000Z"
|
|
});
|
|
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
|
|
|
|
try {
|
|
const { port } = server.address();
|
|
const adminLogin = await postJson(port, "/auth/login", { username: "admin", password: "admin-pass" });
|
|
const adminCookie = adminLogin.cookie;
|
|
const aliceCreate = await postJson(port, "/v1/admin/users", { username: "alice", password: "alice-pass" }, adminCookie);
|
|
const bobCreate = await postJson(port, "/v1/admin/users", { username: "bob", password: "bob-pass" }, adminCookie);
|
|
assert.equal(aliceCreate.status, 201);
|
|
assert.equal(bobCreate.status, 201);
|
|
|
|
const aliceLogin = await postJson(port, "/auth/login", { username: "alice", password: "alice-pass" });
|
|
const bobLogin = await postJson(port, "/auth/login", { username: "bob", password: "bob-pass" });
|
|
|
|
const stored = await putJson(port, "/v1/agent/conversations/cnv_account_sync", {
|
|
projectId: "prj_device_pod_workbench",
|
|
sessionId: "ses_account_sync",
|
|
threadId: "thread-account-sync",
|
|
lastTraceId: "trc_account_sync",
|
|
sessionStatus: "idle",
|
|
messages: [
|
|
{ id: "msg_user", role: "user", title: "用户", text: "在吗", status: "sent", conversationId: "cnv_account_sync", sessionId: "ses_account_sync", threadId: "thread-account-sync" },
|
|
{ id: "msg_agent", role: "agent", title: "Agent", text: "在", status: "completed", traceId: "trc_account_sync", conversationId: "cnv_account_sync", sessionId: "ses_account_sync", threadId: "thread-account-sync" }
|
|
]
|
|
}, aliceLogin.cookie);
|
|
assert.equal(stored.status, 200);
|
|
assert.equal(stored.body.conversation.conversationId, "cnv_account_sync");
|
|
assert.equal(stored.body.conversation.sessionId, "ses_account_sync");
|
|
assert.equal(stored.body.conversation.threadId, "thread-account-sync");
|
|
assert.equal(stored.body.conversation.status, "idle");
|
|
assert.equal(stored.body.conversation.messages.length, 2);
|
|
|
|
const aliceList = await getJson(port, "/v1/agent/conversations?projectId=prj_device_pod_workbench", aliceLogin.cookie);
|
|
assert.equal(aliceList.status, 200);
|
|
assert.equal(aliceList.body.count, 1);
|
|
assert.equal(aliceList.body.defaultConversation.conversationId, "cnv_account_sync");
|
|
assert.equal(aliceList.body.defaultConversation.messages[1].text, "在");
|
|
assert.equal(aliceList.body.defaultConversation.valuesRedacted, true);
|
|
|
|
const bobList = await getJson(port, "/v1/agent/conversations?projectId=prj_device_pod_workbench", bobLogin.cookie);
|
|
assert.equal(bobList.status, 200);
|
|
assert.deepEqual(bobList.body.conversations, []);
|
|
|
|
const bobDirect = await getJson(port, "/v1/agent/conversations/cnv_account_sync", bobLogin.cookie);
|
|
assert.equal(bobDirect.status, 404);
|
|
assert.equal(bobDirect.body.error.code, "agent_conversation_not_found");
|
|
|
|
const workspace = await getJson(port, "/v1/workbench/workspace?projectId=prj_device_pod_workbench", aliceLogin.cookie);
|
|
assert.equal(workspace.status, 200);
|
|
const selected = await postJson(port, `/v1/workbench/workspace/${workspace.body.workspace.workspaceId}/select-conversation`, {
|
|
projectId: "prj_device_pod_workbench",
|
|
conversationId: "cnv_account_sync",
|
|
updatedByClient: "test-suite"
|
|
}, aliceLogin.cookie);
|
|
assert.equal(selected.status, 200);
|
|
assert.equal(selected.body.workspace.selectedConversationId, "cnv_account_sync");
|
|
|
|
const deleted = await deleteJson(port, "/v1/agent/conversations/cnv_account_sync", {
|
|
projectId: "prj_device_pod_workbench",
|
|
workspaceId: workspace.body.workspace.workspaceId,
|
|
updatedByClient: "test-suite"
|
|
}, aliceLogin.cookie);
|
|
assert.equal(deleted.status, 200);
|
|
assert.equal(deleted.body.status, "deleted");
|
|
assert.equal(deleted.body.archivedCount, 1);
|
|
assert.equal(deleted.body.workspace.selectedConversationId, null);
|
|
|
|
const afterDeleteList = await getJson(port, "/v1/agent/conversations?projectId=prj_device_pod_workbench", aliceLogin.cookie);
|
|
assert.equal(afterDeleteList.status, 200);
|
|
assert.equal(afterDeleteList.body.count, 0);
|
|
|
|
const archivedDirect = await getJson(port, "/v1/agent/conversations/cnv_account_sync", aliceLogin.cookie);
|
|
assert.equal(archivedDirect.status, 200);
|
|
assert.equal(archivedDirect.body.conversation.status, "archived");
|
|
|
|
const logout = await postJson(port, "/auth/logout", {}, aliceLogin.cookie);
|
|
assert.equal(logout.status, 200);
|
|
const relogin = await postJson(port, "/auth/login", { username: "alice", password: "alice-pass" });
|
|
const restored = await getJson(port, "/v1/agent/conversations/cnv_account_sync", relogin.cookie);
|
|
assert.equal(restored.status, 200);
|
|
assert.equal(restored.body.conversation.threadId, "thread-account-sync");
|
|
assert.equal(restored.body.conversation.messages[0].text, "在吗");
|
|
} finally {
|
|
await new Promise((resolve, reject) => {
|
|
server.close((error) => (error ? reject(error) : resolve()));
|
|
});
|
|
}
|
|
});
|
|
|
|
test("access controller restores AgentRun mapping by Code Agent traceId", async () => {
|
|
const accessController = createAccessController({ now: () => "2026-06-01T00:00:00.000Z" });
|
|
await accessController.recordAgentSessionOwner({
|
|
ownerUserId: "usr_agent_owner",
|
|
sessionId: "ses_agentrun_trace_lookup",
|
|
projectId: "prj_v02_code_agent",
|
|
conversationId: "cnv_agentrun_trace_lookup",
|
|
threadId: "thread-agentrun-trace-lookup",
|
|
traceId: "trc_agentrun_trace_lookup",
|
|
status: "running",
|
|
session: {
|
|
source: "hwlab-cloud-api-agentrun-v01-adapter",
|
|
agentRun: {
|
|
adapter: "agentrun-v01",
|
|
runId: "run_trace_lookup",
|
|
commandId: "cmd_trace_lookup",
|
|
jobName: "agentrun-v01-runner-trace-lookup",
|
|
namespace: "agentrun-v01",
|
|
backendProfile: "deepseek",
|
|
valuesPrinted: false
|
|
},
|
|
valuesRedacted: true
|
|
}
|
|
});
|
|
|
|
const restored = await accessController.getAgentSessionByTraceId("trc_agentrun_trace_lookup");
|
|
assert.equal(restored.id, "ses_agentrun_trace_lookup");
|
|
assert.equal(restored.lastTraceId, "trc_agentrun_trace_lookup");
|
|
assert.equal(restored.session.agentRun.runId, "run_trace_lookup");
|
|
assert.equal(restored.session.agentRun.commandId, "cmd_trace_lookup");
|
|
assert.equal(restored.session.agentRun.namespace, "agentrun-v01");
|
|
});
|
|
|
|
test("access controller preserves AgentRun runner mapping across conversation snapshot updates", async () => {
|
|
const accessController = createAccessController({ now: () => "2026-06-01T00:00:00.000Z" });
|
|
await accessController.recordAgentSessionOwner({
|
|
ownerUserId: "usr_agent_owner",
|
|
sessionId: "ses_agentrun_reuse_mapping",
|
|
projectId: "prj_v02_code_agent",
|
|
conversationId: "cnv_agentrun_reuse_mapping",
|
|
threadId: "thread-agentrun-requested",
|
|
traceId: "trc_agentrun_reuse_first",
|
|
status: "running",
|
|
session: {
|
|
source: "hwlab-cloud-api-agentrun-v01-adapter",
|
|
agentRun: {
|
|
adapter: "agentrun-v01",
|
|
runId: "run_reuse_mapping",
|
|
commandId: "cmd_reuse_mapping_first",
|
|
runnerId: "runner_reuse_mapping",
|
|
jobName: "agentrun-v01-runner-reuse-mapping",
|
|
namespace: "agentrun-v01",
|
|
backendProfile: "deepseek",
|
|
reuseEligible: true,
|
|
valuesPrinted: false
|
|
},
|
|
valuesRedacted: true
|
|
}
|
|
});
|
|
|
|
await accessController.recordAgentSessionOwner({
|
|
ownerUserId: "usr_agent_owner",
|
|
sessionId: "ses_agentrun_reuse_mapping",
|
|
projectId: "prj_v02_code_agent",
|
|
conversationId: "cnv_agentrun_reuse_mapping",
|
|
threadId: "thread-agentrun-requested",
|
|
traceId: "trc_agentrun_reuse_second",
|
|
status: "active",
|
|
session: {
|
|
source: "workbench-conversation-snapshot",
|
|
messages: [{ role: "assistant", content: "done" }],
|
|
valuesRedacted: true
|
|
}
|
|
});
|
|
|
|
const restored = await accessController.getAgentSession("ses_agentrun_reuse_mapping");
|
|
assert.equal(restored.lastTraceId, "trc_agentrun_reuse_second");
|
|
assert.equal(restored.session.source, "workbench-conversation-snapshot");
|
|
assert.equal(restored.session.agentRun.runId, "run_reuse_mapping");
|
|
assert.equal(restored.session.agentRun.commandId, "cmd_reuse_mapping_first");
|
|
assert.equal(restored.session.agentRun.jobName, "agentrun-v01-runner-reuse-mapping");
|
|
assert.equal(restored.session.agentRun.reuseEligible, true);
|
|
});
|
|
|
|
test("cloud api protects device-pod routes when access control is required", async () => {
|
|
const server = createCloudApiServer({
|
|
env: { HWLAB_ACCESS_CONTROL_REQUIRED: "1" },
|
|
now: () => "2026-05-28T00:00:00.000Z"
|
|
});
|
|
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
|
|
|
|
try {
|
|
const { port } = server.address();
|
|
const response = await fetch(`http://127.0.0.1:${port}/v1/device-pods`);
|
|
assert.equal(response.status, 401);
|
|
const payload = await response.json();
|
|
assert.equal(payload.error.code, "auth_required");
|
|
} finally {
|
|
await new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve())));
|
|
}
|
|
});
|
|
|
|
test("cloud api accepts the assembled Code Agent Device Pod API key with admin device access", async () => {
|
|
const accessController = createAccessController({
|
|
env: {
|
|
HWLAB_ACCESS_CONTROL_REQUIRED: "1",
|
|
HWLAB_BOOTSTRAP_ADMIN_ID: "usr_v02_admin",
|
|
HWLAB_BOOTSTRAP_ADMIN_USERNAME: "admin",
|
|
HWLAB_BOOTSTRAP_ADMIN_PASSWORD: "admin-pass",
|
|
HWLAB_DEVICE_POD_API_KEY: "test-device-pod-api-key"
|
|
},
|
|
now: () => "2026-05-28T00:00:00.000Z"
|
|
});
|
|
const server = createCloudApiServer({
|
|
env: {
|
|
HWLAB_ACCESS_CONTROL_REQUIRED: "1",
|
|
HWLAB_BOOTSTRAP_ADMIN_ID: "usr_v02_admin",
|
|
HWLAB_BOOTSTRAP_ADMIN_USERNAME: "admin",
|
|
HWLAB_BOOTSTRAP_ADMIN_PASSWORD: "admin-pass",
|
|
HWLAB_DEVICE_POD_API_KEY: "test-device-pod-api-key"
|
|
},
|
|
accessController,
|
|
now: () => "2026-05-28T00:00:00.000Z"
|
|
});
|
|
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
|
|
|
|
try {
|
|
const { port } = server.address();
|
|
const adminLogin = await postJson(port, "/auth/login", { username: "admin", password: "admin-pass" });
|
|
await postJson(port, "/v1/admin/device-pods", {
|
|
devicePodId: "device-pod-71-freq",
|
|
profile: { schemaVersion: 1, target: { id: "target-71-freq" }, route: { gatewaySessionId: "gws_unused" } }
|
|
}, adminLogin.cookie);
|
|
|
|
const agentAuth = await accessController.createCodeAgentDevicePodApiKey();
|
|
assert.equal(agentAuth.actor.id, "usr_v02_admin");
|
|
assert.equal(agentAuth.actor.role, "admin");
|
|
assert.equal(agentAuth.tokenSource, "code-agent-device-pod-api-key");
|
|
assert.equal(agentAuth.valuesRedacted, true);
|
|
assert.equal(agentAuth.apiKey, "test-device-pod-api-key");
|
|
|
|
const status = await getJson(port, "/v1/device-pods/device-pod-71-freq/status", null, {
|
|
"x-hwlab-device-pod-api-key": agentAuth.apiKey
|
|
});
|
|
assert.equal(status.status, 200);
|
|
assert.equal(status.body.devicePodId, "device-pod-71-freq");
|
|
assert.equal(status.body.actor.id, "usr_v02_admin");
|
|
assert.equal(status.body.actor.role, "admin");
|
|
assert.equal(JSON.stringify(status.body).includes(agentAuth.apiKey), false);
|
|
|
|
const adminWithApiKey = await postJson(port, "/v1/admin/device-pods", {
|
|
devicePodId: "device-pod-api-key-admin-blocked",
|
|
profile: { schemaVersion: 1, target: { id: "target-blocked" }, route: { gatewaySessionId: "gws_unused" } }
|
|
}, null, {
|
|
"x-hwlab-device-pod-api-key": agentAuth.apiKey
|
|
});
|
|
assert.equal(adminWithApiKey.status, 403);
|
|
assert.equal(adminWithApiKey.body.error.code, "admin_session_required");
|
|
} finally {
|
|
await new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve())));
|
|
}
|
|
});
|
|
|
|
test("cloud api dispatches authorized device jobs to the internal device-pod executor", async () => {
|
|
const executorRequests = [];
|
|
const executor = createServer(async (request, response) => {
|
|
const body = await requestJson(request);
|
|
executorRequests.push({ method: request.method, url: request.url, internalService: request.headers["x-hwlab-internal-service"], internalToken: request.headers["x-hwlab-internal-token"], body });
|
|
if (request.method === "POST" && body.args?.path === "src") {
|
|
response.writeHead(202, { "content-type": "application/json; charset=utf-8" });
|
|
response.end(JSON.stringify({
|
|
accepted: true,
|
|
status: "running",
|
|
contractVersion: "device-pod-executor-v1",
|
|
devicePodId: "device-pod-71-freq",
|
|
traceId: body.traceId,
|
|
operationId: body.operationId,
|
|
job: { id: body.jobId, devicePodId: body.devicePodId, status: "running", intent: body.intent, reason: body.reason, traceId: body.traceId, operationId: body.operationId },
|
|
blocker: null
|
|
}));
|
|
return;
|
|
}
|
|
if (request.method === "GET" && request.url.endsWith("/output")) {
|
|
response.writeHead(200, { "content-type": "application/json; charset=utf-8" });
|
|
response.end(JSON.stringify({
|
|
accepted: true,
|
|
status: "completed",
|
|
contractVersion: "device-pod-executor-v1",
|
|
devicePodId: "device-pod-71-freq",
|
|
traceId: "trc_executor_refresh",
|
|
operationId: "op_executor_refresh",
|
|
job: { id: request.url.split("/").at(-2), devicePodId: "device-pod-71-freq", status: "completed", intent: "workspace.ls" },
|
|
output: {
|
|
executor: { status: "completed" },
|
|
output: { dispatch: { exitCode: 0, stdoutBytes: 15 } },
|
|
text: "executor output",
|
|
bytes: 15,
|
|
truncation: { maxBytes: 12000, truncated: false }
|
|
}
|
|
}));
|
|
return;
|
|
}
|
|
response.writeHead(409, { "content-type": "application/json; charset=utf-8" });
|
|
response.end(JSON.stringify({
|
|
accepted: false,
|
|
status: "blocked",
|
|
contractVersion: "device-pod-executor-v1",
|
|
devicePodId: "device-pod-71-freq",
|
|
traceId: body.traceId,
|
|
operationId: body.operationId,
|
|
job: { id: body.jobId, devicePodId: body.devicePodId, status: "blocked", intent: body.intent, reason: body.reason, traceId: body.traceId, operationId: body.operationId },
|
|
blocker: { code: "gateway_dispatch_unavailable", layer: "device-pod", retryable: true, summary: "executor test blocker" },
|
|
output: { text: "executor output", bytes: 15, truncation: { maxBytes: 12000, truncated: false } }
|
|
}));
|
|
});
|
|
await new Promise((resolve) => executor.listen(0, "127.0.0.1", resolve));
|
|
const executorPort = executor.address().port;
|
|
const server = createCloudApiServer({
|
|
env: {
|
|
HWLAB_ACCESS_CONTROL_REQUIRED: "1",
|
|
HWLAB_BOOTSTRAP_ADMIN_USERNAME: "admin",
|
|
HWLAB_BOOTSTRAP_ADMIN_PASSWORD: "admin-pass",
|
|
HWLAB_DEVICE_POD_INTERNAL_TOKEN: INTERNAL_TOKEN,
|
|
HWLAB_DEVICE_POD_URL: `http://127.0.0.1:${executorPort}`
|
|
},
|
|
now: () => "2026-05-28T00:00:00.000Z"
|
|
});
|
|
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
|
|
|
|
try {
|
|
const { port } = server.address();
|
|
const adminLogin = await postJson(port, "/auth/login", { username: "admin", password: "admin-pass" });
|
|
const userCreate = await postJson(port, "/v1/admin/users", { username: "alice", password: "alice-pass" }, adminLogin.cookie);
|
|
await postJson(port, "/v1/admin/device-pods", {
|
|
devicePodId: "device-pod-71-freq",
|
|
profile: { schemaVersion: 1, target: { id: "target-71-freq" }, route: { gatewaySessionId: "gws_unused" } }
|
|
}, adminLogin.cookie);
|
|
await postJson(port, "/v1/admin/device-pod-grants", { devicePodId: "device-pod-71-freq", userId: userCreate.body.user.id }, adminLogin.cookie);
|
|
const aliceLogin = await postJson(port, "/auth/login", { username: "alice", password: "alice-pass" });
|
|
|
|
const job = await postJson(port, "/v1/device-pods/device-pod-71-freq/jobs", { intent: "workspace.ls", args: { path: "." } }, aliceLogin.cookie);
|
|
assert.equal(job.status, 409);
|
|
assert.equal(job.body.blocker.code, "gateway_dispatch_unavailable");
|
|
assert.equal(job.body.job.status, "blocked");
|
|
assert.equal(executorRequests.length, 1);
|
|
assert.equal(executorRequests[0].internalService, "hwlab-cloud-api");
|
|
assert.equal(executorRequests[0].internalToken, INTERNAL_TOKEN);
|
|
assert.equal(executorRequests[0].body.profileHash, job.body.profileHash);
|
|
assert.equal(executorRequests[0].body.ownerUserId, userCreate.body.user.id);
|
|
assert.equal(executorRequests[0].body.intent, "workspace.ls");
|
|
|
|
const stored = await getJson(port, `/v1/device-pods/device-pod-71-freq/jobs/${job.body.job.id}`, aliceLogin.cookie);
|
|
assert.equal(stored.status, 200);
|
|
assert.equal(stored.body.job.id, job.body.job.id);
|
|
assert.equal(stored.body.blocker.code, "gateway_dispatch_unavailable");
|
|
|
|
const runningJob = await postJson(port, "/v1/device-pods/device-pod-71-freq/jobs", { intent: "workspace.ls", args: { path: "src" } }, aliceLogin.cookie);
|
|
assert.equal(runningJob.status, 202);
|
|
assert.equal(runningJob.body.status, "running");
|
|
const output = await getJson(port, `/v1/device-pods/device-pod-71-freq/jobs/${runningJob.body.job.id}/output`, aliceLogin.cookie);
|
|
assert.equal(output.status, 200);
|
|
assert.equal(output.body.status, "completed");
|
|
assert.equal(output.body.blocker, null);
|
|
assert.equal(output.body.freshness.stale, false);
|
|
assert.equal(output.body.output.text, "executor output");
|
|
assert.deepEqual(output.body.output.output.dispatch, { exitCode: 0, stdoutBytes: 15 });
|
|
assert.equal(output.body.truncation.truncated, false);
|
|
assert.ok(executorRequests.some((item) => item.method === "GET" && item.url.endsWith(`/jobs/${runningJob.body.job.id}/output`)));
|
|
|
|
const events = await getJson(port, "/v1/device-pods/device-pod-71-freq/events", aliceLogin.cookie);
|
|
const completedEvent = events.body.events.find((event) => event.refs.jobId === runningJob.body.job.id);
|
|
assert.equal(completedEvent.status, "completed");
|
|
assert.equal(completedEvent.blocker, null);
|
|
} finally {
|
|
await new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve())));
|
|
await new Promise((resolve, reject) => executor.close((error) => (error ? reject(error) : resolve())));
|
|
}
|
|
});
|
|
|
|
test("cloud api bounds device-pod job output payloads", async () => {
|
|
const longText = "x".repeat(13000);
|
|
const executor = createServer(async (request, response) => {
|
|
const body = await requestJson(request);
|
|
response.writeHead(200, { "content-type": "application/json; charset=utf-8" });
|
|
response.end(JSON.stringify({
|
|
accepted: true,
|
|
status: "completed",
|
|
contractVersion: "device-pod-executor-v1",
|
|
devicePodId: "device-pod-71-freq",
|
|
traceId: body.traceId,
|
|
operationId: body.operationId,
|
|
job: { id: body.jobId, devicePodId: body.devicePodId, status: "completed", intent: body.intent },
|
|
output: { text: longText, nested: { kept: true } },
|
|
text: longText
|
|
}));
|
|
});
|
|
await new Promise((resolve) => executor.listen(0, "127.0.0.1", resolve));
|
|
const executorPort = executor.address().port;
|
|
const server = createCloudApiServer({
|
|
env: {
|
|
HWLAB_ACCESS_CONTROL_REQUIRED: "1",
|
|
HWLAB_BOOTSTRAP_ADMIN_USERNAME: "admin",
|
|
HWLAB_BOOTSTRAP_ADMIN_PASSWORD: "admin-pass",
|
|
HWLAB_DEVICE_POD_INTERNAL_TOKEN: INTERNAL_TOKEN,
|
|
HWLAB_DEVICE_POD_URL: `http://127.0.0.1:${executorPort}`
|
|
},
|
|
now: () => "2026-05-28T00:00:00.000Z"
|
|
});
|
|
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
|
|
|
|
try {
|
|
const { port } = server.address();
|
|
const adminLogin = await postJson(port, "/auth/login", { username: "admin", password: "admin-pass" });
|
|
const userCreate = await postJson(port, "/v1/admin/users", { username: "alice", password: "alice-pass" }, adminLogin.cookie);
|
|
await postJson(port, "/v1/admin/device-pods", {
|
|
devicePodId: "device-pod-71-freq",
|
|
profile: { schemaVersion: 1, target: { id: "target-71-freq" }, route: { gatewaySessionId: "gws_unused" } }
|
|
}, adminLogin.cookie);
|
|
await postJson(port, "/v1/admin/device-pod-grants", { devicePodId: "device-pod-71-freq", userId: userCreate.body.user.id }, adminLogin.cookie);
|
|
const aliceLogin = await postJson(port, "/auth/login", { username: "alice", password: "alice-pass" });
|
|
|
|
const job = await postJson(port, "/v1/device-pods/device-pod-71-freq/jobs", { intent: "workspace.ls", args: { path: "." } }, aliceLogin.cookie);
|
|
assert.equal(job.status, 200);
|
|
const output = await getJson(port, `/v1/device-pods/device-pod-71-freq/jobs/${job.body.job.id}/output`, aliceLogin.cookie);
|
|
assert.equal(output.status, 200);
|
|
assert.equal(output.body.bytes, 12000);
|
|
assert.equal(output.body.text.length, 12000);
|
|
assert.equal(output.body.output.text.length, 12000);
|
|
assert.equal(output.body.truncation.truncated, true);
|
|
assert.equal(output.body.truncation.originalBytes, 13000);
|
|
assert.equal(output.body.output.omitted.reason, "device_job_output_truncated");
|
|
assert.equal(JSON.stringify(output.body).includes(longText), false);
|
|
} finally {
|
|
await new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve())));
|
|
await new Promise((resolve, reject) => executor.close((error) => (error ? reject(error) : resolve())));
|
|
}
|
|
});
|
|
|
|
test("DEVICE_JOB_INTENTS accepts workspace.evidence and debug.evidence for v0.2 #773", () => {
|
|
const fs = require("node:fs");
|
|
const path = require("node:path");
|
|
const src = fs.readFileSync(path.join(__dirname, "access-control.ts"), "utf8");
|
|
assert.match(src, /"workspace\.evidence"/u, "workspace.evidence must be added to DEVICE_JOB_INTENTS");
|
|
assert.match(src, /"debug\.evidence"/u, "debug.evidence must be added to DEVICE_JOB_INTENTS");
|
|
});
|
|
|
|
test("_deviceJobRequiresReason helper considers sub-action for workspace.build / debug.download", () => {
|
|
const fs = require("node:fs");
|
|
const path = require("node:path");
|
|
const src = fs.readFileSync(path.join(__dirname, "access-control.ts"), "utf8");
|
|
assert.match(src, /function _deviceJobRequiresReason\(intent, args, reason\)/u, "_deviceJobRequiresReason must accept reason");
|
|
assert.match(src, /DEVICE_JOB_READ_ONLY_SUB_ACTIONS\.has\(action\)/u, "must consult DEVICE_JOB_READ_ONLY_SUB_ACTIONS");
|
|
assert.match(src, /!DEVICE_JOB_ACTIONABLE_INTENTS\.has\(intent\)\s*\)\s*return\s*true/u, "non-actionable mutating intent must always require reason");
|
|
});
|
|
|
|
test("executorOutputPayload extracts text from evidence and summary fields", () => {
|
|
const fs = require("node:fs");
|
|
const path = require("node:path");
|
|
const src = fs.readFileSync(path.join(__dirname, "access-control.ts"), "utf8");
|
|
const m = src.match(/function executorOutputPayload[\s\S]+?\n\}/u);
|
|
assert.ok(m, "executorOutputPayload not found");
|
|
const body = m[0];
|
|
assert.match(body, /evidence[\s\S]*?\.text/u, "must check evidence.text");
|
|
assert.match(body, /evidence[\s\S]*?\.logTail/u, "must check evidence.logTail");
|
|
assert.match(body, /evidence[\s\S]*?\.summary/u, "must check evidence.summary");
|
|
assert.match(body, /output\.summary/u, "must check output.summary");
|
|
assert.match(body, /nestedOutput\.summary/u, "must check nestedOutput.summary");
|
|
});
|
|
|
|
test("cloud api routes device-pod probe GET requests through executor jobs", async () => {
|
|
const executorRequests = [];
|
|
const executor = createServer(async (request, response) => {
|
|
const body = await requestJson(request);
|
|
executorRequests.push({ method: request.method, url: request.url, body });
|
|
const text = body.intent === "debug.chip-id" ? "chip-id: 0x12345678" : "uart tail output";
|
|
response.writeHead(200, { "content-type": "application/json; charset=utf-8" });
|
|
response.end(JSON.stringify({
|
|
accepted: true,
|
|
status: "completed",
|
|
contractVersion: "device-pod-executor-v1",
|
|
devicePodId: body.devicePodId,
|
|
traceId: body.traceId,
|
|
operationId: body.operationId,
|
|
job: { id: body.jobId, devicePodId: body.devicePodId, status: "completed", intent: body.intent },
|
|
output: { text },
|
|
text
|
|
}));
|
|
});
|
|
await new Promise((resolve) => executor.listen(0, "127.0.0.1", resolve));
|
|
const executorPort = executor.address().port;
|
|
const server = createCloudApiServer({
|
|
env: {
|
|
HWLAB_ACCESS_CONTROL_REQUIRED: "1",
|
|
HWLAB_BOOTSTRAP_ADMIN_USERNAME: "admin",
|
|
HWLAB_BOOTSTRAP_ADMIN_PASSWORD: "admin-pass",
|
|
HWLAB_DEVICE_POD_INTERNAL_TOKEN: INTERNAL_TOKEN,
|
|
HWLAB_DEVICE_POD_URL: `http://127.0.0.1:${executorPort}`
|
|
},
|
|
now: () => "2026-05-28T00:00:00.000Z"
|
|
});
|
|
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
|
|
|
|
try {
|
|
const { port } = server.address();
|
|
const adminLogin = await postJson(port, "/auth/login", { username: "admin", password: "admin-pass" });
|
|
const userCreate = await postJson(port, "/v1/admin/users", { username: "alice", password: "alice-pass" }, adminLogin.cookie);
|
|
await postJson(port, "/v1/admin/device-pods", {
|
|
devicePodId: "device-pod-71-freq",
|
|
profile: { schemaVersion: 1, target: { id: "target-71-freq" }, route: { gatewaySessionId: "gws_unused" } }
|
|
}, adminLogin.cookie);
|
|
await postJson(port, "/v1/admin/device-pod-grants", { devicePodId: "device-pod-71-freq", userId: userCreate.body.user.id }, adminLogin.cookie);
|
|
const aliceLogin = await postJson(port, "/auth/login", { username: "alice", password: "alice-pass" });
|
|
|
|
const chip = await getJson(port, "/v1/device-pods/device-pod-71-freq/debug-probe/chip-id", aliceLogin.cookie);
|
|
assert.equal(chip.status, 200);
|
|
assert.equal(chip.body.interface, "debug-probe");
|
|
assert.equal(chip.body.intent, "debug.chip-id");
|
|
assert.equal(chip.body.job.intent, "debug.chip-id");
|
|
assert.equal(chip.body.output.text, "chip-id: 0x12345678");
|
|
assert.equal(chip.body.source.fake, false);
|
|
|
|
const uart = await getJson(port, "/v1/device-pods/device-pod-71-freq/io-probe/uart/1/tail?durationMs=250&maxBytes=20", aliceLogin.cookie);
|
|
assert.equal(uart.status, 200);
|
|
assert.equal(uart.body.interface, "io-probe");
|
|
assert.equal(uart.body.intent, "io.uart.read");
|
|
assert.equal(uart.body.output.text, "uart tail output");
|
|
assert.equal(uart.body.truncation.maxBytes, 20);
|
|
|
|
assert.equal(executorRequests.length, 2);
|
|
assert.equal(executorRequests[0].method, "POST");
|
|
assert.equal(executorRequests[0].body.intent, "debug.chip-id");
|
|
assert.equal(executorRequests[0].body.ownerUserId, userCreate.body.user.id);
|
|
assert.equal(executorRequests[1].body.intent, "io.uart.read");
|
|
assert.deepEqual(executorRequests[1].body.args, { uartId: "uart/1", durationMs: 250, maxBytes: 20 });
|
|
|
|
const events = await getJson(port, "/v1/device-pods/device-pod-71-freq/events", aliceLogin.cookie);
|
|
assert.equal(events.status, 200);
|
|
assert.ok(events.body.events.some((event) => event.intent === "debug.chip-id"));
|
|
assert.ok(events.body.events.some((event) => event.intent === "io.uart.read"));
|
|
} finally {
|
|
await new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve())));
|
|
await new Promise((resolve, reject) => executor.close((error) => (error ? reject(error) : resolve())));
|
|
}
|
|
});
|
|
|
|
test("cloud api internal device-pod gateway dispatch is service-only and fail-closed without online gateway", async () => {
|
|
const server = createCloudApiServer({
|
|
env: { HWLAB_ACCESS_CONTROL_REQUIRED: "1", HWLAB_DEVICE_POD_INTERNAL_TOKEN: INTERNAL_TOKEN },
|
|
now: () => "2026-05-28T00:00:00.000Z"
|
|
});
|
|
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
|
|
|
|
try {
|
|
const { port } = server.address();
|
|
const external = await postJson(port, "/v1/internal/device-pod/gateway-dispatch", { params: {} });
|
|
assert.equal(external.status, 403);
|
|
assert.equal(external.body.error.code, "device_pod_internal_authority_required");
|
|
|
|
const headerOnly = await postJson(port, "/v1/internal/device-pod/gateway-dispatch", {
|
|
id: "req_devicepod_dispatch_test",
|
|
params: {
|
|
gatewaySessionId: "gws_missing",
|
|
resourceId: "res_devicepod_test",
|
|
capabilityId: "cap_device_host_cli",
|
|
operationId: "op_devicepod_dispatch_test",
|
|
traceId: "trc_devicepod_dispatch_test",
|
|
input: { command: "node tools/device-host-cli.mjs health" }
|
|
}
|
|
}, null, { "x-hwlab-internal-service": "hwlab-device-pod" });
|
|
assert.equal(headerOnly.status, 403);
|
|
assert.equal(headerOnly.body.error.code, "device_pod_internal_authority_required");
|
|
|
|
const blocked = await postJson(port, "/v1/internal/device-pod/gateway-dispatch", {
|
|
id: "req_devicepod_dispatch_test",
|
|
params: {
|
|
gatewaySessionId: "gws_missing",
|
|
resourceId: "res_devicepod_test",
|
|
capabilityId: "cap_device_host_cli",
|
|
operationId: "op_devicepod_dispatch_test",
|
|
traceId: "trc_devicepod_dispatch_test",
|
|
input: { command: "node tools/device-host-cli.mjs health" }
|
|
}
|
|
}, null, devicePodInternalHeaders());
|
|
assert.equal(blocked.status, 409);
|
|
assert.equal(blocked.body.blocker.code, "gateway_dispatch_unavailable");
|
|
} finally {
|
|
await new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve())));
|
|
}
|
|
});
|
|
|
|
test("cloud api internal device-pod gateway dispatch uses gateway poll result", async () => {
|
|
const server = createCloudApiServer({
|
|
env: { HWLAB_ACCESS_CONTROL_REQUIRED: "1", HWLAB_ENVIRONMENT: "v02", HWLAB_DEVICE_POD_INTERNAL_TOKEN: INTERNAL_TOKEN },
|
|
now: () => "2026-05-28T00:00:00.000Z"
|
|
});
|
|
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
|
|
|
|
try {
|
|
const { port } = server.address();
|
|
const poll = await postJson(port, "/v1/gateway/poll", {
|
|
serviceId: "hwlab-gateway",
|
|
gatewayId: "gtw_devicepod_test",
|
|
gatewaySessionId: "gws_devicepod_test",
|
|
resourceId: "res_devicepod_test",
|
|
capabilities: [{ capabilityId: "cap_device_host_cli", resourceId: "res_devicepod_test" }]
|
|
});
|
|
assert.equal(poll.status, 200);
|
|
|
|
const dispatchPromise = postJson(port, "/v1/internal/device-pod/gateway-dispatch", {
|
|
id: "req_devicepod_dispatch_test",
|
|
params: {
|
|
gatewaySessionId: "gws_devicepod_test",
|
|
resourceId: "res_devicepod_test",
|
|
capabilityId: "cap_device_host_cli",
|
|
operationId: "op_devicepod_dispatch_test",
|
|
traceId: "trc_devicepod_dispatch_test",
|
|
input: { command: "node tools/device-host-cli.mjs health", timeoutMs: 1000 }
|
|
}
|
|
}, null, devicePodInternalHeaders());
|
|
|
|
const queued = await postJson(port, "/v1/gateway/poll", { gatewayId: "gtw_devicepod_test", gatewaySessionId: "gws_devicepod_test" });
|
|
assert.equal(queued.status, 200);
|
|
assert.equal(queued.body.type, "request");
|
|
assert.equal(queued.body.request.meta.environment, "v02");
|
|
assert.equal(queued.body.request.method, "hardware.invoke.shell");
|
|
assert.equal(queued.body.request.params.input.command, "node tools/device-host-cli.mjs health");
|
|
|
|
const result = await postJson(port, "/v1/gateway/result", {
|
|
gatewayId: "gtw_devicepod_test",
|
|
gatewaySessionId: "gws_devicepod_test",
|
|
response: {
|
|
jsonrpc: "2.0",
|
|
id: queued.body.request.id,
|
|
result: {
|
|
accepted: true,
|
|
status: "succeeded",
|
|
shellExecuted: true,
|
|
dispatchStatus: "succeeded",
|
|
stdout: "host cli ok",
|
|
exitCode: 0,
|
|
gatewaySessionId: "gws_devicepod_test"
|
|
}
|
|
}
|
|
});
|
|
assert.equal(result.status, 200);
|
|
|
|
const dispatch = await dispatchPromise;
|
|
assert.equal(dispatch.status, 200);
|
|
assert.equal(dispatch.body.meta.environment, "v02");
|
|
assert.equal(dispatch.body.result.status, "completed");
|
|
assert.equal(dispatch.body.result.dispatch.stdout, "host cli ok");
|
|
} finally {
|
|
await new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve())));
|
|
}
|
|
});
|
|
|
|
test("cloud api exposes v1 access status routes and returns structured REST errors", async () => {
|
|
const server = createCloudApiServer({
|
|
env: { HWLAB_ACCESS_CONTROL_REQUIRED: "1" },
|
|
now: () => "2026-05-28T00:00:00.000Z"
|
|
});
|
|
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
|
|
|
|
try {
|
|
const { port } = server.address();
|
|
const session = await getJson(port, "/v1/auth/session");
|
|
assert.equal(session.status, 200);
|
|
assert.equal(session.body.authenticated, false);
|
|
assert.equal(session.body.setupRequired, true);
|
|
assert.equal(session.body.contractVersion, "user-access-v1");
|
|
|
|
const access = await getJson(port, "/v1/access/status");
|
|
assert.equal(access.status, 200);
|
|
assert.equal(access.body.accessControlRequired, true);
|
|
assert.equal(access.body.routes.currentUser, "/v1/users/me");
|
|
|
|
const setup = await getJson(port, "/v1/setup/status");
|
|
assert.equal(setup.status, 200);
|
|
assert.equal(setup.body.setupRequired, true);
|
|
assert.equal(setup.body.bootstrapAdminConfigured, false);
|
|
|
|
const me = await getJson(port, "/v1/users/me");
|
|
assert.equal(me.status, 401);
|
|
assert.equal(me.body.error.code, "auth_required");
|
|
|
|
const missing = await getJson(port, "/v1/not-implemented");
|
|
assert.equal(missing.status, 404);
|
|
assert.equal(missing.body.error.code, "not_found");
|
|
assert.equal(missing.body.error.audit.source.serviceId, "hwlab-cloud-api");
|
|
} finally {
|
|
await new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve())));
|
|
}
|
|
});
|
|
|
|
test("cloud api first-admin setup opens access when bootstrap secret is absent", async () => {
|
|
const server = createCloudApiServer({
|
|
env: { HWLAB_ACCESS_CONTROL_REQUIRED: "1" },
|
|
now: () => "2026-05-28T00:00:00.000Z"
|
|
});
|
|
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
|
|
|
|
try {
|
|
const { port } = server.address();
|
|
const before = await getJson(port, "/v1/setup/status");
|
|
assert.equal(before.status, 200);
|
|
assert.equal(before.body.setupRequired, true);
|
|
assert.equal(before.body.bootstrapAdminConfigured, false);
|
|
assert.equal(before.body.routes.firstAdminSetup, "/v1/setup/first-admin");
|
|
|
|
const setup = await postJson(port, "/v1/setup/first-admin", {
|
|
username: "admin",
|
|
password: "admin-pass",
|
|
displayName: "Initial Admin",
|
|
devicePod: {
|
|
devicePodId: "device-pod-71-freq",
|
|
name: "71-FREQ",
|
|
profile: {
|
|
schemaVersion: 1,
|
|
devicePodId: "device-pod-seed-spoof",
|
|
target: { id: "target-71-freq" },
|
|
route: {
|
|
gatewaySessionId: "gws_first_admin_seed",
|
|
resourceId: "res_windows_host",
|
|
capabilityId: "cap_device_host_cli",
|
|
hostWorkspaceRoot: "F:\\Work\\Project",
|
|
hostCli: "node tools/device-host-cli.mjs"
|
|
}
|
|
}
|
|
}
|
|
});
|
|
assert.equal(setup.status, 201);
|
|
assert.equal(setup.body.created, true);
|
|
assert.equal(setup.body.authenticated, true);
|
|
assert.equal(setup.body.actor.role, "admin");
|
|
assert.equal(setup.body.actor.username, "admin");
|
|
assert.equal(setup.body.setupRequired, false);
|
|
assert.deepEqual(setup.body.devicePodBootstrap, { requested: 1, initialized: 1 });
|
|
assert.equal(setup.body.devicePodsInitialized[0].devicePod.devicePodId, "device-pod-71-freq");
|
|
assert.equal(setup.body.devicePodsInitialized[0].grant.userId, setup.body.actor.id);
|
|
assert.match(setup.body.devicePodsInitialized[0].devicePod.profileHash, /^sha256:/u);
|
|
assert.equal(setup.body.devicePodsInitialized[0].devicePod.profile.route.gatewaySessionId, "redacted");
|
|
assert.equal(JSON.stringify(setup.body).includes("device-pod-seed-spoof"), false);
|
|
assert.equal(JSON.stringify(setup.body).includes("admin-pass"), false);
|
|
assert.equal(JSON.stringify(setup.body).includes("gws_first_admin_seed"), false);
|
|
assert.equal(JSON.stringify(setup.body).includes("F:\\Work\\Project"), false);
|
|
assert.equal(typeof setup.cookie, "string");
|
|
|
|
const session = await getJson(port, "/v1/users/me", setup.cookie);
|
|
assert.equal(session.status, 200);
|
|
assert.equal(session.body.actor.role, "admin");
|
|
assert.equal(JSON.stringify(session.body).includes("passwordHash"), false);
|
|
|
|
const second = await postJson(port, "/v1/setup/first-admin", {
|
|
username: "other-admin",
|
|
password: "other-pass"
|
|
});
|
|
assert.equal(second.status, 409);
|
|
assert.equal(second.body.error.code, "setup_already_completed");
|
|
|
|
const pods = await getJson(port, "/v1/device-pods", setup.cookie);
|
|
assert.equal(pods.status, 200);
|
|
assert.equal(pods.body.devicePods.length, 1);
|
|
assert.equal(pods.body.devicePods[0].devicePodId, "device-pod-71-freq");
|
|
assert.match(pods.body.devicePods[0].profileHash, /^sha256:/u);
|
|
|
|
const job = await postJson(port, "/v1/device-pods/device-pod-71-freq/jobs", {
|
|
intent: "workspace.ls",
|
|
args: { path: "." }
|
|
}, setup.cookie);
|
|
assert.equal(job.status, 409);
|
|
assert.equal(job.body.devicePodId, "device-pod-71-freq");
|
|
assert.equal(job.body.blocker.code, "device_pod_executor_unavailable");
|
|
|
|
const login = await postJson(port, "/auth/login", { username: "admin", password: "admin-pass" });
|
|
assert.equal(login.status, 200);
|
|
assert.equal(login.body.actor.role, "admin");
|
|
} finally {
|
|
await new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve())));
|
|
}
|
|
});
|
|
|
|
test("cloud api bootstrap password synchronizes existing admin", async () => {
|
|
const now = () => "2026-05-28T00:00:00.000Z";
|
|
const firstAccessController = createAccessController({
|
|
env: { HWLAB_ACCESS_CONTROL_REQUIRED: "1" },
|
|
now
|
|
});
|
|
const firstServer = createCloudApiServer({
|
|
accessController: firstAccessController,
|
|
env: { HWLAB_ACCESS_CONTROL_REQUIRED: "1" },
|
|
now
|
|
});
|
|
await new Promise((resolve) => firstServer.listen(0, "127.0.0.1", resolve));
|
|
|
|
try {
|
|
const { port } = firstServer.address();
|
|
const setup = await postJson(port, "/v1/setup/first-admin", {
|
|
username: "admin",
|
|
password: "old-pass"
|
|
});
|
|
assert.equal(setup.status, 201);
|
|
} finally {
|
|
await new Promise((resolve, reject) => firstServer.close((error) => (error ? reject(error) : resolve())));
|
|
}
|
|
|
|
const syncAccessController = createAccessController({
|
|
store: firstAccessController.store,
|
|
env: {
|
|
HWLAB_ACCESS_CONTROL_REQUIRED: "1",
|
|
HWLAB_BOOTSTRAP_ADMIN_ID: "usr_v02_admin",
|
|
HWLAB_BOOTSTRAP_ADMIN_USERNAME: "admin",
|
|
HWLAB_BOOTSTRAP_ADMIN_PASSWORD: "rotated-pass"
|
|
},
|
|
now
|
|
});
|
|
const server = createCloudApiServer({
|
|
accessController: syncAccessController,
|
|
env: { HWLAB_ACCESS_CONTROL_REQUIRED: "1" },
|
|
now
|
|
});
|
|
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
|
|
|
|
try {
|
|
const { port } = server.address();
|
|
const oldLogin = await postJson(port, "/auth/login", { username: "admin", password: "old-pass" });
|
|
assert.equal(oldLogin.status, 401);
|
|
|
|
const rotatedLogin = await postJson(port, "/auth/login", { username: "admin", password: "rotated-pass" });
|
|
assert.equal(rotatedLogin.status, 200);
|
|
assert.equal(rotatedLogin.body.actor.username, "admin");
|
|
assert.equal(rotatedLogin.body.actor.role, "admin");
|
|
assert.equal(JSON.stringify(rotatedLogin.body).includes("rotated-pass"), false);
|
|
} finally {
|
|
await new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve())));
|
|
}
|
|
});
|
|
|
|
test("cloud api first-admin setup validates device-pod seed before creating admin", async () => {
|
|
const server = createCloudApiServer({
|
|
env: { HWLAB_ACCESS_CONTROL_REQUIRED: "1" },
|
|
now: () => "2026-05-28T00:00:00.000Z"
|
|
});
|
|
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
|
|
|
|
try {
|
|
const { port } = server.address();
|
|
const invalid = await postJson(port, "/v1/setup/first-admin", {
|
|
username: "admin",
|
|
password: "admin-pass",
|
|
devicePod: { devicePodId: "device-pod-71-freq" }
|
|
});
|
|
assert.equal(invalid.status, 400);
|
|
assert.equal(invalid.body.error.code, "invalid_params");
|
|
|
|
const before = await getJson(port, "/v1/setup/status");
|
|
assert.equal(before.status, 200);
|
|
assert.equal(before.body.setupRequired, true);
|
|
|
|
const ambiguous = await postJson(port, "/v1/setup/first-admin", {
|
|
username: "admin",
|
|
password: "admin-pass",
|
|
devicePod: { devicePodId: "device-pod-71-freq", profile: { schemaVersion: 1 } },
|
|
devicePods: [{ devicePodId: "device-pod-alt", profile: { schemaVersion: 1 } }]
|
|
});
|
|
assert.equal(ambiguous.status, 400);
|
|
assert.equal(ambiguous.body.error.code, "invalid_params");
|
|
|
|
const secretSeed = await postJson(port, "/v1/setup/first-admin", {
|
|
username: "admin",
|
|
password: "admin-pass",
|
|
devicePod: {
|
|
devicePodId: "device-pod-71-freq",
|
|
profile: {
|
|
schemaVersion: 1,
|
|
target: { id: "target-71-freq" },
|
|
route: { gatewaySessionId: "gws_seed" },
|
|
databaseUrl: "postgres://user:pass@example.invalid/hwlab"
|
|
}
|
|
}
|
|
});
|
|
assert.equal(secretSeed.status, 400);
|
|
assert.equal(secretSeed.body.error.code, "device_pod_profile_secret_forbidden");
|
|
assert.equal(JSON.stringify(secretSeed.body).includes("postgres://"), false);
|
|
|
|
const afterSecretSeed = await getJson(port, "/v1/setup/status");
|
|
assert.equal(afterSecretSeed.status, 200);
|
|
assert.equal(afterSecretSeed.body.setupRequired, true);
|
|
|
|
const setup = await postJson(port, "/v1/setup/first-admin", {
|
|
username: "admin",
|
|
password: "admin-pass"
|
|
});
|
|
assert.equal(setup.status, 201);
|
|
assert.equal(setup.body.actor.role, "admin");
|
|
assert.deepEqual(setup.body.devicePodBootstrap, { requested: 0, initialized: 0 });
|
|
} finally {
|
|
await new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve())));
|
|
}
|
|
});
|
|
|
|
async function postJson(port, path, body, cookie = null, extraHeaders = {}) {
|
|
const response = await fetch(`http://127.0.0.1:${port}${path}`, {
|
|
method: "POST",
|
|
headers: {
|
|
"content-type": "application/json",
|
|
...(cookie ? { cookie } : {}),
|
|
...extraHeaders
|
|
},
|
|
body: JSON.stringify(body)
|
|
});
|
|
return {
|
|
status: response.status,
|
|
cookie: response.headers.get("set-cookie"),
|
|
body: await response.json()
|
|
};
|
|
}
|
|
|
|
function devicePodInternalHeaders(extra = {}) {
|
|
return { ...extra, "x-hwlab-internal-service": "hwlab-device-pod", "x-hwlab-internal-token": INTERNAL_TOKEN };
|
|
}
|
|
|
|
async function putJson(port, path, body, cookie = null, extraHeaders = {}) {
|
|
const response = await fetch(`http://127.0.0.1:${port}${path}`, {
|
|
method: "PUT",
|
|
headers: {
|
|
"content-type": "application/json",
|
|
...(cookie ? { cookie } : {}),
|
|
...extraHeaders
|
|
},
|
|
body: JSON.stringify(body)
|
|
});
|
|
return {
|
|
status: response.status,
|
|
body: await response.json(),
|
|
cookie: response.headers.get("set-cookie")
|
|
};
|
|
}
|
|
|
|
async function patchJson(port, path, body, cookie = null, extraHeaders = {}) {
|
|
const response = await fetch(`http://127.0.0.1:${port}${path}`, {
|
|
method: "PATCH",
|
|
headers: {
|
|
"content-type": "application/json",
|
|
...(cookie ? { cookie } : {}),
|
|
...extraHeaders
|
|
},
|
|
body: JSON.stringify(body)
|
|
});
|
|
return {
|
|
status: response.status,
|
|
body: await response.json(),
|
|
cookie: response.headers.get("set-cookie")
|
|
};
|
|
}
|
|
|
|
async function getJson(port, path, cookie = null, extraHeaders = {}) {
|
|
const response = await fetch(`http://127.0.0.1:${port}${path}`, {
|
|
headers: {
|
|
...(cookie ? { cookie } : {}),
|
|
...extraHeaders
|
|
}
|
|
});
|
|
return {
|
|
status: response.status,
|
|
body: await response.json()
|
|
};
|
|
}
|
|
|
|
async function deleteJson(port, path, body = {}, cookie = null, extraHeaders = {}) {
|
|
const response = await fetch(`http://127.0.0.1:${port}${path}`, {
|
|
method: "DELETE",
|
|
headers: {
|
|
"content-type": "application/json",
|
|
...(cookie ? { cookie } : {}),
|
|
...extraHeaders
|
|
},
|
|
body: JSON.stringify(body)
|
|
});
|
|
return {
|
|
status: response.status,
|
|
body: await response.json(),
|
|
cookie: response.headers.get("set-cookie")
|
|
};
|
|
}
|
|
|
|
async function requestJson(request) {
|
|
const chunks = [];
|
|
for await (const chunk of request) chunks.push(chunk);
|
|
const text = Buffer.concat(chunks).toString("utf8").trim();
|
|
return text ? JSON.parse(text) : {};
|
|
}
|
|
|
|
async function waitForCondition(predicate, timeoutMs = 500) {
|
|
const deadline = Date.now() + timeoutMs;
|
|
while (Date.now() < deadline) {
|
|
if (predicate()) return;
|
|
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
}
|
|
}
|