feat: seed device pods during v02 setup

This commit is contained in:
Codex
2026-05-29 14:17:44 +08:00
parent 4a773a2fe6
commit 4e7d837123
6 changed files with 261 additions and 2 deletions
+1
View File
@@ -256,6 +256,7 @@ manages: many devicePodId
`tools/device-pod-cli.ts` 是 v0.2 正式 CLI 实现,稳定 runner 入口仍是 `/app/skills/device-pod-cli/scripts/device-pod-cli.mjs`。该 `.mjs` 只作为兼容启动器,通过 Bun 运行 TypeScript CLI。正式 CLI 的默认行为是:
- `profile list/show` 调用 cloud-api `/v1/device-pods``/status`,只显示服务端脱敏 profile 摘要和 `profileHash`
- `setup first-admin``admin device-pod upsert/grant` 只作为 cloud-api REST wrapper,用于首次空库 seed 或 admin profile/grant 管理;它们接受显式 `--profile-json``--device-pod-json`,不得读取本地 `.device-pod/*.json` 作为权威 profile。
- `devicePodId:workspace|debug-probe|io-probe...` selector 转换为 `POST /v1/device-pods/{devicePodId}/jobs` 或 job status/output/cancel REST,不直接调用 `/v1/rpc/hardware.invoke.shell`
- mutating job 继续由 cloud-api 侧强制 lease/reasonCLI 只转发 `reason``leaseToken`,不在本地绕过。
- `profile create` 这类本地 profile bootstrap 在正式默认路径中返回 `legacy_profile_create_removed`;管理员应使用 cloud-api admin API 管理服务端 profile/grant。
+3 -1
View File
@@ -272,7 +272,7 @@ code agent prompt、runner 或 worker 不得直接绕过 cloud-api 调用 device
| `POST /auth/login` | 校验本地账号并写入 `user_sessions` token hash。 |
| `GET /auth/session` | 从 cookie 恢复 actor、role 和 session 状态。 |
| `GET /v1/auth/session``GET /v1/users/me``GET /v1/access/status``GET /v1/setup/status` | REST 状态和兼容入口;不得读取或返回 password hash、session token 原文或 Secret 值。 |
| `POST /v1/setup/first-admin` | 仅当 `users` 表为空时创建第一个 `admin` 并建立 session;一旦已有用户必须返回 `409 setup_already_completed`。该入口不读取 Kubernetes Secret,不替代正常 admin API。 |
| `POST /v1/setup/first-admin` | 仅当 `users` 表为空时创建第一个 `admin` 并建立 session可选 `devicePod``devicePods[]` 一次性种下首批服务端权威 profile 并授权给首个 admin一旦已有用户必须返回 `409 setup_already_completed`。该入口不读取 Kubernetes Secret,不替代正常 admin API。 |
| `POST /auth/logout` | 设置 `revoked_at`,撤销当前 browser session。 |
| `POST /v1/admin/users` | admin 创建用户,响应不得返回 `password_hash` 或 token。 |
| `POST /v1/admin/device-pods``PUT /v1/admin/device-pods/{devicePodId}` | admin 管理 device pod profile authority。 |
@@ -281,6 +281,8 @@ code agent prompt、runner 或 worker 不得直接绕过 cloud-api 调用 device
| `POST /v1/device-pods/{devicePodId}/leases``GET/DELETE /v1/device-pods/{devicePodId}/leases/current` | 对已授权 device pod 获取、查看和释放互斥 lease;强副作用 job 必须携带有效 lease token。 |
| `POST /v1/agent/chat` 及 result/trace/cancel | 必须校验 `agent_sessions.owner_user_id`;admin 可跨用户查看和取消。 |
`POST /v1/setup/first-admin` 的 device-pod 初始化只用于空库首次进入系统,不能作为长期 profile 管理入口。每个 seed 必须包含 `devicePodId` 和 object `profile`cloud-api 会写入 `device_pods.profile_json/profile_hash` 并创建 `device_pod_grants(device_pod_id, first_admin_user_id)`。响应只能返回脱敏 profile、profileHash 和 grant summary,不得返回 `gatewaySessionId``hostWorkspaceRoot`、password 或 session token 原文。
## 微服务设计
v0.2 不新增独立用户管理微服务。用户管理、登录、session、device pod grant 和 code agent owner 校验全部放在 `hwlab-cloud-api` 内,理由是:
+78 -1
View File
@@ -590,7 +590,22 @@ test("cloud api first-admin setup opens access when bootstrap secret is absent",
const setup = await postJson(port, "/v1/setup/first-admin", {
username: "admin",
password: "admin-pass",
displayName: "Initial Admin"
displayName: "Initial Admin",
devicePod: {
devicePodId: "device-pod-71-freq",
name: "71-FREQ",
profile: {
schemaVersion: 1,
target: { id: "target-71-freq" },
route: {
gatewaySessionId: "gws_first_admin_seed",
resourceId: "res_windows_host",
capabilityId: "cap_device_host_cli",
hostWorkspaceRoot: "F:\\Work\\Project",
hostCli: "node tools/device-host-cli.mjs"
}
}
}
});
assert.equal(setup.status, 201);
assert.equal(setup.body.created, true);
@@ -598,7 +613,13 @@ test("cloud api first-admin setup opens access when bootstrap secret is absent",
assert.equal(setup.body.actor.role, "admin");
assert.equal(setup.body.actor.username, "admin");
assert.equal(setup.body.setupRequired, false);
assert.deepEqual(setup.body.devicePodBootstrap, { requested: 1, initialized: 1 });
assert.equal(setup.body.devicePodsInitialized[0].devicePod.devicePodId, "device-pod-71-freq");
assert.equal(setup.body.devicePodsInitialized[0].grant.userId, setup.body.actor.id);
assert.equal(setup.body.devicePodsInitialized[0].devicePod.profile.route.gatewaySessionId, "redacted");
assert.equal(JSON.stringify(setup.body).includes("admin-pass"), false);
assert.equal(JSON.stringify(setup.body).includes("gws_first_admin_seed"), false);
assert.equal(JSON.stringify(setup.body).includes("F:\\Work\\Project"), false);
assert.equal(typeof setup.cookie, "string");
const session = await getJson(port, "/v1/users/me", setup.cookie);
@@ -613,6 +634,20 @@ test("cloud api first-admin setup opens access when bootstrap secret is absent",
assert.equal(second.status, 409);
assert.equal(second.body.error.code, "setup_already_completed");
const pods = await getJson(port, "/v1/device-pods", setup.cookie);
assert.equal(pods.status, 200);
assert.equal(pods.body.devicePods.length, 1);
assert.equal(pods.body.devicePods[0].devicePodId, "device-pod-71-freq");
assert.match(pods.body.devicePods[0].profileHash, /^sha256:/u);
const job = await postJson(port, "/v1/device-pods/device-pod-71-freq/jobs", {
intent: "workspace.ls",
args: { path: "." }
}, setup.cookie);
assert.equal(job.status, 409);
assert.equal(job.body.devicePodId, "device-pod-71-freq");
assert.equal(job.body.blocker.code, "gateway_dispatch_unavailable");
const login = await postJson(port, "/auth/login", { username: "admin", password: "admin-pass" });
assert.equal(login.status, 200);
assert.equal(login.body.actor.role, "admin");
@@ -621,6 +656,48 @@ test("cloud api first-admin setup opens access when bootstrap secret is absent",
}
});
test("cloud api first-admin setup validates device-pod seed before creating admin", async () => {
const server = createCloudApiServer({
env: { HWLAB_ACCESS_CONTROL_REQUIRED: "1" },
now: () => "2026-05-28T00:00:00.000Z"
});
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
try {
const { port } = server.address();
const invalid = await postJson(port, "/v1/setup/first-admin", {
username: "admin",
password: "admin-pass",
devicePod: { devicePodId: "device-pod-71-freq" }
});
assert.equal(invalid.status, 400);
assert.equal(invalid.body.error.code, "invalid_params");
const before = await getJson(port, "/v1/setup/status");
assert.equal(before.status, 200);
assert.equal(before.body.setupRequired, true);
const ambiguous = await postJson(port, "/v1/setup/first-admin", {
username: "admin",
password: "admin-pass",
devicePod: { devicePodId: "device-pod-71-freq", profile: { schemaVersion: 1 } },
devicePods: [{ devicePodId: "device-pod-alt", profile: { schemaVersion: 1 } }]
});
assert.equal(ambiguous.status, 400);
assert.equal(ambiguous.body.error.code, "invalid_params");
const setup = await postJson(port, "/v1/setup/first-admin", {
username: "admin",
password: "admin-pass"
});
assert.equal(setup.status, 201);
assert.equal(setup.body.actor.role, "admin");
assert.deepEqual(setup.body.devicePodBootstrap, { requested: 0, initialized: 0 });
} finally {
await new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve())));
}
});
async function postJson(port, path, body, cookie = null, extraHeaders = {}) {
const response = await fetch(`http://127.0.0.1:${port}${path}`, {
method: "POST",
+41
View File
@@ -219,6 +219,7 @@ class AccessController {
const body = await jsonBody(request);
const password = requiredText(body.password, "password");
const devicePodSeeds = firstAdminDevicePodSeeds(body);
const now = this.now();
const user = await this.store.createFirstAdmin?.({
id: this.env.HWLAB_BOOTSTRAP_ADMIN_ID || "usr_bootstrap_admin",
@@ -230,6 +231,20 @@ class AccessController {
if (!user) {
return sendJson(response, 409, errorPayload("setup_already_completed", "First admin setup is closed because at least one user already exists", 409));
}
const devicePodsInitialized = [];
for (const seed of devicePodSeeds) {
const pod = await this.store.upsertDevicePod({ ...seed, now });
const grant = await this.store.grantDevicePod({
devicePodId: pod.id,
userId: user.id,
createdByAdminId: user.id,
now
});
devicePodsInitialized.push({
devicePod: publicDevicePod(pod, { includeAdmin: true }),
grant
});
}
const token = randomBytes(32).toString("base64url");
const session = await this.store.createSession({
userId: user.id,
@@ -244,6 +259,11 @@ class AccessController {
actor: publicActor(user),
session: publicSession({ ...session, user }),
setupRequired: false,
devicePodBootstrap: {
requested: devicePodSeeds.length,
initialized: devicePodsInitialized.length
},
devicePodsInitialized,
contractVersion: "user-access-v1"
});
} catch (error) {
@@ -1132,6 +1152,27 @@ async function jsonBody(request) {
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 firstAdminDevicePodSeeds(body = {}) {
const hasSingle = Object.hasOwn(body, "devicePod");
const hasMany = Object.hasOwn(body, "devicePods");
if (hasSingle && hasMany) throw Object.assign(new Error("Use devicePod or devicePods, not both"), { statusCode: 400, code: "invalid_params" });
if (!hasSingle && !hasMany) return [];
const values = hasMany ? body.devicePods : [body.devicePod];
if (!Array.isArray(values)) throw Object.assign(new Error("devicePods must be an array"), { statusCode: 400, code: "invalid_params" });
return values.map((item, index) => firstAdminDevicePodSeed(item, hasSingle ? "devicePod" : `devicePods[${index}]`));
}
function firstAdminDevicePodSeed(value, field) {
if (!value || typeof value !== "object" || Array.isArray(value)) throw Object.assign(new Error(`${field} must be an object`), { statusCode: 400, code: "invalid_params" });
const id = requiredText(value.devicePodId ?? value.id, `${field}.devicePodId`);
const profile = normalizeObject(value.profile ?? value.profileJson);
if (Object.keys(profile).length === 0) throw Object.assign(new Error(`${field}.profile is required`), { statusCode: 400, code: "invalid_params" });
return {
id,
name: textOr(value.name, id),
status: value.status === "disabled" ? "disabled" : "active",
profile
};
}
function normalizeBaseUrl(value) { const text = textOr(value, "").replace(/\/+$/u, ""); return /^https?:\/\//u.test(text) ? text : ""; }
function boundedLeaseTtlSeconds(value) { const parsed = Number.parseInt(String(value ?? ""), 10); return Math.min(Math.max(Number.isInteger(parsed) && parsed > 0 ? parsed : DEFAULT_DEVICE_LEASE_TTL_SECONDS, 1), MAX_DEVICE_LEASE_TTL_SECONDS); }
function boundedOutputMaxBytes(searchParams) { return Math.min(Math.max(Number.parseInt(searchParams.get("maxBytes") ?? "12000", 10) || 12000, 1), DEVICE_JOB_OUTPUT_MAX_BYTES); }
+63
View File
@@ -6,6 +6,69 @@ import { test } from "bun:test";
import { runDevicePodCli } from "./src/device-pod-cli-lib.ts";
test("device-pod-cli first-admin setup can seed a cloud-api device pod without local profile authority", async () => {
const seen: any[] = [];
const profile = { schemaVersion: 1, target: { id: "target-71-freq" }, route: { gatewaySessionId: "gws_admin_seed" } };
const result = await runDevicePodCli([
"--api-base-url", "http://cloud.test",
"--username", "admin",
"--password", "admin-pass",
"--pod-id", "device-pod-71-freq",
"--name", "71-FREQ",
"--profile-json", JSON.stringify(profile),
"setup",
"first-admin"
], {
fetchImpl: async (url, init) => {
seen.push({ url: String(url), init, body: JSON.parse(String(init?.body ?? "{}")) });
return new Response(JSON.stringify({ created: true, actor: { id: "usr_admin", role: "admin" }, devicePodBootstrap: { requested: 1, initialized: 1 } }), { status: 201, headers: { "content-type": "application/json", "set-cookie": "hwlab_session=session-a" } });
},
now: () => "2026-05-29T00:00:00.000Z"
});
assert.equal(result.exitCode, 0);
assert.equal(result.payload.action, "setup.first-admin");
assert.equal(result.payload.devicePodSeedProvided, true);
assert.equal(result.payload.localProfileAuthority, false);
assert.equal(result.payload.setCookie, "hwlab_session=session-a");
assert.equal(seen[0].url, "http://cloud.test/v1/setup/first-admin");
assert.equal(seen[0].init.headers["x-hwlab-session-token"], undefined);
assert.deepEqual(seen[0].body.devicePod, { devicePodId: "device-pod-71-freq", name: "71-FREQ", profile });
});
test("device-pod-cli admin wrappers call cloud-api profile and grant routes", async () => {
const seen: any[] = [];
const profile = { schemaVersion: 1, target: { id: "target-71-freq" }, route: { gatewaySessionId: "gws_admin_seed" } };
const fetchImpl = async (url, init) => {
seen.push({ url: String(url), init, body: JSON.parse(String(init?.body ?? "{}")) });
return jsonResponse(201, { ok: true });
};
const upsert = await runDevicePodCli([
"--api-base-url", "http://cloud.test",
"--session-token", "session-a",
"--pod-id", "device-pod-71-freq",
"--profile-json", JSON.stringify(profile),
"admin",
"device-pod",
"upsert"
], { fetchImpl, now: () => "2026-05-29T00:00:00.000Z" });
assert.equal(upsert.exitCode, 0);
assert.equal(seen[0].url, "http://cloud.test/v1/admin/device-pods");
assert.equal(seen[0].init.headers["x-hwlab-session-token"], "session-a");
assert.deepEqual(seen[0].body, { devicePodId: "device-pod-71-freq", profile });
const grant = await runDevicePodCli([
"--api-base-url", "http://cloud.test",
"--session-token", "session-a",
"--pod-id", "device-pod-71-freq",
"--user-id", "usr_alice",
"admin",
"grant"
], { fetchImpl, now: () => "2026-05-29T00:00:00.000Z" });
assert.equal(grant.exitCode, 0);
assert.equal(seen[1].url, "http://cloud.test/v1/admin/device-pod-grants");
assert.deepEqual(seen[1].body, { devicePodId: "device-pod-71-freq", userId: "usr_alice" });
});
test("device-pod-cli lists cloud-api authority and ignores local profile routes", async () => {
const root = await mkdtemp(path.join(os.tmpdir(), "hwlab-device-pod-cli-"));
const profileDir = path.join(root, ".device-pod");
+75
View File
@@ -30,6 +30,8 @@ export async function runDevicePodCli(argv: string[], options: { env?: EnvLike;
async function dispatch(target: string, context: any) {
if (["help", "--help", "-h"].includes(target)) return help();
if (target === "setup") return setupCommand(context);
if (target === "admin") return adminCommand(context);
if (target === "login") return login(context);
if (target === "doctor") return doctor(context);
if (target === "health") return health(context);
@@ -53,6 +55,9 @@ function help() {
},
usage: [
"device-pod-cli login --api-base-url URL --username USER --password PASS",
"device-pod-cli setup first-admin --api-base-url URL --username USER --password PASS --pod-id device-pod-71-freq --profile-json JSON",
"device-pod-cli admin device-pod upsert --api-base-url URL --session-token TOKEN --pod-id device-pod-71-freq --profile-json JSON",
"device-pod-cli admin grant --api-base-url URL --session-token TOKEN --pod-id device-pod-71-freq --user-id USER_ID",
"device-pod-cli profile list --api-base-url URL --session-token TOKEN",
"device-pod-cli profile show --pod-id device-pod-71-freq --api-base-url URL --session-token TOKEN",
"device-pod-cli device-pod-71-freq:workspace:/ ls --api-base-url URL --session-token TOKEN",
@@ -63,6 +68,45 @@ function help() {
});
}
async function setupCommand({ parsed, rest, env, fetchImpl }: any) {
const subcommand = rest[0] || "first-admin";
if (subcommand !== "first-admin") throw cliError("unsupported_setup_command", `unsupported setup command: ${subcommand}`);
const devicePod = devicePodSeedFromOptions(parsed, { required: false });
const body = clean({
username: requiredOption(parsed, env, "username", "HWLAB_USERNAME"),
password: requiredOption(parsed, env, "password", "HWLAB_PASSWORD"),
displayName: text(parsed.displayName),
...(devicePod ? { devicePod } : {})
});
const response = await requestJson({ parsed, env, fetchImpl, method: "POST", path: "/v1/setup/first-admin", body, auth: false });
return responsePayload("setup.first-admin", response, { setCookie: response.headers.get("set-cookie") ?? null, devicePodSeedProvided: Boolean(devicePod), localProfileAuthority: false });
}
async function adminCommand(context: any) {
const subcommand = context.rest[0] || "help";
if (subcommand === "device-pod" || subcommand === "profile") return adminDevicePodCommand({ ...context, rest: context.rest.slice(1) });
if (subcommand === "grant" || subcommand === "device-pod-grant") return adminGrantCommand({ ...context, rest: context.rest.slice(1) });
throw cliError("unsupported_admin_command", `unsupported admin command: ${subcommand}`);
}
async function adminDevicePodCommand({ parsed, rest, env, fetchImpl }: any) {
const subcommand = rest[0] || "upsert";
if (!["create", "upsert", "update", "put"].includes(subcommand)) throw cliError("unsupported_admin_device_pod_command", `unsupported admin device-pod command: ${subcommand}`);
const devicePod = devicePodSeedFromOptions(parsed, { required: true });
const method = subcommand === "update" || subcommand === "put" ? "PUT" : "POST";
const path = method === "PUT" ? `/v1/admin/device-pods/${encodeURIComponent(devicePod.devicePodId)}` : "/v1/admin/device-pods";
const response = await requestJson({ parsed, env, fetchImpl, method, path, body: devicePod });
return responsePayload(`admin.device-pod.${subcommand}`, response, { route: { method, path }, devicePodId: devicePod.devicePodId, localProfileAuthority: false });
}
async function adminGrantCommand({ parsed, rest, env, fetchImpl }: any) {
const podId = requiredText(parsed.podId ?? parsed.devicePodId ?? rest[0], "podId");
const userId = requiredText(parsed.userId ?? rest[1], "userId");
const body = { devicePodId: podId, userId };
const response = await requestJson({ parsed, env, fetchImpl, method: "POST", path: "/v1/admin/device-pod-grants", body });
return responsePayload("admin.device-pod-grant", response, { devicePodId: podId, userId });
}
async function login({ parsed, env, fetchImpl }: any) {
const response = await requestJson({ parsed, env, fetchImpl, method: "POST", path: "/auth/login", body: { username: requiredOption(parsed, env, "username", "HWLAB_USERNAME"), password: requiredOption(parsed, env, "password", "HWLAB_PASSWORD") }, auth: false });
return responsePayload("login", response, { setCookie: response.headers.get("set-cookie") ?? null });
@@ -257,6 +301,37 @@ function requiredText(value: unknown, field: string) { const valueText = text(va
function text(value: unknown) { return String(value ?? "").trim(); }
function numberOption(value: unknown) { const parsed = Number.parseInt(String(value ?? ""), 10); return Number.isFinite(parsed) ? parsed : undefined; }
function clean<T extends Record<string, any>>(value: T): T { return Object.fromEntries(Object.entries(value).filter(([, item]) => item !== undefined && item !== "" && item !== false)) as T; }
function devicePodSeedFromOptions(parsed: ParsedArgs, { required = false } = {}) {
const devicePodText = text(parsed.devicePodJson ?? parsed.devicePod);
if (devicePodText) return normalizeDevicePodSeed(parseJsonObjectOption(devicePodText, "devicePodJson"), "devicePodJson");
const profileText = text(parsed.profileJson ?? parsed.profile);
if (!profileText) {
if (required) throw cliError("device_pod_profile_required", "--profile-json or --device-pod-json is required", { field: "profileJson" });
return null;
}
return normalizeDevicePodSeed({
devicePodId: parsed.podId ?? parsed.devicePodId,
name: parsed.name,
status: parsed.status,
profile: parseJsonObjectOption(profileText, "profileJson")
}, "devicePod");
}
function normalizeDevicePodSeed(value: any, field: string) {
if (!value || typeof value !== "object" || Array.isArray(value)) throw cliError("invalid_device_pod_seed", `${field} must be a JSON object`, { field });
const devicePodId = requiredText(value.devicePodId ?? value.id, `${field}.devicePodId`);
const profile = value.profile ?? value.profileJson;
if (!profile || typeof profile !== "object" || Array.isArray(profile)) throw cliError("invalid_device_pod_profile", `${field}.profile must be a JSON object`, { field: `${field}.profile` });
return clean({ devicePodId, name: text(value.name), status: text(value.status), profile });
}
function parseJsonObjectOption(value: string, field: string) {
try {
const parsed = JSON.parse(value);
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error("not an object");
return parsed;
} catch (error: any) {
throw cliError("invalid_json_option", `${field} must be a JSON object`, { field, reason: error?.message ?? String(error) });
}
}
function normalizePath(value: string) { return String(value || ".").trim().replace(/\\/gu, "/").replace(/\s*\/\s*/gu, "/").replace(/\/+/gu, "/").replace(/^\/+|\/+$/gu, "") || "."; }
function normalizeProbePath(value: string) { const normalized = normalizePath(value); if (normalized === ".") return "uart/1"; if (normalized === "uart") throw cliError("invalid_io_probe_path", "io-probe path must be concrete, for example /uart/1"); return normalized; }
function joinPath(base: string, child: string) { const left = normalizePath(base); const right = normalizePath(child || "."); if (!right || right === ".") return left; if (child.startsWith("/")) return right; return normalizePath(`${left}/${right}`); }