From 25a469762bdde8ec3b60d39516461282e6b5078f Mon Sep 17 00:00:00 2001 From: Codex Date: Sun, 31 May 2026 17:01:19 +0800 Subject: [PATCH] fix: grant code agent device pod access --- internal/cloud/access-control.test.ts | 58 ++++++++++++++++++- internal/cloud/access-control.ts | 40 +++++++++++++ .../cloud/code-agent-session-registry.test.ts | 23 ++++++++ internal/cloud/codex-stdio-session-helpers.ts | 3 + internal/cloud/server-code-agent-http.ts | 33 +++++++++-- 5 files changed, 151 insertions(+), 6 deletions(-) diff --git a/internal/cloud/access-control.test.ts b/internal/cloud/access-control.test.ts index fcf8b519..9514e609 100644 --- a/internal/cloud/access-control.test.ts +++ b/internal/cloud/access-control.test.ts @@ -376,6 +376,57 @@ test("cloud api protects device-pod routes when access control is required", asy } }); +test("cloud api issues a redacted Code Agent device-pod session 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" + }, + 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" + }, + 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.createCodeAgentDevicePodSession(); + assert.equal(agentAuth.actor.id, "usr_v02_admin"); + assert.equal(agentAuth.actor.role, "admin"); + assert.equal(agentAuth.tokenSource, "code-agent-device-pod-session"); + assert.equal(agentAuth.valuesRedacted, true); + assert.equal(typeof agentAuth.token, "string"); + assert.equal(JSON.stringify(agentAuth.session).includes(agentAuth.token), false); + + const status = await getJson(port, "/v1/device-pods/device-pod-71-freq/status", null, { + "x-hwlab-session-token": agentAuth.token + }); + 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.token), false); + } finally { + await new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve()))); + } +}); + test("cloud api creates device lease holder session before lease insert", async () => { const accessController = createAccessController({ env: { @@ -1075,9 +1126,12 @@ async function putJson(port, path, body, cookie = null, extraHeaders = {}) { }; } -async function getJson(port, path, cookie = null) { +async function getJson(port, path, cookie = null, extraHeaders = {}) { const response = await fetch(`http://127.0.0.1:${port}${path}`, { - headers: cookie ? { cookie } : {} + headers: { + ...(cookie ? { cookie } : {}), + ...extraHeaders + } }); return { status: response.status, diff --git a/internal/cloud/access-control.ts b/internal/cloud/access-control.ts index 24549dca..b72f29f1 100644 --- a/internal/cloud/access-control.ts +++ b/internal/cloud/access-control.ts @@ -5,6 +5,7 @@ import { getHeader, readBody, sendJson, truthyFlag } from "./server-http-utils.t const SESSION_COOKIE = "hwlab_session"; const SESSION_MAX_AGE_SECONDS = 60 * 60 * 24 * 7; +const CODE_AGENT_DEVICE_POD_SESSION_REFRESH_SKEW_SECONDS = 60 * 5; const DEVICE_JOB_CONTRACT_VERSION = "device-pod-job-v1"; const DEVICE_JOB_INTENTS = new Set([ "workspace.ls", @@ -156,6 +157,7 @@ class AccessController { this.now = now; this.required = required; this.bootstrapAttempted = false; + this.codeAgentDevicePodSession = null; } async handleAuthRoute(request, response, url) { @@ -476,6 +478,38 @@ class AccessController { return sendJson(response, 200, { authenticated: false, loggedOut: true }); } + async createCodeAgentDevicePodSession() { + await this.ensureBootstrap(); + const now = this.now(); + if (this.codeAgentDevicePodSession?.token && !sessionExpiresSoon(this.codeAgentDevicePodSession.expiresAt, now)) { + return this.codeAgentDevicePodSession; + } + const actor = await this.bootstrapAdminActor(); + if (!actor || actor.status !== "active") return null; + const token = randomBytes(32).toString("base64url"); + const expiresAt = new Date(Date.parse(now) + SESSION_MAX_AGE_SECONDS * 1000).toISOString(); + const session = await this.store.createSession({ + userId: actor.id, + tokenHash: sha256(token), + now, + expiresAt + }); + this.codeAgentDevicePodSession = { + token, + expiresAt, + actor: publicActor(actor), + session: publicSession({ ...session, user: actor }), + tokenSource: "code-agent-device-pod-session", + valuesRedacted: true + }; + return this.codeAgentDevicePodSession; + } + + async bootstrapAdminActor() { + return await this.store.findUserByUsername(this.env.HWLAB_BOOTSTRAP_ADMIN_USERNAME || "admin") + ?? await this.store.getUserById(this.env.HWLAB_BOOTSTRAP_ADMIN_ID || "usr_bootstrap_admin"); + } + async ensureBootstrap() { if (this.bootstrapAttempted) return; this.bootstrapAttempted = true; @@ -1363,6 +1397,12 @@ function boundedDurationMs(value, fallback = 1000) { const parsed = Number.parse function uartTailArgs(searchParams, uartId) { return { uartId, durationMs: boundedDurationMs(searchParams.get("durationMs") ?? searchParams.get("duration-ms")), maxBytes: boundedOutputMaxBytes(searchParams) }; } function safeAgentSessionId(value) { const text = textOr(value, ""); return /^ses_[A-Za-z0-9_.:-]+$/u.test(text) ? text : ""; } function leaseTokenFromRequest(request, body) { return textOr(body?.leaseToken, "") || textOr(getHeader(request, "x-hwlab-device-lease-token"), ""); } +function sessionExpiresSoon(expiresAt, now) { + const expiresMs = Date.parse(String(expiresAt ?? "")); + const nowMs = Date.parse(String(now ?? "")); + if (!Number.isFinite(expiresMs) || !Number.isFinite(nowMs)) return true; + return expiresMs - nowMs <= CODE_AGENT_DEVICE_POD_SESSION_REFRESH_SKEW_SECONDS * 1000; +} function devicePodInternalHeaders(serviceId, token) { return token ? { "x-hwlab-internal-service": serviceId, "x-hwlab-internal-token": token } : { "x-hwlab-internal-service": serviceId }; } function hashPassword(password) { const salt = randomBytes(16).toString("hex"); return `sha256:${salt}:${sha256(`${salt}:${password}`)}`; } function verifyPassword(stored, password) { const [, salt, digest] = String(stored ?? "").split(":"); return Boolean(salt && digest && sha256(`${salt}:${password}`) === digest); } diff --git a/internal/cloud/code-agent-session-registry.test.ts b/internal/cloud/code-agent-session-registry.test.ts index 8621ee26..43936393 100644 --- a/internal/cloud/code-agent-session-registry.test.ts +++ b/internal/cloud/code-agent-session-registry.test.ts @@ -12,6 +12,7 @@ import { codexAppServerProviderBaseUrl, createCodexStdioSessionManager } from "./codex-stdio-session.ts"; +import { childProcessEnv } from "./codex-stdio-session-helpers.ts"; import { classifyCodeAgentChatReadiness, classifyCodexRunnerCapability @@ -212,6 +213,28 @@ test("code agent session registry stores bounded non-sensitive conversation fact assert.match(facts.summary, /workspace=\/workspace\/hwlab/u); }); +test("Codex child env carries only device-pod auth needed by Code Agent tools", () => { + const child = childProcessEnv({ + PATH: "/usr/bin", + CODEX_HOME: "/tmp/codex-home", + OPENAI_API_KEY: "test-openai-key-material", + HWLAB_DEVICE_POD_API_URL: "http://127.0.0.1:6667", + HWLAB_CLOUD_API_URL: "http://127.0.0.1:6667", + HWLAB_DEVICE_POD_SESSION_TOKEN: "device-pod-session-token", + HWLAB_SESSION_COOKIE: "hwlab_session=browser-cookie", + HWLAB_SESSION_TOKEN: "browser-session-token", + HWLAB_BEARER_TOKEN: "browser-bearer-token" + }); + + assert.equal(child.HWLAB_DEVICE_POD_API_URL, "http://127.0.0.1:6667"); + assert.equal(child.HWLAB_CLOUD_API_URL, "http://127.0.0.1:6667"); + assert.equal(child.HWLAB_DEVICE_POD_SESSION_TOKEN, "device-pod-session-token"); + assert.equal(child.OPENAI_API_KEY, undefined); + assert.equal(child.HWLAB_SESSION_COOKIE, undefined); + assert.equal(child.HWLAB_SESSION_TOKEN, undefined); + assert.equal(child.HWLAB_BEARER_TOKEN, undefined); +}); + test("code agent session registry inspect locates conversation, session, and trace summary", () => { const registry = createCodeAgentSessionRegistry({ idFactory: () => "ses_inspect_registry" diff --git a/internal/cloud/codex-stdio-session-helpers.ts b/internal/cloud/codex-stdio-session-helpers.ts index e82af7c2..f993c980 100644 --- a/internal/cloud/codex-stdio-session-helpers.ts +++ b/internal/cloud/codex-stdio-session-helpers.ts @@ -1642,6 +1642,9 @@ export function childProcessEnv(env = process.env) { CODEX_HOME: resolveCodexHome(env), CODEX_INTERNAL_ORIGINATOR_OVERRIDE: "hwlab_code_agent", UNIDESK_SKILLS_PATH: "/app/skills", + ...(env.HWLAB_DEVICE_POD_API_URL ? { HWLAB_DEVICE_POD_API_URL: env.HWLAB_DEVICE_POD_API_URL } : {}), + ...(env.HWLAB_CLOUD_API_URL ? { HWLAB_CLOUD_API_URL: env.HWLAB_CLOUD_API_URL } : {}), + ...(env.HWLAB_DEVICE_POD_SESSION_TOKEN ? { HWLAB_DEVICE_POD_SESSION_TOKEN: env.HWLAB_DEVICE_POD_SESSION_TOKEN } : {}), NO_PROXY: noProxy, no_proxy: noProxy, ...(env.LANG ? { LANG: env.LANG } : {}), diff --git a/internal/cloud/server-code-agent-http.ts b/internal/cloud/server-code-agent-http.ts index 72837d2d..ef3feb91 100644 --- a/internal/cloud/server-code-agent-http.ts +++ b/internal/cloud/server-code-agent-http.ts @@ -109,16 +109,18 @@ export async function handleCodeAgentChatHttp(request, response, options) { sendJson(response, responsePayload.status === "failed" && responsePayload.error?.code === "invalid_params" ? 400 : 200, responsePayload); } -function runCodeAgentChat(params, options) { - return handleCodeAgentChat(params, codeAgentChatExecutionOptions(options, params)); +async function runCodeAgentChat(params, options) { + return handleCodeAgentChat(params, await codeAgentChatExecutionOptions(options, params)); } -function codeAgentChatExecutionOptions(options = {}, params = {}) { +async function codeAgentChatExecutionOptions(options = {}, params = {}) { + const env = codeAgentProviderProfileEnv(options.env ?? process.env, params); + Object.assign(env, await codeAgentDevicePodAuthEnv(options, env)); return { callProvider: options.callCodeAgentProvider, timeoutMs: options.codeAgentTimeoutMs, hardTimeoutMs: options.codeAgentHardTimeoutMs, - env: codeAgentProviderProfileEnv(options.env ?? process.env, params), + env, workspace: options.workspace, skillsDirs: options.skillsDirs, skillsDirsExact: options.skillsDirsExact, @@ -131,6 +133,29 @@ function codeAgentChatExecutionOptions(options = {}, params = {}) { }; } +async function codeAgentDevicePodAuthEnv(options = {}, env = process.env) { + if (typeof options.accessController?.createCodeAgentDevicePodSession !== "function") return {}; + const auth = await options.accessController.createCodeAgentDevicePodSession(); + if (!auth?.token) return {}; + const apiUrl = firstNonEmptyValue( + env.HWLAB_CODE_AGENT_DEVICE_POD_API_URL, + env.HWLAB_DEVICE_POD_API_URL, + env.HWLAB_CLOUD_API_URL, + env.HWLAB_PUBLIC_ENDPOINT, + localCloudApiUrl(env) + ); + return { + HWLAB_DEVICE_POD_API_URL: apiUrl, + HWLAB_CLOUD_API_URL: apiUrl, + HWLAB_DEVICE_POD_SESSION_TOKEN: auth.token + }; +} + +function localCloudApiUrl(env = process.env) { + const port = parsePositiveInteger(env.HWLAB_CLOUD_API_PORT, 6667); + return `http://127.0.0.1:${port}`; +} + function codeAgentProviderProfileEnv(env = process.env, params = {}) { const profile = normalizeCodeAgentProviderProfile(params.providerProfile ?? params.codeAgentProviderProfile ?? env.HWLAB_CODE_AGENT_DEFAULT_PROVIDER_PROFILE); if (profile === "runtime-default") return env;