diff --git a/internal/cloud/access-control.test.ts b/internal/cloud/access-control.test.ts new file mode 100644 index 00000000..5f9415ad --- /dev/null +++ b/internal/cloud/access-control.test.ts @@ -0,0 +1,154 @@ +import assert from "node:assert/strict"; +import { test } from "bun:test"; + +import { createCloudApiServer } from "./server.ts"; + +test("cloud api access control grants visible device pods and blocks unavailable gateway jobs", async () => { + const server = createCloudApiServer({ + env: { + HWLAB_ACCESS_CONTROL_REQUIRED: "1", + HWLAB_BOOTSTRAP_ADMIN_USERNAME: "admin", + HWLAB_BOOTSTRAP_ADMIN_PASSWORD: "admin-pass" + }, + 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 podCreate = await postJson(port, "/v1/admin/device-pods", { + devicePodId: "device-pod-71-freq", + name: "71-FREQ", + profile: { + schemaVersion: 1, + 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.profile.route.gatewaySessionId, "redacted"); + assert.equal(JSON.stringify(podCreate.body).includes("gws_missing"), 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 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 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, "gateway_dispatch_unavailable"); + assert.equal(job.body.devicePodId, "device-pod-71-freq"); + assert.equal(job.body.profileHash, visible.body.devicePods[0].profileHash); + + 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, "gateway_dispatch_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/${userCreate.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, userCreate.body.user.id); + + const afterRevoke = await getJson(port, "/v1/device-pods", aliceCookie); + 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 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()))); + } +}); + +async function postJson(port, path, body, cookie = null) { + const response = await fetch(`http://127.0.0.1:${port}${path}`, { + method: "POST", + headers: { + "content-type": "application/json", + ...(cookie ? { cookie } : {}) + }, + body: JSON.stringify(body) + }); + return { + status: response.status, + cookie: response.headers.get("set-cookie"), + body: await response.json() + }; +} + +async function getJson(port, path, cookie = null) { + const response = await fetch(`http://127.0.0.1:${port}${path}`, { + headers: cookie ? { cookie } : {} + }); + return { + status: response.status, + body: await response.json() + }; +} diff --git a/internal/cloud/access-control.ts b/internal/cloud/access-control.ts new file mode 100644 index 00000000..68f41986 --- /dev/null +++ b/internal/cloud/access-control.ts @@ -0,0 +1,686 @@ +import { createHash, randomBytes, randomUUID } from "node:crypto"; + +import { CLOUD_API_SERVICE_ID } from "../audit/index.mjs"; +import { createGatewayShellRequest } from "./gateway-demo-registry.ts"; +import { getHeader, readBody, sendJson, truthyFlag } from "./server-http-utils.ts"; + +const SESSION_COOKIE = "hwlab_session"; +const SESSION_MAX_AGE_SECONDS = 60 * 60 * 24 * 7; +const DEVICE_JOB_CONTRACT_VERSION = "device-pod-job-v1"; +const DEVICE_JOB_INTENTS = new Set([ + "workspace.ls", + "workspace.cat", + "workspace.rg", + "workspace.apply-patch", + "workspace.build", + "debug.status", + "debug.chip-id", + "debug.download", + "debug.reset", + "io.ports", + "io.uart.read", + "io.uart.write" +]); +const ACCESS_SCHEMA_STATEMENTS = Object.freeze([ + `CREATE TABLE IF NOT EXISTS users ( + id TEXT PRIMARY KEY, + username TEXT NOT NULL UNIQUE, + display_name TEXT NOT NULL DEFAULT '', + role TEXT NOT NULL CHECK (role IN ('admin', 'user')), + status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'disabled')), + password_hash TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + )`, + `CREATE TABLE IF NOT EXISTS user_sessions ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES users(id), + session_token_hash TEXT NOT NULL UNIQUE, + created_at TEXT NOT NULL, + last_seen_at TEXT NOT NULL, + expires_at TEXT NOT NULL, + revoked_at TEXT + )`, + `ALTER TABLE agent_sessions ADD COLUMN IF NOT EXISTS owner_user_id TEXT REFERENCES users(id)`, + `ALTER TABLE agent_sessions ADD COLUMN IF NOT EXISTS conversation_id TEXT`, + `ALTER TABLE agent_sessions ADD COLUMN IF NOT EXISTS thread_id TEXT`, + `ALTER TABLE agent_sessions ADD COLUMN IF NOT EXISTS last_trace_id TEXT`, + `ALTER TABLE agent_sessions ADD COLUMN IF NOT EXISTS session_json TEXT NOT NULL DEFAULT '{}'`, + `ALTER TABLE agent_sessions ADD COLUMN IF NOT EXISTS updated_at TEXT`, + `CREATE INDEX IF NOT EXISTS idx_agent_sessions_owner ON agent_sessions(owner_user_id)`, + `CREATE INDEX IF NOT EXISTS idx_agent_sessions_conversation ON agent_sessions(conversation_id)`, + `CREATE TABLE IF NOT EXISTS device_pods ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL DEFAULT '', + status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'disabled')), + profile_json TEXT NOT NULL DEFAULT '{}', + profile_hash TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + )`, + `CREATE TABLE IF NOT EXISTS device_pod_grants ( + device_pod_id TEXT NOT NULL REFERENCES device_pods(id) ON DELETE CASCADE, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + created_by_admin_id TEXT NOT NULL REFERENCES users(id), + created_at TEXT NOT NULL, + PRIMARY KEY (device_pod_id, user_id) + )`, + `CREATE TABLE IF NOT EXISTS device_leases ( + device_pod_id TEXT PRIMARY KEY REFERENCES device_pods(id) ON DELETE CASCADE, + holder_session_id TEXT NOT NULL REFERENCES agent_sessions(id) ON DELETE CASCADE, + holder_user_id TEXT NOT NULL REFERENCES users(id), + lease_token_hash TEXT NOT NULL UNIQUE, + created_at TEXT NOT NULL, + expires_at TEXT NOT NULL, + released_at TEXT + )`, + `CREATE TABLE IF NOT EXISTS device_pod_jobs ( + id TEXT PRIMARY KEY, + device_pod_id TEXT NOT NULL REFERENCES device_pods(id) ON DELETE CASCADE, + owner_user_id TEXT NOT NULL REFERENCES users(id), + status TEXT NOT NULL, + intent TEXT NOT NULL, + args_json TEXT NOT NULL DEFAULT '{}', + reason TEXT NOT NULL DEFAULT '', + trace_id TEXT NOT NULL, + operation_id TEXT NOT NULL, + output_json TEXT NOT NULL DEFAULT '{}', + blocker_json TEXT NOT NULL DEFAULT '{}', + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + completed_at TEXT + )` +]); + +const MUTATING_INTENTS = new Set([ + "workspace.apply-patch", + "workspace.build", + "debug.download", + "debug.reset", + "io.uart.write" +]); + +export function createAccessController(options = {}) { + return new AccessController({ + ...options, + store: options.store ?? accessStoreForRuntime(options.runtimeStore, options) + }); +} + +function accessStoreForRuntime(runtimeStore, options = {}) { + if (runtimeStore && typeof runtimeStore.query === "function") { + return new PostgresAccessStore({ query: runtimeStore.query.bind(runtimeStore), now: options.now }); + } + return new MemoryAccessStore({ now: options.now }); +} + +class AccessController { + constructor({ store, env = process.env, gatewayRegistry = null, now = () => new Date().toISOString(), required = truthyFlag(env.HWLAB_ACCESS_CONTROL_REQUIRED) } = {}) { + this.store = store; + this.env = env; + this.gatewayRegistry = gatewayRegistry; + this.now = now; + this.required = required; + this.bootstrapAttempted = false; + } + + async handleAuthRoute(request, response, url) { + try { + await this.ensureBootstrap(); + if (request.method === "GET" && url.pathname === "/auth/session") { + const auth = await this.authenticate(request, { required: false }); + sendJson(response, 200, { + authenticated: Boolean(auth.actor), + actor: auth.actor ? publicActor(auth.actor) : null, + setupRequired: await this.setupRequired(), + contractVersion: "user-access-v1" + }); + return; + } + + if (request.method === "POST" && url.pathname === "/auth/login") { + await this.handleLogin(request, response); + return; + } + + if (request.method === "POST" && url.pathname === "/auth/logout") { + await this.handleLogout(request, response); + return; + } + + sendJson(response, 404, errorPayload("not_found", "Auth route is not implemented", 404)); + } catch (error) { + sendAccessError(response, error); + } + } + + async handleAdminRoute(request, response, url) { + try { + await this.ensureBootstrap(); + const auth = await this.authenticate(request, { required: true }); + if (!auth.ok) return sendJson(response, auth.status, auth); + if (auth.actor.role !== "admin") { + return sendJson(response, 403, errorPayload("admin_required", "Only admin users can call v0.2 admin APIs", 403)); + } + + if (request.method === "POST" && url.pathname === "/v1/admin/users") { + const body = await jsonBody(request); + const user = await this.store.createUser({ + username: requiredText(body.username, "username"), + displayName: textOr(body.displayName, body.username), + role: body.role === "admin" ? "admin" : "user", + status: body.status === "disabled" ? "disabled" : "active", + passwordHash: hashPassword(requiredText(body.password, "password")), + now: this.now() + }); + return sendJson(response, 201, { created: true, user: redactedUser(user) }); + } + + if ((request.method === "POST" && url.pathname === "/v1/admin/device-pods") || (request.method === "PUT" && url.pathname.startsWith("/v1/admin/device-pods/"))) { + const body = await jsonBody(request); + const devicePodId = request.method === "PUT" + ? decodeURIComponent(url.pathname.split("/").filter(Boolean)[3] ?? "") + : textOr(body.devicePodId ?? body.id, ""); + const pod = await this.store.upsertDevicePod({ + id: requiredText(devicePodId, "devicePodId"), + name: textOr(body.name, devicePodId), + status: body.status === "disabled" ? "disabled" : "active", + profile: normalizeObject(body.profile ?? body.profileJson), + now: this.now() + }); + return sendJson(response, request.method === "POST" ? 201 : 200, { + upserted: true, + devicePod: publicDevicePod(pod, { includeAdmin: true }) + }); + } + + if (request.method === "POST" && url.pathname === "/v1/admin/device-pod-grants") { + const body = await jsonBody(request); + const devicePodId = requiredText(body.devicePodId, "devicePodId"); + const userId = requiredText(body.userId, "userId"); + await this.requireGrantTargets({ devicePodId, userId }); + const grant = await this.store.grantDevicePod({ + devicePodId, + userId, + createdByAdminId: auth.actor.id, + now: this.now() + }); + return sendJson(response, 201, { granted: true, grant }); + } + + if (request.method === "DELETE" && url.pathname.startsWith("/v1/admin/device-pod-grants/")) { + const [, , , devicePodId, userId] = url.pathname.split("/").filter(Boolean).map((part) => decodeURIComponent(part)); + await this.store.revokeDevicePodGrant({ devicePodId: requiredText(devicePodId, "devicePodId"), userId: requiredText(userId, "userId") }); + return sendJson(response, 200, { revoked: true, devicePodId, userId }); + } + + sendJson(response, 404, errorPayload("not_found", "Admin route is not implemented", 404)); + } catch (error) { + sendAccessError(response, error); + } + } + + async handleDevicePodRoute(request, response, url) { + try { + await this.ensureBootstrap(); + const auth = await this.authenticate(request, { required: true }); + if (!auth.ok) return sendJson(response, auth.status, auth); + + if (request.method === "GET" && url.pathname === "/v1/device-pods") { + const pods = await this.store.listVisibleDevicePods(auth.actor); + return sendJson(response, 200, { + serviceId: CLOUD_API_SERVICE_ID, + contractVersion: "device-pod-authority-v1", + status: "ok", + source: authoritySource(), + actor: publicActor(auth.actor), + devicePods: pods.map((pod) => publicDevicePod(pod)), + selectedDevicePodId: pods[0]?.id ?? null, + observedAt: this.now() + }); + } + + const parsed = parseDevicePodPath(url.pathname); + if (!parsed) return sendJson(response, 404, errorPayload("not_found", "Device Pod route is not implemented", 404)); + const pod = await this.store.getVisibleDevicePod(auth.actor, parsed.devicePodId); + if (!pod) return sendJson(response, 404, errorPayload("device_pod_not_found", `Device Pod ${parsed.devicePodId} is not visible to the current actor`, 404)); + + if (request.method === "GET" && parsed.route === "status") return sendJson(response, 200, this.devicePodStatus(pod, auth.actor)); + if (request.method === "GET" && parsed.route === "events") return sendJson(response, 200, await this.devicePodEvents(pod, url.searchParams)); + if (request.method === "GET" && parsed.route === "debug-probe/chip-id") return sendJson(response, 200, this.devicePodProbe(pod, "debug.chip-id")); + if (request.method === "GET" && parsed.route === "io-probe/uart/1") return sendJson(response, 200, this.devicePodProbe(pod, "io.uart.status")); + if (request.method === "GET" && parsed.route === "io-probe/uart/1/tail") return sendJson(response, 200, this.devicePodOutputProbe(pod, url.searchParams)); + if (request.method === "POST" && parsed.route === "jobs") return this.createDevicePodJob(request, response, pod, auth.actor); + if (request.method === "GET" && parsed.route.startsWith("jobs/")) return this.getDevicePodJob(response, pod, parsed.route, auth.actor); + if (request.method === "POST" && parsed.route.startsWith("jobs/") && parsed.route.endsWith("/cancel")) return this.cancelDevicePodJob(response, pod, parsed.route, auth.actor); + + sendJson(response, 404, errorPayload("not_found", "Device Pod route is not implemented", 404)); + } catch (error) { + sendAccessError(response, error); + } + } + + async authenticate(request, { required = this.required } = {}) { + await this.ensureBootstrap(); + const token = sessionTokenFromRequest(request); + if (!token) { + return required ? errorPayload("auth_required", "Authentication is required", 401) : { ok: true, actor: null }; + } + const session = await this.store.findSessionByTokenHash(sha256(token), this.now()); + if (!session?.user || session.user.status !== "active") { + return errorPayload("auth_session_invalid", "Session is missing, expired, revoked, or disabled", 401); + } + await this.store.touchSession(session.id, this.now()); + return { ok: true, actor: session.user, session: publicSession(session) }; + } + + async handleLogin(request, response) { + const body = await jsonBody(request); + const user = await this.store.findUserByUsername(requiredText(body.username, "username")); + if (!user || user.status !== "active" || !verifyPassword(user.passwordHash, requiredText(body.password, "password"))) { + return sendJson(response, 401, errorPayload("invalid_credentials", "Username or password is invalid", 401)); + } + const token = randomBytes(32).toString("base64url"); + const now = this.now(); + const session = await this.store.createSession({ + userId: user.id, + tokenHash: sha256(token), + now, + expiresAt: new Date(Date.parse(now) + SESSION_MAX_AGE_SECONDS * 1000).toISOString() + }); + setSessionCookie(response, token, SESSION_MAX_AGE_SECONDS); + return sendJson(response, 200, { + authenticated: true, + actor: publicActor(user), + session: publicSession({ ...session, user }) + }); + } + + async handleLogout(request, response) { + const token = sessionTokenFromRequest(request); + if (token) await this.store.revokeSessionByTokenHash(sha256(token), this.now()); + clearSessionCookie(response); + return sendJson(response, 200, { authenticated: false, loggedOut: true }); + } + + async ensureBootstrap() { + if (this.bootstrapAttempted) return; + this.bootstrapAttempted = true; + await this.store.ensureSchema?.(); + const count = await this.store.countUsers(); + if (count > 0) return; + const passwordHash = textOr(this.env.HWLAB_BOOTSTRAP_ADMIN_PASSWORD_HASH, "") || (this.env.HWLAB_BOOTSTRAP_ADMIN_PASSWORD ? hashPassword(this.env.HWLAB_BOOTSTRAP_ADMIN_PASSWORD) : ""); + if (!passwordHash) return; + await this.store.createUser({ + id: this.env.HWLAB_BOOTSTRAP_ADMIN_ID || "usr_bootstrap_admin", + username: this.env.HWLAB_BOOTSTRAP_ADMIN_USERNAME || "admin", + displayName: this.env.HWLAB_BOOTSTRAP_ADMIN_DISPLAY_NAME || "HWLAB Admin", + role: "admin", + status: "active", + passwordHash, + now: this.now() + }); + } + + async setupRequired() { + return (await this.store.countUsers()) === 0; + } + + async requireGrantTargets({ devicePodId, userId }) { + const pod = await this.store.getDevicePod(devicePodId); + if (!pod || pod.status !== "active") { + throw Object.assign(new Error(`Device Pod ${devicePodId} does not exist or is disabled`), { statusCode: 404, code: "device_pod_not_found" }); + } + const user = await this.store.getUserById(userId); + if (!user || user.status !== "active") { + throw Object.assign(new Error(`User ${userId} does not exist or is disabled`), { statusCode: 404, code: "user_not_found" }); + } + } + + devicePodStatus(pod, actor) { + const route = pod.profile.route ?? {}; + const gatewaySessionId = textOr(route.gatewaySessionId, ""); + const gatewayOnline = gatewaySessionId && this.gatewayRegistry?.isOnline?.(gatewaySessionId) === true; + const blocker = gatewayOnline ? null : gatewayDispatchBlocker(gatewaySessionId ? "gateway session is not connected" : "profile route.gatewaySessionId is missing"); + return { + serviceId: CLOUD_API_SERVICE_ID, + contractVersion: "device-pod-authority-v1", + status: blocker ? "blocked" : "ok", + observedAt: this.now(), + source: authoritySource(), + actor: publicActor(actor), + devicePod: publicDevicePod(pod), + summary: { + devicePodId: pod.id, + targetId: targetIdFromProfile(pod.profile), + status: blocker ? "blocked" : "ok", + freshness: freshness(this.now(), blocker), + profileHash: pod.profileHash, + blocker + } + }; + } + + async devicePodEvents(pod, searchParams) { + const limit = Math.min(Math.max(Number.parseInt(searchParams.get("limit") ?? "80", 10) || 80, 1), 1000); + const jobs = await this.store.listDevicePodJobs(pod.id, limit); + const events = jobs.map((job) => ({ + eventId: `evt_${job.id}`, + devicePodId: pod.id, + targetId: targetIdFromProfile(pod.profile), + ts: job.updatedAt, + level: job.status === "blocked" || job.status === "failed" ? "warn" : "info", + scope: "job", + intent: job.intent, + status: job.status, + summary: job.blocker?.summary ?? `job ${job.id} ${job.status}`, + blocker: job.blocker, + refs: jobRefs(job) + })); + return { + serviceId: CLOUD_API_SERVICE_ID, + contractVersion: "device-pod-authority-v1", + status: "ok", + observedAt: this.now(), + devicePodId: pod.id, + targetId: targetIdFromProfile(pod.profile), + source: authoritySource(), + events, + lines: events.map(formatEventLine), + truncation: { limit, returned: events.length, truncated: jobs.length >= limit } + }; + } + + devicePodProbe(pod, intent) { + const status = this.devicePodStatus(pod, { id: "system_probe", role: "admin" }); + return { + ...status, + interface: intent.startsWith("debug") ? "debug-probe" : "io-probe", + intent, + blocker: status.summary.blocker, + refs: { traceId: `trc_${pod.id}_${intent.replace(/[^a-z0-9]+/giu, "_")}` } + }; + } + + devicePodOutputProbe(pod, searchParams) { + return { + ...this.devicePodProbe(pod, "io.uart.read"), + text: "", + bytes: 0, + truncation: { + maxBytes: Math.min(Math.max(Number.parseInt(searchParams.get("maxBytes") ?? "12000", 10) || 12000, 1), 12000), + truncated: false + } + }; + } + + async createDevicePodJob(request, response, pod, actor) { + const body = await jsonBody(request); + const intent = requiredText(body.intent, "intent"); + if (!DEVICE_JOB_INTENTS.has(intent)) { + return sendJson(response, 400, errorPayload("unsupported_device_job_intent", `Device job intent ${intent} is not supported`, 400)); + } + const reason = textOr(body.reason, ""); + if (MUTATING_INTENTS.has(intent) && !reason) { + return sendJson(response, 400, errorPayload("device_job_reason_required", `Device job intent ${intent} requires reason`, 400)); + } + const traceId = `trc_devicepod_${randomUUID()}`; + const operationId = `op_devicepod_${randomUUID()}`; + const now = this.now(); + const route = pod.profile.route ?? {}; + const gatewaySessionId = textOr(route.gatewaySessionId, ""); + const gatewayOnline = gatewaySessionId && this.gatewayRegistry?.isOnline?.(gatewaySessionId) === true; + const blocker = gatewayOnline ? null : gatewayDispatchBlocker(gatewaySessionId ? "gateway session is not connected" : "profile route.gatewaySessionId is missing"); + const job = await this.store.createDevicePodJob({ + id: `job_devicepod_${randomUUID()}`, + devicePodId: pod.id, + ownerUserId: actor.id, + status: blocker ? "blocked" : "running", + intent, + args: normalizeObject(body.args), + reason, + traceId, + operationId, + output: {}, + blocker, + now, + completedAt: blocker ? now : null + }); + + if (!blocker) this.dispatchDevicePodJob({ job, pod, actor }); + sendJson(response, blocker ? 409 : 202, this.jobPayload(job, pod)); + } + + async getDevicePodJob(response, pod, route, actor) { + const parts = route.split("/"); + const jobId = decodeURIComponent(parts[1] ?? ""); + const job = await this.store.getDevicePodJob(pod.id, jobId); + if (!job) return sendJson(response, 404, errorPayload("device_job_not_found", `Device job ${jobId} was not found`, 404)); + if (job.ownerUserId !== actor.id && actor.role !== "admin") return sendJson(response, 403, errorPayload("device_job_owner_required", "Only the owner or admin can inspect the job", 403)); + if (parts[2] === "output") return sendJson(response, 200, this.jobOutputPayload(job, pod)); + return sendJson(response, 200, this.jobPayload(job, pod)); + } + + async cancelDevicePodJob(response, pod, route, actor) { + const jobId = decodeURIComponent(route.split("/")[1] ?? ""); + const job = await this.store.getDevicePodJob(pod.id, jobId); + if (!job) return sendJson(response, 404, errorPayload("device_job_not_found", `Device job ${jobId} was not found`, 404)); + if (job.ownerUserId !== actor.id && actor.role !== "admin") return sendJson(response, 403, errorPayload("device_job_owner_required", "Only the owner or admin can cancel the job", 403)); + const canceled = await this.store.updateDevicePodJob(pod.id, jobId, { + status: terminalJobStatus(job.status) ? job.status : "canceled", + blocker: terminalJobStatus(job.status) ? job.blocker : { code: "device_job_canceled", summary: "Device job was canceled by user" }, + completedAt: terminalJobStatus(job.status) ? job.completedAt : this.now(), + updatedAt: this.now() + }); + return sendJson(response, 200, this.jobPayload(canceled, pod)); + } + + dispatchDevicePodJob({ job, pod, actor }) { + const gatewaySessionId = pod.profile.route?.gatewaySessionId; + const request = createGatewayShellRequest({ + id: `req_${job.id}`, + params: { + projectId: "prj_v02_device_pod", + gatewaySessionId, + resourceId: pod.profile.route?.resourceId ?? "res_device_pod", + capabilityId: pod.profile.route?.capabilityId ?? "cap_device_host_cli", + traceId: job.traceId, + operationId: job.operationId, + input: { + command: deviceHostCommand(pod.profile, job), + cwd: pod.profile.route?.hostWorkspaceRoot, + timeoutMs: Number.parseInt(String(job.args.timeoutMs ?? "120000"), 10) || 120000 + } + }, + meta: { traceId: job.traceId, actorId: actor.id } + }); + setImmediate(async () => { + try { + const result = await this.gatewayRegistry.enqueue({ gatewaySessionId, request, timeoutMs: 120000 }); + await this.store.updateDevicePodJob(pod.id, job.id, { + status: result.ok ? "completed" : "failed", + output: result, + blocker: result.ok ? null : gatewayDispatchBlocker(result.error ?? result.status ?? "gateway dispatch failed"), + completedAt: this.now(), + updatedAt: this.now() + }); + } catch (error) { + await this.store.updateDevicePodJob(pod.id, job.id, { + status: "failed", + output: { error: error?.message ?? "gateway dispatch failed" }, + blocker: gatewayDispatchBlocker(error?.message ?? "gateway dispatch failed"), + completedAt: this.now(), + updatedAt: this.now() + }); + } + }); + } + + jobPayload(job, pod) { + return { + serviceId: CLOUD_API_SERVICE_ID, + contractVersion: DEVICE_JOB_CONTRACT_VERSION, + accepted: !["blocked", "failed"].includes(job.status), + status: job.status, + devicePodId: pod.id, + targetId: targetIdFromProfile(pod.profile), + profileHash: pod.profileHash, + traceId: job.traceId, + operationId: job.operationId, + job: publicJob(job), + blocker: job.blocker, + freshness: freshness(job.updatedAt, job.blocker), + outputUrl: `/v1/device-pods/${encodeURIComponent(pod.id)}/jobs/${encodeURIComponent(job.id)}/output`, + cancelUrl: `/v1/device-pods/${encodeURIComponent(pod.id)}/jobs/${encodeURIComponent(job.id)}/cancel` + }; + } + + jobOutputPayload(job, pod) { + const text = typeof job.output?.text === "string" ? job.output.text : JSON.stringify(job.output ?? {}); + return { + ...this.jobPayload(job, pod), + output: job.output ?? {}, + text, + bytes: Buffer.byteLength(text, "utf8"), + truncation: { maxBytes: 12000, truncated: false } + }; + } +} + +class MemoryAccessStore { + constructor({ now = () => new Date().toISOString() } = {}) { + this.now = now; + this.users = new Map(); + this.sessions = new Map(); + this.devicePods = new Map(); + this.grants = new Set(); + this.jobs = new Map(); + } + + async countUsers() { return this.users.size; } + async getUserById(id) { return this.users.get(id) ?? null; } + async findUserByUsername(username) { return [...this.users.values()].find((user) => user.username === username) ?? null; } + async createUser(input) { + const now = input.now ?? this.now(); + const existing = await this.findUserByUsername(input.username); + const user = { id: input.id ?? existing?.id ?? `usr_${randomUUID()}`, username: input.username, displayName: input.displayName ?? existing?.displayName ?? "", role: input.role, status: input.status ?? "active", passwordHash: input.passwordHash ?? existing?.passwordHash ?? null, createdAt: existing?.createdAt ?? now, updatedAt: now }; + this.users.set(user.id, user); + return user; + } + async createSession(input) { + const session = { id: `uss_${randomUUID()}`, userId: input.userId, tokenHash: input.tokenHash, createdAt: input.now, lastSeenAt: input.now, expiresAt: input.expiresAt, revokedAt: null }; + this.sessions.set(session.id, session); + return session; + } + async findSessionByTokenHash(tokenHash, now) { + const session = [...this.sessions.values()].find((item) => item.tokenHash === tokenHash && !item.revokedAt && item.expiresAt > now) ?? null; + return session ? { ...session, user: this.users.get(session.userId) ?? null } : null; + } + async touchSession(id, now) { const session = this.sessions.get(id); if (session) session.lastSeenAt = now; } + async revokeSessionByTokenHash(tokenHash, now) { for (const session of this.sessions.values()) if (session.tokenHash === tokenHash) session.revokedAt = now; } + async upsertDevicePod(input) { + const existing = this.devicePods.get(input.id); + const profile = normalizeObject(input.profile); + const now = input.now ?? this.now(); + const pod = { id: input.id, name: input.name ?? existing?.name ?? input.id, status: input.status ?? existing?.status ?? "active", profile, profileHash: profileHash(profile), createdAt: existing?.createdAt ?? now, updatedAt: now }; + this.devicePods.set(pod.id, pod); + return pod; + } + async grantDevicePod(input) { const grant = { devicePodId: input.devicePodId, userId: input.userId, createdByAdminId: input.createdByAdminId, createdAt: input.now ?? this.now() }; this.grants.add(grantKey(grant.devicePodId, grant.userId)); return grant; } + async revokeDevicePodGrant(input) { this.grants.delete(grantKey(input.devicePodId, input.userId)); } + async getDevicePod(id) { return this.devicePods.get(id) ?? null; } + async listVisibleDevicePods(actor) { return [...this.devicePods.values()].filter((pod) => pod.status === "active" && (actor.role === "admin" || this.grants.has(grantKey(pod.id, actor.id)))); } + async getVisibleDevicePod(actor, id) { return (await this.listVisibleDevicePods(actor)).find((pod) => pod.id === id) ?? null; } + async createDevicePodJob(input) { const job = normalizeJob(input); this.jobs.set(job.id, job); return job; } + async updateDevicePodJob(devicePodId, jobId, patch) { const job = this.jobs.get(jobId); if (!job || job.devicePodId !== devicePodId) return null; const next = { ...job, ...patch, updatedAt: patch.updatedAt ?? this.now() }; this.jobs.set(jobId, next); return next; } + async getDevicePodJob(devicePodId, jobId) { const job = this.jobs.get(jobId); return job?.devicePodId === devicePodId ? job : null; } + async listDevicePodJobs(devicePodId, limit) { return [...this.jobs.values()].filter((job) => job.devicePodId === devicePodId).sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)).slice(0, limit); } +} + +class PostgresAccessStore extends MemoryAccessStore { + constructor({ query, now } = {}) { + super({ now }); + this.query = query; + this.schemaReady = false; + } + + async ensureSchema() { + if (this.schemaReady) return; + for (const sql of ACCESS_SCHEMA_STATEMENTS) await this.query(sql, []); + this.schemaReady = true; + } + + async countUsers() { await this.ensureSchema(); const result = await this.query("SELECT COUNT(*)::int AS count FROM users", []); return Number(result.rows?.[0]?.count ?? 0); } + async getUserById(id) { await this.ensureSchema(); const result = await this.query("SELECT id, username, display_name, role, status, password_hash, created_at, updated_at FROM users WHERE id = $1 LIMIT 1", [id]); return pgUser(result.rows?.[0]); } + async findUserByUsername(username) { await this.ensureSchema(); const result = await this.query("SELECT id, username, display_name, role, status, password_hash, created_at, updated_at FROM users WHERE username = $1 LIMIT 1", [username]); return pgUser(result.rows?.[0]); } + async createUser(input) { + await this.ensureSchema(); + const now = input.now ?? this.now(); + const user = { id: input.id ?? `usr_${randomUUID()}`, username: input.username, displayName: input.displayName ?? "", role: input.role, status: input.status ?? "active", passwordHash: input.passwordHash ?? null, createdAt: now, updatedAt: now }; + const result = await this.query("INSERT INTO users (id, username, display_name, role, status, password_hash, created_at, updated_at) VALUES ($1,$2,$3,$4,$5,$6,$7,$8) ON CONFLICT (username) DO UPDATE SET display_name = EXCLUDED.display_name, role = EXCLUDED.role, status = EXCLUDED.status, password_hash = EXCLUDED.password_hash, updated_at = EXCLUDED.updated_at RETURNING id, username, display_name, role, status, password_hash, created_at, updated_at", [user.id, user.username, user.displayName, user.role, user.status, user.passwordHash, user.createdAt, user.updatedAt]); + return pgUser(result.rows?.[0]); + } + async createSession(input) { await this.ensureSchema(); const session = await super.createSession(input); await this.query("INSERT INTO user_sessions (id, user_id, session_token_hash, created_at, last_seen_at, expires_at, revoked_at) VALUES ($1,$2,$3,$4,$5,$6,$7)", [session.id, session.userId, session.tokenHash, session.createdAt, session.lastSeenAt, session.expiresAt, session.revokedAt]); return session; } + async findSessionByTokenHash(tokenHash, now) { await this.ensureSchema(); const result = await this.query("SELECT s.id, s.user_id, s.session_token_hash, s.created_at, s.last_seen_at, s.expires_at, s.revoked_at, u.username, u.display_name, u.role, u.status, u.password_hash, u.created_at AS user_created_at, u.updated_at AS user_updated_at FROM user_sessions s JOIN users u ON u.id = s.user_id WHERE s.session_token_hash = $1 AND s.revoked_at IS NULL AND s.expires_at > $2 LIMIT 1", [tokenHash, now]); const row = result.rows?.[0]; return row ? pgSession(row) : null; } + async touchSession(id, now) { await this.ensureSchema(); await this.query("UPDATE user_sessions SET last_seen_at = $2 WHERE id = $1", [id, now]); } + async revokeSessionByTokenHash(tokenHash, now) { await this.ensureSchema(); await this.query("UPDATE user_sessions SET revoked_at = $2 WHERE session_token_hash = $1", [tokenHash, now]); } + async upsertDevicePod(input) { await this.ensureSchema(); const pod = await super.upsertDevicePod(input); const result = await this.query("INSERT INTO device_pods (id, name, status, profile_json, profile_hash, created_at, updated_at) VALUES ($1,$2,$3,$4,$5,$6,$7) ON CONFLICT (id) DO UPDATE SET name = EXCLUDED.name, status = EXCLUDED.status, profile_json = EXCLUDED.profile_json, profile_hash = EXCLUDED.profile_hash, updated_at = EXCLUDED.updated_at RETURNING *", [pod.id, pod.name, pod.status, stableJson(pod.profile), pod.profileHash, pod.createdAt, pod.updatedAt]); return pgDevicePod(result.rows?.[0]); } + async grantDevicePod(input) { await this.ensureSchema(); const grant = await super.grantDevicePod(input); await this.query("INSERT INTO device_pod_grants (device_pod_id, user_id, created_by_admin_id, created_at) VALUES ($1,$2,$3,$4) ON CONFLICT (device_pod_id, user_id) DO UPDATE SET created_by_admin_id = EXCLUDED.created_by_admin_id, created_at = EXCLUDED.created_at", [grant.devicePodId, grant.userId, grant.createdByAdminId, grant.createdAt]); return grant; } + async revokeDevicePodGrant(input) { await this.ensureSchema(); await this.query("DELETE FROM device_pod_grants WHERE device_pod_id = $1 AND user_id = $2", [input.devicePodId, input.userId]); } + async getDevicePod(id) { await this.ensureSchema(); const result = await this.query("SELECT * FROM device_pods WHERE id = $1 LIMIT 1", [id]); return pgDevicePod(result.rows?.[0]); } + async listVisibleDevicePods(actor) { await this.ensureSchema(); const sql = actor.role === "admin" ? "SELECT * FROM device_pods WHERE status = 'active' ORDER BY id" : "SELECT p.* FROM device_pods p JOIN device_pod_grants g ON g.device_pod_id = p.id WHERE p.status = 'active' AND g.user_id = $1 ORDER BY p.id"; const result = await this.query(sql, actor.role === "admin" ? [] : [actor.id]); return result.rows.map(pgDevicePod); } + async createDevicePodJob(input) { await this.ensureSchema(); const job = normalizeJob(input); await this.query("INSERT INTO device_pod_jobs (id, device_pod_id, owner_user_id, status, intent, args_json, reason, trace_id, operation_id, output_json, blocker_json, created_at, updated_at, completed_at) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14)", jobParams(job)); return job; } + async updateDevicePodJob(devicePodId, jobId, patch) { await this.ensureSchema(); const current = await this.getDevicePodJob(devicePodId, jobId); if (!current) return null; const next = { ...current, ...patch, updatedAt: patch.updatedAt ?? this.now() }; await this.query("UPDATE device_pod_jobs SET status=$3, output_json=$4, blocker_json=$5, updated_at=$6, completed_at=$7 WHERE device_pod_id=$1 AND id=$2", [devicePodId, jobId, next.status, stableJson(next.output ?? {}), stableJson(next.blocker ?? {}), next.updatedAt, next.completedAt]); return next; } + async getDevicePodJob(devicePodId, jobId) { await this.ensureSchema(); const result = await this.query("SELECT * FROM device_pod_jobs WHERE device_pod_id = $1 AND id = $2 LIMIT 1", [devicePodId, jobId]); return pgJob(result.rows?.[0]); } + async listDevicePodJobs(devicePodId, limit) { await this.ensureSchema(); const result = await this.query("SELECT * FROM device_pod_jobs WHERE device_pod_id = $1 ORDER BY updated_at DESC LIMIT $2", [devicePodId, limit]); return result.rows.map(pgJob); } +} + +function parseDevicePodPath(pathname) { + const prefix = "/v1/device-pods/"; + if (!pathname.startsWith(prefix)) return null; + const [devicePodId, ...rest] = pathname.slice(prefix.length).split("/").map((part) => decodeURIComponent(part)).filter(Boolean); + return devicePodId ? { devicePodId, route: rest.join("/") } : null; +} + +async function jsonBody(request) { + const text = await readBody(request); + try { return text ? JSON.parse(text) : {}; } catch (error) { throw Object.assign(new Error("Invalid JSON body"), { statusCode: 400, code: "parse_error", reason: error.message }); } +} + +function requiredText(value, field) { const text = textOr(value, ""); if (!text) throw Object.assign(new Error(`${field} is required`), { statusCode: 400, code: "invalid_params" }); return text; } +function textOr(value, fallback) { const text = String(value ?? "").trim(); return text || fallback; } +function normalizeObject(value) { return value && typeof value === "object" && !Array.isArray(value) ? { ...value } : {}; } +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); } +function sha256(value) { return createHash("sha256").update(String(value)).digest("hex"); } +function stableJson(value) { return JSON.stringify(sortJson(value)); } +function sortJson(value) { if (Array.isArray(value)) return value.map(sortJson); if (value && typeof value === "object") return Object.fromEntries(Object.keys(value).sort().map((key) => [key, sortJson(value[key])])); return value; } +function profileHash(profile) { return `sha256:${sha256(stableJson(profile))}`; } +function grantKey(devicePodId, userId) { return `${devicePodId}\u0000${userId}`; } +function authoritySource() { return { kind: "CLOUD_API_PROFILE_AUTHORITY", serviceId: CLOUD_API_SERVICE_ID, fake: false, devLiveEvidence: false, note: "Profile, grant, lease, and job authority are owned by hwlab-cloud-api; this is not fake data." }; } +function gatewayDispatchBlocker(summary) { return { code: "gateway_dispatch_unavailable", layer: "device-pod", retryable: true, summary, userMessage: "Device Pod 已授权,但当前没有可用 gateway/device-host-cli 执行通道。" }; } +function freshness(observedAt, blocker) { return { observedAt, ageMs: 0, stale: Boolean(blocker), source: blocker ? "blocked" : "cloud-api" }; } +function publicActor(user) { return user ? { id: user.id, username: user.username, displayName: user.displayName, role: user.role, status: user.status } : null; } +function redactedUser(user) { return publicActor(user); } +function publicSession(session) { return { id: session.id, userId: session.userId, createdAt: session.createdAt, lastSeenAt: session.lastSeenAt, expiresAt: session.expiresAt, revoked: Boolean(session.revokedAt) }; } +function publicDevicePod(pod, { includeAdmin = false } = {}) { return { devicePodId: pod.id, name: pod.name, status: pod.status, targetId: targetIdFromProfile(pod.profile), profileHash: pod.profileHash, profile: redactedProfile(pod.profile), createdAt: pod.createdAt, updatedAt: pod.updatedAt, blocker: null, ...(includeAdmin ? { admin: { profileStored: true, routeStored: Boolean(pod.profile.route) } } : {}) }; } +function redactedProfile(profile = {}) { return { schemaVersion: profile.schemaVersion ?? null, target: { id: profile.target?.id ?? null }, projectWorkspace: { projectPath: profile.projectWorkspace?.projectPath ?? null, targetName: profile.projectWorkspace?.targetName ?? null, hexPath: profile.projectWorkspace?.hexPath ?? null }, debugInterface: { type: profile.debugInterface?.type ?? null }, ioInterface: { uartCount: Array.isArray(profile.ioInterface?.uart) ? profile.ioInterface.uart.length : 0 }, route: { configured: Boolean(profile.route?.gatewaySessionId), gatewaySessionId: "redacted", resourceId: profile.route?.resourceId ? "redacted" : null, capabilityId: profile.route?.capabilityId ? "redacted" : null } }; } +function targetIdFromProfile(profile = {}) { return profile.target?.id ?? profile.targetId ?? null; } +function terminalJobStatus(status) { return ["completed", "failed", "blocked", "canceled"].includes(status); } +function publicJob(job) { return { id: job.id, devicePodId: job.devicePodId, ownerUserId: job.ownerUserId, status: job.status, intent: job.intent, reason: job.reason, traceId: job.traceId, operationId: job.operationId, createdAt: job.createdAt, updatedAt: job.updatedAt, completedAt: job.completedAt }; } +function jobRefs(job) { return { jobId: job.id, traceId: job.traceId, operationId: job.operationId }; } +function formatEventLine(event) { return [event.ts?.slice(11, 19) ?? "00:00:00", event.scope?.toUpperCase() ?? "JOB", event.status, event.intent, event.summary, event.refs?.traceId ? `trace=${event.refs.traceId}` : null, event.blocker?.code ? `blocker=${event.blocker.code}` : null].filter(Boolean).join(" "); } +function normalizeJob(input) { return { id: input.id, devicePodId: input.devicePodId, ownerUserId: input.ownerUserId, status: input.status, intent: input.intent, args: normalizeObject(input.args), reason: input.reason ?? "", traceId: input.traceId, operationId: input.operationId, output: normalizeObject(input.output), blocker: input.blocker ?? null, createdAt: input.now, updatedAt: input.now, completedAt: input.completedAt ?? null }; } +function deviceHostCommand(profile, job) { const hostCli = textOr(profile.route?.hostCli, "node tools/device-host-cli.mjs"); return `${hostCli} ${job.intent} '${stableJson(job.args).replace(/'/gu, "'\\''")}'`; } +function sessionTokenFromRequest(request) { const auth = getHeader(request, "authorization"); if (/^Bearer\s+/iu.test(String(auth ?? ""))) return String(auth).replace(/^Bearer\s+/iu, "").trim(); const header = textOr(getHeader(request, "x-hwlab-session-token"), ""); if (header) return header; const cookie = parseCookie(getHeader(request, "cookie")); return cookie[SESSION_COOKIE] ?? ""; } +function parseCookie(value) { return Object.fromEntries(String(value ?? "").split(";").map((part) => part.trim()).filter(Boolean).map((part) => { const index = part.indexOf("="); return index > 0 ? [part.slice(0, index), decodeURIComponent(part.slice(index + 1))] : [part, ""]; })); } +function setSessionCookie(response, token, maxAge) { response.setHeader("set-cookie", `${SESSION_COOKIE}=${encodeURIComponent(token)}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${maxAge}`); } +function clearSessionCookie(response) { response.setHeader("set-cookie", `${SESSION_COOKIE}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0`); } +function errorPayload(code, message, status) { return { ok: false, status, error: { code, message } }; } +function sendAccessError(response, error) { const status = error?.statusCode ?? 500; sendJson(response, status, errorPayload(error?.code ?? "access_control_error", error?.message ?? "Access control request failed", status)); } +function pgUser(row) { return row ? { id: row.id, username: row.username, displayName: row.display_name, role: row.role, status: row.status, passwordHash: row.password_hash, createdAt: row.created_at, updatedAt: row.updated_at } : null; } +function pgSession(row) { return { id: row.id, userId: row.user_id, tokenHash: row.session_token_hash, createdAt: row.created_at, lastSeenAt: row.last_seen_at, expiresAt: row.expires_at, revokedAt: row.revoked_at, user: { id: row.user_id, username: row.username, displayName: row.display_name, role: row.role, status: row.status, passwordHash: row.password_hash, createdAt: row.user_created_at, updatedAt: row.user_updated_at } }; } +function pgDevicePod(row) { return { id: row.id, name: row.name, status: row.status, profile: parseJson(row.profile_json, {}), profileHash: row.profile_hash, createdAt: row.created_at, updatedAt: row.updated_at }; } +function pgJob(row) { return row ? { id: row.id, devicePodId: row.device_pod_id, ownerUserId: row.owner_user_id, status: row.status, intent: row.intent, args: parseJson(row.args_json, {}), reason: row.reason, traceId: row.trace_id, operationId: row.operation_id, output: parseJson(row.output_json, {}), blocker: parseJson(row.blocker_json, null), createdAt: row.created_at, updatedAt: row.updated_at, completedAt: row.completed_at } : null; } +function jobParams(job) { return [job.id, job.devicePodId, job.ownerUserId, job.status, job.intent, stableJson(job.args), job.reason, job.traceId, job.operationId, stableJson(job.output), stableJson(job.blocker ?? {}), job.createdAt, job.updatedAt, job.completedAt]; } +function parseJson(value, fallback) { if (!value) return fallback; if (typeof value === "object") return value; try { return JSON.parse(String(value)); } catch { return fallback; } } diff --git a/internal/cloud/code-agent-chat.ts b/internal/cloud/code-agent-chat.ts index 0cef327b..a14a7ace 100644 --- a/internal/cloud/code-agent-chat.ts +++ b/internal/cloud/code-agent-chat.ts @@ -133,6 +133,8 @@ export async function handleCodeAgentChat(params = {}, options = {}) { hardTimeoutMs: options.hardTimeoutMs, model: providerPlan.model, codexStdioManager: options.codexStdioManager, + skillsDirs: options.skillsDirs, + skillsDirsExact: options.skillsDirsExact, traceRecorder, conversationFacts: conversationFactsForPrompt(sessionRegistry, conversationId), externalNetworkIntent: null @@ -837,7 +839,7 @@ function notRequestedSkills() { }; } -async function callCodexStdioRunner({ message, conversationId, sessionId, traceId, env, now, workspace, timeoutMs, hardTimeoutMs, model, codexStdioManager, traceRecorder, conversationFacts, externalNetworkIntent = null }) { +async function callCodexStdioRunner({ message, conversationId, sessionId, traceId, env, now, workspace, timeoutMs, hardTimeoutMs, model, codexStdioManager, skillsDirs, skillsDirsExact, traceRecorder, conversationFacts, externalNetworkIntent = null }) { const manager = resolveCodexStdioSessionManager({ codexStdioManager }); try { return await manager.chat({ @@ -851,6 +853,8 @@ async function callCodexStdioRunner({ message, conversationId, sessionId, traceI timeoutMs, hardTimeoutMs, model, + skillsDirs, + skillsDirsExact, traceRecorder, conversationFacts, externalNetworkIntent diff --git a/internal/cloud/codex-stdio-session-helpers.ts b/internal/cloud/codex-stdio-session-helpers.ts index 82576773..d02d0fdf 100644 --- a/internal/cloud/codex-stdio-session-helpers.ts +++ b/internal/cloud/codex-stdio-session-helpers.ts @@ -482,7 +482,7 @@ export function summarizeToolResult(toolResult) { return "tool result captured"; } -export async function collectWorkspaceSidecarEvidence({ message, workspace, traceId, env, now } = {}) { +export async function collectWorkspaceSidecarEvidence({ message, workspace, traceId, env, now, skillsDirs, skillsDirsExact } = {}) { const intent = detectWorkspaceSidecarIntent(message); const toolCalls = []; const events = []; @@ -503,7 +503,7 @@ export async function collectWorkspaceSidecarEvidence({ message, workspace, trac } if (intent.skills) { - skills = await discoverSkillsForStdio({ env, traceId }); + skills = await discoverSkillsForStdio({ env, traceId, skillsDirs, skillsDirsExact }); toolCalls.push({ id: `tool_${randomUUID()}`, type: "file-read", @@ -718,8 +718,8 @@ export async function workspaceSmokeToolCalls({ workspace, traceId, now }) { return calls; } -export async function discoverSkillsForStdio({ env = process.env, traceId } = {}) { - const checkedDirs = resolveSkillDirs(env); +export async function discoverSkillsForStdio({ env = process.env, traceId, skillsDirs, skillsDirsExact } = {}) { + const checkedDirs = resolveSkillDirs(env, { skillsDirs, skillsDirsExact }); const sources = []; const items = []; for (const dir of checkedDirs) { @@ -792,14 +792,17 @@ export async function discoverSkillsForStdio({ env = process.env, traceId } = {} }; } -export function resolveSkillDirs(env = process.env) { - const configured = String(firstNonEmpty(env.HWLAB_CODE_AGENT_SKILLS_DIRS, env.UNIDESK_SKILLS_PATH, "")) - .split(/[,;]/u) - .flatMap((part) => part.split(path.delimiter)) - .map((dir) => dir.trim()) - .filter(Boolean); - if (env.HWLAB_CODE_AGENT_SKILLS_STRICT === "1") { - return [...new Set(configured.map((dir) => path.resolve(dir)))]; +export function resolveSkillDirs(env = process.env, options = {}) { + const configured = Array.isArray(options.skillsDirs) + ? options.skillsDirs + : String(firstNonEmpty(env.HWLAB_CODE_AGENT_SKILLS_DIRS, env.UNIDESK_SKILLS_PATH, "")) + .split(/[,;]/u) + .flatMap((part) => part.split(path.delimiter)); + const normalized = configured + .filter((dir) => typeof dir === "string" && dir.trim()) + .map((dir) => path.resolve(dir.trim())); + if (options.skillsDirsExact === true || env.HWLAB_CODE_AGENT_SKILLS_STRICT === "1") { + return [...new Set(normalized)]; } return [path.resolve("/app/skills")]; } diff --git a/internal/cloud/codex-stdio-session.ts b/internal/cloud/codex-stdio-session.ts index 94e8bafd..38914b26 100644 --- a/internal/cloud/codex-stdio-session.ts +++ b/internal/cloud/codex-stdio-session.ts @@ -541,7 +541,9 @@ export function createCodexStdioSessionManager(options = {}) { workspace, traceId, env, - now + now, + skillsDirs: params.skillsDirs, + skillsDirsExact: params.skillsDirsExact }); for (const event of sidecar.events) traceRecorder.append(event); if (sidecar.intent?.skills && sidecar.skills?.status !== "ready") { diff --git a/internal/cloud/server-code-agent-http.ts b/internal/cloud/server-code-agent-http.ts index 52346f59..60f282b6 100644 --- a/internal/cloud/server-code-agent-http.ts +++ b/internal/cloud/server-code-agent-http.ts @@ -69,7 +69,9 @@ export async function handleCodeAgentChatHttp(request, response, options) { const traceId = safeTraceId(getHeader(request, "x-trace-id") || params.traceId) || `trc_${randomUUID()}`; const chatParams = { ...params, - traceId + traceId, + ownerUserId: options.actor?.id ?? params.ownerUserId, + ownerRole: options.actor?.role ?? params.ownerRole }; if (codeAgentChatShortConnectionRequested(request, params, options)) { @@ -219,7 +221,7 @@ function submitCodeAgentChatTurn({ params, options, traceId }) { try { const payload = await runCodeAgentChat(params, options); if (isCodeAgentResultCanceled(results.get(traceId))) return; - results.set(traceId, payload); + results.set(traceId, annotateOwner(payload, params)); traceStore.append(traceId, { type: "result", status: payload.status === "completed" ? "completed" : "failed", @@ -287,6 +289,15 @@ export async function handleCodeAgentChatResultHttp(request, response, url, opti } const results = options.codeAgentChatResults; const result = results?.get(traceId) ?? null; + if (result && !canAccessOwnedResult(result, options.actor)) { + sendJson(response, 403, { + error: { + code: "agent_session_owner_required", + message: "Only the session owner or admin can read this Code Agent result" + } + }); + return; + } if (result && result.status !== "running") { sendJson(response, 200, compactCodeAgentChatResultPayload(result, options)); return; @@ -550,10 +561,24 @@ export async function handleCodeAgentCancelHttp(request, response, options) { userMessage: "当前 Codex stdio 请求已取消;输入、sessionId、traceId 和最后 trace event 已保留,可重试上一条消息。", updatedAt: new Date().toISOString() }; - options.codeAgentChatResults?.set(traceId, payload); + options.codeAgentChatResults?.set(traceId, annotateOwner(payload, { ownerUserId: options.actor?.id, ownerRole: options.actor?.role })); sendJson(response, 200, payload); } +function annotateOwner(payload, params = {}) { + if (!params.ownerUserId) return payload; + return { + ...payload, + ownerUserId: params.ownerUserId, + ownerRole: params.ownerRole ?? null + }; +} + +function canAccessOwnedResult(result, actor) { + if (!result?.ownerUserId || !actor) return true; + return actor.role === "admin" || actor.id === result.ownerUserId; +} + function isCodeAgentResultCanceled(result) { return result?.status === "canceled" || result?.canceled === true || result?.error?.code === "codex_stdio_canceled"; } @@ -631,6 +656,16 @@ export async function handleCodeAgentTraceHttp(request, response, url, options) }); return; } + const result = options.codeAgentChatResults?.get(traceId) ?? null; + if (result && !canAccessOwnedResult(result, options.actor)) { + sendJson(response, 403, { + error: { + code: "agent_session_owner_required", + message: "Only the session owner or admin can read this Code Agent trace" + } + }); + return; + } if (parts[5] === "stream") { sendTraceSse(response, traceStore, traceId, options); diff --git a/internal/cloud/server-live-builds.test.ts b/internal/cloud/server-live-builds.test.ts index 93b27d83..b9580878 100644 --- a/internal/cloud/server-live-builds.test.ts +++ b/internal/cloud/server-live-builds.test.ts @@ -373,3 +373,56 @@ test("cloud api ignores old repository reports and keeps missing buildCreatedAt await rm(tempRoot, { recursive: true, force: true }); } }); + +test("cloud api v02 live builds omit removed simulator and tunnel services from default inventory", async () => { + const server = createCloudApiServer({ + env: { + PATH: "", + HWLAB_ENVIRONMENT: "v02", + HWLAB_GITOPS_PROFILE: "v02" + }, + liveBuildMetadata: { + deployManifest: { + services: [ + { serviceId: "hwlab-cloud-api", profile: "v02", replicas: 1 }, + { serviceId: "hwlab-gateway-simu", profile: "v02", replicas: 1 }, + { serviceId: "hwlab-box-simu", profile: "v02", replicas: 1 }, + { serviceId: "hwlab-patch-panel", profile: "v02", replicas: 1 }, + { serviceId: "hwlab-router", profile: "v02", replicas: 1 }, + { serviceId: "hwlab-tunnel-client", profile: "v02", replicas: 1 } + ] + }, + artifactCatalog: { + services: [ + { serviceId: "hwlab-gateway-simu" }, + { serviceId: "hwlab-cloud-web" } + ] + } + }, + fetchImpl: async () => ({ + ok: false, + status: 503, + async text() { + return JSON.stringify({ error: "offline" }); + } + }) + }); + 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/live-builds`); + assert.equal(response.status, 200); + const payload = await response.json(); + const serviceIds = payload.services.map((service) => service.serviceId); + assert.equal(serviceIds.includes("hwlab-cloud-api"), true); + assert.equal(serviceIds.includes("hwlab-cloud-web"), true); + assert.equal(serviceIds.includes("hwlab-gateway-simu"), false); + assert.equal(serviceIds.includes("hwlab-box-simu"), false); + assert.equal(serviceIds.includes("hwlab-patch-panel"), false); + assert.equal(serviceIds.includes("hwlab-router"), false); + assert.equal(serviceIds.includes("hwlab-tunnel-client"), false); + } finally { + await new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve()))); + } +}); diff --git a/internal/cloud/server-rest-payloads.ts b/internal/cloud/server-rest-payloads.ts index 06b9d918..40ff9331 100644 --- a/internal/cloud/server-rest-payloads.ts +++ b/internal/cloud/server-rest-payloads.ts @@ -40,6 +40,13 @@ const LIVE_BUILD_EXTERNAL_COMPONENTS = Object.freeze([ externalReason: "外部镜像或非 HWLAB 构建产物" }) ]); +const V02_REMOVED_SERVICE_IDS = Object.freeze([ + "hwlab-gateway-simu", + "hwlab-box-simu", + "hwlab-patch-panel", + "hwlab-router", + "hwlab-tunnel-client" +]); function defaultRuntimeEnvironment(env = process.env) { const value = env.HWLAB_GITOPS_PROFILE || env.HWLAB_ENVIRONMENT || ENVIRONMENT_DEV; @@ -49,7 +56,7 @@ function defaultRuntimeEnvironment(env = process.env) { export async function buildLiveBuildsPayload(options = {}, dependencies = {}) { const env = options.env ?? process.env; const metadata = await loadLiveBuildMetadata(options); - const inventory = liveBuildServiceInventory(metadata); + const inventory = liveBuildServiceInventory(metadata, env); const services = await Promise.all( inventory.map((service) => observeLiveBuildService(service, { ...options, env, buildHealthPayload: dependencies.buildHealthPayload })) ); @@ -697,7 +704,7 @@ function normalizeMetadataFile(file) { }; } -function liveBuildServiceInventory(metadata) { +function liveBuildServiceInventory(metadata, env = process.env) { const deployServices = Array.isArray(metadata.deployManifest.payload?.services) ? metadata.deployManifest.payload.services : []; @@ -710,7 +717,7 @@ function liveBuildServiceInventory(metadata) { ...SERVICE_IDS, ...deployServices.map((service) => service.serviceId), ...artifactByServiceId.keys() - ]).filter((serviceId) => String(serviceId).startsWith("hwlab-")); + ]).filter((serviceId) => String(serviceId).startsWith("hwlab-") && !serviceRemovedForRuntimeProfile(serviceId, env)); return [ ...serviceIds.map((serviceId) => { @@ -732,6 +739,11 @@ function liveBuildServiceInventory(metadata) { ]; } +function serviceRemovedForRuntimeProfile(serviceId, env = process.env) { + const profile = String(env.HWLAB_GITOPS_PROFILE || env.HWLAB_ENVIRONMENT || "").trim().toLowerCase(); + return profile === "v02" && V02_REMOVED_SERVICE_IDS.includes(serviceId); +} + function uniqueStrings(values) { return [...new Set(values.map((value) => String(value ?? "").trim()).filter(Boolean))]; } diff --git a/internal/cloud/server.ts b/internal/cloud/server.ts index 39bf9404..dbd67f9a 100644 --- a/internal/cloud/server.ts +++ b/internal/cloud/server.ts @@ -45,6 +45,7 @@ import { handleM3IoControl } from "./m3-io-control.ts"; import { createConfiguredCloudRuntimeStore } from "../db/runtime-store.ts"; +import { createAccessController } from "./access-control.ts"; import { buildDevicePodRestPayload, buildDevicePodStatus @@ -125,10 +126,12 @@ export function createCloudApiServer(options = {}) { staleMs: parsePositiveInteger(env.HWLAB_GATEWAY_DEMO_STALE_MS, 30000), dispatchTimeoutMs: parsePositiveInteger(env.HWLAB_GATEWAY_DISPATCH_TIMEOUT_MS, 120000) }); + const accessController = options.accessController || createAccessController({ ...options, env, runtimeStore, gatewayRegistry }); return createServer(async (request, response) => { try { - await routeRequest(request, response, { ...options, env, runtimeStore, gatewayRegistry, sessionRegistry, traceStore, codexStdioManager, codeAgentChatResults }); + await routeRequest(request, response, { ...options, env, runtimeStore, gatewayRegistry, accessController, sessionRegistry, traceStore, codexStdioManager, codeAgentChatResults }); } catch (error) { + if (error?.alreadySent) return; sendJson(response, 500, { error: { code: "internal_error", @@ -236,6 +239,11 @@ async function routeRequest(request, response, options) { return; } + if (url.pathname === "/auth" || url.pathname.startsWith("/auth/")) { + await options.accessController.handleAuthRoute(request, response, url); + return; + } + if (request.method === "POST" && (url.pathname === "/rpc" || url.pathname === "/json-rpc")) { await handleRpcHttpRequest(request, response, options); return; @@ -321,8 +329,10 @@ async function handleRestAdapter(request, response, url, options) { route: "/v1/device-pods", statusRoute: "/v1/device-pods/{devicePodId}/status", eventsRoute: "/v1/device-pods/{devicePodId}/events", - contractVersion: "device-pod-fake-v1", - frontendCallsOnly: "/v1/device-pods", + contractVersion: "device-pod-authority-v1", + authority: "hwlab-cloud-api", + fakeFallback: false, + jobsRoute: "/v1/device-pods/{devicePodId}/jobs", legacyHardwareUi: "disabled" }, m3IoControl: describeM3IoControl(options) @@ -330,8 +340,13 @@ async function handleRestAdapter(request, response, url, options) { return; } - if (request.method === "GET" && (url.pathname === "/v1/device-pods" || url.pathname.startsWith("/v1/device-pods/"))) { - sendJson(response, 200, await buildDevicePodCloudApiPayload(url, options)); + if (url.pathname.startsWith("/v1/admin/")) { + await options.accessController.handleAdminRoute(request, response, url); + return; + } + + if (url.pathname === "/v1/device-pods" || url.pathname.startsWith("/v1/device-pods/")) { + await options.accessController.handleDevicePodRoute(request, response, url); return; } @@ -376,27 +391,37 @@ async function handleRestAdapter(request, response, url, options) { } if (request.method === "POST" && url.pathname === "/v1/agent/chat") { - await handleCodeAgentChatHttp(request, response, options); + const nextOptions = await codeAgentOptions(request, response, options); + if (!nextOptions) return; + await handleCodeAgentChatHttp(request, response, nextOptions); return; } if (request.method === "GET" && url.pathname === "/v1/agent/chat/inspect") { - await handleCodeAgentInspectHttp(request, response, url, options); + const nextOptions = await codeAgentOptions(request, response, options); + if (!nextOptions) return; + await handleCodeAgentInspectHttp(request, response, url, nextOptions); return; } if (request.method === "GET" && url.pathname.startsWith("/v1/agent/chat/result/")) { - await handleCodeAgentChatResultHttp(request, response, url, options); + const nextOptions = await codeAgentOptions(request, response, options); + if (!nextOptions) return; + await handleCodeAgentChatResultHttp(request, response, url, nextOptions); return; } if (request.method === "POST" && url.pathname === "/v1/agent/chat/cancel") { - await handleCodeAgentCancelHttp(request, response, options); + const nextOptions = await codeAgentOptions(request, response, options); + if (!nextOptions) return; + await handleCodeAgentCancelHttp(request, response, nextOptions); return; } if (request.method === "GET" && url.pathname.startsWith("/v1/agent/chat/trace/")) { - await handleCodeAgentTraceHttp(request, response, url, options); + const nextOptions = await codeAgentOptions(request, response, options); + if (!nextOptions) return; + await handleCodeAgentTraceHttp(request, response, url, nextOptions); return; } @@ -468,6 +493,15 @@ async function handleRestAdapter(request, response, url, options) { sendJson(response, statusForRpcResponse(rpcResponse), rpcResponse); } +async function codeAgentOptions(request, response, options) { + const auth = await options.accessController.authenticate(request, { required: options.accessController.required }); + if (!auth.ok) { + sendJson(response, auth.status, auth); + return null; + } + return { ...options, actor: auth.actor ?? null, authSession: auth.session ?? null }; +} + async function handleGatewayPollHttp(request, response, options) { const body = await readBody(request, options.bodyLimitBytes); let params = {}; diff --git a/internal/db/migrations/0001_cloud_core_skeleton.sql b/internal/db/migrations/0001_cloud_core_skeleton.sql index 10fb02f5..30afaa09 100644 --- a/internal/db/migrations/0001_cloud_core_skeleton.sql +++ b/internal/db/migrations/0001_cloud_core_skeleton.sql @@ -10,6 +10,27 @@ CREATE TABLE IF NOT EXISTS projects ( updated_at TEXT NOT NULL ); +CREATE TABLE IF NOT EXISTS users ( + id TEXT PRIMARY KEY, + username TEXT NOT NULL UNIQUE, + display_name TEXT NOT NULL DEFAULT '', + role TEXT NOT NULL CHECK (role IN ('admin', 'user')), + status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'disabled')), + password_hash TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS user_sessions ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES users(id), + session_token_hash TEXT NOT NULL UNIQUE, + created_at TEXT NOT NULL, + last_seen_at TEXT NOT NULL, + expires_at TEXT NOT NULL, + revoked_at TEXT +); + CREATE TABLE IF NOT EXISTS gateway_sessions ( id TEXT PRIMARY KEY, project_id TEXT, @@ -83,9 +104,24 @@ CREATE TABLE IF NOT EXISTS agent_sessions ( agent_id TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'pending', started_at TEXT, - ended_at TEXT + ended_at TEXT, + owner_user_id TEXT REFERENCES users(id), + conversation_id TEXT, + thread_id TEXT, + last_trace_id TEXT, + session_json TEXT NOT NULL DEFAULT '{}', + updated_at TEXT ); +ALTER TABLE agent_sessions ADD COLUMN IF NOT EXISTS owner_user_id TEXT REFERENCES users(id); +ALTER TABLE agent_sessions ADD COLUMN IF NOT EXISTS conversation_id TEXT; +ALTER TABLE agent_sessions ADD COLUMN IF NOT EXISTS thread_id TEXT; +ALTER TABLE agent_sessions ADD COLUMN IF NOT EXISTS last_trace_id TEXT; +ALTER TABLE agent_sessions ADD COLUMN IF NOT EXISTS session_json TEXT NOT NULL DEFAULT '{}'; +ALTER TABLE agent_sessions ADD COLUMN IF NOT EXISTS updated_at TEXT; +CREATE INDEX IF NOT EXISTS idx_agent_sessions_owner ON agent_sessions(owner_user_id); +CREATE INDEX IF NOT EXISTS idx_agent_sessions_conversation ON agent_sessions(conversation_id); + CREATE TABLE IF NOT EXISTS worker_sessions ( id TEXT PRIMARY KEY, agent_session_id TEXT, @@ -116,6 +152,51 @@ CREATE TABLE IF NOT EXISTS evidence_records ( created_at TEXT NOT NULL ); +CREATE TABLE IF NOT EXISTS device_pods ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL DEFAULT '', + status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'disabled')), + profile_json TEXT NOT NULL DEFAULT '{}', + profile_hash TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS device_pod_grants ( + device_pod_id TEXT NOT NULL REFERENCES device_pods(id) ON DELETE CASCADE, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + created_by_admin_id TEXT NOT NULL REFERENCES users(id), + created_at TEXT NOT NULL, + PRIMARY KEY (device_pod_id, user_id) +); + +CREATE TABLE IF NOT EXISTS device_leases ( + device_pod_id TEXT PRIMARY KEY REFERENCES device_pods(id) ON DELETE CASCADE, + holder_session_id TEXT NOT NULL REFERENCES agent_sessions(id) ON DELETE CASCADE, + holder_user_id TEXT NOT NULL REFERENCES users(id), + lease_token_hash TEXT NOT NULL UNIQUE, + created_at TEXT NOT NULL, + expires_at TEXT NOT NULL, + released_at TEXT +); + +CREATE TABLE IF NOT EXISTS device_pod_jobs ( + id TEXT PRIMARY KEY, + device_pod_id TEXT NOT NULL REFERENCES device_pods(id) ON DELETE CASCADE, + owner_user_id TEXT NOT NULL REFERENCES users(id), + status TEXT NOT NULL, + intent TEXT NOT NULL, + args_json TEXT NOT NULL DEFAULT '{}', + reason TEXT NOT NULL DEFAULT '', + trace_id TEXT NOT NULL, + operation_id TEXT NOT NULL, + output_json TEXT NOT NULL DEFAULT '{}', + blocker_json TEXT NOT NULL DEFAULT '{}', + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + completed_at TEXT +); + CREATE TABLE IF NOT EXISTS hwlab_schema_migrations ( id TEXT PRIMARY KEY, schema_version TEXT NOT NULL, @@ -126,7 +207,7 @@ CREATE TABLE IF NOT EXISTS hwlab_schema_migrations ( INSERT INTO hwlab_schema_migrations (id, schema_version, applied_at, migration_json) VALUES ( '0001_cloud_core_skeleton', - 'runtime-durable-postgres-v1', + 'runtime-durable-postgres-v2', CURRENT_TIMESTAMP, '{"path":"internal/db/migrations/0001_cloud_core_skeleton.sql","runtime":"cloud-api"}' ) diff --git a/internal/db/runtime-store.ts b/internal/db/runtime-store.ts index 7bfe430c..d3a875fc 100644 --- a/internal/db/runtime-store.ts +++ b/internal/db/runtime-store.ts @@ -36,12 +36,19 @@ export const RUNTIME_DURABLE_ENV = "HWLAB_CLOUD_RUNTIME_DURABLE"; const requiredPostgresSchema = CLOUD_RUNTIME_DURABLE_TABLE_COLUMNS; const postgresCountTables = Object.freeze([ + "users", + "user_sessions", "gateway_sessions", "box_resources", "box_capabilities", "hardware_operations", "audit_events", - "evidence_records" + "evidence_records", + "agent_sessions", + "device_pods", + "device_pod_grants", + "device_leases", + "device_pod_jobs" ]); export function createCloudRuntimeStore(options = {}) { @@ -1749,12 +1756,19 @@ function parseJsonColumn(value, fallback) { } function toCountKey(table) { + if (table === "users") return "users"; + if (table === "user_sessions") return "userSessions"; if (table === "gateway_sessions") return "gatewaySessions"; if (table === "box_resources") return "boxResources"; if (table === "box_capabilities") return "boxCapabilities"; if (table === "hardware_operations") return "hardwareOperations"; if (table === "audit_events") return "auditEvents"; if (table === "evidence_records") return "evidenceRecords"; + if (table === "agent_sessions") return "agentSessions"; + if (table === "device_pods") return "devicePods"; + if (table === "device_pod_grants") return "devicePodGrants"; + if (table === "device_leases") return "deviceLeases"; + if (table === "device_pod_jobs") return "devicePodJobs"; return table; } diff --git a/internal/db/schema.ts b/internal/db/schema.ts index f60d6d23..005bc8fe 100644 --- a/internal/db/schema.ts +++ b/internal/db/schema.ts @@ -1,7 +1,7 @@ import { TABLES } from "../protocol/index.mjs"; export const CLOUD_CORE_MIGRATION_ID = "0001_cloud_core_skeleton"; -export const CLOUD_RUNTIME_DURABLE_ADAPTER_SCHEMA_VERSION = "runtime-durable-postgres-v1"; +export const CLOUD_RUNTIME_DURABLE_ADAPTER_SCHEMA_VERSION = "runtime-durable-postgres-v2"; export const CLOUD_RUNTIME_DURABLE_MIGRATION_TABLE = "hwlab_schema_migrations"; export const FROZEN_CLOUD_CORE_TABLES = Object.freeze([...TABLES]); @@ -14,6 +14,25 @@ export const CLOUD_RUNTIME_DURABLE_MIGRATION_TABLE_COLUMNS = Object.freeze([ ]); export const CLOUD_RUNTIME_DURABLE_TABLE_COLUMNS = Object.freeze({ + users: Object.freeze([ + "id", + "username", + "display_name", + "role", + "status", + "password_hash", + "created_at", + "updated_at" + ]), + user_sessions: Object.freeze([ + "id", + "user_id", + "session_token_hash", + "created_at", + "last_seen_at", + "expires_at", + "revoked_at" + ]), gateway_sessions: Object.freeze([ "id", "project_id", @@ -68,6 +87,60 @@ export const CLOUD_RUNTIME_DURABLE_TABLE_COLUMNS = Object.freeze({ "uri", "metadata_json", "created_at" + ]), + agent_sessions: Object.freeze([ + "id", + "project_id", + "agent_id", + "status", + "started_at", + "ended_at", + "owner_user_id", + "conversation_id", + "thread_id", + "last_trace_id", + "session_json", + "updated_at" + ]), + device_pods: Object.freeze([ + "id", + "name", + "status", + "profile_json", + "profile_hash", + "created_at", + "updated_at" + ]), + device_pod_grants: Object.freeze([ + "device_pod_id", + "user_id", + "created_by_admin_id", + "created_at" + ]), + device_leases: Object.freeze([ + "device_pod_id", + "holder_session_id", + "holder_user_id", + "lease_token_hash", + "created_at", + "expires_at", + "released_at" + ]), + device_pod_jobs: Object.freeze([ + "id", + "device_pod_id", + "owner_user_id", + "status", + "intent", + "args_json", + "reason", + "trace_id", + "operation_id", + "output_json", + "blocker_json", + "created_at", + "updated_at", + "completed_at" ]) }); diff --git a/internal/protocol/index.mjs b/internal/protocol/index.mjs index c81808f3..aaf1d90b 100644 --- a/internal/protocol/index.mjs +++ b/internal/protocol/index.mjs @@ -22,6 +22,8 @@ export const SERVICE_IDS = Object.freeze([ export const TABLES = Object.freeze([ "projects", + "users", + "user_sessions", "gateway_sessions", "box_resources", "box_capabilities", @@ -32,7 +34,11 @@ export const TABLES = Object.freeze([ "agent_sessions", "worker_sessions", "agent_trace_events", - "evidence_records" + "evidence_records", + "device_pods", + "device_pod_grants", + "device_leases", + "device_pod_jobs" ]); export const ERROR_CODES = Object.freeze({ diff --git a/scripts/src/check-plan.mjs b/scripts/src/check-plan.mjs index 5b8b90a5..5e11e3f9 100644 --- a/scripts/src/check-plan.mjs +++ b/scripts/src/check-plan.mjs @@ -51,6 +51,8 @@ export const checkProfiles = Object.freeze({ { id: "check-028-cloud-api-run-bun", group: "cloud-api", command: ["node","scripts/run-bun.mjs","build","internal/cloud/json-rpc.ts","--target=bun","--packages=external","--outdir=/tmp/hwlab-ts-check"] }, { id: "check-029-cloud-api-run-bun", group: "cloud-api", command: ["node","scripts/run-bun.mjs","build","internal/cloud/code-agent-chat.ts","--target=bun","--packages=external","--outdir=/tmp/hwlab-ts-check"] }, { id: "check-030-cloud-api-run-bun", group: "cloud-api", command: ["node","scripts/run-bun.mjs","build","internal/cloud/m3-io-control.ts","--target=bun","--packages=external","--outdir=/tmp/hwlab-ts-check"] }, + { id: "check-030a-cloud-api-access-control", group: "cloud-api", command: ["node","scripts/run-bun.mjs","build","internal/cloud/access-control.ts","--target=bun","--packages=external","--outdir=/tmp/hwlab-ts-check"] }, + { id: "check-030b-cloud-api-access-control-test", group: "cloud-api", command: ["node","scripts/run-bun.mjs","build","internal/cloud/access-control.test.ts","--target=bun","--packages=external","--outdir=/tmp/hwlab-ts-check"] }, { id: "check-031-cloud-api-run-bun", group: "cloud-api", command: ["node","scripts/run-bun.mjs","build","internal/cloud/server.ts","--target=bun","--packages=external","--outdir=/tmp/hwlab-ts-check"] }, { id: "check-032-cloud-api-run-bun", group: "cloud-api", command: ["node","scripts/run-bun.mjs","build","internal/cloud/server-test-helpers.ts","--target=bun","--packages=external","--outdir=/tmp/hwlab-ts-check"] }, { id: "check-033-cloud-api-run-bun", group: "cloud-api", command: ["node","scripts/run-bun.mjs","build","internal/cloud/code-agent-trace-store.test.ts","--target=bun","--packages=external","--outdir=/tmp/hwlab-ts-check"] }, @@ -160,7 +162,7 @@ export const checkProfiles = Object.freeze({ { id: "check-143-cloud-api-run-bun", group: "cloud-api", command: ["node","scripts/run-bun.mjs","scripts/code-agent-chat-smoke.mjs"] }, { id: "check-144-cloud-api-run-bun", group: "cloud-api", command: ["node","scripts/run-bun.mjs","scripts/cloud-api-runtime-smoke.mjs"] }, { id: "check-145-repo-bootstrap-skills-sh", group: "repo", command: ["sh","-n","scripts/bootstrap-skills.sh","scripts/worker-entrypoint.sh"] }, - { id: "check-146-cloud-api-run-bun", group: "cloud-api", command: ["node","scripts/run-bun.mjs","test","cmd/hwlab-cloud-api/runtime-options.test.ts","internal/cloud/code-agent-session-registry.test.ts","internal/cloud/json-rpc.test.ts","internal/cloud/m3-io-control.test.ts","internal/cloud/code-agent-trace-store.test.ts","internal/cloud/server-live-builds.test.ts","internal/cloud/server-health.test.ts","internal/cloud/server-agent-chat.test.ts","internal/cloud/server-m3-http.test.ts","internal/db/runtime-store.test.ts","internal/db/schema.test.ts"] } + { id: "check-146-cloud-api-run-bun", group: "cloud-api", command: ["node","scripts/run-bun.mjs","test","cmd/hwlab-cloud-api/runtime-options.test.ts","internal/cloud/access-control.test.ts","internal/cloud/code-agent-session-registry.test.ts","internal/cloud/json-rpc.test.ts","internal/cloud/m3-io-control.test.ts","internal/cloud/code-agent-trace-store.test.ts","internal/cloud/server-live-builds.test.ts","internal/cloud/server-health.test.ts","internal/cloud/server-agent-chat.test.ts","internal/cloud/server-m3-http.test.ts","internal/db/runtime-store.test.ts","internal/db/schema.test.ts"] } ]) });