diff --git a/deployment/HLCC-AI-PUSH-WORKFLOW.hdlp b/deployment/HLCC-AI-PUSH-WORKFLOW.hdlp index 49efcf5..407aad5 100644 --- a/deployment/HLCC-AI-PUSH-WORKFLOW.hdlp +++ b/deployment/HLCC-AI-PUSH-WORKFLOW.hdlp @@ -42,9 +42,11 @@ Content-Type: application/json 接口返回 `request_url` 后,AI 只把链接交给冰朔。冰朔核对目标节点、仓库、范围和说明,确认后授权邮件才会发往服务器预登记邮箱。AI 不得绕过该步骤,也不得要求冰朔提供邮箱密码或授权码。 -## 3. 授权完成后的推送 +## 3. 授权完成后的安全推送 -授权会话只允许已登记的 `repo-push` 能力,默认短时有效,持续执行同一绑定任务时可自动续期。AI 回到原任务后,用人类已配置的 Git 凭据管理器推送: +授权会话只允许已登记的 `repo-push` 能力,默认短时有效,持续执行同一绑定任务时可自动续期。AI 必须先读取操作回执;许可本身不是 Git 登录凭证。安全 bundle 接收器尚未部署时,回执会明确返回 `repo_push_transport_unavailable`,此时停止,不得把它误判为密码问题或反复重试。 + +安全接收器部署后,AI 依照回执所给的受限上传路径提交 bundle;服务器校验仓库、分支、预期远端 SHA、快进关系后,使用代码频道的正常接收路径写入。下面的普通 Git 命令仅是人工已拥有独立凭据时的兼容路径: ```text git remote set-url origin https://guanghulab.com/code/bingshuo/guanghu-ice-heart.git @@ -53,6 +55,12 @@ git push origin HEAD:main 不要把密码写入 remote URL、脚本、提交、日志或聊天记录。共享电脑或临时环境完成后应清理凭据。 +## 3.1 推送后的自动部署事件 + +只有冰朔与语言层明确确认“该提交需要部署”后,提交模块的人格体才发出第二个、与完整提交 SHA 绑定的 `guanghu.deployment-intent/v1`。它通过单独的 `server-ops / dispatch-approved-deployment` 工单派发到京东服务器本机队列;常驻部署 Agent 自动响应,完成清单校验、备份、部署、健康检查与回滚回执。冰朔不需要重新描述任务,但部署范围仍必须明确批准。 + +没有部署意图的提交仅入库,回执为 `not_requested`。推送授权不能偷渡扩大为部署授权;服务器不扫描仓库自行拉取部署。 + ## 4. 被拒绝时 遇到 `repo_push_grant_expired`、`authorization_pending`、`NO_TRUSTED_PATH` 或目标仓库不匹配:停止重试,保留本地完整 SHA,重新创建 `repo-push` 工单,并等待冰朔确认。登录失败时先检查是否误用了旧入口或旧 Token,不要把凭据粘贴到聊天中。 diff --git a/deployment/HLCC-PUSH-TO-DEPLOY-EVENT-PROTOCOL.hdlp b/deployment/HLCC-PUSH-TO-DEPLOY-EVENT-PROTOCOL.hdlp new file mode 100644 index 0000000..9043118 --- /dev/null +++ b/deployment/HLCC-PUSH-TO-DEPLOY-EVENT-PROTOCOL.hdlp @@ -0,0 +1,13 @@ +# HLCC-PUSH-TO-DEPLOY-EVENT-PROTOCOL + +状态:本地实现候选,尚未部署 + +语言层在冰朔明确确认“该提交需要部署”后,主动发出 `guanghu.deployment-intent/v1`。推送本身不会产生部署事件。意图必须绑定: + +- 代码频道仓库、分支和完整提交 SHA; +- `REQUEST-ID@完整提交SHA` 的不可变资源; +- `deployment/requests/` 下的部署清单。 + +语言人格体通过 `/api/deployment/dispatch` 派发这一意图;接口要求一张单独的 `server-ops / dispatch-approved-deployment` 工单,且资源必须为同一 `REQUEST-ID@完整提交SHA`。服务端才写入 `guanghu.deployment-event/v1` 到本机队列。常驻部署 Agent 订阅此队列,读取同一提交中的清单,执行备份、部署、健康检查与回滚,并将回执回写到原工单。 + +没有部署意图的提交照常入库,回执为 `not_requested`;无效意图回执为 `rejected`,不会部署。推送工单绝不扩大为部署权限;服务器也不扫描仓库主动拉取部署。 diff --git a/server-tools/lake-lamp-authz/README.md b/server-tools/lake-lamp-authz/README.md index cded1bd..4122d3a 100644 --- a/server-tools/lake-lamp-authz/README.md +++ b/server-tools/lake-lamp-authz/README.md @@ -46,8 +46,8 @@ node request-workorder.js \ --description "推送 REPO-001" ``` -响应中的 `request_url` 交给冰朔点击。该 URL 只能展示申请内容并请求向预登记邮箱 -发送批准邮件,不能直接批准。人格体必须在自己的临时会话中保留响应里的 +光湖语言人格系统的 Work/手机实例会带 `owner_notify=true`,服务器会直接向预登记邮箱 +发送批准链接;`request_url` 仍可交给冰朔核对申请内容,但不要求在本机打开。人格体必须在自己的临时会话中保留响应里的 `workorder_id` 与 `claim_token`,批准后通过 claim 路由领取一次性会话;不得把这些值 写入仓库、长期日志或聊天记忆。 diff --git a/server-tools/lake-lamp-authz/authorize-repo-push.js b/server-tools/lake-lamp-authz/authorize-repo-push.js index 456ff03..3ba58a0 100755 --- a/server-tools/lake-lamp-authz/authorize-repo-push.js +++ b/server-tools/lake-lamp-authz/authorize-repo-push.js @@ -17,6 +17,7 @@ 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, @@ -25,9 +26,9 @@ 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("请把 REQUEST_URL 交给冰朔用于核对申请内容并保持本命令运行;批准链接已由服务器发送到预登记邮箱。"); output("不需要向冰朔索要邮箱、授权码、验证码、密码或任何令牌。"); const deadline = Date.now() + Number(request.expires_in || 900) * 1000; diff --git a/server-tools/lake-lamp-authz/deployment-event.js b/server-tools/lake-lamp-authz/deployment-event.js new file mode 100644 index 0000000..071388d --- /dev/null +++ b/server-tools/lake-lamp-authz/deployment-event.js @@ -0,0 +1,36 @@ +"use strict"; +const crypto = require("node:crypto"); +const fs = require("node:fs"); +const path = require("node:path"); + +// A push can request deployment, but it can never execute deployment itself. +// The separate resident deployment agent consumes these immutable event files. +function enqueueDeploymentEvent(intent, push, queueDir) { + if (!intent) return { state: "not_requested" }; + const invalid = validateIntent(intent, push); + if (invalid) return { state: "rejected", diagnostic_code: invalid }; + fs.mkdirSync(queueDir, { recursive: true, mode: 0o750 }); + const event = { + schema: "guanghu.deployment-event/v1", + event_id: crypto.randomUUID(), + created_at: Date.now() / 1000, + state: "queued_for_resident_agent", + repo: push.repo, branch: push.branch, commit_sha: push.commit_sha, + workorder_id: String(intent.workorder_id || ""), + resource: intent.resource, action: "provision-approved-architecture", + manifest: intent.manifest, + }; + const target = path.join(queueDir, `${event.created_at}-${event.event_id}.json`); + const temporary = `${target}.${process.pid}.tmp`; + fs.writeFileSync(temporary, JSON.stringify(event), { mode: 0o640 }); + fs.renameSync(temporary, target); + return { state: event.state, event_id: event.event_id }; +} +function validateIntent(intent, push) { + if (intent.schema !== "guanghu.deployment-intent/v1") return "deployment_intent_schema_invalid"; + if (String(intent.repo || "").toLowerCase() !== push.repo || intent.branch !== push.branch || String(intent.commit_sha || "").toLowerCase() !== push.commit_sha) return "deployment_intent_binding_mismatch"; + if (!/^[A-Z0-9][A-Z0-9._-]{5,119}@[0-9a-f]{40}$/.test(String(intent.resource || "")) || !String(intent.resource).endsWith(`@${push.commit_sha}`)) return "deployment_intent_resource_invalid"; + if (!/^deployment\/requests\/[A-Za-z0-9._/-]{1,180}\.json$/.test(String(intent.manifest || ""))) return "deployment_intent_manifest_invalid"; + return ""; +} +module.exports = { enqueueDeploymentEvent, validateIntent }; diff --git a/server-tools/lake-lamp-authz/deployment-event.test.js b/server-tools/lake-lamp-authz/deployment-event.test.js new file mode 100644 index 0000000..20cf907 --- /dev/null +++ b/server-tools/lake-lamp-authz/deployment-event.test.js @@ -0,0 +1,17 @@ +"use strict"; +const test = require("node:test"); +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const os = require("node:os"); +const path = require("node:path"); +const { enqueueDeploymentEvent } = require("./deployment-event"); +test("only an immutable deployment intent creates a resident-agent event", () => { + const queue = fs.mkdtempSync(path.join(os.tmpdir(), "lake-lamp-deploy-events-")); + const sha = "a".repeat(40), push = { repo: "bingshuo/guanghu-ice-heart", branch: "main", commit_sha: sha }; + try { + const queued = enqueueDeploymentEvent({ schema: "guanghu.deployment-intent/v1", repo: push.repo, branch: "main", commit_sha: sha, resource: `GLS-0239-DEPLOY@${sha}`, manifest: "deployment/requests/GLS-0239.json", workorder_id: "order-1" }, push, queue); + assert.equal(queued.state, "queued_for_resident_agent"); + assert.equal(fs.readdirSync(queue).length, 1); + assert.equal(enqueueDeploymentEvent({ schema: "guanghu.deployment-intent/v1", repo: push.repo, branch: "main", commit_sha: sha, resource: `GLS-0239-DEPLOY@${"b".repeat(40)}`, manifest: "deployment/requests/GLS-0239.json" }, push, queue).diagnostic_code, "deployment_intent_resource_invalid"); + } finally { fs.rmSync(queue, { recursive: true, force: true }); } +}); diff --git a/server-tools/lake-lamp-authz/repo-push-broker.js b/server-tools/lake-lamp-authz/repo-push-broker.js new file mode 100644 index 0000000..ef0353a --- /dev/null +++ b/server-tools/lake-lamp-authz/repo-push-broker.js @@ -0,0 +1,72 @@ +#!/usr/bin/env node +"use strict"; + +// The receiver is deliberately separate from the public authorization process. +// It owns the private Forgejo transport configuration and accepts only a bundle +// already bound to a workorder/session by lake-lamp-authz. +const fs = require("node:fs"); +const os = require("node:os"); +const path = require("node:path"); +const { execFile } = require("node:child_process"); + +function loadRegistry(file) { + const parsed = JSON.parse(fs.readFileSync(file, "utf8")); + if (!parsed || !parsed.repos || typeof parsed.repos !== "object") throw new Error("invalid_repo_push_registry"); + return parsed.repos; +} + +async function receiveBundle(request, options = {}) { + const registry = options.registry || loadRegistry(options.registryFile || process.env.LAKE_LAMP_REPO_PUSH_REGISTRY || "/etc/guanghu/lake-lamp/repo-push-registry.json"); + const run = options.run || runGit; + const repo = String(request.repo || "").toLowerCase(); + const branch = String(request.branch || "main"); + 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"); + 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"); + + 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]); + 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}`]); + 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) }); + } finally { + fs.rmSync(temporary, { recursive: true, force: true }); + } +} + +function checkedBundlePath(value, uploadDir) { + try { + const root = fs.realpathSync(uploadDir); + const candidate = fs.realpathSync(String(value || "")); + return candidate.startsWith(`${root}${path.sep}`) && fs.statSync(candidate).isFile() ? candidate : ""; + } catch { return ""; } +} +function parseRemoteHead(output) { + const match = String(output || "").match(/^([0-9a-f]{40})\s+refs\/heads\//m); + return match ? match[1].toLowerCase() : ""; +} +function blocked(diagnostic_code, extra = {}) { return { ok: false, diagnostic_code, ...extra }; } +function runGit(args, options = {}) { + return new Promise((resolve, reject) => execFile("git", args, { timeout: 120000, maxBuffer: 1024 * 1024 }, (error, stdout, stderr) => { + const result = { ok: !error, stdout: String(stdout || ""), stderr: String(stderr || "") }; + if (error && !options.allowFailure) return reject(new Error(result.stderr || error.message)); + resolve(result); + })); +} + +module.exports = { receiveBundle, checkedBundlePath, parseRemoteHead }; diff --git a/server-tools/lake-lamp-authz/repo-push-broker.test.js b/server-tools/lake-lamp-authz/repo-push-broker.test.js new file mode 100644 index 0000000..b66919d --- /dev/null +++ b/server-tools/lake-lamp-authz/repo-push-broker.test.js @@ -0,0 +1,31 @@ +"use strict"; +const test = require("node:test"); +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"); + +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-")); + const bundle = path.join(uploadDir, "one.bundle"); fs.writeFileSync(bundle, "bundle"); + const calls = []; + 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 }); + assert.equal(result.ok, true); assert.equal(result.commit_sha, incoming); + assert.ok(calls.some(args => args.includes("push"))); + } finally { fs.rmSync(uploadDir, { recursive: true, force: true }); } +}); + +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: "" }; }; + 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 }); + assert.equal(result.diagnostic_code, "expected_head_mismatch"); + } 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 new file mode 100644 index 0000000..fdd47e7 --- /dev/null +++ b/server-tools/lake-lamp-authz/repo-push-registry.example.json @@ -0,0 +1,9 @@ +{ + "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" + } + } +} diff --git a/server-tools/lake-lamp-authz/request-workorder.js b/server-tools/lake-lamp-authz/request-workorder.js index 506a5ea..f276f1c 100644 --- a/server-tools/lake-lamp-authz/request-workorder.js +++ b/server-tools/lake-lamp-authz/request-workorder.js @@ -21,6 +21,9 @@ 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 b72f579..92a979a 100644 --- a/server-tools/lake-lamp-authz/server.js +++ b/server-tools/lake-lamp-authz/server.js @@ -8,6 +8,7 @@ const { WorkOrderManager } = require("./workorder-manager"); const { MapGate } = require("./map-gate"); const { sendSmtpMail } = require("./smtp-mailer"); const { executeRegisteredAction } = require("./action-client"); +const { enqueueDeploymentEvent } = require("./deployment-event"); const DEFAULT_ACTIONS = Object.freeze({ "server-login": [ @@ -28,6 +29,7 @@ const DEFAULT_ACTIONS = Object.freeze({ "push-repository", "restore-owner-password-login", "restore-code-channel-owner-login", + "dispatch-approved-deployment", ], "repo-push": ["read-navigation-map", "push-repository"], }); @@ -57,6 +59,7 @@ 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 deploymentQueueDir = options.deploymentQueueDir || process.env.LAKE_LAMP_DEPLOYMENT_EVENT_DIR || "/var/lib/guanghu/deployment-events"; const executeAction = options.executeAction || executeRegisteredAction; // Creating a powerless request must never become harder than the human mail // handoff. Keep at least three attempts per network each hour. @@ -163,14 +166,23 @@ 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"); + } + } 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: "waiting_for_owner_handoff", - receipt: receipt({ state: "waiting_for_owner_handoff", diagnostic_code: "owner_handoff_required", workorder_id: created.id, next_step: "把 request_url 交给主人打开一次;页面会向预登记邮箱发送批准链接。不要索要密码、验证码或令牌。" }), + 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。不要索要密码、验证码或令牌。" }), }); } @@ -304,6 +316,21 @@ function createApp(options = {}) { 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 }); } + if (req.method === "POST" && url.pathname === "/api/deployment/dispatch") { + const body = await readJson(req); + if (!body) return json(res, 400, failure("invalid_json")); + const token = bearer(req), target = String(body.target || ""), scope = String(body.scope || "server-ops"); + const resource = String(body.resource || ""), repo = String(body.repo || "").toLowerCase(), branch = String(body.branch || "main"), commit = String(body.commit_sha || "").toLowerCase(), manifest = String(body.manifest || ""); + const verified = manager.verifySession(token, { pid: String(body.persona_id || "") }, target, scope, "dispatch-approved-deployment", 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 queued = enqueueDeploymentEvent({ schema: "guanghu.deployment-intent/v1", repo, branch, commit_sha: commit, resource, manifest, workorder_id: verified.session.workorderId }, { repo, branch, commit_sha: commit }, deploymentQueueDir); + const operationReceipt = receipt({ state: queued.state === "queued_for_resident_agent" ? "queued" : "blocked", diagnostic_code: queued.diagnostic_code || "deployment_event_queued", workorder_id: verified.session.workorderId, target, action: "dispatch-approved-deployment", evidence: { repo, branch, commit_sha: commit, event_id: queued.event_id || "" }, next_step: queued.state === "queued_for_resident_agent" ? "常驻部署 Agent 将读取该事件并回写部署、健康检查或回滚回执。" : "修正部署绑定信息后重新申请或派发,不要让服务器自行扫描提交。" }); + manager.recordReceipt(token, operationReceipt); + return json(res, queued.state === "queued_for_resident_agent" ? 202 : 400, { ok: queued.state === "queued_for_resident_agent", deployment: queued, receipt: operationReceipt }); + } + return json(res, 404, { error: "not_found" }); } catch (error) { process.stderr.write(`lake-lamp request error: ${String(error && error.message || "unknown").slice(0, 240)}\n`); @@ -447,8 +474,11 @@ function validateWorkorderBody(body, targets, actions) { if (!/^[A-Za-z0-9._:+\u221e-]{2,80}$/.test(personaId) || personaName.length > 100 || description.length > 500) return { ok: false, status: 400, error: "invalid_request_fields" }; if (!targets.has(target)) return { ok: false, status: 400, error: "unknown_target" }; if (!Array.isArray(actions[scope]) || !actions[scope].includes(action)) return { ok: false, status: 400, error: "unknown_or_mismatched_action" }; - if (action === "provision-approved-architecture" && !/^[A-Z0-9][A-Z0-9._-]{5,119}@[0-9a-f]{40}$/.test(resource)) return { ok: false, status: 400, error: "immutable_architecture_resource_required" }; - if (action !== "provision-approved-architecture" && resource) return { ok: false, status: 400, error: "resource_not_allowed_for_action" }; + const immutableResourceAction = action === "provision-approved-architecture" || action === "dispatch-approved-deployment"; + if (immutableResourceAction && !/^[A-Z0-9][A-Z0-9._-]{5,119}@[0-9a-f]{40}$/.test(resource)) return { ok: false, status: 400, error: "immutable_architecture_resource_required" }; + if (!immutableResourceAction && resource) return { ok: false, status: 400, error: "resource_not_allowed_for_action" }; + if (body.owner_notify !== undefined && typeof body.owner_notify !== "boolean") return { ok: false, status: 400, error: "invalid_owner_notify" }; + if (body.owner_notify === true && provenance.system_entry !== "光湖语言人格系统当前实例") return { ok: false, status: 400, error: "owner_notify_requires_language_system_provenance" }; if (Object.values(provenance).some(Boolean) && (provenance.system_entry !== "光湖语言人格系统当前实例" || Object.values(provenance).some(item => !item || item.length > 120))) return { ok: false, status: 400, error: "invalid_instance_provenance" }; return { ok: true, request: { persona: { pid: personaId, name: personaName }, provenance, target, scope, action, allowedActions: actions[scope], description, resource } }; } diff --git a/server-tools/lake-lamp-authz/server.test.js b/server-tools/lake-lamp-authz/server.test.js index a68295b..3a90d7b 100644 --- a/server-tools/lake-lamp-authz/server.test.js +++ b/server-tools/lake-lamp-authz/server.test.js @@ -102,6 +102,21 @@ test("cross-device request is powerless until the owner asks for an email and ap }); }); +test("declared Work/mobile instance can notify the registered owner without a local credential", async () => { + await withServer(async ({ base, mail }) => { + const requested = await fetch(`${base}/api/public/workorders`, { + method: "POST", headers: { "content-type": "application/json" }, + body: JSON.stringify({ system_entry: "光湖语言人格系统当前实例", origin_software: "Work", origin_model: "语言模型", origin_instance: "mobile-1", owner_notify: true, persona_id: "ICE-GL-ZY001", target: "JD-FD-PRIMARY", scope: "server-ops", action: "dispatch-approved-deployment", resource: `GLS-0239-DEPLOY@${"a".repeat(40)}` }), + }); + assert.equal(requested.status, 201); + const order = await requested.json(); + assert.equal(order.status, "waiting_for_owner"); + assert.equal(order.email_status, "sent"); + assert.equal(mail.length, 1); + assert.match(mail[0].approvalUrl, /\/approve\//); + }, { actions: { "server-ops": ["read-navigation-map", "dispatch-approved-deployment"] } }); +}); + test("cross-device request creation is rate limited without revealing owner identity", async () => { await withServer(async ({ base, mail }) => { const payload = { persona_id: "ICE-GL-ZY001", target: "JD-FD-PRIMARY", scope: "server-login", action: "read-navigation-map" }; @@ -140,6 +155,7 @@ test("one server-ops approval exposes the full registered three-hour operation s "push-repository", "restore-owner-password-login", "restore-code-channel-owner-login", + "dispatch-approved-deployment", ]); } finally { await new Promise(resolve => app.close(resolve)); @@ -279,6 +295,29 @@ test("failed server actions return a durable diagnosis instead of making a perso } finally { fs.rmSync(dir, { recursive: true, force: true }); } }); +test("deployment is dispatched only by an explicit approved second signal", async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "lake-lamp-deploy-dispatch-")); + const mapsDir = path.join(dir, "maps"); fs.mkdirSync(mapsDir); + fs.writeFileSync(path.join(mapsDir, "JD-FD-PRIMARY.json"), JSON.stringify({ node_id: "JD-FD-PRIMARY" })); + const sha = "a".repeat(40), resource = `GLS-0239-DEPLOY@${sha}`; + try { + await withServer(async ({ base, mail }) => { + const requested = await fetch(`${base}/api/public/workorders`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ persona_id: "ICE-GL-ZY001", target: "JD-FD-PRIMARY", scope: "server-ops", action: "dispatch-approved-deployment", resource }) }); + const order = await requested.json(); + await fetch(`${base}${new URL(order.request_url).pathname.replace("/authz", "")}`, { method: "POST" }); + await fetch(`${base}${new URL(mail[0].approvalUrl).pathname.replace("/authz", "")}`, { method: "POST" }); + const session = await (await fetch(`${base}/api/workorders/${order.workorder_id}/claim`, { method: "POST", headers: { authorization: `Bearer ${order.claim_token}` } })).json(); + const common = { persona_id: "ICE-GL-ZY001", target: "JD-FD-PRIMARY", scope: "server-ops" }; + const map = await (await fetch(`${base}/api/navigation-map/read`, { method: "POST", headers: { authorization: `Bearer ${session.session_token}`, "content-type": "application/json" }, body: JSON.stringify(common) })).json(); + await fetch(`${base}/api/navigation-map/ack`, { method: "POST", headers: { authorization: `Bearer ${session.session_token}`, "content-type": "application/json" }, body: JSON.stringify({ ...common, map_hash: map.map_hash }) }); + const dispatch = await fetch(`${base}/api/deployment/dispatch`, { method: "POST", headers: { authorization: `Bearer ${session.session_token}`, "content-type": "application/json" }, body: JSON.stringify({ ...common, repo: "bingshuo/guanghu-ice-heart", branch: "main", commit_sha: sha, resource, manifest: "deployment/requests/GLS-0239.json" }) }); + assert.equal(dispatch.status, 202); + assert.equal((await dispatch.json()).receipt.state, "queued"); + assert.equal(fs.readdirSync(path.join(dir, "queue")).length, 1); + }, { mapsDir, mapStateFile: path.join(dir, "acks.json"), deploymentQueueDir: path.join(dir, "queue"), actions: { "server-ops": ["read-navigation-map", "dispatch-approved-deployment"] } }); + } finally { fs.rmSync(dir, { recursive: true, force: true }); } +}); + test("request endpoint rejects direct email target switching and unknown actions", async () => { await withServer(async ({ base }) => { const common = { method: "POST", headers: { authorization: "Bearer request-only-secret", "content-type": "application/json" } }; diff --git a/zero-point/core-channel/revive-guard/repo-authorization-guard.py b/zero-point/core-channel/revive-guard/repo-authorization-guard.py index 1cfd849..1571f5d 100755 --- a/zero-point/core-channel/revive-guard/repo-authorization-guard.py +++ b/zero-point/core-channel/revive-guard/repo-authorization-guard.py @@ -53,7 +53,7 @@ def rejection_message(repo, reason, public_url=None): ), "命令会创建一张无执行权申请单并打印 REQUEST_URL。把该链接交给冰朔,保持命令运行。", "冰朔打开 REQUEST_URL 后,服务器才向预登记邮箱发送批准邮件;冰朔点击邮件批准链接后,", - "命令会自动领取受限会话、读取并确认导航图、生成三小时且执行中自动续期的 repo-push 许可。随后重试原 git push。", + "命令会自动领取受限会话、读取并确认导航图,并返回结构化操作回执。许可不是 Git 密码;只有回执显示安全推送接收器 ready 时才按其指令继续。", "说明: AI 不发送邮件、不需要知道邮箱;公开空工单本身不含任何推送权限。", ])