feat(hlcc): dispatch approved deployments by explicit signal
This commit is contained in:
parent
357ac5e67a
commit
5897eb6850
13 changed files with 270 additions and 11 deletions
|
|
@ -42,9 +42,11 @@ Content-Type: application/json
|
||||||
|
|
||||||
接口返回 `request_url` 后,AI 只把链接交给冰朔。冰朔核对目标节点、仓库、范围和说明,确认后授权邮件才会发往服务器预登记邮箱。AI 不得绕过该步骤,也不得要求冰朔提供邮箱密码或授权码。
|
接口返回 `request_url` 后,AI 只把链接交给冰朔。冰朔核对目标节点、仓库、范围和说明,确认后授权邮件才会发往服务器预登记邮箱。AI 不得绕过该步骤,也不得要求冰朔提供邮箱密码或授权码。
|
||||||
|
|
||||||
## 3. 授权完成后的推送
|
## 3. 授权完成后的安全推送
|
||||||
|
|
||||||
授权会话只允许已登记的 `repo-push` 能力,默认短时有效,持续执行同一绑定任务时可自动续期。AI 回到原任务后,用人类已配置的 Git 凭据管理器推送:
|
授权会话只允许已登记的 `repo-push` 能力,默认短时有效,持续执行同一绑定任务时可自动续期。AI 必须先读取操作回执;许可本身不是 Git 登录凭证。安全 bundle 接收器尚未部署时,回执会明确返回 `repo_push_transport_unavailable`,此时停止,不得把它误判为密码问题或反复重试。
|
||||||
|
|
||||||
|
安全接收器部署后,AI 依照回执所给的受限上传路径提交 bundle;服务器校验仓库、分支、预期远端 SHA、快进关系后,使用代码频道的正常接收路径写入。下面的普通 Git 命令仅是人工已拥有独立凭据时的兼容路径:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
git remote set-url origin https://guanghulab.com/code/bingshuo/guanghu-ice-heart.git
|
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、脚本、提交、日志或聊天记录。共享电脑或临时环境完成后应清理凭据。
|
不要把密码写入 remote URL、脚本、提交、日志或聊天记录。共享电脑或临时环境完成后应清理凭据。
|
||||||
|
|
||||||
|
## 3.1 推送后的自动部署事件
|
||||||
|
|
||||||
|
只有冰朔与语言层明确确认“该提交需要部署”后,提交模块的人格体才发出第二个、与完整提交 SHA 绑定的 `guanghu.deployment-intent/v1`。它通过单独的 `server-ops / dispatch-approved-deployment` 工单派发到京东服务器本机队列;常驻部署 Agent 自动响应,完成清单校验、备份、部署、健康检查与回滚回执。冰朔不需要重新描述任务,但部署范围仍必须明确批准。
|
||||||
|
|
||||||
|
没有部署意图的提交仅入库,回执为 `not_requested`。推送授权不能偷渡扩大为部署授权;服务器不扫描仓库自行拉取部署。
|
||||||
|
|
||||||
## 4. 被拒绝时
|
## 4. 被拒绝时
|
||||||
|
|
||||||
遇到 `repo_push_grant_expired`、`authorization_pending`、`NO_TRUSTED_PATH` 或目标仓库不匹配:停止重试,保留本地完整 SHA,重新创建 `repo-push` 工单,并等待冰朔确认。登录失败时先检查是否误用了旧入口或旧 Token,不要把凭据粘贴到聊天中。
|
遇到 `repo_push_grant_expired`、`authorization_pending`、`NO_TRUSTED_PATH` 或目标仓库不匹配:停止重试,保留本地完整 SHA,重新创建 `repo-push` 工单,并等待冰朔确认。登录失败时先检查是否误用了旧入口或旧 Token,不要把凭据粘贴到聊天中。
|
||||||
|
|
|
||||||
13
deployment/HLCC-PUSH-TO-DEPLOY-EVENT-PROTOCOL.hdlp
Normal file
13
deployment/HLCC-PUSH-TO-DEPLOY-EVENT-PROTOCOL.hdlp
Normal 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`,不会部署。推送工单绝不扩大为部署权限;服务器也不扫描仓库主动拉取部署。
|
||||||
|
|
@ -46,8 +46,8 @@ node request-workorder.js \
|
||||||
--description "推送 REPO-001"
|
--description "推送 REPO-001"
|
||||||
```
|
```
|
||||||
|
|
||||||
响应中的 `request_url` 交给冰朔点击。该 URL 只能展示申请内容并请求向预登记邮箱
|
光湖语言人格系统的 Work/手机实例会带 `owner_notify=true`,服务器会直接向预登记邮箱
|
||||||
发送批准邮件,不能直接批准。人格体必须在自己的临时会话中保留响应里的
|
发送批准链接;`request_url` 仍可交给冰朔核对申请内容,但不要求在本机打开。人格体必须在自己的临时会话中保留响应里的
|
||||||
`workorder_id` 与 `claim_token`,批准后通过 claim 路由领取一次性会话;不得把这些值
|
`workorder_id` 与 `claim_token`,批准后通过 claim 路由领取一次性会话;不得把这些值
|
||||||
写入仓库、长期日志或聊天记忆。
|
写入仓库、长期日志或聊天记忆。
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ async function authorizeRepoPush(options, deps = {}) {
|
||||||
origin_software: options.software || "仓库推送客户端",
|
origin_software: options.software || "仓库推送客户端",
|
||||||
origin_model: options.model || "未声明模型",
|
origin_model: options.model || "未声明模型",
|
||||||
origin_instance: options.instance || "当前实例",
|
origin_instance: options.instance || "当前实例",
|
||||||
|
owner_notify: true,
|
||||||
persona_id: persona,
|
persona_id: persona,
|
||||||
persona_name: options.name || persona,
|
persona_name: options.name || persona,
|
||||||
target,
|
target,
|
||||||
|
|
@ -25,9 +26,9 @@ async function authorizeRepoPush(options, deps = {}) {
|
||||||
description: options.description || `申请推送 ${repo}`,
|
description: options.description || `申请推送 ${repo}`,
|
||||||
});
|
});
|
||||||
|
|
||||||
output("[LL-WORKORDER-CREATED] 无执行权申请单已创建;尚未发送邮件,也没有推送权限。");
|
output("[LL-WORKORDER-CREATED] 无执行权申请单已创建;服务器已向预登记邮箱发送批准链接,也没有推送权限。");
|
||||||
output(`REQUEST_URL=${request.request_url}`);
|
output(`REQUEST_URL=${request.request_url}`);
|
||||||
output("请把 REQUEST_URL 交给冰朔并保持本命令运行。冰朔打开页面后,服务器才发送预登记邮箱邮件。");
|
output("请把 REQUEST_URL 交给冰朔用于核对申请内容并保持本命令运行;批准链接已由服务器发送到预登记邮箱。");
|
||||||
output("不需要向冰朔索要邮箱、授权码、验证码、密码或任何令牌。");
|
output("不需要向冰朔索要邮箱、授权码、验证码、密码或任何令牌。");
|
||||||
|
|
||||||
const deadline = Date.now() + Number(request.expires_in || 900) * 1000;
|
const deadline = Date.now() + Number(request.expires_in || 900) * 1000;
|
||||||
|
|
|
||||||
36
server-tools/lake-lamp-authz/deployment-event.js
Normal file
36
server-tools/lake-lamp-authz/deployment-event.js
Normal 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 };
|
||||||
17
server-tools/lake-lamp-authz/deployment-event.test.js
Normal file
17
server-tools/lake-lamp-authz/deployment-event.test.js
Normal 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 }); }
|
||||||
|
});
|
||||||
72
server-tools/lake-lamp-authz/repo-push-broker.js
Normal file
72
server-tools/lake-lamp-authz/repo-push-broker.js
Normal 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 };
|
||||||
31
server-tools/lake-lamp-authz/repo-push-broker.test.js
Normal file
31
server-tools/lake-lamp-authz/repo-push-broker.test.js
Normal 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 }); }
|
||||||
|
});
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -21,6 +21,9 @@ async function main() {
|
||||||
action: args.action,
|
action: args.action,
|
||||||
description: args.description || "",
|
description: args.description || "",
|
||||||
resource: args.resource || "",
|
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) {
|
if (requestToken) {
|
||||||
const qqId = process.env.GUANGHU_OWNER_QQ_ID || "";
|
const qqId = process.env.GUANGHU_OWNER_QQ_ID || "";
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ const { WorkOrderManager } = require("./workorder-manager");
|
||||||
const { MapGate } = require("./map-gate");
|
const { MapGate } = require("./map-gate");
|
||||||
const { sendSmtpMail } = require("./smtp-mailer");
|
const { sendSmtpMail } = require("./smtp-mailer");
|
||||||
const { executeRegisteredAction } = require("./action-client");
|
const { executeRegisteredAction } = require("./action-client");
|
||||||
|
const { enqueueDeploymentEvent } = require("./deployment-event");
|
||||||
|
|
||||||
const DEFAULT_ACTIONS = Object.freeze({
|
const DEFAULT_ACTIONS = Object.freeze({
|
||||||
"server-login": [
|
"server-login": [
|
||||||
|
|
@ -28,6 +29,7 @@ const DEFAULT_ACTIONS = Object.freeze({
|
||||||
"push-repository",
|
"push-repository",
|
||||||
"restore-owner-password-login",
|
"restore-owner-password-login",
|
||||||
"restore-code-channel-owner-login",
|
"restore-code-channel-owner-login",
|
||||||
|
"dispatch-approved-deployment",
|
||||||
],
|
],
|
||||||
"repo-push": ["read-navigation-map", "push-repository"],
|
"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"),
|
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 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;
|
const executeAction = options.executeAction || executeRegisteredAction;
|
||||||
// Creating a powerless request must never become harder than the human mail
|
// Creating a powerless request must never become harder than the human mail
|
||||||
// handoff. Keep at least three attempts per network each hour.
|
// handoff. Keep at least three attempts per network each hour.
|
||||||
|
|
@ -163,14 +166,23 @@ function createApp(options = {}) {
|
||||||
const validation = validateWorkorderBody(body, targets, actions);
|
const validation = validateWorkorderBody(body, targets, actions);
|
||||||
if (!validation.ok) return json(res, validation.status, { error: validation.error });
|
if (!validation.ok) return json(res, validation.status, { error: validation.error });
|
||||||
const created = manager.request(validation.request);
|
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, {
|
return json(res, 201, {
|
||||||
ok: true,
|
ok: true,
|
||||||
workorder_id: created.id,
|
workorder_id: created.id,
|
||||||
claim_token: created.claimToken,
|
claim_token: created.claimToken,
|
||||||
request_url: `${publicBaseUrl}/request/${created.handoffToken}`,
|
request_url: `${publicBaseUrl}/request/${created.handoffToken}`,
|
||||||
expires_in: created.expiresIn,
|
expires_in: created.expiresIn,
|
||||||
status: "waiting_for_owner_handoff",
|
status: emailStatus === "sent" ? "waiting_for_owner" : "waiting_for_owner_handoff",
|
||||||
receipt: receipt({ state: "waiting_for_owner_handoff", diagnostic_code: "owner_handoff_required", workorder_id: created.id, next_step: "把 request_url 交给主人打开一次;页面会向预登记邮箱发送批准链接。不要索要密码、验证码或令牌。" }),
|
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 });
|
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" });
|
return json(res, 404, { error: "not_found" });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
process.stderr.write(`lake-lamp request error: ${String(error && error.message || "unknown").slice(0, 240)}\n`);
|
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 (!/^[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 (!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 (!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" };
|
const immutableResourceAction = action === "provision-approved-architecture" || action === "dispatch-approved-deployment";
|
||||||
if (action !== "provision-approved-architecture" && resource) return { ok: false, status: 400, error: "resource_not_allowed_for_action" };
|
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" };
|
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 } };
|
return { ok: true, request: { persona: { pid: personaId, name: personaName }, provenance, target, scope, action, allowedActions: actions[scope], description, resource } };
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 () => {
|
test("cross-device request creation is rate limited without revealing owner identity", async () => {
|
||||||
await withServer(async ({ base, mail }) => {
|
await withServer(async ({ base, mail }) => {
|
||||||
const payload = { persona_id: "ICE-GL-ZY001", target: "JD-FD-PRIMARY", scope: "server-login", action: "read-navigation-map" };
|
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",
|
"push-repository",
|
||||||
"restore-owner-password-login",
|
"restore-owner-password-login",
|
||||||
"restore-code-channel-owner-login",
|
"restore-code-channel-owner-login",
|
||||||
|
"dispatch-approved-deployment",
|
||||||
]);
|
]);
|
||||||
} finally {
|
} finally {
|
||||||
await new Promise(resolve => app.close(resolve));
|
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 }); }
|
} 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 () => {
|
test("request endpoint rejects direct email target switching and unknown actions", async () => {
|
||||||
await withServer(async ({ base }) => {
|
await withServer(async ({ base }) => {
|
||||||
const common = { method: "POST", headers: { authorization: "Bearer request-only-secret", "content-type": "application/json" } };
|
const common = { method: "POST", headers: { authorization: "Bearer request-only-secret", "content-type": "application/json" } };
|
||||||
|
|
|
||||||
|
|
@ -53,7 +53,7 @@ def rejection_message(repo, reason, public_url=None):
|
||||||
),
|
),
|
||||||
"命令会创建一张无执行权申请单并打印 REQUEST_URL。把该链接交给冰朔,保持命令运行。",
|
"命令会创建一张无执行权申请单并打印 REQUEST_URL。把该链接交给冰朔,保持命令运行。",
|
||||||
"冰朔打开 REQUEST_URL 后,服务器才向预登记邮箱发送批准邮件;冰朔点击邮件批准链接后,",
|
"冰朔打开 REQUEST_URL 后,服务器才向预登记邮箱发送批准邮件;冰朔点击邮件批准链接后,",
|
||||||
"命令会自动领取受限会话、读取并确认导航图、生成三小时且执行中自动续期的 repo-push 许可。随后重试原 git push。",
|
"命令会自动领取受限会话、读取并确认导航图,并返回结构化操作回执。许可不是 Git 密码;只有回执显示安全推送接收器 ready 时才按其指令继续。",
|
||||||
"说明: AI 不发送邮件、不需要知道邮箱;公开空工单本身不含任何推送权限。",
|
"说明: AI 不发送邮件、不需要知道邮箱;公开空工单本身不含任何推送权限。",
|
||||||
])
|
])
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue