diff --git a/docs/reference/spec-device-pod.md b/docs/reference/spec-device-pod.md index 3fb4ec75..abf7ce01 100644 --- a/docs/reference/spec-device-pod.md +++ b/docs/reference/spec-device-pod.md @@ -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/reason;CLI 只转发 `reason` 和 `leaseToken`,不在本地绕过。 - `profile create` 这类本地 profile bootstrap 在正式默认路径中返回 `legacy_profile_create_removed`;管理员应使用 cloud-api admin API 管理服务端 profile/grant。 diff --git a/docs/reference/spec-user-access.md b/docs/reference/spec-user-access.md index 01497d1c..d1a1b7b0 100644 --- a/docs/reference/spec-user-access.md +++ b/docs/reference/spec-user-access.md @@ -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` 内,理由是: diff --git a/internal/cloud/access-control.test.ts b/internal/cloud/access-control.test.ts index a8923403..a82dff5b 100644 --- a/internal/cloud/access-control.test.ts +++ b/internal/cloud/access-control.test.ts @@ -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", diff --git a/internal/cloud/access-control.ts b/internal/cloud/access-control.ts index 7f64d5cb..bcdd2f24 100644 --- a/internal/cloud/access-control.ts +++ b/internal/cloud/access-control.ts @@ -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); } diff --git a/tools/device-pod-cli.test.ts b/tools/device-pod-cli.test.ts index 60e8e12a..66115962 100644 --- a/tools/device-pod-cli.test.ts +++ b/tools/device-pod-cli.test.ts @@ -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"); diff --git a/tools/src/device-pod-cli-lib.ts b/tools/src/device-pod-cli-lib.ts index 08896f8a..dcbb21cf 100644 --- a/tools/src/device-pod-cli-lib.ts +++ b/tools/src/device-pod-cli-lib.ts @@ -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>(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}`); }