fix(jd): publish Guanghu router runtime source
This commit is contained in:
parent
e82a377b58
commit
e2bb573a58
14 changed files with 1492 additions and 73 deletions
|
|
@ -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) {
|
|||
<dt>目标节点</dt><dd>${escapeHtml(order.target)}</dd><dt>授权范围</dt><dd>${escapeHtml(order.scope)}</dd>
|
||||
<dt>登记动作</dt><dd>${escapeHtml(order.action)}</dd><dt>绑定资源</dt><dd>${escapeHtml(order.resource || "无")}</dd><dt>说明</dt><dd>${escapeHtml(order.description || "未附加说明")}</dd></dl>
|
||||
</div>
|
||||
<p class="notice">这张页面本身没有执行权。确认内容无误后,服务器只会向预登记邮箱发送一次真正的批准链接。</p>
|
||||
<form method="post"><button type="submit">发送我的授权邮件</button></form>
|
||||
<p class="notice">这张页面本身没有执行权。邮箱只通过本页的加密连接直达京东节点,不会返回给发起申请的AI。无论是否匹配,页面都会显示相同结果。</p>
|
||||
<form method="post">
|
||||
<label for="owner-email">冰朔登记邮箱</label>
|
||||
<input id="owner-email" name="email" type="email" inputmode="email" autocomplete="email" maxlength="254" required placeholder="请输入你的邮箱">
|
||||
<button type="submit">向我的邮箱发送一次授权申请</button>
|
||||
</form>
|
||||
`);
|
||||
}
|
||||
|
||||
function emailSentPage(order) {
|
||||
return document("授权邮件已发送", `<p class="eyebrow">OWNER VERIFICATION</p><h1>请打开邮箱完成批准</h1><div class="panel"><p>申请单已锁定到 <strong>${escapeHtml(order && order.target || "登记节点")}</strong>。真正的批准链接只发送到服务器预登记邮箱。</p></div><p class="notice">批准后回到原来的 AI 对话,让它领取一次性会话。无需向 AI 提供验证码、密码或邮箱授权码。</p>`);
|
||||
return document("检查你的邮箱", `<p class="eyebrow">OWNER VERIFICATION</p><h1>如果信息匹配,你会收到一封邮件</h1><div class="panel"><p>申请单已锁定到 <strong>${escapeHtml(order && order.target || "登记节点")}</strong>。系统不会在页面上透露邮箱是否登记。</p></div><p class="notice">收到邮件后请核对人格体、来源软件、目标节点、授权范围和动作,再点击批准。批准后回到原来的 AI 对话,让它领取一次性三小时会话;不要向AI提供邮箱、验证码、密码或授权码。</p>`);
|
||||
}
|
||||
|
||||
function requestErrorPage(reason) {
|
||||
|
|
@ -427,7 +822,7 @@ function approvalErrorPage(reason) {
|
|||
|
||||
function document(title, body) {
|
||||
return `<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>${escapeHtml(title)} · 光湖</title><style>
|
||||
:root{color-scheme:dark}*{box-sizing:border-box}body{margin:0;min-height:100vh;display:grid;place-items:center;background:radial-gradient(circle at 20% 10%,#17344d,#09111b 55%,#05090e);color:#eaf4fb;font:16px/1.7 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;padding:24px}.shell{width:min(680px,100%);padding:42px;border:1px solid #29475d;border-radius:24px;background:rgba(10,22,33,.94);box-shadow:0 24px 80px #0008}.eyebrow{color:#6ed5ff;letter-spacing:.18em;font-size:12px}h1{font-size:clamp(30px,6vw,48px);line-height:1.15;margin:10px 0 28px}.panel{background:#102638;border:1px solid #24465d;border-radius:16px;padding:20px 24px}dl{display:grid;grid-template-columns:110px 1fr;gap:12px;margin:0}dt{color:#8ba4b6}dd{margin:0;font-weight:650}small{display:block;color:#7893a6;font-weight:400}.notice{color:#9eb2c0;margin:20px 0}button{width:100%;border:0;border-radius:14px;padding:16px;background:#67d4ff;color:#042235;font-weight:800;font-size:17px;cursor:pointer}@media(max-width:520px){.shell{padding:28px 22px}dl{grid-template-columns:1fr;gap:2px}dd{margin-bottom:12px}}
|
||||
:root{color-scheme:dark}*{box-sizing:border-box}body{margin:0;min-height:100vh;display:grid;place-items:center;background:radial-gradient(circle at 20% 10%,#17344d,#09111b 55%,#05090e);color:#eaf4fb;font:16px/1.7 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;padding:24px}.shell{width:min(680px,100%);padding:42px;border:1px solid #29475d;border-radius:24px;background:rgba(10,22,33,.94);box-shadow:0 24px 80px #0008}.eyebrow{color:#6ed5ff;letter-spacing:.18em;font-size:12px}h1{font-size:clamp(30px,6vw,48px);line-height:1.15;margin:10px 0 28px}.panel{background:#102638;border:1px solid #24465d;border-radius:16px;padding:20px 24px}dl{display:grid;grid-template-columns:110px 1fr;gap:12px;margin:0}dt{color:#8ba4b6}dd{margin:0;font-weight:650}small{display:block;color:#7893a6;font-weight:400}.notice{color:#9eb2c0;margin:20px 0}label{display:block;margin:0 0 8px;color:#c9dae5;font-weight:700}input{width:100%;border:1px solid #365d76;border-radius:14px;padding:15px 16px;margin:0 0 14px;background:#07131d;color:#eaf4fb;font:inherit;outline:none}input:focus{border-color:#67d4ff;box-shadow:0 0 0 3px #67d4ff22}button{width:100%;border:0;border-radius:14px;padding:16px;background:#67d4ff;color:#042235;font-weight:800;font-size:17px;cursor:pointer}@media(max-width:520px){.shell{padding:28px 22px}dl{grid-template-columns:1fr;gap:2px}dd{margin-bottom:12px}}
|
||||
</style></head><body><main class="shell">${body}</main></body></html>`;
|
||||
}
|
||||
|
||||
|
|
@ -440,6 +835,59 @@ function readJson(req) {
|
|||
});
|
||||
}
|
||||
|
||||
function readBinaryBody(req, destination, maxBytes, options = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
let received = 0;
|
||||
const output = fs.createWriteStream(destination, {
|
||||
flags: options.append ? "a" : "wx",
|
||||
mode: 0o640,
|
||||
});
|
||||
const fail = error => {
|
||||
output.destroy();
|
||||
fs.rmSync(destination, { force: true });
|
||||
reject(error);
|
||||
};
|
||||
req.on("data", chunk => {
|
||||
received += chunk.length;
|
||||
if (received > maxBytes) {
|
||||
const error = new Error("repo bundle too large");
|
||||
error.code = "BODY_TOO_LARGE";
|
||||
req.destroy(error);
|
||||
return;
|
||||
}
|
||||
if (!output.write(chunk)) req.pause();
|
||||
});
|
||||
output.on("drain", () => req.resume());
|
||||
req.on("end", () => output.end());
|
||||
req.on("error", fail);
|
||||
output.on("error", fail);
|
||||
output.on("finish", () => {
|
||||
if (received < 1) return fail(new Error("repo bundle empty"));
|
||||
resolve(received);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function readForm(req) {
|
||||
return new Promise((resolve, reject) => {
|
||||
let raw = "";
|
||||
req.on("data", chunk => {
|
||||
raw += chunk;
|
||||
if (raw.length > 4 * 1024) {
|
||||
const error = new Error("body too large");
|
||||
error.code = "BODY_TOO_LARGE";
|
||||
reject(error);
|
||||
req.destroy();
|
||||
}
|
||||
});
|
||||
req.on("end", () => {
|
||||
try { resolve(Object.fromEntries(new URLSearchParams(raw))); }
|
||||
catch { resolve({}); }
|
||||
});
|
||||
req.on("error", reject);
|
||||
});
|
||||
}
|
||||
|
||||
function bearer(req) { return String(req.headers.authorization || "").replace(/^Bearer\s+/i, ""); }
|
||||
function bearerMatches(req, expected) { return Boolean(expected) && safeEqual(bearer(req), expected); }
|
||||
function safeEqual(left, right) { const a = Buffer.from(String(left)); const b = Buffer.from(String(right)); return a.length === b.length && crypto.timingSafeEqual(a, b); }
|
||||
|
|
@ -458,10 +906,14 @@ function loadApprovers(file, ownerEmail) {
|
|||
}
|
||||
function selectApprover(approvers, order) {
|
||||
const eligible = approvers.filter(item => matches(item.targets, order.target) && matches(item.scopes, order.scope));
|
||||
return eligible.find(item => item.persona_ids.includes(order.persona.pid)) || eligible.find(item => item.default) || null;
|
||||
return eligible.find(item => item.persona_ids.includes(order.persona.pid))
|
||||
|| eligible.find(item => !item.targets.includes("*") && !item.scopes.includes("*"))
|
||||
|| eligible.find(item => item.default)
|
||||
|| null;
|
||||
}
|
||||
function matches(values, value) { return values.includes("*") || values.includes(value); }
|
||||
function validEmail(value) { return typeof value === "string" && value.length <= 254 && /^[^@\s]+@[^@\s]+$/.test(value); }
|
||||
function normalizeEmail(value) { return String(value || "").trim().normalize("NFKC").toLowerCase(); }
|
||||
function clientAddress(req) {
|
||||
const forwarded = String(req.headers["x-forwarded-for"] || "").split(",").map(value => value.trim()).filter(Boolean);
|
||||
return String(forwarded[forwarded.length - 1] || req.socket.remoteAddress || "unknown").slice(0, 96);
|
||||
|
|
@ -505,8 +957,12 @@ function validateWorkorderBody(body, targets, actions) {
|
|||
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" };
|
||||
const immutableResourceAction = action === "provision-approved-architecture" || action === "dispatch-approved-deployment";
|
||||
const repoPushResourceAction = action === "push-repository";
|
||||
const linkedNodeResourceAction = action === "authorize-linked-node-session";
|
||||
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 (repoPushResourceAction && resource && !/^bingshuo\/[a-z0-9._-]+@[a-z0-9][a-z0-9._/-]{0,199}$/.test(resource)) return { ok: false, status: 400, error: "repo_push_resource_invalid" };
|
||||
if (linkedNodeResourceAction && !/^[A-Z0-9][A-Z0-9._-]{5,119}:[A-Za-z0-9._-]{3,120}$/.test(resource)) return { ok: false, status: 400, error: "linked_node_resource_required" };
|
||||
if (!immutableResourceAction && !repoPushResourceAction && !linkedNodeResourceAction && 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" };
|
||||
|
|
|
|||
Loading…
Reference in a new issue