From 0c5fe5a6c4e89240e86faedde7d65a99cb873fa3 Mon Sep 17 00:00:00 2001 From: Bindy-lbb <70745012+Bindy-lbb@users.noreply.github.com> Date: Sun, 20 Sep 2026 14:27:12 +0800 Subject: [PATCH 1/2] feat(approvals): govern once and run scopes --- apps/desktop/renderer/src/App.tsx | 9 +-- .../src/approvals/ApprovalDetailDrawer.tsx | 51 +++++++++----- apps/desktop/renderer/src/approvals/types.ts | 7 +- .../renderer/src/approvals/useApprovals.ts | 6 +- .../desktop/renderer/src/turns/TurnThread.tsx | 13 +++- apps/desktop/renderer/src/turns/adapter.ts | 1 + apps/desktop/renderer/src/turns/types.ts | 1 + .../renderer/test/approval-queue.test.tsx | 10 +++ .../renderer/test/approvals-state.test.tsx | 17 +++++ apps/server/src/approvals/context.ts | 1 + apps/server/src/approvals/service.ts | 34 ++++++--- apps/server/src/approvals/store.ts | 10 ++- apps/server/src/engine/driver-cli.ts | 9 ++- apps/server/src/turns/store.ts | 7 +- apps/server/test/approval.test.ts | 8 ++- apps/server/test/approvals-center.test.ts | 69 ++++++++++++++++++- docs/approvals-p0.md | 12 +++- packages/shared/src/approval-scope.ts | 30 ++++++++ packages/shared/src/approvals.ts | 4 +- packages/shared/src/index.ts | 1 + packages/shared/src/turns.ts | 3 + packages/ui/src/locales/en.ts | 5 ++ packages/ui/src/locales/zh.ts | 5 ++ 23 files changed, 261 insertions(+), 52 deletions(-) create mode 100644 packages/shared/src/approval-scope.ts diff --git a/apps/desktop/renderer/src/App.tsx b/apps/desktop/renderer/src/App.tsx index d6c6aba7..a441307f 100644 --- a/apps/desktop/renderer/src/App.tsx +++ b/apps/desktop/renderer/src/App.tsx @@ -353,7 +353,8 @@ function AppInner({ approvalId: a.id, positionId: a.source.positionId, positionName: positionNames[a.source.positionId], category: a.action.kind, description: a.action.description, target: a.action.target, requestedAt: a.requestedAt, expiresAt: a.expiresAt, - decision: a.status === "granted" ? { kind: "granted", scope: "once", decidedAt: a.decision?.decidedAt, decidedBy: a.decision?.decidedBy, reason: a.decision?.reason } : a.status === "denied" ? { kind: "denied", reason: a.decision?.reason, decidedAt: a.decision?.decidedAt, decidedBy: a.decision?.decidedBy } : { kind: a.status }, + decision: a.status === "granted" ? { kind: "granted", scope: a.decision?.scope ?? "once", decidedAt: a.decision?.decidedAt, decidedBy: a.decision?.decidedBy, reason: a.decision?.reason } : a.status === "denied" ? { kind: "denied", reason: a.decision?.reason, decidedAt: a.decision?.decidedAt, decidedBy: a.decision?.decidedBy } : { kind: a.status }, + scopeAllowed: a.context?.scope.allowed ?? ["once"], canDecide: a.canDecide, busy: approvalState.busy.has(a.id), error: approvalState.errors[a.id], unavailableReason: a.unavailableReason, executionPhase: a.execution.phase, requestReason: a.requestReason, context: a.context, source: a.source, executionTurnId: a.execution.turnId, executionErrorCode: a.execution.errorCode, @@ -1284,9 +1285,9 @@ function AppInner({ /** Both approval entry points use the same durable server-owned decision. */ const verdictTurn = useCallback( - async (turn: TurnRecord, decision: "granted" | "denied", reason?: string) => { + async (turn: TurnRecord, decision: "granted" | "denied", reason?: string, scope: "once" | "run" = "once") => { const approval = approvalState.items.find(a => a.source.turnId === turn.id && a.source.positionId === turn.positionId && a.approvalId === turn.approvalRequest?.approvalId); - if (approval) await approvalState.decide(approval.id, decision, reason); + if (approval) await approvalState.decide(approval.id, decision, reason, scope); }, [approvalState.items, approvalState.decide], ); @@ -2051,7 +2052,7 @@ function AppInner({ loading={approvalState.loading && !approvalState.ready} errorMessage={approvalState.error} onNavigateToOrg={() => setActiveModule("org")} - onApprove={(id, reason) => { void approvalState.decide(id, "granted", reason); }} + onApprove={(id, reason, scope) => { void approvalState.decide(id, "granted", reason, scope); }} onDeny={(id, reason) => { void approvalState.decide(id, "denied", reason); }} onOpenSource={openApprovalSource} onOpenEvidence={openApprovalEvidence} diff --git a/apps/desktop/renderer/src/approvals/ApprovalDetailDrawer.tsx b/apps/desktop/renderer/src/approvals/ApprovalDetailDrawer.tsx index 97c27062..f7ad715e 100644 --- a/apps/desktop/renderer/src/approvals/ApprovalDetailDrawer.tsx +++ b/apps/desktop/renderer/src/approvals/ApprovalDetailDrawer.tsx @@ -14,7 +14,7 @@ * the operator's verdict; audit details remain in the run history. */ import { useEffect, useState } from "react"; -import { Alert, Button, Drawer, Input, Space, Tag } from "antd"; +import { Alert, Button, Drawer, Input, Radio, Space, Tag } from "antd"; import { useT } from "@roleweave/ui"; import { approvalExpiryState, @@ -50,11 +50,13 @@ export function ApprovalDetailDrawer({ }: ApprovalDetailDrawerProps) { const t = useT(); const [reason, setReason] = useState(""); + const [scope, setScope] = useState<"once" | "run">("once"); useEffect(() => { // Reset the reason field whenever the drawer switches to a different // approval or closes; do not leak reasons across items. setReason(""); + setScope("once"); }, [item?.approvalId, open]); if (!item) { @@ -88,7 +90,8 @@ export function ApprovalDetailDrawer({ const handleApprove = () => { if (disabled) return; - onApprove(item.approvalId, reasonForCallback); + if (scope === "run") onApprove(item.approvalId, reasonForCallback, scope); + else onApprove(item.approvalId, reasonForCallback); }; const handleDeny = () => { if (disabled) return; @@ -234,29 +237,39 @@ export function ApprovalDetailDrawer({ ? t("apr.alertDenied") : t(`apr.status.${item.decision.kind}`) } - description={ - item.decision.kind === "denied" && item.decision.reason + description={item.decision.kind === "granted" + ? t("apr.effectiveScope", { scope: t(`apr.scope.${item.decision.scope}`) }) + : item.decision.kind === "denied" && item.decision.reason ? t("apr.reasonPrefix", { reason: safeApprovalText(item.decision.reason) }) - : undefined - } + : undefined} showIcon /> ) : expired ? ( ) : ( -
-

{t("apr.reasonOptional")}

- setReason(event.target.value)} - placeholder={t("apr.reasonPh")} - autoSize={{ minRows: 2, maxRows: 4 }} - maxLength={MAX_APPROVAL_REASON_BYTES} - showCount - data-testid="approval-reason-input" - disabled={disabled} - /> -
+ <> + {item.scopeAllowed?.includes("run") ?
+

{t("apr.scopeTitle")}

+ setScope(event.target.value)} disabled={disabled}> + {t("apr.scope.once")} + {t("apr.scope.run")} + +

{t("apr.scopeRunHint")}

+
: null} +
+

{t("apr.reasonOptional")}

+ setReason(event.target.value)} + placeholder={t("apr.reasonPh")} + autoSize={{ minRows: 2, maxRows: 4 }} + maxLength={MAX_APPROVAL_REASON_BYTES} + showCount + data-testid="approval-reason-input" + disabled={disabled} + /> +
+ )}
diff --git a/apps/desktop/renderer/src/approvals/types.ts b/apps/desktop/renderer/src/approvals/types.ts index 84c14154..e9ae93bc 100644 --- a/apps/desktop/renderer/src/approvals/types.ts +++ b/apps/desktop/renderer/src/approvals/types.ts @@ -47,6 +47,8 @@ export interface ApprovalQueueItem { category: ApprovalCategory; description: string; target?: string; + /** The server's projection of the engine-declared eligible grant scopes. */ + scopeAllowed?: Array<"once" | "run">; requestedAt?: string; expiresAt?: string; /** Snapshot of the position permissions.toolDeny list; only used for the @@ -59,8 +61,9 @@ export interface ApprovalQueueItem { } export interface ApprovalQueueCallbacks { - /** granted defaults to scope=once (contract default per §5.1); reason optional. */ - onApprove: (approvalId: string, reason?: string) => void; + /** granted defaults to scope=once; run is available only when the server + * projected it as eligible. */ + onApprove: (approvalId: string, reason?: string, scope?: "once" | "run") => void; /** denied MUST allow an empty reason (contract permits absent reason). */ onDeny: (approvalId: string, reason?: string) => void; /** Open the persisted source conversation when the source is addressable. */ diff --git a/apps/desktop/renderer/src/approvals/useApprovals.ts b/apps/desktop/renderer/src/approvals/useApprovals.ts index 9ab1eee5..a92d6125 100644 --- a/apps/desktop/renderer/src/approvals/useApprovals.ts +++ b/apps/desktop/renderer/src/approvals/useApprovals.ts @@ -62,15 +62,15 @@ export function useApprovals(workspacePath: string | undefined) { return () => { if (current()) owner.current = {}; clearInterval(timer); window.removeEventListener("focus", focus); }; }, [workspacePath, t]); - const decide = useCallback(async (id: string, decision: "granted" | "denied", reason?: string) => { + const decide = useCallback(async (id: string, decision: "granted" | "denied", reason?: string, scope: "once" | "run" = "once") => { const generation = owner.current; const item = cache.current.items.find(a => a.id === id); if (!item || !cache.current.token || !item.canDecide || inFlight.current.has(id)) return; const prior = pending.current.get(id); - if (prior && (prior.decision !== decision || prior.reason !== reason)) { + if (prior && (prior.decision !== decision || prior.reason !== reason || prior.scope !== scope)) { setErrors(e => ({ ...e, [id]: t("apr.retrySameDecision") })); return; } - const request = prior ?? { requestId: crypto.randomUUID(), expectedVersion: item.version, decision, ...(reason ? { reason } : {}) }; + const request = prior ?? { requestId: crypto.randomUUID(), expectedVersion: item.version, decision, scope, ...(reason ? { reason } : {}) }; pending.current.set(id, request); inFlight.current.add(id); setBusy(new Set(inFlight.current)); setErrors(e => { const next = { ...e }; delete next[id]; return next; }); try { diff --git a/apps/desktop/renderer/src/turns/TurnThread.tsx b/apps/desktop/renderer/src/turns/TurnThread.tsx index 3cbf788d..7eacc6f5 100644 --- a/apps/desktop/renderer/src/turns/TurnThread.tsx +++ b/apps/desktop/renderer/src/turns/TurnThread.tsx @@ -24,7 +24,7 @@ export interface TurnThreadProps { canRetry?: (turn: TurnRecord) => boolean; onRetry?: (turn: TurnRecord) => void; /** Operator verdict for a turn settled as engine.approval_required. */ - onVerdict?: (turn: TurnRecord, decision: "granted" | "denied", reason?: string) => void; + onVerdict?: (turn: TurnRecord, decision: "granted" | "denied", reason?: string, scope?: "once" | "run") => void; /** Approval ids whose verdict was already dispatched; their cards settle * into a decided state so the operator cannot submit duplicate or * contradictory verdicts after a history reload. */ @@ -183,7 +183,7 @@ function ApprovalCard({ turn: TurnRecord; busy: boolean; decided: boolean; - onVerdict: (turn: TurnRecord, decision: "granted" | "denied", reason?: string) => void; + onVerdict: (turn: TurnRecord, decision: "granted" | "denied", reason?: string, scope?: "once" | "run") => void; }) { const t = useT(); const kindCopy: Record = { @@ -193,6 +193,7 @@ function ApprovalCard({ tool: t("apr.kind.tool"), }; const [reason, setReason] = useState(""); + const [scope, setScope] = useState<"once" | "run">("once"); const request = turn.approvalRequest; if (request === undefined) return null; const trimmedReason = reason.trim(); @@ -225,11 +226,17 @@ function ApprovalCard({ onChange={(event) => setReason(event.target.value)} />
+ {request.scopeAllowed?.includes("run") ? : null} diff --git a/apps/desktop/renderer/src/turns/adapter.ts b/apps/desktop/renderer/src/turns/adapter.ts index 4de963d1..f30b8aa3 100644 --- a/apps/desktop/renderer/src/turns/adapter.ts +++ b/apps/desktop/renderer/src/turns/adapter.ts @@ -32,6 +32,7 @@ function approvalRequest(record: ApiTurnRecord): TurnApprovalRequest | undefined kind: event.action.kind, description: event.action.description, ...(event.action.target !== undefined ? { target: event.action.target } : {}), + ...(event.action.scope !== undefined ? { scopeAllowed: event.action.scope.allowed } : {}), ...(event.expiresAt !== undefined ? { expiresAt: event.expiresAt } : {}), }; } diff --git a/apps/desktop/renderer/src/turns/types.ts b/apps/desktop/renderer/src/turns/types.ts index cad13aad..9a2d7a28 100644 --- a/apps/desktop/renderer/src/turns/types.ts +++ b/apps/desktop/renderer/src/turns/types.ts @@ -53,6 +53,7 @@ export interface TurnApprovalRequest { kind: string; description: string; target?: string; + scopeAllowed?: Array<"once" | "run">; expiresAt?: string; } diff --git a/apps/desktop/renderer/test/approval-queue.test.tsx b/apps/desktop/renderer/test/approval-queue.test.tsx index 36b939a7..307245c9 100644 --- a/apps/desktop/renderer/test/approval-queue.test.tsx +++ b/apps/desktop/renderer/test/approval-queue.test.tsx @@ -135,6 +135,16 @@ describe("P0 \u5ba1\u6279\u961f\u5217 (\u2461)", () => { expect(onDeny).toHaveBeenCalledWith("appr-abc", "\u8d85\u51fa Context Scope"); }); + it("only offers server-eligible run scope and records the selected boundary", async () => { + const onApprove = vi.fn(); + render(); + fireEvent.click(screen.getByTestId("approval-card-appr-abc")); + expect(await screen.findByTestId("approval-scope-choice")).toBeInTheDocument(); + fireEvent.click(screen.getByRole("radio", { name: "仅本回合" })); + fireEvent.click(screen.getByTestId("approval-approve-button")); + expect(onApprove).toHaveBeenCalledWith("appr-abc", undefined, "run"); + }); + it("\u5df2\u88c1\u51b3\u9879\u9501\u5b9a\uff1a\u4e0d\u80fd\u91cd\u590d\u88c1\u51b3\uff0c\u62d2\u7edd\u8bc1\u636e\u63d0\u793a\u4fdd\u7559", async () => { const onApprove = vi.fn(); const onDeny = vi.fn(); diff --git a/apps/desktop/renderer/test/approvals-state.test.tsx b/apps/desktop/renderer/test/approvals-state.test.tsx index 35c30598..c0ea03ce 100644 --- a/apps/desktop/renderer/test/approvals-state.test.tsx +++ b/apps/desktop/renderer/test/approvals-state.test.tsx @@ -44,6 +44,23 @@ describe("authoritative approval state", () => { await act(() => result.current.decide(row.id, "denied", "reason")); expect(decideApproval.mock.calls[0]![0].requestId).toBe(decideApproval.mock.calls[1]![0].requestId); }); + it("sends a run boundary only when the caller selected it and keeps it for retry", async () => { + const eligible = { ...row, context: { + risk: "high" as const, requestedCapability: "write" as const, impact: "workspace_write" as const, + permissions: { mode: "approval_required" as const, allowedTools: [], deniedTools: [] }, + preview: { status: "unavailable" as const, reason: "engine_preview_not_supplied" as const }, + scope: { allowed: ["once", "run"] as Array<"once" | "run"> }, + } }; + const decideApproval = vi.fn().mockRejectedValueOnce(new Error("offline")).mockResolvedValueOnce({ status: 202, body: { ...eligible, status: "granted", decision: { scope: "run" } } }); + bridge({ listApprovals: vi.fn(async () => page([eligible])), decideApproval }); + const { result } = renderHook(() => useApprovals("/a")); + await waitFor(() => expect(result.current.ready).toBe(true)); + await act(() => result.current.decide(row.id, "granted", undefined, "run")); + await act(() => result.current.decide(row.id, "granted", undefined, "run")); + expect(decideApproval.mock.calls[0]![0].scope).toBe("run"); + expect(decideApproval.mock.calls[1]![0].scope).toBe("run"); + expect(decideApproval.mock.calls[0]![0].requestId).toBe(decideApproval.mock.calls[1]![0].requestId); + }); it("drops responses from a prior workspace generation including A → B → A", async () => { let release!: (value: unknown) => void; const listApprovals = vi.fn().mockImplementationOnce(() => new Promise(resolve => { release = resolve; })).mockImplementation(async () => page([], "new")); diff --git a/apps/server/src/approvals/context.ts b/apps/server/src/approvals/context.ts index b680778f..e3adfa3c 100644 --- a/apps/server/src/approvals/context.ts +++ b/apps/server/src/approvals/context.ts @@ -36,5 +36,6 @@ export function buildApprovalContext( deniedTools: role.toolDeny.slice(0, 128), }, preview: { status: "unavailable", reason: "engine_preview_not_supplied" }, + scope: { allowed: action.scope?.allowed ?? ["once"] }, }; } diff --git a/apps/server/src/approvals/service.ts b/apps/server/src/approvals/service.ts index 61b277c4..83816379 100644 --- a/apps/server/src/approvals/service.ts +++ b/apps/server/src/approvals/service.ts @@ -1,5 +1,5 @@ import crypto from "node:crypto"; -import { OrgApiError, errorCodes, validatePendingApproval, type ApprovalRecord, type ApprovalView, type ApprovalDecisionRequest, type TurnRecord, type WorkbenchSession } from "@roleweave/shared"; +import { OrgApiError, approvalRunScopeBindingInput, errorCodes, validatePendingApproval, type ApprovalRecord, type ApprovalView, type ApprovalDecisionRequest, type TurnRecord, type WorkbenchSession } from "@roleweave/shared"; import type { ControlPlaneContext } from "../context.js"; import type { OpenWorkspace } from "../workspace-state.js"; import { assertTurnWorkspace, executeTurn } from "../routes/turns.js"; @@ -18,12 +18,29 @@ const uuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; export function parseDecision(value: unknown): ApprovalDecisionRequest { if (!value || typeof value !== "object" || Array.isArray(value)) throw new OrgApiError(errorCodes.approval_request_invalid, 400, "Invalid decision"); const d = value as ApprovalDecisionRequest; - if (Object.keys(d).some(k => !["requestId", "expectedVersion", "decision", "reason"].includes(k)) || + if (Object.keys(d).some(k => !["requestId", "expectedVersion", "decision", "reason", "scope"].includes(k)) || typeof d.requestId !== "string" || !uuid.test(d.requestId) || !Number.isSafeInteger(d.expectedVersion) || d.expectedVersion < 1 || + (d.scope !== undefined && d.scope !== "once" && d.scope !== "run") || !validatePendingApproval({ approvalId: "validation", decision: d.decision, decidedBy: "operator", ...(d.reason === undefined ? {} : { reason: d.reason }) }).ok) { throw new OrgApiError(errorCodes.approval_request_invalid, 400, "Invalid decision or reason (maximum 1024 UTF-8 bytes)"); } - return d; + return { ...d, scope: d.scope ?? "once" }; +} + +function validRunScopeBinding(record: ApprovalRecord): boolean { + const offer = record.action.scope; + if (!offer?.allowed.includes("run") || !offer.runBinding) return false; + const input = approvalRunScopeBindingInput(record.approvalId, record.source.runId, record.action, record.expiresAt); + const expected = `sha256:${crypto.createHash("sha256").update(input).digest("hex")}`; + return crypto.timingSafeEqual(Buffer.from(offer.runBinding), Buffer.from(expected)); +} + +function approvalContext(record: ApprovalRecord, role: Parameters[1]) { + const context = buildApprovalContext(record.action, role); + // The engine may declare a syntactically valid offer, but it becomes visible + // as a selectable boundary only after the control plane verifies that it is + // bound to this exact approval, source run, action and expiry. + return { ...context, scope: { allowed: validRunScopeBinding(record) ? ["once", "run"] as Array<"once" | "run"> : ["once"] as Array<"once" | "run"> } }; } function publicView(record: ApprovalRecord): ApprovalRecord { @@ -90,11 +107,11 @@ export class ApprovalService { const record: ApprovalRecord = { schemaVersion: "workbench-approval.v1", id, version: 1, approvalId: event.approvalId, source, action: event.action, ...(event.reason ? { requestReason: event.reason } : {}), - ...(role ? { context: buildApprovalContext(event.action, role) } : {}), requestedAt: event.timestamp, ...(event.expiresAt ? { expiresAt: event.expiresAt } : {}), status: turn.error?.code === "engine.approval_required" ? "pending" : "indeterminate", execution: { phase: "not_started" }, createdAt: now, updatedAt: now, }; + if (role) record.context = approvalContext(record, role); await this.save(ws, record); byId.set(id, record); } } @@ -102,7 +119,7 @@ export class ApprovalService { const before = JSON.stringify(a); if (!a.context) { const role = ws.organization.roles.find(entry => entry.id === a.source.positionId); - if (role) a.context = buildApprovalContext(a.action, role); + if (role) a.context = approvalContext(a, role); } if (a.execution.turnId && !this.active.has(`${ws.dir}\0${a.id}`)) { const result = turns.find(t => t.turnId === a.execution.turnId && t.positionId === a.source.positionId && t.conversationId === a.source.conversationId); @@ -171,10 +188,11 @@ export class ApprovalService { if (!a) throw new OrgApiError(errorCodes.approval_missing, 404, "Approval not found"); const prior = items.find(x => x.decision?.requestId === request.requestId); if (prior) { - if (prior.id !== id || prior.decision!.decision !== request.decision || prior.decision!.reason !== request.reason || prior.decision!.expectedVersion !== request.expectedVersion) throw conflict("Request id was already used for another decision"); + if (prior.id !== id || prior.decision!.decision !== request.decision || prior.decision!.scope !== request.scope || prior.decision!.reason !== request.reason || prior.decision!.expectedVersion !== request.expectedVersion) throw conflict("Request id was already used for another decision"); return { status: 200, record: await this.view(ws, prior) }; } if (a.status === "expired") throw new OrgApiError(errorCodes.approval_expired, 410, "Approval expired"); + if (request.scope === "run" && (request.decision !== "granted" || !validRunScopeBinding(a))) throw conflict("This approval is not eligible for run scope"); const view = await this.view(ws, a); if (!view.canDecide || a.version !== request.expectedVersion) throw conflict(view.unavailableReason ?? "Approval changed; refresh before deciding"); const source = turns.find(t => t.turnId === a.source.turnId && t.positionId === a.source.positionId && (t.conversationRef ?? t.conversationId) === a.source.conversationId); @@ -189,7 +207,7 @@ export class ApprovalService { this.assertToken(ws, token); if (a.expiresAt && Date.now() >= Date.parse(a.expiresAt)) throw new OrgApiError(errorCodes.approval_expired, 410, "Approval expired"); a.status = request.decision; - a.decision = { ...request, scope: "once", decidedBy: "operator", decidedAt: new Date().toISOString() }; + a.decision = { ...request, decidedBy: "operator", decidedAt: new Date().toISOString() }; // Starting is persisted BEFORE any dispatch. A crash here is deliberately // indeterminate: replaying an external side effect would be unsafe. a.execution = { phase: "starting", turnId }; @@ -208,7 +226,7 @@ export class ApprovalService { const result = await executeTurn(this.ctx, undefined, { positionId: a.source.positionId, engine: a.source.engine, input: `${request.decision === "granted" ? "[审批裁决] 请继续执行以下原任务中已批准的动作" : "[审批裁决] 已拒绝以下原任务的动作,不得执行"}\n${source.input}`, - pendingApproval: { approvalId: a.approvalId, decision: request.decision, decidedBy: "operator", scope: "once", ...(request.reason ? { reason: request.reason } : {}), ...(a.expiresAt ? { expiresAt: a.expiresAt } : {}) }, + pendingApproval: { approvalId: a.approvalId, decision: request.decision, decidedBy: "operator", scope: request.scope, ...(request.reason ? { reason: request.reason } : {}), ...(a.expiresAt ? { expiresAt: a.expiresAt } : {}) }, }, session, undefined, undefined, ws, { turnId, reservation, beforeRun: async () => { diff --git a/apps/server/src/approvals/store.ts b/apps/server/src/approvals/store.ts index 463e85d8..b7d099db 100644 --- a/apps/server/src/approvals/store.ts +++ b/apps/server/src/approvals/store.ts @@ -3,7 +3,7 @@ import os from "node:os"; import path from "node:path"; import fs from "node:fs/promises"; import { constants } from "node:fs"; -import { OrgApiError, errorCodes, turnEngines, validatePendingApproval, type ApprovalRecord } from "@roleweave/shared"; +import { OrgApiError, errorCodes, isApprovalScopeOffer, turnEngines, validatePendingApproval, type ApprovalRecord } from "@roleweave/shared"; import { atomicWriteJson, nodeAtomicTurnWriteOperations } from "../turns/store.js"; import { redactApprovalText } from "./context.js"; @@ -61,6 +61,7 @@ function valid(value: unknown): value is ApprovalRecord { ![s.positionId, s.conversationId, s.turnId, s.runId, a.approvalId].every(text) || !turnEngines.includes(s.engine) || a.id !== approvalIdentity(s, a.approvalId) || !a.action || !["write", "exec", "network", "tool"].includes(a.action.kind) || !text(a.action.description) || (a.action.target !== undefined && !text(a.action.target)) || + (a.action.scope !== undefined && !isApprovalScopeOffer(a.action.scope)) || (a.requestReason !== undefined && !text(a.requestReason)) || !time(a.requestedAt) || !time(a.createdAt) || !time(a.updatedAt) || (a.expiresAt !== undefined && !time(a.expiresAt)) || !["pending", "granted", "denied", "expired", "cancelled", "indeterminate"].includes(a.status) || @@ -69,7 +70,7 @@ function valid(value: unknown): value is ApprovalRecord { if (a.decision) { const d = a.decision; if (!/^[a-f0-9-]{36}$/.test(d.requestId) || !Number.isSafeInteger(d.expectedVersion) || d.expectedVersion < 1 || - !time(d.decidedAt) || d.scope !== "once" || d.decision !== a.status || + !time(d.decidedAt) || (d.scope !== "once" && d.scope !== "run") || d.decision !== a.status || !validatePendingApproval({ approvalId: a.approvalId, decision: d.decision, decidedBy: d.decidedBy, scope: d.scope, ...(d.reason === undefined ? {} : { reason: d.reason }) }).ok) return false; } if (a.context !== undefined) { @@ -81,7 +82,10 @@ function valid(value: unknown): value is ApprovalRecord { !["workspace_write", "command_execution", "external_network", "restricted_tool"].includes(c.impact) || !c.permissions || !["read_only", "approval_required"].includes(c.permissions.mode) || !boundedList(c.permissions.allowedTools) || !boundedList(c.permissions.deniedTools) || - !c.preview || c.preview.status !== "unavailable" || c.preview.reason !== "engine_preview_not_supplied") return false; + !c.preview || c.preview.status !== "unavailable" || c.preview.reason !== "engine_preview_not_supplied" || + (c.scope !== undefined && (!Array.isArray(c.scope.allowed) || c.scope.allowed.length < 1 || c.scope.allowed.length > 2 || + c.scope.allowed[0] !== "once" || new Set(c.scope.allowed).size !== c.scope.allowed.length || + c.scope.allowed.some(scope => scope !== "once" && scope !== "run")))) return false; } return true; } diff --git a/apps/server/src/engine/driver-cli.ts b/apps/server/src/engine/driver-cli.ts index b9081e9f..010a3307 100644 --- a/apps/server/src/engine/driver-cli.ts +++ b/apps/server/src/engine/driver-cli.ts @@ -1,5 +1,6 @@ import { spawn } from "node:child_process"; import { StringDecoder } from "node:string_decoder"; +import { isApprovalScopeOffer } from "@roleweave/shared"; import type { EngineOrgApplySuccess, EngineEvent, @@ -223,7 +224,7 @@ function parseEngineEvent(line: string): EngineEvent { if (!isRecord(unknownEvent.action)) { throw new EngineProtocolError("engine.v1 approval.requested action is invalid"); } - exactKeys(unknownEvent.action, ["kind", "description"], ["target"]); + exactKeys(unknownEvent.action, ["kind", "description"], ["target", "scope"]); if ( !boundedApprovalId(unknownEvent.approvalId) || !APPROVAL_ACTION_KINDS.has(unknownEvent.action.kind as string) || @@ -232,7 +233,8 @@ function parseEngineEvent(line: string): EngineEvent { !boundedNonEmptyText(unknownEvent.action.target, APPROVAL_TARGET_MAX_BYTES)) || (unknownEvent.reason !== undefined && !boundedNonEmptyText(unknownEvent.reason, APPROVAL_DESCRIPTION_MAX_BYTES)) || - !optionalIsoTimestamp(unknownEvent.expiresAt) + !optionalIsoTimestamp(unknownEvent.expiresAt) || + (unknownEvent.action.scope !== undefined && !isApprovalScopeOffer(unknownEvent.action.scope)) ) { throw new EngineProtocolError("engine.v1 approval.requested fields are invalid or unbounded"); } @@ -246,6 +248,9 @@ function parseEngineEvent(line: string): EngineEvent { ...(unknownEvent.action.target !== undefined ? { target: unknownEvent.action.target } : {}), + ...(unknownEvent.action.scope !== undefined + ? { scope: unknownEvent.action.scope } + : {}), }, ...(unknownEvent.reason !== undefined ? { reason: unknownEvent.reason } : {}), ...(unknownEvent.expiresAt !== undefined diff --git a/apps/server/src/turns/store.ts b/apps/server/src/turns/store.ts index b44fec6c..f48b529e 100644 --- a/apps/server/src/turns/store.ts +++ b/apps/server/src/turns/store.ts @@ -6,6 +6,7 @@ import { TURN_HISTORY_SCHEMA_VERSION, TURN_RECORD_SCHEMA_VERSION, errorCodes, + isApprovalScopeOffer, isPositionId, isEngineModelId, turnEngines, @@ -695,7 +696,7 @@ function validateEngineEvent(raw: unknown): EngineEvent | null { ["reason", "expiresAt"], ) || !isObjectRecord(value.action) || - !hasExactKeys(value.action, ["kind", "description"], ["target"]) || + !hasExactKeys(value.action, ["kind", "description"], ["target", "scope"]) || !isBoundedApprovalId(value.approvalId) || !APPROVAL_ACTION_KINDS.has(value.action.kind as string) || !isBoundedNonEmptyText(value.action.description, APPROVAL_DESCRIPTION_MAX_BYTES) || @@ -703,7 +704,8 @@ function validateEngineEvent(raw: unknown): EngineEvent | null { !isBoundedNonEmptyText(value.action.target, APPROVAL_TARGET_MAX_BYTES)) || (value.reason !== undefined && !isBoundedNonEmptyText(value.reason, APPROVAL_DESCRIPTION_MAX_BYTES)) || - !isOptionalIsoTimestamp(value.expiresAt) + !isOptionalIsoTimestamp(value.expiresAt) || + (value.action.scope !== undefined && !isApprovalScopeOffer(value.action.scope)) ) return null; return { ...base, @@ -713,6 +715,7 @@ function validateEngineEvent(raw: unknown): EngineEvent | null { kind: value.action.kind as "exec" | "write" | "network" | "tool", description: value.action.description, ...(value.action.target !== undefined ? { target: value.action.target } : {}), + ...(value.action.scope !== undefined ? { scope: value.action.scope } : {}), }, ...(value.reason !== undefined ? { reason: value.reason } : {}), ...(value.expiresAt !== undefined ? { expiresAt: value.expiresAt as string } : {}), diff --git a/apps/server/test/approval.test.ts b/apps/server/test/approval.test.ts index b75f3a98..79020422 100644 --- a/apps/server/test/approval.test.ts +++ b/apps/server/test/approval.test.ts @@ -107,7 +107,7 @@ test("CLI driver mirrors the engine.v1 approval events verbatim into a trusted s ...base, type: "approval.requested", approvalId: "appr-1", - action: { kind: "exec", description: "rm -rf build", target: "scripts/clean.sh" }, + action: { kind: "exec", description: "rm -rf build", target: "scripts/clean.sh", scope: { version: "approval-scope-offer.v1", allowed: ["once", "run"], runBinding: "sha256:${"0".repeat(64)}" } }, reason: "destructive command", expiresAt: "2026-08-24T01:00:00.000Z", })); @@ -138,6 +138,7 @@ test("CLI driver mirrors the engine.v1 approval events verbatim into a trusted s kind: "exec", description: "rm -rf build", target: "scripts/clean.sh", + scope: { version: "approval-scope-offer.v1", allowed: ["once", "run"], runBinding: `sha256:${"0".repeat(64)}` }, }); assert.equal(requested.reason, "destructive command"); assert.equal(requested.expiresAt, "2026-08-24T01:00:00.000Z"); @@ -156,6 +157,11 @@ test("CLI driver fails closed on malformed approval events without faking a term runId: "run-1", timestamp: "2026-08-24T00:00:00.000Z", type: "approval.requested", approvalId: "appr-1", action: { kind: "spawn", description: "run" }, }), + // A run offer without an exact binding is unsafe. + JSON.stringify({ + runId: "run-1", timestamp: "2026-08-24T00:00:00.000Z", type: "approval.requested", + approvalId: "appr-1", action: { kind: "exec", description: "run", scope: { version: "approval-scope-offer.v1", allowed: ["once", "run"] } }, + }), // approvalId beyond the 256 bound JSON.stringify({ runId: "run-1", timestamp: "2026-08-24T00:00:00.000Z", type: "approval.requested", diff --git a/apps/server/test/approvals-center.test.ts b/apps/server/test/approvals-center.test.ts index b0905168..065ad4bf 100644 --- a/apps/server/test/approvals-center.test.ts +++ b/apps/server/test/approvals-center.test.ts @@ -4,6 +4,7 @@ import fs from "node:fs/promises"; import path from "node:path"; import test from "node:test"; import type { ApprovalList, ApprovalView, EngineEvent, GroupConversation, TurnRunDriver, TurnRunRequest, TurnRunResult } from "@roleweave/shared"; +import { approvalRunScopeBindingInput } from "@roleweave/shared"; import { api, copyExampleWorkspace, startTestServer, type TestServer } from "./helpers.js"; import { approvals } from "../src/approvals/service.js"; @@ -12,6 +13,8 @@ class ApprovalDriver implements TurnRunDriver { expiresAt = new Date(Date.now() + 60000).toISOString(); target = "report.md"; reason = "The write needs operator approval"; + runScope = false; + invalidRunBinding = false; hold?: Promise; async turnRun(request: TurnRunRequest): Promise { this.calls.push(request); @@ -21,10 +24,17 @@ class ApprovalDriver implements TurnRunDriver { const events: EngineEvent[] = [{ ...base, type: "run.started" }]; if (d) { await this.hold; - if (d.decision === "granted") events.push({ ...base, type: "approval.granted", approvalId: d.approvalId, grantedBy: "operator", scope: "once" }, { ...base, type: "run.completed", output: "done", terminalReason: "goal_met" }); + if (d.decision === "granted") events.push({ ...base, type: "approval.granted", approvalId: d.approvalId, grantedBy: "operator", scope: d.scope ?? "once" }, { ...base, type: "run.completed", output: "done", terminalReason: "goal_met" }); else events.push({ ...base, type: "approval.denied", approvalId: d.approvalId, deniedBy: "operator" }, { ...base, type: "run.failed", error: { code: "engine.approval_denied", message: "denied", retryable: false, terminalReason: "cancelled" } }); - } else events.push({ ...base, type: "approval.requested", approvalId: "same-engine-id", action: { kind: "write", description: "write report", target: this.target }, reason: this.reason, expiresAt: this.expiresAt }, + } else { + const action = { kind: "write" as const, description: "write report", target: this.target }; + const binding = `sha256:${crypto.createHash("sha256").update(approvalRunScopeBindingInput("same-engine-id", runId, action, this.expiresAt)).digest("hex")}`; + events.push({ ...base, type: "approval.requested", approvalId: "same-engine-id", action: { + ...action, + ...(this.runScope ? { scope: { version: "approval-scope-offer.v1" as const, allowed: ["once", "run"] as Array<"once" | "run">, runBinding: this.invalidRunBinding ? `sha256:${"0".repeat(64)}` : binding } } : {}), + }, reason: this.reason, expiresAt: this.expiresAt }, { ...base, type: "run.failed", error: { code: "engine.approval_required", message: "waiting", retryable: true, terminalReason: "engine_internal_error" } }); + } for (const event of events) request.onEvent?.(event); return { status: "trusted", events, diagnostic: "" }; } @@ -105,6 +115,61 @@ test("approval center restores the source session, preserves expiry, is idempote } finally { await s.close(); } }); +test("run scope is opt-in, source-bound, durable, and idempotent", async () => { + const driver = new ApprovalDriver(); + driver.runScope = true; + const s = await startTestServer(undefined, driver); + try { + await open(s, await copyExampleWorkspace()); await request(s); + const snapshot = await list(s), a = snapshot.items[0]!, route = `/approvals/${a.id}/decision`; + assert.deepEqual(a.context?.scope.allowed, ["once", "run"]); + const decision = { ...body(snapshot, a), scope: "run" as const }; + const accepted = await api(s.baseUrl, route, { token: s.token, method: "POST", body: decision }); + assert.equal(accepted.status, 202); + assert.equal((accepted.body as ApprovalView).decision?.scope, "run"); + const done = await settle(s, a.id); + assert.equal(done.decision?.scope, "run"); + assert.equal(driver.calls.find(call => call.envelope.pendingApproval)?.envelope.pendingApproval?.scope, "run"); + const replay = await api(s.baseUrl, route, { token: s.token, method: "POST", body: { ...decision, workspaceToken: (await list(s)).workspaceToken } }); + assert.equal(replay.status, 200, "same request retains the original scope"); + const changedScope = await api(s.baseUrl, route, { token: s.token, method: "POST", body: { ...decision, scope: "once", workspaceToken: (await list(s)).workspaceToken } }); + assert.equal(changedScope.status, 409, "a request id cannot be replayed with a wider or narrower boundary"); + } finally { await s.close(); } +}); + +test("run scope rejects undeclared, tampered, denied, and expired offers before dispatch", async () => { + for (const variant of ["undeclared", "tampered"] as const) { + const driver = new ApprovalDriver(); + driver.runScope = variant === "tampered"; + driver.invalidRunBinding = variant === "tampered"; + const s = await startTestServer(undefined, driver); + try { + await open(s, await copyExampleWorkspace()); await request(s); + const snapshot = await list(s), a = snapshot.items[0]!; + assert.deepEqual(a.context?.scope.allowed, ["once"], `${variant} offer must not be rendered as an eligible run scope`); + const response = await api(s.baseUrl, `/approvals/${a.id}/decision`, { token: s.token, method: "POST", body: { ...body(snapshot, a), scope: "run" } }); + assert.equal(response.status, 409, variant); + assert.equal(driver.calls.filter(call => call.envelope.pendingApproval).length, 0, variant); + } finally { await s.close(); } + } + const driver = new ApprovalDriver(); driver.runScope = true; + const s = await startTestServer(undefined, driver); + try { + await open(s, await copyExampleWorkspace()); await request(s); + const snapshot = await list(s), a = snapshot.items[0]!; + const denied = await api(s.baseUrl, `/approvals/${a.id}/decision`, { token: s.token, method: "POST", body: { ...body(snapshot, a, "denied"), scope: "run" } }); + assert.equal(denied.status, 409); + driver.expiresAt = new Date(Date.now() - 1).toISOString(); + // Existing records retain their request expiry; creating a new expiring + // request proves expiry wins before a run-scope grant can be dispatched. + await request(s, "community-operator"); + const expired = (await list(s)).items.find(item => item.source.positionId === "community-operator")!; + assert.equal(expired.status, "expired"); + const response = await api(s.baseUrl, `/approvals/${expired.id}/decision`, { token: s.token, method: "POST", body: { ...body(await list(s), expired), scope: "run" } }); + assert.equal(response.status, 410); + } finally { await s.close(); } +}); + test("group approvals stay visible but read-only and never dispatch a recovery turn", async () => { const driver = new ApprovalDriver(), s = await startTestServer(undefined, driver); try { diff --git a/docs/approvals-p0.md b/docs/approvals-p0.md index ab88b15d..efdd9e67 100644 --- a/docs/approvals-p0.md +++ b/docs/approvals-p0.md @@ -4,17 +4,25 @@ ## 行为 -审批中心和会话卡通过同一个本地审批服务读取状态和提交裁决。服务端绑定原岗位、原会话、原回合与引擎;批准范围固定为 `once`,保留原到期时间。裁决保存与后续执行结果分别展示。 +审批中心和会话卡通过同一个本地审批服务读取状态和提交裁决。服务端绑定原岗位、原会话、原回合与引擎;默认批准范围为 `once`,保留原到期时间。裁决保存与后续执行结果分别展示。 个人 session 和旧版岗位会话可以裁决。群聊来源只读展示,批准和拒绝按钮固定禁用,不能由审批中心裁决;已归档会话必须重新发起任务。旧接口直接提交 `pendingApproval` 返回 `409 approval_endpoint_required`,不会启动引擎。 批准或拒绝会启动一条携带 `pendingApproval` 的新恢复回合;原始 `engine.approval_required` 回合及其事件记录保持不变,不会在原回合上续写或改写终态。 +### #401:一次性与本回合范围 + +引擎可以在 `approval.requested.action.scope` 中声明 `approval-scope-offer.v1`。`allowed` 必须以 `once` 开始;只有同时声明 `run` 和 `runBinding` 时,才可能选择本回合范围。`runBinding` 是 `sha256:` 加十六进制 SHA-256,输入为 shared 的 `approvalRunScopeBindingInput(approvalId, sourceRunId, {kind, description, target}, expiresAt)` 的规范 JSON。 + +服务端重新计算摘要并在写入裁决前比对:绑定到不同审批编号、请求 run、动作(含目标)或有效期的 offer 一律不能升级为 `run`。`run` 只允许批准,不允许拒绝;来源已过期、失配、重放或版本冲突仍按原路径拒绝。审计记录持久化实际 `decision.scope`,同一个 `requestId` 重试时必须携带完全相同的范围。 + +对 UI,服务端只投影校验通过的 `context.scope.allowed`;默认与旧记录均仅显示“仅此动作”。选择“仅本回合”仅会把该恢复回合的 `pendingApproval.scope` 设为 `run`,不会放宽该岗位、后续回合或其他动作。审批历史显示实际生效范围。 + ## 接口 - `GET /approvals?status=all|pending|decided&limit=50&cursor=...&workspacePath=...`:返回分页 items、pendingCount、revision、workspaceToken、nextCursor 和 syncState。limit 最大 200。快照改变后旧游标返回 `409 approval_snapshot_changed`。 - `GET /approvals/:id`:详情含 `canDecide` 和 `unavailableReason`。 -- `POST /approvals/:id/decision`:请求包含 `workspaceToken`、`requestId`、`expectedVersion`、`decision` 和可选 `reason`。理由上限为 1024 UTF-8 字节。 +- `POST /approvals/:id/decision`:请求包含 `workspaceToken`、`requestId`、`expectedVersion`、`decision`、可选 `scope`(省略即 `once`)和可选 `reason`。理由上限为 1024 UTF-8 字节。`scope: run` 仅接受引擎声明且服务端验证了绑定的批准请求。 - 首次持久化成功返回 202;相同请求幂等返回 200;相反裁决、旧版本或工作区实例失配返回 409;过期返回 410;格式错误返回 400。 - `approvals.changed` 在记录落盘后发布,携带工作区路径、审批记录 ID 和版本。客户端将 SSE 作为刷新提示,重连、聚焦、返回审批中心及每 10 秒补查快照。 diff --git a/packages/shared/src/approval-scope.ts b/packages/shared/src/approval-scope.ts new file mode 100644 index 00000000..562fdcf0 --- /dev/null +++ b/packages/shared/src/approval-scope.ts @@ -0,0 +1,30 @@ +export const APPROVAL_SCOPE_OFFER_VERSION = "approval-scope-offer.v1" as const; + +export interface ApprovalScopeOffer { + version: typeof APPROVAL_SCOPE_OFFER_VERSION; + /** Always includes once; run is opt-in and must carry a binding. */ + allowed: Array<"once" | "run">; + runBinding?: string; +} + +export interface ApprovalScopeAction { + kind: string; + description: string; + target?: string; +} + +export function approvalRunScopeBindingInput(approvalId: string, runId: string, action: ApprovalScopeAction, expiresAt?: string): string { + return JSON.stringify({ approvalId, runId, action: { kind: action.kind, description: action.description, ...(action.target === undefined ? {} : { target: action.target }) }, ...(expiresAt === undefined ? {} : { expiresAt }) }); +} + +export function isApprovalScopeOffer(value: unknown): value is ApprovalScopeOffer { + if (!value || typeof value !== "object" || Array.isArray(value)) return false; + const offer = value as Record; + if (Object.keys(offer).some(key => !["version", "allowed", "runBinding"].includes(key)) || + offer.version !== APPROVAL_SCOPE_OFFER_VERSION || !Array.isArray(offer.allowed) || + offer.allowed.length < 1 || offer.allowed.length > 2 || offer.allowed[0] !== "once" || + new Set(offer.allowed).size !== offer.allowed.length || offer.allowed.some(scope => scope !== "once" && scope !== "run")) return false; + return offer.allowed.includes("run") + ? typeof offer.runBinding === "string" && /^sha256:[a-f0-9]{64}$/.test(offer.runBinding) + : offer.runBinding === undefined; +} diff --git a/packages/shared/src/approvals.ts b/packages/shared/src/approvals.ts index 0c7d9381..7c65a359 100644 --- a/packages/shared/src/approvals.ts +++ b/packages/shared/src/approvals.ts @@ -22,12 +22,14 @@ export interface ApprovalContext { status: "unavailable"; reason: "engine_preview_not_supplied"; }; + scope: { allowed: Array<"once" | "run"> }; } export interface ApprovalDecisionRequest { requestId: string; expectedVersion: number; decision: "granted" | "denied"; + scope: "once" | "run"; reason?: string; } export interface ApprovalRecord { @@ -49,7 +51,7 @@ export interface ApprovalRecord { requestedAt: string; expiresAt?: string; status: ApprovalStatus; - decision?: ApprovalDecisionRequest & { decidedBy: "operator"; decidedAt: string; scope: "once" }; + decision?: ApprovalDecisionRequest & { decidedBy: "operator"; decidedAt: string }; execution: { phase: ApprovalPhase; turnId?: string; errorCode?: string }; createdAt: string; updatedAt: string; diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 1731b808..c96270b6 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -5,6 +5,7 @@ export * from "./position-id.js"; export * from "./pending-approval.js"; export * from "./approvals.js"; export * from "./approval-redaction.js"; +export * from "./approval-scope.js"; export * from "./change-manifest.js"; export * from "./hire.js"; export * from "./position-profile.js"; diff --git a/packages/shared/src/turns.ts b/packages/shared/src/turns.ts index 8e4e0003..7c17c69d 100644 --- a/packages/shared/src/turns.ts +++ b/packages/shared/src/turns.ts @@ -4,6 +4,7 @@ * package only adds workbench-local persistence records. */ import { createRequire } from "node:module"; +import type { ApprovalScopeOffer } from "./approval-scope.js"; export const TURN_ENVELOPE_SCHEMA_VERSION = "turn-envelope.v1" as const; /** Upstream de#205 (DE-CONVREF-001): v1alpha2 = v1 + optional conversationRef. */ @@ -71,6 +72,8 @@ export interface ApprovalRequestedEvent extends EngineEventBase { kind: TurnApprovalActionKind; description: string; target?: string; + /** Engine-declared scope eligibility; absent means once only. */ + scope?: ApprovalScopeOffer; }; reason?: string; expiresAt?: string; diff --git a/packages/ui/src/locales/en.ts b/packages/ui/src/locales/en.ts index 93a342db..09af52e2 100644 --- a/packages/ui/src/locales/en.ts +++ b/packages/ui/src/locales/en.ts @@ -827,6 +827,11 @@ export const enCatalog: Record = { "apr.decidedNote": "This approval is handled and cannot be used again", "apr.reasonOptional": "Denial reason (optional)", "apr.grant": "Approve and continue", + "apr.scopeTitle": "Approval boundary", + "apr.scope.once": "This action only", + "apr.scope.run": "This run only", + "apr.scopeRunHint": "This grant applies only to the bound recovery run and expires with this request.", + "apr.effectiveScope": "Effective boundary: {scope}", "apr.deny": "Deny", "apr.queue": "Approval Queue", "apr.queueLedeA": "Collects the ", diff --git a/packages/ui/src/locales/zh.ts b/packages/ui/src/locales/zh.ts index dbb65cdb..c5128867 100644 --- a/packages/ui/src/locales/zh.ts +++ b/packages/ui/src/locales/zh.ts @@ -830,6 +830,11 @@ export const zhCatalog: Record = { "apr.decidedNote": "这个审批已处理,不能重复操作", "apr.reasonOptional": "拒绝理由(可选)", "apr.grant": "批准并继续", + "apr.scopeTitle": "审批范围", + "apr.scope.once": "仅此动作", + "apr.scope.run": "仅本回合", + "apr.scopeRunHint": "该授权只作用于绑定的恢复回合,并随本请求过期。", + "apr.effectiveScope": "实际范围:{scope}", "apr.deny": "拒绝", "apr.queue": "审批队列", "apr.queueLedeA": "汇总散落在各岗位回合里的 ", From fb37f8f81f29e87fb250f945fc09a6f4bd748186 Mon Sep 17 00:00:00 2001 From: Bindy-lbb <70745012+Bindy-lbb@users.noreply.github.com> Date: Sun, 20 Sep 2026 14:38:19 +0800 Subject: [PATCH 2/2] fix(packaging): include approval scope runtime --- apps/desktop/packaging/runtime-layout.cjs | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/desktop/packaging/runtime-layout.cjs b/apps/desktop/packaging/runtime-layout.cjs index d4a9d490..36e2042c 100644 --- a/apps/desktop/packaging/runtime-layout.cjs +++ b/apps/desktop/packaging/runtime-layout.cjs @@ -146,6 +146,7 @@ const SHARED_RUNTIME_FILES = [ "dist/api.js", "dist/approval-preview.js", "dist/approval-redaction.js", + "dist/approval-scope.js", "dist/approvals.js", "dist/attachments.js", "dist/avatar.js",