feat: add v02 access device pod authority
This commit is contained in:
@@ -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()
|
||||
};
|
||||
}
|
||||
@@ -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; } }
|
||||
@@ -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
|
||||
|
||||
@@ -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")];
|
||||
}
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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())));
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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))];
|
||||
}
|
||||
|
||||
+44
-10
@@ -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 = {};
|
||||
|
||||
@@ -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"}'
|
||||
)
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
+74
-1
@@ -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"
|
||||
])
|
||||
});
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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"] }
|
||||
])
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user