diff --git a/cmd/hwlab-device-pod/main.test.ts b/cmd/hwlab-device-pod/main.test.ts index 262e06c2..ab278a65 100644 --- a/cmd/hwlab-device-pod/main.test.ts +++ b/cmd/hwlab-device-pod/main.test.ts @@ -216,9 +216,15 @@ test("device pod executor dispatches internal jobs through cloud-api gateway ada dispatch: { shellExecuted: true, dispatchStatus: "succeeded", - stdout: "device-host-cli ok", exitCode: 0 }, + shellExecuted: true, + dispatchStatus: "succeeded", + stdout: "device-host-cli ok", + stderr: "", + exitCode: 0, + stdoutTruncated: false, + stderrTruncated: false, auditId: "aud_devicepod_test", evidenceId: "evd_devicepod_test", gateway: { gatewayId: "gtw_devicepod_test" } @@ -268,6 +274,9 @@ test("device pod executor dispatches internal jobs through cloud-api gateway ada const output = await waitForJobStatus(service.port, "device-pod-test", "job_devicepod_gateway_test", "completed"); assert.equal(output.status, "completed"); assert.equal(output.output.text, "device-host-cli ok"); + assert.equal(output.output.dispatch.stdoutBytes, 18); + assert.equal(output.output.dispatch.exitCode, 0); + assert.equal(output.output.dispatch.stdoutTruncated, false); assert.equal(output.output.auditId, "aud_devicepod_test"); assert.equal(dispatches.length, 1); assert.equal(dispatches[0].internalService, "hwlab-device-pod"); diff --git a/cmd/hwlab-device-pod/main.ts b/cmd/hwlab-device-pod/main.ts index 5571c27a..6c4e05ea 100644 --- a/cmd/hwlab-device-pod/main.ts +++ b/cmd/hwlab-device-pod/main.ts @@ -406,7 +406,7 @@ function jobFromGatewayDispatch(job, payload, httpStatus) { output: boundedOutput({ text: dispatchText, httpStatus, - dispatch, + dispatch: gatewayDispatchSummary(result, dispatch), gateway: result.gateway ?? null, auditId: result.auditId ?? null, evidenceId: result.evidenceId ?? null, @@ -417,6 +417,8 @@ function jobFromGatewayDispatch(job, payload, httpStatus) { } function gatewayDispatchText(result, dispatch) { + if (typeof result.stdout === "string" && result.stdout) return result.stdout; + if (typeof result.stderr === "string" && result.stderr) return result.stderr; if (typeof dispatch.stdout === "string" && dispatch.stdout) return dispatch.stdout; if (typeof dispatch.stderr === "string" && dispatch.stderr) return dispatch.stderr; if (typeof result.text === "string") return result.text; @@ -424,6 +426,25 @@ function gatewayDispatchText(result, dispatch) { return ""; } +function gatewayDispatchSummary(result, dispatch) { + const value = (key) => dispatch[key] ?? result[key]; + return pruneUndefined({ + shellExecuted: value("shellExecuted"), + dispatchStatus: value("dispatchStatus"), + status: value("status"), + exitCode: value("exitCode"), + signal: value("signal"), + timedOut: value("timedOut"), + cwd: value("cwd"), + command: value("command"), + stdoutBytes: textBytes(value("stdout")), + stderrBytes: textBytes(value("stderr")), + stdoutTruncated: value("stdoutTruncated"), + stderrTruncated: value("stderrTruncated"), + durationMs: value("durationMs") + }); +} + function deviceHostCommand(profile, job) { const args = deviceHostArgs(job.intent, job.args); if (!args) return null; @@ -725,6 +746,14 @@ function boundedOutput(output, maxBytes = DEVICE_JOB_OUTPUT_MAX_BYTES) { return { ...bounded, bytes: Math.min(buffer.length, maxBytes), truncation: { maxBytes, truncated: clipped, originalBytes: buffer.length } }; } +function pruneUndefined(value) { + return Object.fromEntries(Object.entries(value).filter(([, item]) => item !== undefined)); +} + +function textBytes(value) { + return typeof value === "string" ? Buffer.byteLength(value, "utf8") : undefined; +} + function freshness(observedAt, blocker) { return { observedAt, ageMs: 0, stale: Boolean(blocker), source: blocker ? "blocked" : "device-pod-executor" }; } diff --git a/tools/device-pod-cli.test.ts b/tools/device-pod-cli.test.ts index 5e9c9b6a..1eb3919e 100644 --- a/tools/device-pod-cli.test.ts +++ b/tools/device-pod-cli.test.ts @@ -121,6 +121,70 @@ test("device-pod-cli auto-locates from assembled runtime and rejects manual URL assert.equal(rejected.payload.error.code, "runtime_endpoint_manual_url_forbidden"); }); +test("device-pod-cli reuses matching hwlab-cli web session cookie when no explicit auth is assembled", async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "hwlab-device-pod-cli-session-")); + await mkdir(path.join(root, ".state/hwlab-cli"), { recursive: true }); + await writeFile(path.join(root, ".state/hwlab-cli/session.json"), JSON.stringify({ + baseUrl: "http://74.48.78.17:19666", + cookie: "hwlab_session=session-from-web", + user: { username: "admin" } + }), "utf8"); + const originalCwd = process.cwd(); + process.chdir(root); + try { + const result = await runDevicePodCli(["profile", "list"], { + env: { + HWLAB_RUNTIME_NAMESPACE: "hwlab-v02", + HWLAB_RUNTIME_LANE: "v02", + HWLAB_RUNTIME_ENDPOINT_LOCKED: "1", + HWLAB_CODE_AGENT_ASSEMBLED_RUNTIME: "1" + }, + fetchImpl: async (url, init) => { + assert.equal(String(url), "http://74.48.78.17:19667/v1/device-pods"); + assert.equal(init?.headers?.cookie, "hwlab_session=session-from-web"); + assert.equal(init?.headers?.["x-hwlab-session-token"], undefined); + return jsonResponse(200, { ok: true, devicePods: [] }); + }, + now: () => "2026-06-02T00:00:00.000Z" + }); + assert.equal(result.exitCode, 0); + assert.equal(result.payload.runtimeEndpoint.source, "runtime-namespace"); + assert.equal(result.payload.runtimeEndpoint.explicitOverride, false); + } finally { + process.chdir(originalCwd); + } +}); + +test("device-pod-cli ignores stale hwlab-cli web session from another runtime scope", async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "hwlab-device-pod-cli-stale-session-")); + await mkdir(path.join(root, ".state/hwlab-cli"), { recursive: true }); + await writeFile(path.join(root, ".state/hwlab-cli/session.json"), JSON.stringify({ + baseUrl: "http://74.48.78.17:17666", + cookie: "hwlab_session=dev-session" + }), "utf8"); + const originalCwd = process.cwd(); + process.chdir(root); + try { + const result = await runDevicePodCli(["profile", "list"], { + env: { + HWLAB_RUNTIME_NAMESPACE: "hwlab-v02", + HWLAB_RUNTIME_LANE: "v02", + HWLAB_RUNTIME_ENDPOINT_LOCKED: "1", + HWLAB_CODE_AGENT_ASSEMBLED_RUNTIME: "1" + }, + fetchImpl: async (_url, init) => { + assert.equal(init?.headers?.cookie, undefined); + return jsonResponse(401, { ok: false, error: { code: "auth_required" } }); + }, + now: () => "2026-06-02T00:00:00.000Z" + }); + assert.equal(result.exitCode, 1); + assert.equal(result.payload.httpStatus, 401); + } finally { + process.chdir(originalCwd); + } +}); + test("device-pod-cli keeps manual API URL only as unlocked local debug override", async () => { const seen: string[] = []; const result = await runDevicePodCli(["--api-base-url", "http://debug.test", "--session-token", "debug-session", "profile", "list"], { diff --git a/tools/src/device-pod-cli-lib.ts b/tools/src/device-pod-cli-lib.ts index 18ca6c65..8bdc7feb 100644 --- a/tools/src/device-pod-cli-lib.ts +++ b/tools/src/device-pod-cli-lib.ts @@ -1,4 +1,7 @@ -import { resolveRuntimeEndpoint, runtimeEndpointVisibility } from "./runtime-endpoint-resolver.ts"; +import { readFile } from "node:fs/promises"; +import path from "node:path"; + +import { resolveRuntimeEndpoint, runtimeEndpointVisibility, sameRuntimeEndpointScope } from "./runtime-endpoint-resolver.ts"; const VERSION = "0.2.0-rest"; const CLI_NAME = "device-pod-cli"; @@ -371,9 +374,10 @@ async function requestJson({ parsed, env, fetchImpl, method, path, body, auth = const timeout = setTimeout(() => controller.abort(), numberOption(parsed.timeoutMs) ?? DEFAULT_TIMEOUT_MS); try { const url = `${baseUrl}${path}`; + const authHeaderValues = auth ? await authHeaders(parsed, env, endpoint) : {}; const response = await fetchImpl(`${baseUrl}${path}`, { method, - headers: clean({ accept: "application/json", ...(body ? { "content-type": "application/json" } : {}), ...(auth ? authHeaders(parsed, env) : {}), ...extraHeaders }), + headers: clean({ accept: "application/json", ...(body ? { "content-type": "application/json" } : {}), ...authHeaderValues, ...extraHeaders }), body: body ? JSON.stringify(body) : undefined, signal: controller.signal }); @@ -466,7 +470,37 @@ const uartJsonRpcOptionKeys = [ "baudRate" ]; function defaultOperation(surface: string) { return surface === "workspace" ? "ls" : surface === "debug-probe" ? "status" : "read"; } -function authHeaders(parsed: ParsedArgs, env: EnvLike) { const cookie = text(parsed.cookie ?? env.HWLAB_SESSION_COOKIE); const sessionToken = text(parsed.sessionToken ?? env.HWLAB_DEVICE_POD_SESSION_TOKEN ?? env.HWLAB_CLOUD_API_SESSION_TOKEN ?? env.HWLAB_SESSION_TOKEN); const bearer = text(parsed.bearerToken ?? env.HWLAB_BEARER_TOKEN); return clean({ ...(cookie ? { cookie: cookie.includes("=") ? cookie : `hwlab_session=${encodeURIComponent(cookie)}` } : {}), ...(sessionToken ? { "x-hwlab-session-token": sessionToken } : {}), ...(bearer ? { authorization: `Bearer ${bearer}` } : {}) }); } +async function authHeaders(parsed: ParsedArgs, env: EnvLike, endpoint: any) { + const explicitCookie = text(parsed.cookie ?? env.HWLAB_SESSION_COOKIE); + const sessionToken = text(parsed.sessionToken ?? env.HWLAB_DEVICE_POD_SESSION_TOKEN ?? env.HWLAB_CLOUD_API_SESSION_TOKEN ?? env.HWLAB_SESSION_TOKEN); + const bearer = text(parsed.bearerToken ?? env.HWLAB_BEARER_TOKEN); + const stateCookie = !explicitCookie && !sessionToken && !bearer ? await savedHwlabCliCookie(parsed, env, endpoint) : ""; + const cookie = explicitCookie || stateCookie; + return clean({ + ...(cookie ? { cookie: cookie.includes("=") ? cookie : `hwlab_session=${encodeURIComponent(cookie)}` } : {}), + ...(sessionToken ? { "x-hwlab-session-token": sessionToken } : {}), + ...(bearer ? { authorization: `Bearer ${bearer}` } : {}) + }); +} + +async function savedHwlabCliCookie(parsed: ParsedArgs, env: EnvLike, endpoint: any) { + const file = hwlabCliSessionFile(parsed, env); + let stored: any; + try { + stored = JSON.parse(await readFile(file, "utf8")); + } catch { + return ""; + } + const cookie = text(stored?.cookie); + const storedBaseUrl = text(stored?.baseUrl); + if (!cookie || !storedBaseUrl) return ""; + return storedBaseUrl === endpoint?.baseUrl || sameRuntimeEndpointScope(storedBaseUrl, endpoint?.baseUrl) ? cookie : ""; +} + +function hwlabCliSessionFile(parsed: ParsedArgs, env: EnvLike) { + const explicit = text(parsed.stateFile ?? env.HWLAB_CLI_STATE_FILE ?? env.HWLAB_SESSION_STATE_FILE); + return path.resolve(process.cwd(), explicit || ".state/hwlab-cli/session.json"); +} function responsePayload(action: string, response: any, extra: Record = {}, parsed: ParsedArgs = { _: [] }) { const success = response.status >= 200 && response.status < 300 && response.body?.ok !== false; const full = parsed.full === true;