From e2bb573a589ee5c019f2f713ecf5521d9e9ce534 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=86=B0=E6=9C=94?= <565183519@qq.com> Date: Wed, 29 Jul 2026 23:37:26 +0800 Subject: [PATCH] fix(jd): publish Guanghu router runtime source --- .../lake-lamp-authz/authorize-repo-push.js | 7 +- .../authorize-repo-push.test.js | 6 +- .../lake-lamp-authz/guanghu-router.js | 277 ++++++++++ .../lake-lamp-authz/guanghu-router.test.js | 162 ++++++ .../lake-lamp-authz/lake-lamp-authz.service | 2 +- .../lake-lamp-authz/repo-push-broker.js | 100 +++- .../lake-lamp-authz/repo-push-broker.test.js | 80 ++- .../repo-push-registry.example.json | 15 +- .../lake-lamp-authz/request-workorder.js | 3 - server-tools/lake-lamp-authz/server.js | 512 +++++++++++++++++- server-tools/lake-lamp-authz/server.test.js | 334 +++++++++++- server-tools/lake-lamp-authz/smtp-mailer.js | 4 +- .../lake-lamp-authz/workorder-manager.js | 36 ++ .../lake-lamp-authz/workorder-manager.test.js | 27 + 14 files changed, 1492 insertions(+), 73 deletions(-) create mode 100644 server-tools/lake-lamp-authz/guanghu-router.js create mode 100644 server-tools/lake-lamp-authz/guanghu-router.test.js diff --git a/server-tools/lake-lamp-authz/authorize-repo-push.js b/server-tools/lake-lamp-authz/authorize-repo-push.js index 28d0112..e2b33b8 100755 --- a/server-tools/lake-lamp-authz/authorize-repo-push.js +++ b/server-tools/lake-lamp-authz/authorize-repo-push.js @@ -17,7 +17,6 @@ async function authorizeRepoPush(options, deps = {}) { origin_software: options.software || "仓库推送客户端", origin_model: options.model || "未声明模型", origin_instance: options.instance || "当前实例", - owner_notify: true, persona_id: persona, persona_name: options.name || persona, target, @@ -26,10 +25,10 @@ async function authorizeRepoPush(options, deps = {}) { description: options.description || `申请推送 ${repo}`, }); - output("[LL-WORKORDER-CREATED] 无执行权申请单已创建;服务器已向预登记邮箱发送批准链接,也没有推送权限。"); + output("[LL-WORKORDER-CREATED] 无执行权申请单已创建;尚未发送邮件,也没有推送权限。"); output(`REQUEST_URL=${request.request_url}`); - output("请把 REQUEST_URL 交给冰朔用于核对申请内容并保持本命令运行;批准链接已由服务器发送到预登记邮箱。"); - output("不需要向冰朔索要邮箱、授权码、验证码、密码或任何令牌。"); + output("请把 REQUEST_URL 交给冰朔并保持本命令运行;冰朔会在京东节点托管页面亲自输入登记邮箱。"); + output("人格体不得索要、接收、记录或转存邮箱、授权码、验证码、密码和任何令牌。"); const deadline = Date.now() + Number(request.expires_in || 900) * 1000; let session; diff --git a/server-tools/lake-lamp-authz/authorize-repo-push.test.js b/server-tools/lake-lamp-authz/authorize-repo-push.test.js index e8413b2..f3f0bce 100644 --- a/server-tools/lake-lamp-authz/authorize-repo-push.test.js +++ b/server-tools/lake-lamp-authz/authorize-repo-push.test.js @@ -50,7 +50,11 @@ test("repo-push helper stops with a server receipt when no safe transport is dep while (!lines.some(line => line.startsWith("REQUEST_URL="))) await new Promise(resolve => setTimeout(resolve, 1)); const requestUrl = lines.find(line => line.startsWith("REQUEST_URL=")).slice("REQUEST_URL=".length); const requestPath = new URL(requestUrl).pathname.replace("/authz", ""); - assert.equal((await fetch(`${base}${requestPath}`, { method: "POST" })).status, 200); + assert.equal((await fetch(`${base}${requestPath}`, { + method: "POST", + headers: { "content-type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ email: "owner@example.invalid" }).toString(), + })).status, 200); assert.equal(mail.length, 1); const approvalPath = new URL(mail[0].approvalUrl).pathname.replace("/authz", ""); assert.equal((await fetch(`${base}${approvalPath}`, { method: "POST" })).status, 200); diff --git a/server-tools/lake-lamp-authz/guanghu-router.js b/server-tools/lake-lamp-authz/guanghu-router.js new file mode 100644 index 0000000..ce33706 --- /dev/null +++ b/server-tools/lake-lamp-authz/guanghu-router.js @@ -0,0 +1,277 @@ +"use strict"; + +const crypto = require("node:crypto"); +const fs = require("node:fs"); + +const CONNECT_SCHEMA = "guanghu.router-connect/v1"; +const APPROVAL_SCHEMA = "guanghu.router-approval/v1"; +const MAX_CLOCK_SKEW_SECONDS = 120; + +class GuanghuRouter { + constructor({ devices = [], challengeTtl = 60, routeTokenTtl = 30 } = {}) { + this.devices = new Map( + devices + .filter(validDevice) + .map(device => [device.device_id, Object.freeze({ ...device })]), + ); + this.challengeTtl = Math.max(15, Number(challengeTtl) || 60); + this.routeTokenTtl = Math.max(10, Number(routeTokenTtl) || 30); + this.challenges = new Map(); + this.routeTokens = new Map(); + this.connections = new Map(); + } + + challenge(deviceId, now = Date.now() / 1000) { + const device = this.devices.get(String(deviceId || "")); + if (!device || !device.enabled) return { ok: false, reason: "device_not_registered" }; + const challengeId = crypto.randomUUID(); + const nonce = randomToken(); + this.challenges.set(challengeId, { + deviceId: device.device_id, + nonce, + expiresAt: now + this.challengeTtl, + }); + return { + ok: true, + schema: CONNECT_SCHEMA, + challengeId, + nonce, + expiresAt: now + this.challengeTtl, + serverTime: now, + }; + } + + authorizeConnection(input, now = Date.now() / 1000) { + const deviceId = String(input && input.deviceId || ""); + const challengeId = String(input && input.challengeId || ""); + const challenge = this.challenges.get(challengeId); + const device = this.devices.get(deviceId); + if (!device || !device.enabled) return { ok: false, reason: "device_not_registered" }; + if (!challenge || challenge.deviceId !== deviceId) return { ok: false, reason: "challenge_not_found" }; + if (now > challenge.expiresAt) { + this.challenges.delete(challengeId); + return { ok: false, reason: "challenge_expired" }; + } + const clientTimestamp = Number(input && input.clientTimestamp); + if (!Number.isFinite(clientTimestamp) || Math.abs(now - clientTimestamp) > MAX_CLOCK_SKEW_SECONDS) { + return { ok: false, reason: "device_clock_out_of_range" }; + } + const message = canonicalConnect({ + deviceId, + challengeId, + nonce: challenge.nonce, + clientTimestamp, + }); + if (!verifyDeviceSignature(device, message, input && input.signature)) { + return { ok: false, reason: "device_signature_invalid" }; + } + + this.challenges.delete(challengeId); + const routeToken = randomToken(); + this.routeTokens.set(tokenHash(routeToken), { + deviceId, + expiresAt: now + this.routeTokenTtl, + }); + return { + ok: true, + deviceId, + deviceLabel: device.label, + ownerId: device.owner_id, + routeToken, + expiresAt: now + this.routeTokenTtl, + }; + } + + open(routeToken, send, now = Date.now() / 1000) { + const key = tokenHash(routeToken || ""); + const pending = this.routeTokens.get(key); + if (!pending) return { ok: false, reason: "route_token_not_found" }; + this.routeTokens.delete(key); + if (now > pending.expiresAt) return { ok: false, reason: "route_token_expired" }; + const device = this.devices.get(pending.deviceId); + if (!device || !device.enabled) return { ok: false, reason: "device_not_registered" }; + + const connectionId = crypto.randomUUID(); + const existing = this.connections.get(device.device_id); + if (existing) existing.close(now, "replaced"); + const connection = { + connectionId, + device, + send, + openedAt: now, + closed: false, + close: (closedAt = Date.now() / 1000, reason = "client_closed") => { + if (connection.closed) return; + connection.closed = true; + if (this.connections.get(device.device_id) === connection) { + this.connections.delete(device.device_id); + } + send({ + type: "router.closed", + reason, + receipt: routeReceipt("offline", device, connectionId, closedAt), + }); + }, + }; + this.connections.set(device.device_id, connection); + const receipt = routeReceipt("online", device, connectionId, now); + send({ type: "router.connected", connection_id: connectionId, receipt }); + return { + ok: true, + state: "online", + connectionId, + deviceId: device.device_id, + ownerId: device.owner_id, + receipt, + close: connection.close, + }; + } + + isApproverOnline(approverId) { + return [...this.connections.values()].some(connection => ( + !connection.closed && connection.device.owner_id === String(approverId || "") + )); + } + + deliver(approverId, order) { + const event = { + type: "authorization.requested", + digest: workorderDigest(order), + workorder: order, + }; + let delivered = 0; + for (const connection of this.connections.values()) { + if (!connection.closed && connection.device.owner_id === String(approverId || "")) { + connection.send(event); + delivered += 1; + } + } + return delivered; + } + + verifyApproval(deviceId, order, signature) { + const connection = this.connections.get(String(deviceId || "")); + if (!connection || connection.closed) return { ok: false, reason: "device_route_offline" }; + const digest = workorderDigest(order); + const message = canonicalApproval({ + deviceId: connection.device.device_id, + workorderId: order.id, + digest, + }); + if (!verifyDeviceSignature(connection.device, message, signature)) { + return { ok: false, reason: "device_signature_invalid" }; + } + return { + ok: true, + authorizerId: connection.device.owner_id, + deviceId: connection.device.device_id, + digest, + }; + } +} + +function loadDevices(file) { + if (!file || !fs.existsSync(file)) return []; + const parsed = JSON.parse(fs.readFileSync(file, "utf8")); + if (!Array.isArray(parsed)) throw new Error("HoloLake device registry must be a JSON array"); + return parsed.filter(validDevice); +} + +function validDevice(device) { + return Boolean( + device + && typeof device.device_id === "string" + && /^[A-Za-z0-9._-]{3,128}$/.test(device.device_id) + && typeof device.owner_id === "string" + && device.owner_id.length > 0 + && typeof device.label === "string" + && device.label.length > 0 + && typeof device.public_key === "string" + && /^[A-Za-z0-9_-]{40,64}$/.test(device.public_key) + && typeof device.enabled === "boolean", + ); +} + +function verifyDeviceSignature(device, message, signature) { + try { + const publicKey = crypto.createPublicKey({ + key: { kty: "OKP", crv: "Ed25519", x: device.public_key }, + format: "jwk", + }); + return crypto.verify( + null, + Buffer.from(message), + publicKey, + Buffer.from(String(signature || ""), "base64url"), + ); + } catch { + return false; + } +} + +function canonicalConnect({ deviceId, challengeId, nonce, clientTimestamp }) { + return [ + CONNECT_SCHEMA, + String(deviceId), + String(challengeId), + String(nonce), + String(clientTimestamp), + ].join("\n"); +} + +function canonicalApproval({ deviceId, workorderId, digest }) { + return [ + APPROVAL_SCHEMA, + String(deviceId), + String(workorderId), + String(digest), + ].join("\n"); +} + +function workorderDigest(order) { + const value = { + id: String(order.id || ""), + persona_id: String(order.persona && order.persona.pid || ""), + persona_name: String(order.persona && order.persona.name || ""), + target: String(order.target || ""), + scope: String(order.scope || ""), + action: String(order.action || ""), + allowed_actions: [...(order.allowed_actions || order.allowedActions || [order.action])].map(String), + description: String(order.description || ""), + resource: String(order.resource || ""), + created_at: Number(order.createdAt || 0), + expires_at: Number(order.expiresAt || 0), + }; + return crypto.createHash("sha256").update(JSON.stringify(value)).digest("hex"); +} + +function routeReceipt(state, device, connectionId, now) { + return { + schema: "guanghu.route-receipt/v1", + receipt_id: crypto.randomUUID(), + state, + device_id: device.device_id, + owner_id: device.owner_id, + node_id: "JD-FD-PRIMARY", + connection_id: connectionId, + occurred_at: now, + }; +} + +function randomToken() { + return crypto.randomBytes(32).toString("base64url"); +} + +function tokenHash(value) { + return crypto.createHash("sha256").update(String(value)).digest("hex"); +} + +module.exports = { + APPROVAL_SCHEMA, + CONNECT_SCHEMA, + GuanghuRouter, + canonicalApproval, + canonicalConnect, + loadDevices, + workorderDigest, +}; diff --git a/server-tools/lake-lamp-authz/guanghu-router.test.js b/server-tools/lake-lamp-authz/guanghu-router.test.js new file mode 100644 index 0000000..4094228 --- /dev/null +++ b/server-tools/lake-lamp-authz/guanghu-router.test.js @@ -0,0 +1,162 @@ +"use strict"; + +const test = require("node:test"); +const assert = require("node:assert/strict"); +const crypto = require("node:crypto"); +const { + GuanghuRouter, + canonicalApproval, + canonicalConnect, + workorderDigest, +} = require("./guanghu-router"); + +function registeredDevice() { + const { publicKey, privateKey } = crypto.generateKeyPairSync("ed25519"); + const publicJwk = publicKey.export({ format: "jwk" }); + return { + device: { + device_id: "HL-BS-MAC-001", + owner_id: "owner", + label: "冰朔的 HoloLake", + public_key: publicJwk.x, + enabled: true, + }, + privateKey, + }; +} + +function signedConnect(router, device, privateKey, now) { + const challenge = router.challenge(device.device_id, now); + assert.equal(challenge.ok, true); + const signature = crypto.sign( + null, + Buffer.from(canonicalConnect({ + deviceId: device.device_id, + challengeId: challenge.challengeId, + nonce: challenge.nonce, + clientTimestamp: now, + })), + privateKey, + ).toString("base64url"); + return router.authorizeConnection({ + deviceId: device.device_id, + challengeId: challenge.challengeId, + clientTimestamp: now, + signature, + }, now); +} + +test("only a registered device with a valid signature receives a one-time route token", () => { + const { device, privateKey } = registeredDevice(); + const router = new GuanghuRouter({ devices: [device] }); + assert.deepEqual(router.challenge("unknown-device", 1_000), { + ok: false, + reason: "device_not_registered", + }); + + const challenge = router.challenge(device.device_id, 1_000); + const rejected = router.authorizeConnection({ + deviceId: device.device_id, + challengeId: challenge.challengeId, + clientTimestamp: 1_000, + signature: "invalid", + }, 1_000); + assert.equal(rejected.ok, false); + assert.equal(rejected.reason, "device_signature_invalid"); + + const authorized = signedConnect(router, device, privateKey, 1_001); + assert.equal(authorized.ok, true); + assert.match(authorized.routeToken, /^[A-Za-z0-9_-]{40,}$/); + assert.equal(router.isApproverOnline("owner"), false); +}); + +test("the open transport is the online fact and closing it makes the device offline", () => { + const { device, privateKey } = registeredDevice(); + const router = new GuanghuRouter({ devices: [device] }); + const authorized = signedConnect(router, device, privateKey, 2_000); + const events = []; + const opened = router.open(authorized.routeToken, event => events.push(event), 2_001); + + assert.equal(opened.ok, true); + assert.equal(opened.state, "online"); + assert.equal(router.isApproverOnline("owner"), true); + assert.equal(events[0].type, "router.connected"); + assert.equal(events[0].receipt.state, "online"); + assert.equal(router.open(authorized.routeToken, () => {}, 2_002).reason, "route_token_not_found"); + + opened.close(2_003); + assert.equal(router.isApproverOnline("owner"), false); + assert.equal(events.at(-1).type, "router.closed"); + assert.equal(events.at(-1).receipt.state, "offline"); +}); + +test("authorization cards travel through the already-open route", () => { + const { device, privateKey } = registeredDevice(); + const router = new GuanghuRouter({ devices: [device] }); + const authorized = signedConnect(router, device, privateKey, 3_000); + const events = []; + router.open(authorized.routeToken, event => events.push(event), 3_001); + const order = { + id: "203e12af-f821-4b62-b80f-b3d73df05161", + persona: { pid: "ICE-GL-ZY001", name: "铸渊" }, + target: "JD-FD-PRIMARY", + scope: "server-login", + action: "read-navigation-map", + allowed_actions: ["read-navigation-map", "inspect-services"], + description: "进入第五域", + resource: "", + createdAt: 3_000, + expiresAt: 4_000, + state: "pending", + }; + assert.equal(router.deliver("owner", order), 1); + const card = events.at(-1); + assert.equal(card.type, "authorization.requested"); + assert.equal(card.workorder.id, order.id); + assert.equal(card.digest, workorderDigest(order)); +}); + +test("the bound device signs the exact authorization card digest", () => { + const { device, privateKey } = registeredDevice(); + const router = new GuanghuRouter({ devices: [device] }); + const authorized = signedConnect(router, device, privateKey, 4_000); + router.open(authorized.routeToken, () => {}, 4_001); + const order = { + id: "203e12af-f821-4b62-b80f-b3d73df05161", + persona: { pid: "ICE-GL-ZY001", name: "铸渊" }, + target: "JD-FD-PRIMARY", + scope: "server-login", + action: "read-navigation-map", + allowed_actions: ["read-navigation-map", "inspect-services"], + description: "进入第五域", + resource: "", + createdAt: 4_000, + expiresAt: 5_000, + state: "pending", + }; + const digest = workorderDigest(order); + const signature = crypto.sign( + null, + Buffer.from(canonicalApproval({ + deviceId: device.device_id, + workorderId: order.id, + digest, + })), + privateKey, + ).toString("base64url"); + + const verified = router.verifyApproval( + device.device_id, + order, + signature, + ); + assert.equal(verified.ok, true); + assert.equal(verified.authorizerId, "owner"); + assert.equal(verified.deviceId, device.device_id); + + const changed = { ...order, action: "inspect-services" }; + assert.equal( + router.verifyApproval(device.device_id, changed, signature).reason, + "device_signature_invalid", + ); +}); diff --git a/server-tools/lake-lamp-authz/lake-lamp-authz.service b/server-tools/lake-lamp-authz/lake-lamp-authz.service index 7a776dd..6e8b43b 100644 --- a/server-tools/lake-lamp-authz/lake-lamp-authz.service +++ b/server-tools/lake-lamp-authz/lake-lamp-authz.service @@ -16,7 +16,7 @@ NoNewPrivileges=true PrivateTmp=true ProtectSystem=strict ProtectHome=true -ReadWritePaths=/var/lib/guanghu/lake-lamp-authz /var/lib/guanghu/repo-authorizations +ReadWritePaths=/var/lib/guanghu/lake-lamp-authz /var/lib/guanghu/repo-authorizations /var/lib/guanghu/repo-push-uploads /var/lib/guanghu/forgejo/repositories/bingshuo/hololake-platform.git RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 LockPersonality=true diff --git a/server-tools/lake-lamp-authz/repo-push-broker.js b/server-tools/lake-lamp-authz/repo-push-broker.js index ef0353a..7556282 100644 --- a/server-tools/lake-lamp-authz/repo-push-broker.js +++ b/server-tools/lake-lamp-authz/repo-push-broker.js @@ -7,6 +7,8 @@ const fs = require("node:fs"); const os = require("node:os"); const path = require("node:path"); +const crypto = require("node:crypto"); +const { fileURLToPath } = require("node:url"); const { execFile } = require("node:child_process"); function loadRegistry(file) { @@ -23,7 +25,10 @@ async function receiveBundle(request, options = {}) { const expectedHead = String(request.expected_head || "").toLowerCase(); const entry = registry[repo]; if (!entry) return blocked("repo_not_allowlisted"); - if (!/^[a-z0-9][a-z0-9._/-]{0,199}$/.test(branch) || branch !== String(entry.branch || "main")) return blocked("branch_not_allowlisted"); + const allowedBranches = Array.isArray(entry.branches) + ? entry.branches.map(String) + : [String(entry.branch || "main")]; + if (!/^[a-z0-9][a-z0-9._/-]{0,199}$/.test(branch) || !allowedBranches.includes(branch)) return blocked("branch_not_allowlisted"); if (!/^[0-9a-f]{40}$/.test(expectedHead)) return blocked("expected_head_required"); const bundlePath = checkedBundlePath(request.bundle_path, options.uploadDir || process.env.LAKE_LAMP_REPO_UPLOAD_DIR || "/var/lib/guanghu/repo-push-uploads"); if (!bundlePath) return blocked("bundle_path_invalid"); @@ -31,16 +36,53 @@ async function receiveBundle(request, options = {}) { const temporary = fs.mkdtempSync(path.join(options.tempDir || os.tmpdir(), "lake-lamp-receive-")); const bare = path.join(temporary, "quarantine.git"); try { - await run(["init", "--bare", bare]); + // Clone the registered repository into quarantine instead of initializing an + // empty repository and fetching refs. A registered source may itself be + // shallow; cloning preserves its shallow boundary metadata, while a ref-only + // fetch can leave the prerequisite commit present but its parents unreadable. + await run(["clone", "--bare", "--no-local", entry.remote, bare]); await run(["-C", bare, "bundle", "verify", bundlePath]); await run(["-C", bare, "fetch", bundlePath, `refs/heads/${branch}:refs/heads/incoming`]); const incoming = (await run(["-C", bare, "rev-parse", "refs/heads/incoming"])).stdout.trim().toLowerCase(); if (!/^[0-9a-f]{40}$/.test(incoming)) return blocked("bundle_branch_missing"); - const remoteHead = parseRemoteHead((await run(["ls-remote", "--exit-code", "--heads", entry.remote, `refs/heads/${branch}`])).stdout); - if (remoteHead !== expectedHead) return blocked("expected_head_mismatch", { expected_head: expectedHead, actual_head: remoteHead || "" }); - const ancestry = await run(["-C", bare, "merge-base", "--is-ancestor", expectedHead, incoming], { allowFailure: true }); - if (!ancestry.ok) return blocked("non_fast_forward_bundle", { expected_head: expectedHead, incoming_commit: incoming }); - await run(["-C", bare, "push", entry.remote, `refs/heads/incoming:refs/heads/${branch}`]); + const remoteResult = await run( + ["ls-remote", "--exit-code", "--heads", entry.remote, `refs/heads/${branch}`], + { allowFailure: true }, + ); + const remoteHead = parseRemoteHead(remoteResult.stdout); + const expectedMissing = expectedHead === "0".repeat(40); + if ((expectedMissing && remoteHead) || (!expectedMissing && remoteHead !== expectedHead)) { + return blocked("expected_head_mismatch", { expected_head: expectedHead, actual_head: remoteHead || "" }); + } + if (!expectedMissing) { + const ancestry = await run(["-C", bare, "merge-base", "--is-ancestor", expectedHead, incoming], { allowFailure: true }); + if (!ancestry.ok) return blocked("non_fast_forward_bundle", { expected_head: expectedHead, incoming_commit: incoming }); + } + const targetPath = localRepositoryPath(entry.remote); + if (!targetPath) return blocked("repository_receive_path_invalid"); + const transferRef = `refs/guanghu-router/${crypto.randomUUID()}`; + try { + await run([ + "-C", + targetPath, + "fetch", + "--no-tags", + bare, + `refs/heads/incoming:${transferRef}`, + ]); + const transferred = (await run(["-C", targetPath, "rev-parse", transferRef])).stdout.trim().toLowerCase(); + if (transferred !== incoming) return blocked("repository_transfer_mismatch"); + await run([ + "-C", + targetPath, + "update-ref", + `refs/heads/${branch}`, + incoming, + expectedMissing ? "0".repeat(40) : expectedHead, + ]); + } finally { + await run(["-C", targetPath, "update-ref", "-d", transferRef], { allowFailure: true }); + } return { ok: true, repo, branch, commit_sha: incoming, verification_url: String(entry.verification_url || ""), diagnostic_code: "repo_push_succeeded", deployment: { state: "not_requested", next_step: "仅当冰朔与语言层确认该提交需要部署时,再创建绑定此 SHA 的部署工单并显式派发。" } }; } catch (error) { return blocked("repo_push_receiver_failed", { evidence: String(error && error.message || "receiver failure").slice(0, 600) }); @@ -49,6 +91,41 @@ async function receiveBundle(request, options = {}) { } } +function resolveRepository(remoteUrl, registry) { + const requested = normalizedSourceUrl(remoteUrl); + if (!requested) return blocked("local_repository_remote_missing"); + for (const [repo, entry] of Object.entries(registry || {})) { + const sourceUrls = Array.isArray(entry && entry.source_urls) + ? entry.source_urls + : []; + if (sourceUrls.some(value => normalizedSourceUrl(value) === requested)) { + return { + ok: true, + diagnostic_code: "repository_registered", + repo, + branches: Array.isArray(entry.branches) + ? entry.branches.map(String) + : [String(entry.branch || "main")], + }; + } + } + return blocked("repository_not_registered"); +} + +function normalizedSourceUrl(value) { + return String(value || "").trim().replace(/\/+$/, "").toLowerCase(); +} + +function localRepositoryPath(remote) { + try { + const value = String(remote || ""); + const resolved = value.startsWith("file://") ? fileURLToPath(value) : value; + return path.isAbsolute(resolved) ? resolved : ""; + } catch { + return ""; + } +} + function checkedBundlePath(value, uploadDir) { try { const root = fs.realpathSync(uploadDir); @@ -69,4 +146,11 @@ function runGit(args, options = {}) { })); } -module.exports = { receiveBundle, checkedBundlePath, parseRemoteHead }; +module.exports = { + loadRegistry, + receiveBundle, + checkedBundlePath, + parseRemoteHead, + resolveRepository, + localRepositoryPath, +}; diff --git a/server-tools/lake-lamp-authz/repo-push-broker.test.js b/server-tools/lake-lamp-authz/repo-push-broker.test.js index b66919d..e668a16 100644 --- a/server-tools/lake-lamp-authz/repo-push-broker.test.js +++ b/server-tools/lake-lamp-authz/repo-push-broker.test.js @@ -4,7 +4,42 @@ const assert = require("node:assert/strict"); const fs = require("node:fs"); const os = require("node:os"); const path = require("node:path"); -const { receiveBundle } = require("./repo-push-broker"); +const { receiveBundle, resolveRepository } = require("./repo-push-broker"); + +test("repository resolution is exact and returns an explicit unregistered receipt", () => { + const registry = { + "bingshuo/hololake-platform": { + branches: ["main", "feat/hldp-runtime-browser"], + remote: "file:///srv/forgejo/hololake-platform.git", + source_urls: [ + "https://guanghulab.com/fifth-domain/bingshuo/hololake-platform.git", + ], + }, + }; + assert.deepEqual( + resolveRepository( + "https://guanghulab.com/fifth-domain/bingshuo/hololake-platform.git", + registry, + ), + { + ok: true, + diagnostic_code: "repository_registered", + repo: "bingshuo/hololake-platform", + branches: ["main", "feat/hldp-runtime-browser"], + }, + ); + assert.deepEqual(resolveRepository("", registry), { + ok: false, + diagnostic_code: "local_repository_remote_missing", + }); + assert.deepEqual( + resolveRepository("https://example.invalid/unknown.git", registry), + { + ok: false, + diagnostic_code: "repository_not_registered", + }, + ); +}); test("receiver permits only an allowlisted fast-forward bundle with an exact base", async () => { const uploadDir = fs.mkdtempSync(path.join(os.tmpdir(), "lake-lamp-upload-")); @@ -13,9 +48,9 @@ test("receiver permits only an allowlisted fast-forward bundle with an exact bas const base = "a".repeat(40), incoming = "b".repeat(40); const run = async args => { calls.push(args); if (args.includes("rev-parse")) return { ok: true, stdout: `${incoming}\n` }; if (args[0] === "ls-remote") return { ok: true, stdout: `${base}\trefs/heads/main\n` }; return { ok: true, stdout: "" }; }; try { - const result = await receiveBundle({ repo: "bingshuo/guanghu-ice-heart", branch: "main", expected_head: base, bundle_path: bundle }, { uploadDir, registry: { "bingshuo/guanghu-ice-heart": { branch: "main", remote: "http://local/code.git", verification_url: "https://example.invalid/commits/main" } }, run }); + const result = await receiveBundle({ repo: "bingshuo/guanghu-ice-heart", branch: "main", expected_head: base, bundle_path: bundle }, { uploadDir, registry: { "bingshuo/guanghu-ice-heart": { branch: "main", remote: "/srv/local/code.git", verification_url: "https://example.invalid/commits/main" } }, run }); assert.equal(result.ok, true); assert.equal(result.commit_sha, incoming); - assert.ok(calls.some(args => args.includes("push"))); + assert.ok(calls.some(args => args.includes("update-ref"))); } finally { fs.rmSync(uploadDir, { recursive: true, force: true }); } }); @@ -23,9 +58,44 @@ test("receiver refuses a changed remote base before it can push", async () => { const uploadDir = fs.mkdtempSync(path.join(os.tmpdir(), "lake-lamp-upload-")); const bundle = path.join(uploadDir, "one.bundle"); fs.writeFileSync(bundle, "bundle"); const base = "a".repeat(40), changed = "c".repeat(40); - const run = async args => { if (args.includes("rev-parse")) return { ok: true, stdout: `${"b".repeat(40)}\n` }; if (args[0] === "ls-remote") return { ok: true, stdout: `${changed}\trefs/heads/main\n` }; if (args.includes("push")) throw new Error("must not push"); return { ok: true, stdout: "" }; }; + const run = async args => { if (args.includes("rev-parse")) return { ok: true, stdout: `${"b".repeat(40)}\n` }; if (args[0] === "ls-remote") return { ok: true, stdout: `${changed}\trefs/heads/main\n` }; if (args.includes("update-ref")) throw new Error("must not update"); return { ok: true, stdout: "" }; }; try { - const result = await receiveBundle({ repo: "bingshuo/guanghu-ice-heart", branch: "main", expected_head: base, bundle_path: bundle }, { uploadDir, registry: { "bingshuo/guanghu-ice-heart": { branch: "main", remote: "http://local/code.git" } }, run }); + const result = await receiveBundle({ repo: "bingshuo/guanghu-ice-heart", branch: "main", expected_head: base, bundle_path: bundle }, { uploadDir, registry: { "bingshuo/guanghu-ice-heart": { branch: "main", remote: "/srv/local/code.git" } }, run }); assert.equal(result.diagnostic_code, "expected_head_mismatch"); } finally { fs.rmSync(uploadDir, { recursive: true, force: true }); } }); + +test("receiver can create only an explicitly allowlisted branch from an exact missing base", async () => { + const uploadDir = fs.mkdtempSync(path.join(os.tmpdir(), "lake-lamp-upload-")); + const bundle = path.join(uploadDir, "one.bundle"); fs.writeFileSync(bundle, "bundle"); + const incoming = "b".repeat(40); + const calls = []; + const run = async (args, options = {}) => { + calls.push(args); + if (args.includes("rev-parse")) return { ok: true, stdout: `${incoming}\n` }; + if (args[0] === "ls-remote") return { ok: options.allowFailure === true, stdout: "" }; + return { ok: true, stdout: "" }; + }; + try { + const result = await receiveBundle({ + repo: "bingshuo/hololake-platform", + branch: "feat/hldp-runtime-browser", + expected_head: "0".repeat(40), + bundle_path: bundle, + }, { + uploadDir, + registry: { + "bingshuo/hololake-platform": { + branches: ["main", "feat/hldp-runtime-browser"], + remote: "/srv/local/code.git", + }, + }, + run, + }); + assert.equal(result.ok, true); + assert.equal(result.commit_sha, incoming); + assert.ok(calls.some(args => args.includes("update-ref"))); + } finally { + fs.rmSync(uploadDir, { recursive: true, force: true }); + } +}); diff --git a/server-tools/lake-lamp-authz/repo-push-registry.example.json b/server-tools/lake-lamp-authz/repo-push-registry.example.json index fdd47e7..bf220d0 100644 --- a/server-tools/lake-lamp-authz/repo-push-registry.example.json +++ b/server-tools/lake-lamp-authz/repo-push-registry.example.json @@ -1,9 +1,16 @@ { + "schema": "guanghu.repo-push-registry/v1", "repos": { - "bingshuo/guanghu-ice-heart": { - "branch": "main", - "remote": "http://127.0.0.1:3000/bingshuo/guanghu-ice-heart.git", - "verification_url": "https://guanghulab.com/code/bingshuo/guanghu-ice-heart/commits/main" + "bingshuo/hololake-platform": { + "branches": [ + "main", + "feat/hldp-runtime-browser" + ], + "remote": "file:///srv/guanghu/private-transport/hololake-platform.git", + "source_urls": [ + "https://guanghulab.com/fifth-domain/bingshuo/hololake-platform.git" + ], + "verification_url": "https://guanghulab.com/fifth-domain/bingshuo/hololake-platform/commits/branch/{branch}" } } } diff --git a/server-tools/lake-lamp-authz/request-workorder.js b/server-tools/lake-lamp-authz/request-workorder.js index f276f1c..506a5ea 100644 --- a/server-tools/lake-lamp-authz/request-workorder.js +++ b/server-tools/lake-lamp-authz/request-workorder.js @@ -21,9 +21,6 @@ async function main() { action: args.action, description: args.description || "", resource: args.resource || "", - // Mobile / Work instances have no local credential. Their declared language - // system workorder asks the server to notify the registered approver. - owner_notify: !requestToken, }; if (requestToken) { const qqId = process.env.GUANGHU_OWNER_QQ_ID || ""; diff --git a/server-tools/lake-lamp-authz/server.js b/server-tools/lake-lamp-authz/server.js index 73e1e17..d17df28 100644 --- a/server-tools/lake-lamp-authz/server.js +++ b/server-tools/lake-lamp-authz/server.js @@ -9,6 +9,12 @@ const { MapGate } = require("./map-gate"); const { sendSmtpMail } = require("./smtp-mailer"); const { executeRegisteredAction } = require("./action-client"); const { enqueueDeploymentEvent } = require("./deployment-event"); +const { GuanghuRouter, loadDevices } = require("./guanghu-router"); +const { + loadRegistry: loadRepoPushRegistry, + receiveBundle, + resolveRepository, +} = require("./repo-push-broker"); const DEFAULT_ACTIONS = Object.freeze({ "server-login": [ @@ -35,15 +41,23 @@ const DEFAULT_ACTIONS = Object.freeze({ "dispatch-approved-deployment", ], "repo-push": ["read-navigation-map", "push-repository"], + "linked-node-ops": ["authorize-linked-node-session"], }); function createApp(options = {}) { const requestToken = options.requestToken || process.env.LAKE_LAMP_REQUEST_TOKEN || ""; + const broadcastToken = options.broadcastToken || process.env.LAKE_LAMP_BROADCAST_TOKEN || ""; const ownerEmail = options.ownerEmail || process.env.LAKE_LAMP_OWNER_EMAIL || ""; const approvers = options.approvers || loadApprovers(options.approversFile || process.env.LAKE_LAMP_APPROVERS_FILE || "", ownerEmail); const publicBaseUrl = String(options.publicBaseUrl || process.env.LAKE_LAMP_PUBLIC_URL || "").replace(/\/$/, ""); const targets = new Set(options.targets || splitCsv(process.env.LAKE_LAMP_TARGETS || "JD-FD-PRIMARY,BS-GZ-006")); const actions = options.actions || DEFAULT_ACTIONS; + const devices = options.devices || loadDevices( + options.devicesFile + || process.env.GUANGHU_ROUTER_DEVICES_FILE + || "/etc/guanghu/lake-lamp/hololake-devices.json", + ); + const router = options.router || new GuanghuRouter({ devices }); const manager = options.manager || new WorkOrderManager({ approvalTtl: Number(options.approvalTtl || process.env.LAKE_LAMP_APPROVAL_TTL || 3 * 60 * 60), sessionTtl: Number(options.sessionTtl || process.env.LAKE_LAMP_SESSION_TTL || 3 * 60 * 60), @@ -62,6 +76,28 @@ function createApp(options = {}) { stateFile: Object.prototype.hasOwnProperty.call(options, "mapStateFile") ? options.mapStateFile : (process.env.LAKE_LAMP_MAP_STATE_FILE || "/var/lib/guanghu/lake-lamp-authz/map-acks.json"), }); const repoGrantDir = options.repoGrantDir || process.env.LAKE_LAMP_REPO_GRANT_DIR || "/var/lib/guanghu/repo-authorizations"; + const repoUploadDir = options.repoUploadDir || process.env.LAKE_LAMP_REPO_UPLOAD_DIR || "/var/lib/guanghu/repo-push-uploads"; + const repoPushRegistryFile = options.repoPushRegistryFile || process.env.LAKE_LAMP_REPO_PUSH_REGISTRY || "/etc/guanghu/lake-lamp/repo-push-registry.json"; + const repoPushRegistry = options.repoPushRegistry || ( + fs.existsSync(repoPushRegistryFile) ? loadRepoPushRegistry(repoPushRegistryFile) : {} + ); + const receiveRepoBundle = options.receiveRepoBundle || ( + request => receiveBundle(request, { + registry: repoPushRegistry, + uploadDir: repoUploadDir, + }) + ); + const maxRepoBundleBytes = Math.max( + 1024 * 1024, + Number(options.maxRepoBundleBytes || process.env.LAKE_LAMP_MAX_REPO_BUNDLE_BYTES || 256 * 1024 * 1024), + ); + const maxRepoChunkBytes = Math.max( + 8 * 1024, + Math.min( + 48 * 1024, + Number(options.maxRepoChunkBytes || process.env.LAKE_LAMP_MAX_REPO_CHUNK_BYTES || 32 * 1024), + ), + ); const deploymentQueueDir = options.deploymentQueueDir || process.env.LAKE_LAMP_DEPLOYMENT_EVENT_DIR || "/var/lib/guanghu/deployment-events"; const deploymentRegistryFile = options.deploymentRegistryFile || process.env.LAKE_LAMP_DEPLOYMENT_REPOSITORIES || "/etc/guanghu/lake-lamp/deployment-repositories.json"; const executeAction = options.executeAction || executeRegisteredAction; @@ -103,13 +139,30 @@ function createApp(options = {}) { return { ok: true, order: issued.order }; } + function bindAndDeliver(created) { + const inspected = manager.inspectHandoff(created.handoffToken); + if (!inspected.ok) return { delivered: 0, approver: null, order: null }; + const approver = selectApprover(approvers, inspected.order); + if (!approver || !manager.bindApprover(created.handoffToken, approver.id)) { + return { delivered: 0, approver: null, order: inspected.order }; + } + const bound = manager.inspectHandoff(created.handoffToken); + const order = bound.ok ? bound.order : inspected.order; + return { + approver, + order, + delivered: router.deliver(approver.id, order), + }; + } + return http.createServer(async (req, res) => { try { const url = new URL(req.url, "http://localhost"); if (req.method === "GET" && url.pathname === "/health") return json(res, 200, { ok: true, service: "lake-lamp-authz", - auth_mode: "email-link", + auth_mode: "guanghu-router-with-email-fallback", + primary_authorization_channel: "guanghu_router", approval_ttl: manager.approvalTtl, session_ttl: manager.sessionTtl, max_session_lifetime: manager.maxSessionLifetime, @@ -122,8 +175,10 @@ function createApp(options = {}) { optional_fields: ["persona_name", "description", "resource"], targets: [...targets], scopes: actions, - owner_handoff: "open request_url and request pre-registered mailbox verification", - workflow: ["create_workorder", "owner_handoff", "claim_session", "read_navigation_map", "ack_navigation_map", "check_session_status", "execute_registered_action", "read_operation_receipt"], + owner_handoff: "an online HoloLake receives the authorization card through the Guanghu Router; request_url is an email recovery fallback only", + email_visibility: "the requesting AI never receives the submitted mailbox address", + public_auto_email: false, + workflow: ["create_workorder", "guanghu_router_authorization_or_email_fallback", "claim_session", "read_navigation_map", "ack_navigation_map", "check_session_status", "execute_registered_action", "read_operation_receipt"], diagnostics: diagnosticCatalog(), approval_ttl: manager.approvalTtl, session_ttl: manager.sessionTtl, @@ -135,6 +190,127 @@ function createApp(options = {}) { }, }); + if (req.method === "POST" && url.pathname === "/api/repositories/resolve") { + const body = await readJson(req); + if (!body) return json(res, 400, failure("invalid_json")); + const resolved = resolveRepository(body.remote_url, repoPushRegistry); + return json(res, resolved.ok ? 200 : 404, resolved); + } + + if (req.method === "POST" && url.pathname === "/api/guanghu-router/challenge") { + const body = await readJson(req); + if (!body) return json(res, 400, { error: "invalid_json" }); + const challenged = router.challenge( + String(body.device_id || ""), + Math.floor(Date.now() / 1000), + ); + if (!challenged.ok) return json(res, 403, { error: challenged.reason }); + return json(res, 200, { + ok: true, + schema: challenged.schema, + challenge_id: challenged.challengeId, + nonce: challenged.nonce, + expires_at: challenged.expiresAt, + server_time: challenged.serverTime, + }); + } + + if (req.method === "POST" && url.pathname === "/api/guanghu-router/connect") { + const body = await readJson(req); + if (!body) return json(res, 400, { error: "invalid_json" }); + const connected = router.authorizeConnection({ + deviceId: String(body.device_id || ""), + challengeId: String(body.challenge_id || ""), + clientTimestamp: Number(body.client_timestamp), + signature: String(body.signature || ""), + }, Math.floor(Date.now() / 1000)); + if (!connected.ok) return json(res, 403, { error: connected.reason }); + return json(res, 200, { + ok: true, + device_id: connected.deviceId, + device_label: connected.deviceLabel, + owner_id: connected.ownerId, + route_token: connected.routeToken, + expires_at: connected.expiresAt, + next_step: "使用一次性 route_token 打开光湖路由持续连接;只有收到 router.connected 回执后才显示上线。", + }); + } + + if (req.method === "GET" && url.pathname === "/api/guanghu-router/stream") { + const queued = []; + let streaming = false; + const send = event => { + if (!streaming) { + queued.push(event); + } else if (!res.destroyed && !res.writableEnded) { + res.write(`event: ${event.type}\ndata: ${JSON.stringify(event)}\n\n`); + } + }; + const opened = router.open(bearer(req), send, Math.floor(Date.now() / 1000)); + if (!opened.ok) return json(res, 403, { error: opened.reason }); + res.writeHead(200, { + "content-type": "text/event-stream; charset=utf-8", + "cache-control": "no-store, no-transform", + "connection": "keep-alive", + "x-accel-buffering": "no", + "x-content-type-options": "nosniff", + }); + streaming = true; + for (const event of queued) send(event); + for (const order of manager.pendingForApprover(opened.ownerId)) { + router.deliver(opened.ownerId, order); + } + // This is transport framing only: it carries no application event, + // mutates no online state, and writes no heartbeat record. Its sole + // purpose is to stop the public nginx front door from treating an + // otherwise healthy, idle SSE route as a dead upstream after 60s. + const transportKeepalive = setInterval(() => { + if (!res.destroyed && !res.writableEnded) { + res.write(": guanghu-router-transport\n\n"); + } + }, 15_000); + transportKeepalive.unref?.(); + res.on("close", () => { + clearInterval(transportKeepalive); + opened.close(Date.now() / 1000, "transport_closed"); + }); + return; + } + + const routerApprovalMatch = url.pathname.match(/^\/api\/guanghu-router\/authorizations\/([0-9a-f-]{36})\/approve$/i); + if (req.method === "POST" && routerApprovalMatch) { + const body = await readJson(req); + if (!body) return json(res, 400, { error: "invalid_json" }); + const inspected = manager.inspectPending(routerApprovalMatch[1]); + if (!inspected.ok) return json(res, 410, { error: inspected.reason }); + const verified = router.verifyApproval( + String(body.device_id || ""), + inspected.order, + String(body.signature || ""), + ); + if (!verified.ok) return json(res, 403, { error: verified.reason }); + const approved = manager.approveById( + routerApprovalMatch[1], + verified.authorizerId, + ); + if (!approved.ok) return json(res, 409, { error: approved.reason }); + return json(res, 200, { + ok: true, + receipt: receipt({ + state: "approved", + diagnostic_code: "broadcast_console_approved", + workorder_id: approved.order.id, + target: approved.order.target, + action: approved.order.action, + evidence: { + device_id: verified.deviceId, + authorization_digest: verified.digest, + }, + next_step: "申请方现在可以使用原 claim_token 领取受限三小时会话。", + }), + }); + } + const requestMatch = url.pathname.match(/^\/request\/([A-Za-z0-9_-]{20,})$/); if (requestMatch && req.method === "GET") { const inspected = manager.inspectHandoff(requestMatch[1]); @@ -144,14 +320,24 @@ function createApp(options = {}) { if (requestMatch && req.method === "POST") { const inspected = manager.inspectHandoff(requestMatch[1]); if (!inspected.ok) return html(res, 410, requestErrorPage(inspected.reason)); - // Refreshing or reopening an already-sent request must not consume a - // second rate-limit slot. It also must not send a duplicate email. + const form = await readForm(req); + // The response deliberately stays generic whether the submitted + // mailbox is registered, invalid, or already used. The browser posts + // directly to this service over HTTPS; the requesting AI never sees + // or stores the mailbox value. if (inspected.order.approval_email_sent) return html(res, 200, emailSentPage(inspected.order)); const source = clientAddress(req); if (!publicMailLimiter.take(source) || !publicMailGlobalLimiter.take("global")) return html(res, 429, requestErrorPage("rate_limited")); - const sent = await sendApprovalEmail(requestMatch[1]); - if (!sent.ok && sent.reason !== "approval_email_already_sent") return html(res, sent.reason === "authorization_email_failed" ? 502 : 410, requestErrorPage(sent.reason)); - return html(res, 200, emailSentPage(sent.order)); + const approver = selectApprover(approvers, inspected.order); + const submittedEmail = normalizeEmail(form && form.email); + const registeredEmail = normalizeEmail(approver && approver.email); + if (validEmail(submittedEmail) && registeredEmail && safeEqual(sha256(submittedEmail), sha256(registeredEmail))) { + const sent = await sendApprovalEmail(requestMatch[1]); + if (!sent.ok && sent.reason !== "approval_email_already_sent") { + process.stderr.write(`lake-lamp owner handoff failed: ${String(sent.reason || "unknown").slice(0, 80)}\n`); + } + } + return html(res, 200, emailSentPage(inspected.order)); } const approvalMatch = url.pathname.match(/^\/approve\/([A-Za-z0-9_-]{20,})$/); @@ -174,23 +360,65 @@ function createApp(options = {}) { const validation = validateWorkorderBody(body, targets, actions); if (!validation.ok) return json(res, validation.status, { error: validation.error }); const created = manager.request(validation.request); - let emailStatus = "not_requested"; - if (body.owner_notify === true) { - if (!publicMailLimiter.take(source) || !publicMailGlobalLimiter.take("global")) emailStatus = "rate_limited"; - else { - const sent = await sendApprovalEmail(created.handoffToken); - emailStatus = sent.ok ? "sent" : String(sent.reason || "failed"); - } - } + const delivery = bindAndDeliver(created); + const throughRouter = delivery.delivered > 0; return json(res, 201, { ok: true, workorder_id: created.id, claim_token: created.claimToken, request_url: `${publicBaseUrl}/request/${created.handoffToken}`, expires_in: created.expiresIn, - status: emailStatus === "sent" ? "waiting_for_owner" : "waiting_for_owner_handoff", - email_status: emailStatus, - receipt: receipt({ state: emailStatus === "sent" ? "waiting_for_owner" : "waiting_for_owner_handoff", diagnostic_code: emailStatus === "sent" ? "owner_email_sent" : "owner_handoff_required", workorder_id: created.id, next_step: emailStatus === "sent" ? "主人邮箱已收到批准链接;等待批准后领取会话。" : "把 request_url 交给主人打开一次;或由光湖语言人格系统在建单时显式提交 owner_notify=true。不要索要密码、验证码或令牌。" }), + status: throughRouter ? "waiting_for_broadcast_console" : "waiting_for_owner_handoff", + delivery: { + channel: throughRouter ? "guanghu_router" : "email_recovery_fallback", + delivered_devices: delivery.delivered, + }, + email_status: throughRouter ? "fallback_not_needed" : "owner_input_required", + public_auto_email: false, + receipt: receipt({ + state: throughRouter ? "waiting_for_broadcast_console" : "waiting_for_owner_handoff", + diagnostic_code: throughRouter ? "broadcast_console_delivery_confirmed" : "owner_handoff_required", + workorder_id: created.id, + evidence: throughRouter ? { delivered_devices: delivery.delivered } : {}, + next_step: throughRouter + ? "等待冰朔在 HoloLake 广播主控台核对并点击授权;不要发送邮件。" + : "HoloLake 当前没有在线路由连接。把 request_url 交给主人,通过服务器托管页面恢复设备绑定或完成邮件灾备授权。", + }), + }); + } + + if (req.method === "POST" && url.pathname === "/api/broadcast/workorders") { + if (!bearerMatches(req, broadcastToken)) { + return json(res, 401, { error: "broadcast_tower_auth_required" }); + } + const body = await readJson(req); + if (!body) return json(res, 400, { error: "invalid_json" }); + const validation = validateWorkorderBody(body, targets, actions); + if (!validation.ok) { + return json(res, validation.status, { error: validation.error }); + } + const created = manager.request(validation.request); + const sent = await sendApprovalEmail(created.handoffToken); + if (!sent.ok) return json(res, 502, { error: sent.reason }); + return json(res, 201, { + ok: true, + workorder_id: created.id, + claim_token: created.claimToken, + expires_in: created.expiresIn, + status: "waiting_for_owner", + delivery: { + channel: "fifth_domain_broadcast_email", + delivered_devices: 0, + }, + email_status: "sent", + receipt: receipt({ + state: "waiting_for_owner", + diagnostic_code: "owner_email_sent_by_registered_broadcast_tower", + workorder_id: created.id, + target: validation.request.target, + action: validation.request.action, + next_step: "等待目标节点主人点击邮件批准链接;广播塔随后使用原 claim_token 领取会话。", + }), }); } @@ -204,9 +432,27 @@ function createApp(options = {}) { if (!body.recipient_fingerprint || !safeEqual(body.recipient_fingerprint, expectedFingerprint)) return json(res, 403, { error: "owner_identity_mismatch" }); const created = manager.request(validation.request); + const delivery = bindAndDeliver(created); + if (delivery.delivered > 0) { + return json(res, 201, { + ok: true, + workorder_id: created.id, + claim_token: created.claimToken, + expires_in: created.expiresIn, + status: "waiting_for_broadcast_console", + delivery: { channel: "guanghu_router", delivered_devices: delivery.delivered }, + }); + } const sent = await sendApprovalEmail(created.handoffToken); if (!sent.ok) return json(res, 502, { error: sent.reason }); - return json(res, 201, { ok: true, workorder_id: created.id, claim_token: created.claimToken, expires_in: created.expiresIn, status: "waiting_for_owner" }); + return json(res, 201, { + ok: true, + workorder_id: created.id, + claim_token: created.claimToken, + expires_in: created.expiresIn, + status: "waiting_for_owner", + delivery: { channel: "email_recovery_fallback", delivered_devices: 0 }, + }); } const claimMatch = url.pathname.match(/^\/api\/workorders\/([0-9a-f-]{36})\/claim$/i); @@ -308,8 +554,13 @@ function createApp(options = {}) { const scope = String(body.scope || "repo-push"); const repo = String(body.repo || "").toLowerCase(); if (!/^bingshuo\/[a-z0-9._-]+$/.test(repo)) return json(res, 400, failure("repo_not_allowlisted")); + const branch = String(body.branch || "main"); + const resource = `${repo}@${branch}`; const verified = manager.verifySession(token, { pid: String(body.persona_id || "") }, target, scope, "push-repository"); if (!verified.ok) return json(res, 403, failure(verified.reason)); + if (verified.session.resource && verified.session.resource !== resource) { + return json(res, 403, failure("resource_mismatch")); + } const map = mapGate.read(target); const mapVerified = mapGate.verify(token, target, map.hash); if (!mapVerified.ok) return json(res, 423, failure(mapVerified.reason, "先读取并确认导航图。", { required_action: "read-navigation-map" })); @@ -319,9 +570,149 @@ function createApp(options = {}) { const temp = `${grantFile}.${process.pid}.tmp`; fs.writeFileSync(temp, JSON.stringify(grant), { mode: 0o640 }); fs.renameSync(temp, grantFile); - const operationReceipt = receipt({ state: "blocked", diagnostic_code: "repo_push_transport_unavailable", workorder_id: verified.session.workorderId, target, action: "push-repository", next_step: "服务器已登记本次推送许可,但安全推送接收器尚未部署;不要重试裸 git push、不要索要账号密码。等待受限 bundle 接收器上线后按同一工单回执执行。" }); + const entry = repoPushRegistry[repo]; + const configured = Boolean(entry); + const operationReceipt = receipt({ + state: configured ? "ready" : "blocked", + diagnostic_code: configured ? "repo_push_transport_ready" : "repo_push_transport_unavailable", + workorder_id: verified.session.workorderId, + target, + action: "push-repository", + next_step: configured + ? "使用同一会话向受限 bundle 接收器上传一次 Git bundle;服务器将核验仓库、分支、精确远端基线和快进关系。" + : "服务器已登记本次推送许可,但安全推送接收器尚未部署;不要重试裸 git push、不要索要账号密码。等待受限 bundle 接收器上线后按同一工单回执执行。", + }); manager.recordReceipt(token, operationReceipt); - return json(res, 200, { ok: true, repo, target, expires_at: grant.expires_at, transport: { status: "not_configured", diagnostic_code: "repo_push_transport_unavailable", next_step: operationReceipt.next_step }, receipt: operationReceipt }); + return json(res, 200, { + ok: true, + repo, + branch, + target, + expires_at: grant.expires_at, + transport: { + status: configured ? "ready" : "not_configured", + diagnostic_code: operationReceipt.diagnostic_code, + upload_url: configured ? `${publicBaseUrl}/api/repo-push/bundle` : "", + max_bundle_bytes: configured ? maxRepoBundleBytes : 0, + max_request_bytes: configured ? maxRepoChunkBytes : 0, + next_step: operationReceipt.next_step, + }, + receipt: operationReceipt, + }); + } + + if (req.method === "PUT" && url.pathname === "/api/repo-push/bundle") { + const token = bearer(req); + const repo = String(url.searchParams.get("repo") || "").toLowerCase(); + const branch = String(url.searchParams.get("branch") || ""); + const expectedHead = String(url.searchParams.get("expected_head") || "").toLowerCase(); + const personaId = String(url.searchParams.get("persona_id") || ""); + const target = String(url.searchParams.get("target") || ""); + const scope = String(url.searchParams.get("scope") || "repo-push"); + const resource = `${repo}@${branch}`; + if (!repoPushRegistry[repo]) return json(res, 404, failure("repository_not_registered")); + const verified = manager.verifySession( + token, + { pid: personaId }, + target, + scope, + "push-repository", + Date.now() / 1000, + resource, + ); + if (!verified.ok) return json(res, 403, failure(verified.reason)); + const map = mapGate.read(target); + if (!mapGate.verify(token, target, map.hash).ok) { + return json(res, 423, failure("map_ack_required", "先读取并确认导航图。", { required_action: "read-navigation-map" })); + } + const declaredLength = Number(req.headers["content-length"]); + const uploadId = String(req.headers["x-guanghu-upload-id"] || ""); + const chunkIndex = Number(req.headers["x-guanghu-chunk-index"]); + const chunkCount = Number(req.headers["x-guanghu-chunk-count"]); + const chunkOffset = Number(req.headers["x-guanghu-chunk-offset"]); + const chunked = uploadId !== ""; + if (!Number.isSafeInteger(declaredLength) + || declaredLength < 1 + || declaredLength > (chunked ? maxRepoChunkBytes : maxRepoBundleBytes)) { + return json(res, 413, failure("repo_bundle_size_invalid")); + } + fs.mkdirSync(repoUploadDir, { recursive: true, mode: 0o2770 }); + if (chunked && ( + !/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(uploadId) + || !Number.isSafeInteger(chunkIndex) + || !Number.isSafeInteger(chunkCount) + || !Number.isSafeInteger(chunkOffset) + || chunkIndex < 0 + || chunkCount < 1 + || chunkIndex >= chunkCount + || chunkCount > Math.ceil(maxRepoBundleBytes / (8 * 1024)) + || chunkOffset < 0 + || chunkOffset + declaredLength > maxRepoBundleBytes + )) { + return json(res, 400, failure("repo_bundle_chunk_invalid")); + } + const bundlePath = path.join( + repoUploadDir, + chunked ? `${uploadId}.bundle.part` : `${crypto.randomUUID()}.bundle`, + ); + if (chunked) { + const currentSize = fs.existsSync(bundlePath) ? fs.statSync(bundlePath).size : 0; + if ((chunkIndex === 0 && currentSize !== 0) || (chunkIndex > 0 && currentSize !== chunkOffset)) { + return json(res, 409, failure("repo_bundle_chunk_out_of_order", "", { + expected_offset: currentSize, + })); + } + } + try { + await readBinaryBody(req, bundlePath, chunked ? maxRepoChunkBytes : maxRepoBundleBytes, { + append: chunked && chunkIndex > 0, + }); + if (chunked && chunkIndex + 1 < chunkCount) { + return json(res, 202, { + ok: true, + upload: { + state: "partial", + upload_id: uploadId, + next_chunk_index: chunkIndex + 1, + received_bytes: chunkOffset + declaredLength, + }, + }); + } + const result = await receiveRepoBundle({ + repo, + branch, + expected_head: expectedHead, + bundle_path: bundlePath, + }); + const operationReceipt = receipt({ + state: result.ok ? "succeeded" : "blocked", + diagnostic_code: result.diagnostic_code || (result.ok ? "repo_push_succeeded" : "repo_push_receiver_failed"), + workorder_id: verified.session.workorderId, + target, + action: "push-repository", + evidence: { + repo, + branch, + expected_head: expectedHead, + commit_sha: result.commit_sha || "", + verification_url: result.verification_url || "", + receiver_evidence: String(result.evidence || "").slice(0, 600), + }, + next_step: result.ok + ? "从光湖代码频道回读分支与提交 SHA;本次上传不自动部署。" + : "读取 diagnostic_code 和精确基线回执;不要改用裸 git push 或猜测账号密码。", + }); + manager.recordReceipt(token, operationReceipt); + return json(res, result.ok ? 200 : 409, { + ok: result.ok, + repository: result, + receipt: operationReceipt, + }); + } finally { + if (!chunked || chunkIndex + 1 === chunkCount) { + fs.rmSync(bundlePath, { force: true }); + } + } } if (req.method === "POST" && url.pathname === "/api/deployment/dispatch") { @@ -398,13 +789,17 @@ function requestPage(order) {
这张页面本身没有执行权。确认内容无误后,服务器只会向预登记邮箱发送一次真正的批准链接。
- +这张页面本身没有执行权。邮箱只通过本页的加密连接直达京东节点,不会返回给发起申请的AI。无论是否匹配,页面都会显示相同结果。
+ `); } function emailSentPage(order) { - return document("授权邮件已发送", `OWNER VERIFICATION
申请单已锁定到 ${escapeHtml(order && order.target || "登记节点")}。真正的批准链接只发送到服务器预登记邮箱。
批准后回到原来的 AI 对话,让它领取一次性会话。无需向 AI 提供验证码、密码或邮箱授权码。
`); + return document("检查你的邮箱", `OWNER VERIFICATION
申请单已锁定到 ${escapeHtml(order && order.target || "登记节点")}。系统不会在页面上透露邮箱是否登记。
收到邮件后请核对人格体、来源软件、目标节点、授权范围和动作,再点击批准。批准后回到原来的 AI 对话,让它领取一次性三小时会话;不要向AI提供邮箱、验证码、密码或授权码。
`); } function requestErrorPage(reason) { @@ -427,7 +822,7 @@ function approvalErrorPage(reason) { function document(title, body) { return `光湖 · 小湖灯安全协议系统 |
光湖 · 小湖灯安全协议系统 |