zigmini 1.0.0-beta.2 → 1.1.0-beta.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.ko.md +36 -3
- package/README.md +36 -3
- package/assets/agent/gateway-request-rules.md +1 -1
- package/assets/agent/gateway-rules.md +6 -1
- package/assets/android/zigmini.apk +0 -0
- package/assets/android/zigmini.apk.json +3 -3
- package/assets/build.json +1 -1
- package/assets/document-reader/document-reader +0 -0
- package/assets/document-reader/host-selector +0 -0
- package/assets/document-reader/manifest.json +27 -0
- package/docs/manual.ko.md +120 -11
- package/docs/manual.md +123 -12
- package/host/document-reader/DocumentRead.swift +370 -0
- package/host/document-reader/HostSelector.swift +192 -0
- package/host/document-reader/Protocol.swift +647 -0
- package/host/document-reader/main.swift +22 -0
- package/host/omo-bridge/agent-aside-tool.ts +119 -0
- package/host/omo-bridge/agent-aside.ts +522 -0
- package/host/omo-bridge/agent-collaboration-files.ts +282 -0
- package/host/omo-bridge/agent-collaboration.ts +385 -0
- package/host/omo-bridge/agent-control.ts +400 -28
- package/host/omo-bridge/agent-execution.sb +42 -0
- package/host/omo-bridge/agent-execution.ts +158 -0
- package/host/omo-bridge/agent-tool-adapters.ts +388 -0
- package/host/omo-bridge/agent-tool-permissions.ts +96 -0
- package/host/omo-bridge/aside-tabs.ts +41 -0
- package/host/omo-bridge/attachment-read.ts +100 -0
- package/host/omo-bridge/bridge.ts +10 -0
- package/host/omo-bridge/managed-call.ts +46 -0
- package/host/omo-bridge/task-control.ts +39 -5
- package/host/omo-check/patches/attachments-293/README.md +138 -0
- package/host/omo-check/patches/attachments-293/document-content.js +234 -0
- package/host/omo-check/patches/attachments-293/document-content.types.txt +91 -0
- package/host/omo-check/patches/attachments-293/omo-5.1.51-documents.patch +831 -0
- package/host/omo-check/patches/attachments-293/omo-5.1.51-gateway-port.manifest.json +471 -0
- package/host/omo-check/patches/omo-5.1.51-agent-tool-authority.patch +471 -0
- package/host/omo-check/patches/omo-5.1.51-chatgpt-terra.patch +21 -0
- package/host/omo-check/patches/omo-5.1.51-gateway-port.manifest.json +44 -6
- package/host/omo-check/patches/omo-5.1.51-memory-receipts.patch +5 -0
- package/package.json +5 -3
- package/src/agent/api.ts +345 -20
- package/src/agent/artifacts.ts +196 -0
- package/src/agent/aside-target-routes.ts +369 -0
- package/src/agent/aside-targets.ts +215 -0
- package/src/agent/collaboration-admission.ts +47 -0
- package/src/agent/collaboration-read.ts +117 -0
- package/src/agent/collaboration-session.ts +196 -0
- package/src/agent/collaboration-types.ts +258 -0
- package/src/agent/collaboration.ts +399 -0
- package/src/agent/definition.ts +65 -3
- package/src/agent/fields.ts +9 -3
- package/src/agent/file-core.ts +325 -0
- package/src/agent/files.ts +87 -8
- package/src/agent/list.ts +3 -3
- package/src/agent/managed-call.ts +144 -0
- package/src/agent/tool-catalog.ts +184 -0
- package/src/agent/tool-delivery.ts +359 -0
- package/src/agent/tool-permission-types.ts +81 -0
- package/src/agent/tool-permissions.ts +1366 -0
- package/src/attachments/api.ts +161 -0
- package/src/attachments/document-reader.ts +325 -0
- package/src/attachments/host-selector.ts +410 -0
- package/src/attachments/managed-binding.ts +83 -0
- package/src/attachments/prepare.ts +214 -0
- package/src/attachments/selector-port.ts +28 -0
- package/src/attachments/service.ts +729 -0
- package/src/attachments/store.ts +698 -0
- package/src/attachments/types.ts +123 -0
- package/src/cli/doctor.ts +30 -10
- package/src/decisions.ts +109 -10
- package/src/engine/apply-patch.ts +21 -0
- package/src/engine/compat.json +2 -1
- package/src/engine/install.ts +45 -4
- package/src/engine/sandbox-cell.ts +2 -0
- package/src/gc.ts +33 -225
- package/src/housekeeping/api.ts +163 -0
- package/src/housekeeping/audit.ts +61 -0
- package/src/housekeeping/collectors/agent-collaboration.ts +33 -0
- package/src/housekeeping/collectors/agent-drafts.ts +43 -0
- package/src/housekeeping/collectors/agent-file-trash.ts +36 -0
- package/src/housekeeping/collectors/agent-trash.ts +28 -0
- package/src/housekeeping/collectors/attachment-cleanup.ts +35 -0
- package/src/housekeeping/collectors/db-backup.ts +25 -0
- package/src/housekeeping/collectors/docker-build-cache.ts +29 -0
- package/src/housekeeping/collectors/docker-image.ts +135 -0
- package/src/housekeeping/collectors/omo-version.ts +32 -0
- package/src/housekeeping/collectors/orphan-test-process.ts +205 -0
- package/src/housekeeping/collectors/reconcile-work.ts +55 -0
- package/src/housekeeping/collectors/rpc-daemon.ts +39 -0
- package/src/housekeeping/collectors/shared.ts +149 -0
- package/src/housekeeping/collectors/temp-dir.ts +28 -0
- package/src/housekeeping/collectors/update-file.ts +25 -0
- package/src/housekeeping/collectors/worktree.ts +193 -0
- package/src/housekeeping/engine.ts +125 -0
- package/src/housekeeping/reconcile/work.ts +130 -0
- package/src/housekeeping/registry.ts +63 -0
- package/src/housekeeping/trash.ts +59 -0
- package/src/housekeeping/types.ts +31 -0
- package/src/hub.ts +1095 -41
- package/src/main.ts +230 -31
- package/src/message-references.ts +14 -5
- package/src/profile-prompt.ts +13 -1
- package/src/profile.ts +13 -5
- package/src/protocol.ts +41 -6
- package/src/security-allow.ts +79 -2
- package/src/security-check.ts +1 -1
- package/src/store.ts +1946 -23
- package/src/test-crash.ts +20 -0
- package/src/usage.ts +18 -7
- package/src/work-watch.ts +55 -7
- package/src/work.ts +192 -3
- package/web/DESIGN.md +35 -1
- package/web/agents.d.ts +189 -12
- package/web/agents.js +898 -39
- package/web/app.js +2634 -210
- package/web/attachments.d.ts +91 -0
- package/web/attachments.js +422 -0
- package/web/collaboration.d.ts +87 -0
- package/web/collaboration.js +290 -0
- package/web/decisions.d.ts +39 -2
- package/web/decisions.js +354 -2
- package/web/gc.d.ts +26 -5
- package/web/gc.js +113 -13
- package/web/i18n/agents.d.ts +4 -0
- package/web/i18n/agents.js +200 -8
- package/web/i18n/attachments.js +78 -0
- package/web/i18n/decisions.d.ts +4 -0
- package/web/i18n/decisions.js +68 -0
- package/web/i18n/gc.d.ts +4 -0
- package/web/i18n/gc.js +184 -2
- package/web/i18n/main.js +80 -8
- package/web/i18n/tour.d.ts +4 -0
- package/web/i18n/tour.js +37 -0
- package/web/index.html +20 -3
- package/web/profile.d.ts +2 -2
- package/web/profile.js +16 -10
- package/web/style.css +342 -21
- package/web/tooltip.d.ts +2 -0
- package/web/tooltip.js +12 -0
- package/web/tour.d.ts +57 -0
- package/web/tour.js +450 -0
|
@@ -0,0 +1,359 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 294 item 5: the gateway side of a granted agent tool operation (plan C4/C5/C6/C10). src/main.ts runs it in the gateway
|
|
3
|
+
* process, so it lives under src/ (the Docker image and the packaged gateway ship src/ without host/).
|
|
4
|
+
*
|
|
5
|
+
* - enginePolicy: the exact policy the engine applies (zgw.agent_tool_policy). Blocked access or no grant never allows.
|
|
6
|
+
* - deliverGrantedOperation: policy ACK -> reserve -> start CAS -> one engine execute -> stored result -> one resume.
|
|
7
|
+
* A start without a result, a lost execute response and outcome_unknown are never executed again (C5).
|
|
8
|
+
* A collaboration request only stores the owner outcome; the 292 dispatcher owns that continuation (292 N6).
|
|
9
|
+
* - authorizeRecipientTool: the recipient check 292 calls at start/dispatch/helper start and final release.
|
|
10
|
+
* Sender grants never transfer: only the recipient's own active grant of the current epoch counts.
|
|
11
|
+
* - createAgentToolDeliverer: the production trigger over the Store ledger.
|
|
12
|
+
*/
|
|
13
|
+
import { createHash } from "node:crypto";
|
|
14
|
+
import type { AgentAccessView } from "./tool-permission-types.ts";
|
|
15
|
+
import type { SessionAsideTarget } from "./aside-target-routes.ts";
|
|
16
|
+
|
|
17
|
+
export interface GrantView {
|
|
18
|
+
readonly id: string;
|
|
19
|
+
readonly agentId: string;
|
|
20
|
+
readonly incarnation: number;
|
|
21
|
+
readonly authorityEpoch: number;
|
|
22
|
+
readonly toolKey: { readonly sourceKind: string; readonly ownerId: string; readonly originalName: string; readonly registrationDigest: string };
|
|
23
|
+
readonly operations: readonly string[];
|
|
24
|
+
readonly state: "active" | "revoked" | "suspended";
|
|
25
|
+
readonly revision: number;
|
|
26
|
+
/** Approved resources from the ledger; null for a legacy grant without stored resources. */
|
|
27
|
+
readonly resources?: readonly string[] | null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface EnginePolicy {
|
|
31
|
+
readonly revision: number;
|
|
32
|
+
/** `resources` is informational for the engine (it matches by name); the adapters enforce it at the tool boundary. */
|
|
33
|
+
readonly grants: readonly { readonly toolName: string; readonly owner?: string; readonly operation?: string; readonly resources: readonly string[] }[];
|
|
34
|
+
readonly missing: "hold" | "deny";
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const usable = (view: AgentAccessView, grant: GrantView, agentId: string) =>
|
|
38
|
+
grant.agentId === agentId && grant.state === "active" && grant.authorityEpoch === view.authorityEpoch && grant.incarnation === view.lifecycleEpoch;
|
|
39
|
+
|
|
40
|
+
/** The engine's operation id of a held direct call (agent-session.js `agent-op:${toolCall.id}`). The ledger stores it as the logical call. */
|
|
41
|
+
export const agentToolOperationId = (toolCallId: string) => `agent-op:${toolCallId}`;
|
|
42
|
+
/** The engine operation the session records for a click/fill awaiting its one-shot confirmation (C8); the ledger stores it as the logical call. */
|
|
43
|
+
export const agentToolActionId = (toolCallId: string) => `agent-action:${toolCallId}`;
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Tools an agent session runs without a persistent grant: ask_user_question (bootstrap communication, C1) and
|
|
47
|
+
* zgw_attachment_read (its own 293 selection grant is checked per call by the gateway route, C10), and the collaboration
|
|
48
|
+
* tools host/omo-bridge/agent-collaboration.ts registers (COLLABORATION_TOOLS; F3 D2: the gateway's collaboration routes
|
|
49
|
+
* check targets, artifacts and the recipient's grants per call, so a card would only stall the request). Added to every
|
|
50
|
+
* policy the engine applies, so a pushed policy never holds them; a blocked agent (missing: "deny") gets none.
|
|
51
|
+
*/
|
|
52
|
+
export const SESSION_BASELINE_TOOLS: readonly string[] = ["ask_user_question", "zgw_attachment_read", "zgw_agent_request", "zgw_agent_result", "zgw_agent_requests", "zgw_agent_cancel", "zgw_agent_files"];
|
|
53
|
+
export function withSessionBaseline(policy: EnginePolicy): EnginePolicy {
|
|
54
|
+
if (policy.missing === "deny") return policy;
|
|
55
|
+
return { ...policy, grants: [...policy.grants, ...SESSION_BASELINE_TOOLS.map((toolName) => ({ toolName, resources: [] }))] };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** The catalog operations each usable grant allows, by tool name; adapters check one operation against it (C7). */
|
|
59
|
+
export function grantedOperations(view: AgentAccessView, grants: readonly GrantView[]): Record<string, string[]> {
|
|
60
|
+
const out: Record<string, string[]> = {};
|
|
61
|
+
if (view.accessState === "blocked") return out;
|
|
62
|
+
for (const grant of grants) {
|
|
63
|
+
if (!usable(view, grant, view.agentId)) continue;
|
|
64
|
+
const name = grant.toolKey.originalName;
|
|
65
|
+
out[name] = [...new Set([...(out[name] ?? []), ...grant.operations])].sort();
|
|
66
|
+
}
|
|
67
|
+
return out;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** The policy for one agent. Blocked access pushes no grant and refuses instead of holding (C10). */
|
|
71
|
+
export function enginePolicy(view: AgentAccessView, grants: readonly GrantView[]): EnginePolicy {
|
|
72
|
+
if (view.accessState === "blocked") return { revision: Math.max(view.policyRevision, 1), grants: [], missing: "deny" };
|
|
73
|
+
const out: { toolName: string; owner?: string; operation?: string; resources: readonly string[] }[] = [];
|
|
74
|
+
for (const grant of grants) {
|
|
75
|
+
if (!usable(view, grant, view.agentId)) continue;
|
|
76
|
+
const resources = grant.resources ?? [];
|
|
77
|
+
if (grant.toolKey.sourceKind === "mcp") for (const operation of grant.operations) out.push({ toolName: grant.toolKey.originalName, owner: grant.toolKey.ownerId, operation, resources });
|
|
78
|
+
else out.push({ toolName: grant.toolKey.originalName, resources });
|
|
79
|
+
}
|
|
80
|
+
return { revision: Math.max(view.policyRevision, 1), grants: out, missing: "hold" };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export interface RecipientToolCall {
|
|
84
|
+
readonly recipientAgentId: string;
|
|
85
|
+
readonly toolKey: GrantView["toolKey"];
|
|
86
|
+
readonly operation: string;
|
|
87
|
+
/** The recipient authority epoch bound when 292 admitted the request. */
|
|
88
|
+
readonly authorityEpoch: number;
|
|
89
|
+
}
|
|
90
|
+
export type RecipientToolOutcome =
|
|
91
|
+
| { readonly decision: "allow"; readonly grantId: string; readonly grantRevision: number; readonly authorityEpoch: number }
|
|
92
|
+
| { readonly decision: "deny"; readonly reason: "agent_access_blocked" | "authority_epoch_mismatch" | "agent_mismatch" | "no_recipient_grant" };
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* 292 recipient check. Order (C10): access state -> authority epoch -> the recipient's own exact grant. Read the view and
|
|
96
|
+
* grants in the same Store transaction as the start or release; the result is a snapshot, not a lease.
|
|
97
|
+
*/
|
|
98
|
+
export function authorizeRecipientTool(view: AgentAccessView, grants: readonly GrantView[], call: RecipientToolCall): RecipientToolOutcome {
|
|
99
|
+
if (view.agentId !== call.recipientAgentId) return { decision: "deny", reason: "agent_mismatch" };
|
|
100
|
+
if (view.accessState === "blocked") return { decision: "deny", reason: "agent_access_blocked" };
|
|
101
|
+
if (view.authorityEpoch !== call.authorityEpoch) return { decision: "deny", reason: "authority_epoch_mismatch" };
|
|
102
|
+
const grant = grants.find((g) => usable(view, g, call.recipientAgentId) &&
|
|
103
|
+
g.toolKey.originalName === call.toolKey.originalName && g.toolKey.registrationDigest === call.toolKey.registrationDigest &&
|
|
104
|
+
g.toolKey.ownerId === call.toolKey.ownerId && g.toolKey.sourceKind === call.toolKey.sourceKind && g.operations.includes(call.operation));
|
|
105
|
+
return grant ? { decision: "allow", grantId: grant.id, grantRevision: grant.revision, authorityEpoch: grant.authorityEpoch }
|
|
106
|
+
: { decision: "deny", reason: "no_recipient_grant" };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export interface LedgerInvocation {
|
|
110
|
+
readonly state: "ready" | "reserved" | "started" | "succeeded" | "failed" | "outcome_unknown" | "cancelled";
|
|
111
|
+
readonly agentId: string;
|
|
112
|
+
readonly executorGeneration: number;
|
|
113
|
+
readonly resultRef: string | null;
|
|
114
|
+
}
|
|
115
|
+
/** The gateway ledger, in process (Store) or over the signed internal routes. */
|
|
116
|
+
export interface AgentToolLedgerPort {
|
|
117
|
+
invocation(id: string): LedgerInvocation | null | Promise<LedgerInvocation | null>;
|
|
118
|
+
policy(agentId: string): { view: AgentAccessView; grants: readonly GrantView[] } | Promise<{ view: AgentAccessView; grants: readonly GrantView[] }>;
|
|
119
|
+
policyAck(revision: number): void | Promise<void>;
|
|
120
|
+
reserve(id: string, generation: number): unknown;
|
|
121
|
+
start(id: string, generation: number): { state: "started" } | { state: "cancelled"; reason: string } | Promise<{ state: "started" } | { state: "cancelled"; reason: string }>;
|
|
122
|
+
result(id: string, generation: number, outcome: { state: "succeeded" | "failed" | "outcome_unknown"; resultRef: string }): { continuationId: string } | Promise<{ continuationId: string }>;
|
|
123
|
+
}
|
|
124
|
+
/** The engine's host-only RPC commands (item 3 patch). */
|
|
125
|
+
export interface AgentToolEnginePort {
|
|
126
|
+
policy(policy: EnginePolicy): Promise<{ revision: number }>;
|
|
127
|
+
execute(operationId: string): Promise<{ executed: boolean; state: string; result?: unknown }>;
|
|
128
|
+
resume(operationId: string, continuationId: string): Promise<{ accepted: boolean; duplicate: boolean }>;
|
|
129
|
+
/** agent-control exposes the granted schemas only after the ACK (zgw.agent_tool_tools). */
|
|
130
|
+
expose?(revision: number, names: readonly string[]): Promise<unknown>;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export type DeliveryOutcome =
|
|
134
|
+
| { readonly state: "resumed" | "duplicate"; readonly continuationId: string }
|
|
135
|
+
| { readonly state: "stored"; readonly continuationId: string }
|
|
136
|
+
| { readonly state: "cancelled"; readonly reason: string }
|
|
137
|
+
| { readonly state: "policy_unacknowledged"; readonly revision: number }
|
|
138
|
+
| { readonly state: "outcome_unknown"; readonly reason: "started_without_result" | "execute_response_lost" | "engine_unknown" }
|
|
139
|
+
| { readonly state: "not_deliverable"; readonly reason: string };
|
|
140
|
+
|
|
141
|
+
export const resultRef = (result: unknown) => `sha256:${createHash("sha256").update(JSON.stringify(result ?? null)).digest("hex")}`;
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Runs one granted operation at most once and continues the original work at most once. Calling it again after a
|
|
145
|
+
* success only re-delivers the stored continuation (the engine deduplicates by continuation id); nothing re-runs.
|
|
146
|
+
*/
|
|
147
|
+
export async function deliverGrantedOperation(
|
|
148
|
+
ports: { ledger: AgentToolLedgerPort; engine: AgentToolEnginePort; beforeExecute?: () => Promise<unknown> },
|
|
149
|
+
input: { invocationId: string; operationId: string; generation: number; collaboration: boolean },
|
|
150
|
+
): Promise<DeliveryOutcome> {
|
|
151
|
+
const { ledger, engine } = ports;
|
|
152
|
+
const current = await ledger.invocation(input.invocationId);
|
|
153
|
+
if (!current) return { state: "not_deliverable", reason: "invocation_not_found" };
|
|
154
|
+
if (current.state === "succeeded" || current.state === "failed") {
|
|
155
|
+
const { continuationId } = await ledger.result(input.invocationId, current.executorGeneration, { state: current.state, resultRef: current.resultRef ?? "" });
|
|
156
|
+
return continueOnce(engine, input, continuationId);
|
|
157
|
+
}
|
|
158
|
+
if (current.state === "started") return { state: "outcome_unknown", reason: "started_without_result" };
|
|
159
|
+
if (current.state !== "ready" && current.state !== "reserved") return { state: "not_deliverable", reason: current.state };
|
|
160
|
+
// Grant first, then the engine's ACK of that exact revision, then the start CAS (C4 steps 3-5, C5).
|
|
161
|
+
const { view, grants } = await ledger.policy(current.agentId);
|
|
162
|
+
const policy = enginePolicy(view, grants);
|
|
163
|
+
const ack = await engine.policy(policy);
|
|
164
|
+
if (ack.revision !== policy.revision) return { state: "policy_unacknowledged", revision: policy.revision };
|
|
165
|
+
try {
|
|
166
|
+
await ledger.policyAck(policy.revision);
|
|
167
|
+
} catch (error) {
|
|
168
|
+
// A revoke between the engine ACK and its record raised the revision and cancelled the row: the cancelled row wins (C5).
|
|
169
|
+
if ((await ledger.invocation(input.invocationId))?.state === "cancelled") return { state: "cancelled", reason: "cancelled" };
|
|
170
|
+
throw error;
|
|
171
|
+
}
|
|
172
|
+
await engine.expose?.(policy.revision, grantedToolNames(policy));
|
|
173
|
+
await ledger.reserve(input.invocationId, input.generation);
|
|
174
|
+
const started = await ledger.start(input.invocationId, input.generation);
|
|
175
|
+
if (started.state === "cancelled") return { state: "cancelled", reason: started.reason };
|
|
176
|
+
// C8: an action's one-shot confirmation reaches the session only after start consumed its ticket.
|
|
177
|
+
await ports.beforeExecute?.();
|
|
178
|
+
let executed: Awaited<ReturnType<AgentToolEnginePort["execute"]>>;
|
|
179
|
+
try {
|
|
180
|
+
executed = await engine.execute(input.operationId);
|
|
181
|
+
} catch {
|
|
182
|
+
// The request may have run. A lost answer is never a reason to run again; the row stays started for review.
|
|
183
|
+
return { state: "outcome_unknown", reason: "execute_response_lost" };
|
|
184
|
+
}
|
|
185
|
+
const state = executed.state === "succeeded" ? "succeeded" : executed.state === "failed" || executed.state === "not_granted" ? "failed" : "outcome_unknown";
|
|
186
|
+
const { continuationId } = await ledger.result(input.invocationId, input.generation, { state, resultRef: resultRef(executed.result ?? executed.state) });
|
|
187
|
+
if (state === "outcome_unknown") return { state: "outcome_unknown", reason: "engine_unknown" };
|
|
188
|
+
return continueOnce(engine, input, continuationId);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
async function continueOnce(engine: AgentToolEnginePort, input: { operationId: string; collaboration: boolean }, continuationId: string): Promise<DeliveryOutcome> {
|
|
192
|
+
// 292 N6: the collaboration continuation belongs to the 292 dispatcher alone; 294 does not prompt.
|
|
193
|
+
if (input.collaboration) return { state: "stored", continuationId };
|
|
194
|
+
const resumed = await engine.resume(input.operationId, continuationId);
|
|
195
|
+
return { state: resumed.accepted ? "resumed" : "duplicate", continuationId };
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* The engine answers an accepted continuation through its prompt admission (`{disposition, clientInputId, admission?}`,
|
|
200
|
+
* connection-handler.js zgw.agent_tool_resume -> handleClientInput), not with `accepted`; only a repeat of the same
|
|
201
|
+
* continuation answers `{accepted:false, duplicate:true}`. Anything else is not an answer the delivery can classify.
|
|
202
|
+
*/
|
|
203
|
+
export function resumeAnswer(answer: unknown): { accepted: boolean; duplicate: boolean } {
|
|
204
|
+
const value = answer as { accepted?: unknown; duplicate?: unknown; disposition?: unknown } | null | undefined;
|
|
205
|
+
if (value?.duplicate === true) return { accepted: false, duplicate: true };
|
|
206
|
+
if (value?.accepted === false) return { accepted: false, duplicate: false };
|
|
207
|
+
if (value?.accepted === true || typeof value?.disposition === "string") return { accepted: true, duplicate: false };
|
|
208
|
+
throw new Error("agent_tool_resume_answer_invalid");
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** Engine port over an RPC command function (OmoClient.command with the session id, or a direct handler). */
|
|
212
|
+
export function engineCommands(command: (type: string, params: Record<string, unknown>) => Promise<unknown>): AgentToolEnginePort {
|
|
213
|
+
return {
|
|
214
|
+
policy: async (policy) => command("zgw.agent_tool_policy", { policy: withSessionBaseline(policy) }) as Promise<{ revision: number }>,
|
|
215
|
+
execute: async (operationId) => command("zgw.agent_tool_execute", { operationId }) as Promise<{ executed: boolean; state: string; result?: unknown }>,
|
|
216
|
+
resume: async (operationId, continuationId) => resumeAnswer(await command("zgw.agent_tool_resume", { operationId, continuationId })),
|
|
217
|
+
expose: async (revision, names) => command("extension_request", { name: "zgw.agent_tool_tools", data: { revision, names } }),
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
export type SessionPolicyOutcome =
|
|
222
|
+
| { readonly state: "applied"; readonly revision: number }
|
|
223
|
+
| { readonly state: "legacy" }
|
|
224
|
+
| { readonly state: "policy_unacknowledged"; readonly revision: number };
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* D2: a managed agent session gets the authority when it opens, before any turn: the current policy is applied, its
|
|
228
|
+
* ACK persisted for that durable session, and the granted schemas exposed. A held call then waits for consent instead
|
|
229
|
+
* of being refused, and a later allow starts without a new ACK race. A legacy agent keeps the previous rules (C9).
|
|
230
|
+
*/
|
|
231
|
+
export async function pushSessionPolicy(
|
|
232
|
+
ports: { ledger: Pick<DeliveryLedger, "policyGrants" | "ackPolicy"> & { mode(agentId: string): "legacy" | "managed" }; engine: AgentToolEnginePort },
|
|
233
|
+
input: { agentId: string; durableSessionId: string },
|
|
234
|
+
): Promise<SessionPolicyOutcome> {
|
|
235
|
+
if (ports.ledger.mode(input.agentId) !== "managed") return { state: "legacy" };
|
|
236
|
+
const { view, grants } = ports.ledger.policyGrants(input.agentId);
|
|
237
|
+
const policy = enginePolicy(view, grants);
|
|
238
|
+
const ack = await ports.engine.policy(policy);
|
|
239
|
+
if (ack.revision !== policy.revision) return { state: "policy_unacknowledged", revision: policy.revision };
|
|
240
|
+
ports.ledger.ackPolicy(input.durableSessionId, input.agentId, policy.revision);
|
|
241
|
+
await ports.engine.expose?.(policy.revision, grantedToolNames(policy));
|
|
242
|
+
return { state: "applied", revision: policy.revision };
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/** The ledger calls the production deliverer uses (src/agent/tool-permissions.ts AgentToolPermissionStore). */
|
|
246
|
+
export interface DeliveryLedger {
|
|
247
|
+
deliveryTarget(invocationId: string): { invocationId: string; operationId: string; agentId: string; convId: string; durableSessionId: string; collaboration: boolean } | null;
|
|
248
|
+
readyInvocations(convId: string): string[];
|
|
249
|
+
invocation(id: string): LedgerInvocation | null;
|
|
250
|
+
policyGrants(agentId: string): { view: AgentAccessView; grants: readonly GrantView[] };
|
|
251
|
+
ackPolicy(durableSessionId: string, agentId: string, revision: number): unknown;
|
|
252
|
+
reserve(id: string, generation: number): unknown;
|
|
253
|
+
start(id: string, generation: number, ack: { durableSessionId: string }): { state: "started" } | { state: "cancelled"; reason: string };
|
|
254
|
+
result(id: string, generation: number, outcome: { state: "succeeded" | "failed" | "outcome_unknown"; resultRef: string }): { continuationId: string };
|
|
255
|
+
/** C4 queued -> continued, persisted once the engine accepted the continuation or already had it. */
|
|
256
|
+
continued(continuationId: string): unknown;
|
|
257
|
+
/** Started executions a generation that is not live in this process left without a result (C5: outcome unknown). */
|
|
258
|
+
staleStarted(convId: string): string[];
|
|
259
|
+
}
|
|
260
|
+
export type ProductionDelivery = DeliveryOutcome | { readonly state: "collaboration_owned" } | { readonly state: "engine_unavailable" } | { readonly state: "in_flight" };
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* The production trigger after an owner allow and after a session opens. One delivery per execution at a time; the ACK is
|
|
264
|
+
* persisted for the target session and start requires it. A collaboration request is never run here (292 N6), and a
|
|
265
|
+
* conversation without a live session keeps its execution ready until that session opens.
|
|
266
|
+
*/
|
|
267
|
+
export function createAgentToolDeliverer(deps: {
|
|
268
|
+
ledger: DeliveryLedger;
|
|
269
|
+
engine: (convId: string) => ((type: string, params: Record<string, unknown>) => Promise<unknown>) | null;
|
|
270
|
+
generation: number;
|
|
271
|
+
/** D4: the tab the owner bound to the call (sessionAsideTarget over the gateway registry); null for other calls. */
|
|
272
|
+
target?: (invocationId: string) => { args: unknown; target: SessionAsideTarget } | { unavailable: string } | null;
|
|
273
|
+
/** C8: the stored args and ticket of an action execution (ledger actionCarry); null for other executions. */
|
|
274
|
+
action?: (invocationId: string) => { args: unknown; ticket: string } | null;
|
|
275
|
+
/** C5: an outcome that cannot be proven goes to the owner's needs_review surface (Store.reviewAgentToolOutcome). */
|
|
276
|
+
review?: (invocationId: string, reason: string) => void;
|
|
277
|
+
}) {
|
|
278
|
+
const flying = new Set<string>();
|
|
279
|
+
const bindings = new Map<string, Promise<unknown>>();
|
|
280
|
+
const deliver = async (invocationId: string): Promise<ProductionDelivery> => {
|
|
281
|
+
const target = deps.ledger.deliveryTarget(invocationId);
|
|
282
|
+
if (!target) return { state: "not_deliverable", reason: "invocation_not_found" };
|
|
283
|
+
if (target.collaboration) return { state: "collaboration_owned" };
|
|
284
|
+
const command = deps.engine(target.convId);
|
|
285
|
+
if (!command) return { state: "engine_unavailable" };
|
|
286
|
+
if (flying.has(invocationId)) return { state: "in_flight" };
|
|
287
|
+
flying.add(invocationId);
|
|
288
|
+
try {
|
|
289
|
+
const outcome = await deliverOnce(invocationId, target, command);
|
|
290
|
+
// C4: the accepted (or already held) continuation is persisted, so a restart never delivers it again.
|
|
291
|
+
if (outcome.state === "resumed" || outcome.state === "duplicate") deps.ledger.continued(outcome.continuationId);
|
|
292
|
+
if (outcome.state === "outcome_unknown") deps.review?.(invocationId, outcome.reason);
|
|
293
|
+
return outcome;
|
|
294
|
+
} finally {
|
|
295
|
+
flying.delete(invocationId);
|
|
296
|
+
}
|
|
297
|
+
};
|
|
298
|
+
const deliverOnce = async (invocationId: string, target: NonNullable<ReturnType<DeliveryLedger["deliveryTarget"]>>,
|
|
299
|
+
command: (type: string, params: Record<string, unknown>) => Promise<unknown>): Promise<ProductionDelivery> => {
|
|
300
|
+
const { ledger } = deps;
|
|
301
|
+
const action = deps.action?.(invocationId) ?? null;
|
|
302
|
+
const carry = (data: unknown) => command("extension_request", { name: "zgw.aside_action", data });
|
|
303
|
+
const run = async () => {
|
|
304
|
+
try {
|
|
305
|
+
return await deliverGrantedOperation({
|
|
306
|
+
engine: engineCommands(command),
|
|
307
|
+
ledger: {
|
|
308
|
+
invocation: (id) => ledger.invocation(id),
|
|
309
|
+
policy: (agentId) => ledger.policyGrants(agentId),
|
|
310
|
+
policyAck: (revision) => { ledger.ackPolicy(target.durableSessionId, target.agentId, revision); },
|
|
311
|
+
reserve: (id, generation) => ledger.reserve(id, generation),
|
|
312
|
+
start: (id, generation) => ledger.start(id, generation, { durableSessionId: target.durableSessionId }),
|
|
313
|
+
result: (id, generation, outcome) => ledger.result(id, generation, outcome),
|
|
314
|
+
},
|
|
315
|
+
...(action ? { beforeExecute: () => carry(action) } : {}),
|
|
316
|
+
}, { invocationId, operationId: target.operationId, generation: deps.generation, collaboration: false });
|
|
317
|
+
} finally {
|
|
318
|
+
if (action) await carry({ args: action.args, ticket: null })
|
|
319
|
+
.catch((error: unknown) => console.error(`aside action clear failed (${target.convId}): ${error instanceof Error ? error.message : String(error)}`));
|
|
320
|
+
}
|
|
321
|
+
};
|
|
322
|
+
const bound = deps.target?.(invocationId) ?? null;
|
|
323
|
+
if (bound === null) return await run();
|
|
324
|
+
// A bound tab that can no longer be used never starts the call; the execution stays ready for a new selection.
|
|
325
|
+
if ("unavailable" in bound) return { state: "not_deliverable", reason: `target_${bound.unavailable}` };
|
|
326
|
+
// The engine runs the stored args unchanged; the session matches the carried tab to the call by those args, so
|
|
327
|
+
// target-bound deliveries of one conversation run one at a time.
|
|
328
|
+
const turn = (bindings.get(target.convId) ?? Promise.resolve()).then(async () => {
|
|
329
|
+
await command("extension_request", { name: "zgw.aside_target", data: bound });
|
|
330
|
+
try {
|
|
331
|
+
return await run();
|
|
332
|
+
} finally {
|
|
333
|
+
await command("extension_request", { name: "zgw.aside_target", data: { args: bound.args, target: null } })
|
|
334
|
+
.catch((error: unknown) => console.error(`aside target clear failed (${target.convId}): ${error instanceof Error ? error.message : String(error)}`));
|
|
335
|
+
}
|
|
336
|
+
});
|
|
337
|
+
bindings.set(target.convId, turn.then(() => undefined, () => undefined));
|
|
338
|
+
return await turn;
|
|
339
|
+
};
|
|
340
|
+
/**
|
|
341
|
+
* When the conversation's session opens: every execution still owed a delivery (ready, a dead reservation, a stored
|
|
342
|
+
* result whose continuation is queued) is delivered once; a dead generation's start without a result goes to review.
|
|
343
|
+
*/
|
|
344
|
+
const deliverReady = async (convId: string): Promise<ProductionDelivery[]> => {
|
|
345
|
+
const out: ProductionDelivery[] = [];
|
|
346
|
+
for (const id of deps.ledger.staleStarted(convId)) {
|
|
347
|
+
deps.review?.(id, "started_without_result");
|
|
348
|
+
out.push({ state: "outcome_unknown", reason: "started_without_result" });
|
|
349
|
+
}
|
|
350
|
+
for (const id of deps.ledger.readyInvocations(convId)) out.push(await deliver(id));
|
|
351
|
+
return out;
|
|
352
|
+
};
|
|
353
|
+
return { deliver, deliverReady };
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/** Tools the agent session exposes after the engine ACKed a grant (C5: schema after save and ACK). */
|
|
357
|
+
export function grantedToolNames(policy: EnginePolicy): string[] {
|
|
358
|
+
return policy.missing === "deny" && policy.grants.length === 0 ? [] : [...new Set(policy.grants.map((grant) => grant.toolName))];
|
|
359
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/** Shared types for 294 agent tool permissions. The ledger (C2) and APIs (C3) build on these shapes. */
|
|
2
|
+
|
|
3
|
+
/** Collaboration contract (292 contract, AgentAccessView). 294 owns the values; 292 only reads them. */
|
|
4
|
+
export interface AgentAccessView {
|
|
5
|
+
readonly agentId: string;
|
|
6
|
+
readonly lifecycleEpoch: number;
|
|
7
|
+
readonly accessState: "open" | "blocked";
|
|
8
|
+
readonly authorityEpoch: number;
|
|
9
|
+
readonly policyRevision: number;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export type ToolEffect = "read" | "local_write" | "external_write" | "execution" | "control";
|
|
13
|
+
export type ToolSourceKind = "builtin" | "extension" | "mcp" | "gateway";
|
|
14
|
+
export type ToolAvailability = "registered" | "mcp_dynamic" | "gateway_agent" | "gateway_work" | "internal_provider" | "planned" | "unknown";
|
|
15
|
+
export type ToolKind =
|
|
16
|
+
| "file_read" | "file_write" | "exec" | "exec_control" | "web" | "media" | "display" | "lsp" | "mcp_docs" | "mcp_ast"
|
|
17
|
+
| "mcp_resource" | "mcp_proxy" | "browser" | "subtask" | "session" | "self_state" | "memory" | "relocation"
|
|
18
|
+
| "communication" | "discovery" | "work_role" | "agent_history" | "attachment" | "bootstrap" | "unclassified";
|
|
19
|
+
export type RestrictionAdapter =
|
|
20
|
+
| "path_scope" | "restricted_exec" | "owned_exec_handle" | "web_origin" | "media_io" | "display_surface" | "browser_target"
|
|
21
|
+
| "owned_child" | "owned_session" | "self_state" | "self_identity" | "relocation_target" | "metadata_only" | "role_only"
|
|
22
|
+
| "own_history" | "attachment_selection" | "mcp_resource" | "mcp_proxy" | "not_grantable" | "exact_args";
|
|
23
|
+
|
|
24
|
+
/** Permission identity. The exposed (display) name is not part of it. */
|
|
25
|
+
export interface ToolKey {
|
|
26
|
+
readonly sourceKind: ToolSourceKind;
|
|
27
|
+
readonly ownerId: string;
|
|
28
|
+
readonly originalName: string;
|
|
29
|
+
readonly registrationDigest: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Registration metadata as read from the engine tool list or an MCP registration. Never executes the tool. */
|
|
33
|
+
export interface RegisteredToolMeta {
|
|
34
|
+
readonly name: string;
|
|
35
|
+
readonly sourceKind: ToolSourceKind;
|
|
36
|
+
readonly ownerId: string;
|
|
37
|
+
readonly originalName: string;
|
|
38
|
+
readonly parameters: unknown;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface ToolDescriptor {
|
|
42
|
+
readonly key: ToolKey;
|
|
43
|
+
readonly exposedName: string;
|
|
44
|
+
readonly kind: ToolKind;
|
|
45
|
+
readonly effect: ToolEffect;
|
|
46
|
+
readonly operations: readonly string[];
|
|
47
|
+
readonly scopeSchema: string;
|
|
48
|
+
readonly availability: ToolAvailability;
|
|
49
|
+
readonly restrictionAdapter: RestrictionAdapter;
|
|
50
|
+
readonly registrationDigest: string;
|
|
51
|
+
readonly opaque: boolean;
|
|
52
|
+
readonly bootstrap: boolean;
|
|
53
|
+
readonly grantable: boolean;
|
|
54
|
+
readonly roleOnly: "work" | null;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export type GrantState = "active" | "revoked" | "suspended";
|
|
58
|
+
|
|
59
|
+
export interface ToolGrant {
|
|
60
|
+
readonly id: string;
|
|
61
|
+
readonly agentId: string;
|
|
62
|
+
/** 292 agent_lifecycle.epoch at grant time. */
|
|
63
|
+
readonly incarnation: number;
|
|
64
|
+
readonly authorityEpoch: number;
|
|
65
|
+
readonly toolKey: ToolKey;
|
|
66
|
+
readonly operations: readonly string[];
|
|
67
|
+
readonly state: GrantState;
|
|
68
|
+
readonly revision: number;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export type DenyReason =
|
|
72
|
+
| "agent_access_blocked" | "lifecycle_epoch_mismatch" | "authority_epoch_mismatch" | "not_grantable" | "role_only"
|
|
73
|
+
| "unknown_operation" | "no_grant" | "grant_agent_mismatch" | "grant_not_active" | "grant_tool_mismatch"
|
|
74
|
+
| "operation_not_granted" | "proxy_target_unresolved";
|
|
75
|
+
|
|
76
|
+
export type AuthorizationOutcome =
|
|
77
|
+
| { readonly decision: "allow" }
|
|
78
|
+
| { readonly decision: "deny"; readonly reason: DenyReason }
|
|
79
|
+
| { readonly decision: "hold"; readonly reason: "tool_changed" };
|
|
80
|
+
|
|
81
|
+
export type GrantReconcile = { readonly state: "active" } | { readonly state: "suspended"; readonly reason: "tool_changed" } | { readonly state: "unrelated" };
|