feat(hlcc): dispatch approved deployments by explicit signal

This commit is contained in:
冰朔 2026-07-26 15:36:14 +08:00
commit 5897eb6850
13 changed files with 270 additions and 11 deletions

View file

@ -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不要把凭据粘贴到聊天中。

View file

@ -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`,不会部署。推送工单绝不扩大为部署权限;服务器也不扫描仓库主动拉取部署。

View file

@ -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 路由领取一次性会话;不得把这些值
写入仓库、长期日志或聊天记忆。

View file

@ -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;

View file

@ -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 };

View file

@ -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 }); }
});

View file

@ -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 };

View file

@ -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 }); }
});

View file

@ -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"
}
}
}

View file

@ -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 || "";

View file

@ -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 } };
}

View file

@ -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" } };

View file

@ -53,7 +53,7 @@ def rejection_message(repo, reason, public_url=None):
),
"命令会创建一张无执行权申请单并打印 REQUEST_URL。把该链接交给冰朔保持命令运行。",
"冰朔打开 REQUEST_URL 后,服务器才向预登记邮箱发送批准邮件;冰朔点击邮件批准链接后,",
"命令会自动领取受限会话、读取并确认导航图、生成三小时且执行中自动续期的 repo-push 许可。随后重试原 git push",
"命令会自动领取受限会话、读取并确认导航图,并返回结构化操作回执。许可不是 Git 密码;只有回执显示安全推送接收器 ready 时才按其指令继续",
"说明: AI 不发送邮件、不需要知道邮箱;公开空工单本身不含任何推送权限。",
])