@absolutejs/agent-control 0.1.0 → 0.3.0

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.md CHANGED
@@ -8,3 +8,34 @@ leased idempotency records so operator retries cannot change the requested input
8
8
  Scopes are `agents:read`, `agents:revoke`, and `agents:restore`. Mutations require
9
9
  an `operationId` and `reason`; PostgreSQL operations can be safely reclaimed
10
10
  after a crashed operator process's lease expires.
11
+
12
+ `createAgentControlConsoleHandler()` adds a dependency-free authenticated
13
+ operator console to the same package. Its snapshot shows agent status, pending
14
+ approvals, durable runs and budgets, delegation scope and expiry, memory
15
+ metadata, and scope-specific reputation without exposing memory contents or raw
16
+ action inputs. Approval decisions require `agents:approve`, same-origin
17
+ requests, an explicit mutation-intent header, a reason, and an idempotency key.
18
+
19
+ ```ts
20
+ const console = createAgentControlConsoleHandler({
21
+ authorize: verifyOperator,
22
+ operations: createPostgresOperationStore({ client }),
23
+ data: {
24
+ snapshot: (operator) => loadSafeOperatorSnapshot(operator),
25
+ decideApproval: (decision) => applyAgencyDecision(decision),
26
+ },
27
+ });
28
+ ```
29
+
30
+ The HTML console uses a per-response Content Security Policy nonce, constructs
31
+ all remote data with DOM `textContent`, cannot be framed, stores no cache, and
32
+ has no third-party assets. The data adapter keeps it provider-neutral and lets
33
+ applications compose `@absolutejs/agency`, `agent-runtime`, `agent-memory`, and
34
+ `agent-reputation` without creating hard dependencies.
35
+
36
+ `createAgentPlaygroundHandler()` adds a provider-neutral plan-then-execute UI
37
+ and API. Planning is a separate, explicitly effect-free adapter operation. The
38
+ server stores the full input and only returns its digest, expires plans quickly,
39
+ binds them to the operator, refuses denied or approval-pending plans, and uses
40
+ the same leased idempotency store for execution. The browser cannot mutate a
41
+ plan between review and execution.
@@ -0,0 +1,63 @@
1
+ import type { OperationStore, Operator } from "./index";
2
+ export type AgentControlSnapshot = {
3
+ agents: Array<{
4
+ description?: string;
5
+ id: string;
6
+ status: string;
7
+ }>;
8
+ approvals: Array<{
9
+ action: string;
10
+ agentId: string;
11
+ id: string;
12
+ requestedAt: string;
13
+ risk?: string;
14
+ summary: string;
15
+ }>;
16
+ delegations: Array<{
17
+ expiresAt?: string;
18
+ from: string;
19
+ id: string;
20
+ scopes: string[];
21
+ to: string;
22
+ }>;
23
+ memories: Array<{
24
+ agentId: string;
25
+ id: string;
26
+ scope: string;
27
+ updatedAt: string;
28
+ }>;
29
+ reputations: Array<{
30
+ confidence: number;
31
+ scope: string;
32
+ score: number;
33
+ subject: string;
34
+ }>;
35
+ runs: Array<{
36
+ agentId: string;
37
+ budget?: {
38
+ limit: number;
39
+ spent: number;
40
+ unit: string;
41
+ };
42
+ id: string;
43
+ startedAt: string;
44
+ status: string;
45
+ }>;
46
+ };
47
+ export type AgentControlConsoleData = {
48
+ decideApproval: (input: {
49
+ approvalId: string;
50
+ decision: "approve" | "deny";
51
+ operatorId: string;
52
+ reason: string;
53
+ }) => Promise<unknown>;
54
+ snapshot: (operator: Operator) => Promise<AgentControlSnapshot>;
55
+ };
56
+ export declare const sanitizeAgentControlSnapshot: (source: AgentControlSnapshot) => AgentControlSnapshot;
57
+ export declare const createAgentControlConsoleHandler: (options: {
58
+ authorize: (request: Request) => Promise<Operator | undefined> | Operator | undefined;
59
+ basePath?: string;
60
+ data: AgentControlConsoleData;
61
+ now?: () => number;
62
+ operations: OperationStore;
63
+ }) => (request: Request) => Promise<Response | null>;
package/dist/index.d.ts CHANGED
@@ -1,4 +1,6 @@
1
1
  import type { AgentControlPlane } from "@absolutejs/agency";
2
+ export * from "./console";
3
+ export * from "./playground";
2
4
  export type Operator = {
3
5
  id: string;
4
6
  scopes: readonly string[];
package/dist/index.js CHANGED
@@ -1,29 +1,319 @@
1
1
  // @bun
2
+ // src/console.ts
3
+ var hash = async (value) => [
4
+ ...new Uint8Array(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(JSON.stringify(value))))
5
+ ].map((byte) => byte.toString(16).padStart(2, "0")).join("");
6
+ var bounded = (value, field) => {
7
+ if (typeof value !== "string" || value.length > 2000)
8
+ throw new Error(`Invalid agent control snapshot ${field}`);
9
+ return value;
10
+ };
11
+ var boundedNumber = (value, field) => {
12
+ if (typeof value !== "number" || !Number.isFinite(value))
13
+ throw new Error(`Invalid agent control snapshot ${field}`);
14
+ return value;
15
+ };
16
+ var rows = (value, map) => {
17
+ if (!Array.isArray(value) || value.length > 1000)
18
+ throw new Error("Agent control snapshot collection is too large");
19
+ return value.map(map);
20
+ };
21
+ var sanitizeAgentControlSnapshot = (source) => ({
22
+ agents: rows(source.agents, (item) => ({
23
+ ...item.description === undefined ? {} : { description: bounded(item.description, "agent.description") },
24
+ id: bounded(item.id, "agent.id"),
25
+ status: bounded(item.status, "agent.status")
26
+ })),
27
+ approvals: rows(source.approvals, (item) => ({
28
+ action: bounded(item.action, "approval.action"),
29
+ agentId: bounded(item.agentId, "approval.agentId"),
30
+ id: bounded(item.id, "approval.id"),
31
+ requestedAt: bounded(item.requestedAt, "approval.requestedAt"),
32
+ ...item.risk === undefined ? {} : { risk: bounded(item.risk, "approval.risk") },
33
+ summary: bounded(item.summary, "approval.summary")
34
+ })),
35
+ delegations: rows(source.delegations, (item) => ({
36
+ ...item.expiresAt === undefined ? {} : { expiresAt: bounded(item.expiresAt, "delegation.expiresAt") },
37
+ from: bounded(item.from, "delegation.from"),
38
+ id: bounded(item.id, "delegation.id"),
39
+ scopes: rows(item.scopes, (scope) => bounded(scope, "delegation.scope")),
40
+ to: bounded(item.to, "delegation.to")
41
+ })),
42
+ memories: rows(source.memories, (item) => ({
43
+ agentId: bounded(item.agentId, "memory.agentId"),
44
+ id: bounded(item.id, "memory.id"),
45
+ scope: bounded(item.scope, "memory.scope"),
46
+ updatedAt: bounded(item.updatedAt, "memory.updatedAt")
47
+ })),
48
+ reputations: rows(source.reputations, (item) => ({
49
+ confidence: boundedNumber(item.confidence, "reputation.confidence"),
50
+ scope: bounded(item.scope, "reputation.scope"),
51
+ score: boundedNumber(item.score, "reputation.score"),
52
+ subject: bounded(item.subject, "reputation.subject")
53
+ })),
54
+ runs: rows(source.runs, (item) => ({
55
+ agentId: bounded(item.agentId, "run.agentId"),
56
+ ...item.budget === undefined ? {} : {
57
+ budget: {
58
+ limit: boundedNumber(item.budget.limit, "run.budget.limit"),
59
+ spent: boundedNumber(item.budget.spent, "run.budget.spent"),
60
+ unit: bounded(item.budget.unit, "run.budget.unit")
61
+ }
62
+ },
63
+ id: bounded(item.id, "run.id"),
64
+ startedAt: bounded(item.startedAt, "run.startedAt"),
65
+ status: bounded(item.status, "run.status")
66
+ }))
67
+ });
68
+ var page = (nonce) => `<!doctype html>
69
+ <html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
70
+ <title>AbsoluteJS Agent Control</title><style nonce="${nonce}">
71
+ :root{color-scheme:dark;font:15px/1.45 system-ui,sans-serif;background:#09090b;color:#fafafa}body{margin:0}header{padding:1.2rem 1.5rem;border-bottom:1px solid #27272a;display:flex;align-items:center;justify-content:space-between}main{padding:1.5rem;display:grid;gap:1.25rem}.status{color:#a1a1aa}.error{color:#fca5a5}section{background:#18181b;border:1px solid #27272a;border-radius:.75rem;overflow:auto}h2{font-size:1rem;margin:0;padding:1rem}table{border-collapse:collapse;width:100%;min-width:42rem}th,td{text-align:left;padding:.7rem 1rem;border-top:1px solid #27272a;vertical-align:top}th{color:#a1a1aa;font-size:.8rem;text-transform:uppercase}button{background:#27272a;color:#fff;border:1px solid #3f3f46;border-radius:.4rem;padding:.4rem .65rem;margin:.15rem;cursor:pointer}button.approve{background:#14532d}button.deny{background:#7f1d1d}code{font-size:.8rem} @media(max-width:700px){main{padding:.75rem}header{padding:1rem}}
72
+ </style></head><body><header><strong>AbsoluteJS Agent Control</strong><span id="status" class="status">Loading\u2026</span></header><main id="content"></main>
73
+ <script nonce="${nonce}">(()=>{const base=location.pathname.replace(//$/,"");const root=document.querySelector("#content"),status=document.querySelector("#status");const columns={agents:["id","status","description"],approvals:["id","agentId","action","summary","risk","requestedAt"],runs:["id","agentId","status","startedAt","budget"],delegations:["id","from","to","scopes","expiresAt"],memories:["id","agentId","scope","updatedAt"],reputations:["subject","scope","score","confidence"]};const text=v=>v==null?"":typeof v==="object"?JSON.stringify(v):String(v);function render(data){root.replaceChildren();for(const [name,fields] of Object.entries(columns)){const section=document.createElement("section"),heading=document.createElement("h2"),table=document.createElement("table"),head=document.createElement("tr");heading.textContent=name[0].toUpperCase()+name.slice(1);for(const field of fields){const th=document.createElement("th");th.textContent=field;head.append(th)}if(name==="approvals"){const th=document.createElement("th");th.textContent="Decision";head.append(th)}table.append(head);for(const item of data[name]??[]){const row=document.createElement("tr");for(const field of fields){const td=document.createElement("td");td.textContent=text(item[field]);row.append(td)}if(name==="approvals"){const td=document.createElement("td");for(const decision of ["approve","deny"]){const button=document.createElement("button");button.className=decision;button.textContent=decision;button.addEventListener("click",()=>decide(item.id,decision));td.append(button)}row.append(td)}table.append(row)}section.append(heading,table);root.append(section)}}async function load(){try{const response=await fetch(base+"/api/snapshot",{headers:{accept:"application/json"}});if(!response.ok)throw new Error("Snapshot failed: "+response.status);render(await response.json());status.textContent="Live";status.className="status"}catch(error){status.textContent=error.message;status.className="error"}}async function decide(id,decision){const reason=prompt("Reason for "+decision+":");if(!reason)return;status.textContent="Applying\u2026";const response=await fetch(base+"/api/approvals/"+encodeURIComponent(id)+"/"+decision,{method:"POST",headers:{"content-type":"application/json","idempotency-key":crypto.randomUUID(),"x-agent-control-intent":"mutate"},body:JSON.stringify({reason})});if(!response.ok){status.textContent="Decision failed: "+response.status;status.className="error";return}await load()}load()})();</script></body></html>`;
74
+ var createAgentControlConsoleHandler = (options) => {
75
+ const basePath = (options.basePath ?? "/agent-control").replace(/\/$/u, "");
76
+ const now = options.now ?? Date.now;
77
+ return async (request) => {
78
+ const url = new URL(request.url);
79
+ if (url.pathname !== basePath && !url.pathname.startsWith(`${basePath}/`))
80
+ return null;
81
+ const operator = await options.authorize(request);
82
+ if (!operator)
83
+ return new Response("Unauthorized", { status: 401 });
84
+ const required = request.method === "GET" ? "agents:read" : "agents:approve";
85
+ if (!operator.scopes.includes(required))
86
+ return new Response("Forbidden", { status: 403 });
87
+ if (request.method === "GET" && (url.pathname === basePath || url.pathname === `${basePath}/`)) {
88
+ const nonce = crypto.randomUUID().replaceAll("-", "");
89
+ return new Response(page(nonce), {
90
+ headers: {
91
+ "cache-control": "no-store",
92
+ "content-security-policy": `default-src 'none'; connect-src 'self'; img-src 'self'; style-src 'nonce-${nonce}'; script-src 'nonce-${nonce}'; base-uri 'none'; form-action 'self'; frame-ancestors 'none'`,
93
+ "content-type": "text/html; charset=utf-8",
94
+ "referrer-policy": "no-referrer",
95
+ "x-content-type-options": "nosniff"
96
+ }
97
+ });
98
+ }
99
+ if (request.method === "GET" && url.pathname === `${basePath}/api/snapshot`) {
100
+ return Response.json(sanitizeAgentControlSnapshot(await options.data.snapshot(operator)), {
101
+ headers: { "cache-control": "no-store" }
102
+ });
103
+ }
104
+ const match = new RegExp(`^${basePath.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&")}/api/approvals/([^/]+)/(approve|deny)$`, "u").exec(url.pathname);
105
+ if (!match?.[1] || !match[2] || request.method !== "POST")
106
+ return new Response(null, { status: 405 });
107
+ if (request.headers.get("origin") !== url.origin || request.headers.get("x-agent-control-intent") !== "mutate")
108
+ return new Response("Cross-site mutation denied", { status: 403 });
109
+ const operationId = request.headers.get("idempotency-key");
110
+ const body = await request.json().catch(() => {
111
+ return;
112
+ });
113
+ if (!operationId || !body?.reason || body.reason.length > 1000)
114
+ return Response.json({ error: "Idempotency-Key and a bounded reason are required" }, { status: 400 });
115
+ const input = {
116
+ approvalId: decodeURIComponent(match[1]),
117
+ decision: match[2],
118
+ operatorId: operator.id,
119
+ reason: body.reason
120
+ };
121
+ const digest = await hash(input);
122
+ let claim;
123
+ try {
124
+ claim = await options.operations.claim(operationId, digest, now(), 30000);
125
+ } catch (error) {
126
+ return Response.json({ error: error instanceof Error ? error.message : "conflict" }, { status: 409 });
127
+ }
128
+ if (claim.response !== undefined)
129
+ return Response.json(claim.response);
130
+ if (!claim.acquired)
131
+ return Response.json({ error: "operation in progress" }, { status: 409, headers: { "retry-after": "5" } });
132
+ const response = await options.data.decideApproval(input);
133
+ if (!await options.operations.complete(operationId, digest, response))
134
+ throw new Error("Approval operation completion lost");
135
+ return Response.json(response);
136
+ };
137
+ };
138
+ // src/playground.ts
139
+ var createMemoryAgentPlaygroundPlanStore = (now = Date.now) => {
140
+ const plans = new Map;
141
+ return {
142
+ get: async (id, operatorId) => {
143
+ const plan = plans.get(id);
144
+ if (!plan || plan.operatorId !== operatorId || plan.expiresAt <= now())
145
+ return;
146
+ return structuredClone(plan);
147
+ },
148
+ save: async (plan) => {
149
+ for (const [id, existing] of plans)
150
+ if (existing.expiresAt <= now())
151
+ plans.delete(id);
152
+ if (plans.has(plan.planId))
153
+ throw new Error("Playground plan already exists");
154
+ plans.set(plan.planId, structuredClone(plan));
155
+ }
156
+ };
157
+ };
158
+ var digest = async (value) => [
159
+ ...new Uint8Array(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(JSON.stringify(value))))
160
+ ].map((byte) => byte.toString(16).padStart(2, "0")).join("");
161
+ var text = (value, field, maximum = 2000) => {
162
+ if (typeof value !== "string" || value === "" || value.length > maximum)
163
+ throw new Error(`Invalid playground ${field}`);
164
+ return value;
165
+ };
166
+ var list = (values, map) => {
167
+ if (!Array.isArray(values) || values.length > 1000)
168
+ throw new Error("Playground collection is too large");
169
+ return values.map(map);
170
+ };
171
+ var safeCatalog = (items) => list(items, (item) => ({
172
+ ...item.description === undefined ? {} : { description: text(item.description, "description") },
173
+ id: text(item.id, "agent id", 256),
174
+ name: text(item.name, "agent name", 256),
175
+ operations: list(item.operations, (value) => text(value, "operation", 256)),
176
+ protocols: list(item.protocols, (value) => text(value, "protocol", 128))
177
+ }));
178
+ var page2 = (nonce) => `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>AbsoluteJS Agent Playground</title><style nonce="${nonce}">:root{color-scheme:dark;font:15px/1.5 system-ui;background:#09090b;color:#fafafa}body{max-width:70rem;margin:auto;padding:2rem}label{display:block;margin:1rem 0}.grid{display:grid;grid-template-columns:1fr 1fr;gap:1rem}select,textarea,button{font:inherit;background:#18181b;color:#fff;border:1px solid #3f3f46;border-radius:.5rem;padding:.7rem;width:100%;box-sizing:border-box}textarea{min-height:14rem;font-family:ui-monospace,monospace}button{cursor:pointer;margin:.4rem 0}.execute{background:#14532d}pre{white-space:pre-wrap;background:#18181b;padding:1rem;border-radius:.5rem}.error{color:#fca5a5}@media(max-width:700px){.grid{grid-template-columns:1fr}}</style></head><body><h1>Agent Playground</h1><p>Plan first. Review exact effects. Execute only a bound server-side plan.</p><div class="grid"><div><label>Agent<select id="agent"></select></label><label>Operation<select id="operation"></select></label><label>JSON input<textarea id="input">{}</textarea></label><button id="plan">Create effect-free plan</button><button id="execute" class="execute" disabled>Execute reviewed plan</button></div><div><h2>Bound plan</h2><pre id="output">No plan yet.</pre></div></div><script nonce="${nonce}">(()=>{const base=location.pathname.replace(//$/,""),agent=document.querySelector("#agent"),operation=document.querySelector("#operation"),input=document.querySelector("#input"),output=document.querySelector("#output"),execute=document.querySelector("#execute");let plan;const headers={"content-type":"application/json","x-agent-control-intent":"mutate"};async function catalog(){const data=await fetch(base+"/api/catalog").then(r=>r.json());for(const item of data){const option=document.createElement("option");option.value=item.id;option.textContent=item.name;option.dataset.operations=JSON.stringify(item.operations);agent.append(option)}changed()}function changed(){operation.replaceChildren();for(const value of JSON.parse(agent.selectedOptions[0]?.dataset.operations??"[]")){const option=document.createElement("option");option.value=value;option.textContent=value;operation.append(option)}}agent.addEventListener("change",changed);document.querySelector("#plan").addEventListener("click",async()=>{try{const response=await fetch(base+"/api/plans",{method:"POST",headers,body:JSON.stringify({agentId:agent.value,operation:operation.value,input:JSON.parse(input.value)})});const data=await response.json();if(!response.ok)throw new Error(data.error??("HTTP "+response.status));plan=data;output.textContent=JSON.stringify(data,null,2);execute.disabled=data.status!=="ready"}catch(error){output.textContent=error.message;output.className="error"}});execute.addEventListener("click",async()=>{if(!plan)return;execute.disabled=true;const response=await fetch(base+"/api/plans/"+encodeURIComponent(plan.planId)+"/execute",{method:"POST",headers:{...headers,"idempotency-key":crypto.randomUUID()},body:"{}"});output.textContent=JSON.stringify(await response.json(),null,2)});catalog()})();</script></body></html>`;
179
+ var createAgentPlaygroundHandler = (options) => {
180
+ const base = (options.basePath ?? "/agent-playground").replace(/\/$/u, "");
181
+ const now = options.now ?? Date.now;
182
+ const mutationAllowed = (request, url) => request.headers.get("origin") === url.origin && request.headers.get("x-agent-control-intent") === "mutate";
183
+ return async (request) => {
184
+ const url = new URL(request.url);
185
+ if (url.pathname !== base && !url.pathname.startsWith(`${base}/`))
186
+ return null;
187
+ const operator = await options.authorize(request);
188
+ if (!operator)
189
+ return new Response("Unauthorized", { status: 401 });
190
+ const scope = request.method === "GET" ? "agents:read" : url.pathname.endsWith("/execute") ? "agents:execute" : "agents:simulate";
191
+ if (!operator.scopes.includes(scope))
192
+ return new Response("Forbidden", { status: 403 });
193
+ if (request.method === "GET" && (url.pathname === base || url.pathname === `${base}/`)) {
194
+ const nonce = crypto.randomUUID().replaceAll("-", "");
195
+ return new Response(page2(nonce), {
196
+ headers: {
197
+ "cache-control": "no-store",
198
+ "content-security-policy": `default-src 'none'; connect-src 'self'; style-src 'nonce-${nonce}'; script-src 'nonce-${nonce}'; base-uri 'none'; frame-ancestors 'none'`,
199
+ "content-type": "text/html; charset=utf-8",
200
+ "x-content-type-options": "nosniff"
201
+ }
202
+ });
203
+ }
204
+ if (request.method === "GET" && url.pathname === `${base}/api/catalog`)
205
+ return Response.json(safeCatalog(await options.adapter.catalog(operator)), {
206
+ headers: { "cache-control": "no-store" }
207
+ });
208
+ if (request.method === "POST" && url.pathname === `${base}/api/plans`) {
209
+ if (!mutationAllowed(request, url))
210
+ return new Response("Cross-site mutation denied", { status: 403 });
211
+ const raw = await request.text();
212
+ if (new TextEncoder().encode(raw).byteLength > 256000)
213
+ return Response.json({ error: "Input is too large" }, { status: 413 });
214
+ let body;
215
+ try {
216
+ body = JSON.parse(raw);
217
+ } catch {
218
+ return Response.json({ error: "Input must be valid JSON" }, { status: 400 });
219
+ }
220
+ const agentId = text(body.agentId, "agent id", 256);
221
+ const operation = text(body.operation, "operation", 256);
222
+ const inputDigest = await digest(body.input ?? null);
223
+ const proposed = await options.adapter.plan({ agentId, input: structuredClone(body.input ?? null), operation }, operator);
224
+ if (proposed.status !== "ready" && proposed.status !== "approval-required" && proposed.status !== "denied")
225
+ throw new Error("Invalid playground plan status");
226
+ if (proposed.spend && (!Number.isSafeInteger(proposed.spend.amountMinor) || proposed.spend.amountMinor < 0 || !/^[A-Z]{3}$/u.test(proposed.spend.currency)))
227
+ throw new Error("Invalid playground plan spend");
228
+ const plan2 = {
229
+ agentId,
230
+ effects: list(proposed.effects, (value) => text(value, "effect", 128)),
231
+ expiresAt: now() + (options.planTtlMs ?? 10 * 60000),
232
+ input: structuredClone(body.input ?? null),
233
+ inputDigest,
234
+ operation,
235
+ operatorId: operator.id,
236
+ planId: `plan_${crypto.randomUUID()}`,
237
+ ...proposed.spend ? {
238
+ spend: {
239
+ amountMinor: proposed.spend.amountMinor,
240
+ currency: text(proposed.spend.currency, "currency", 3)
241
+ }
242
+ } : {},
243
+ status: proposed.status,
244
+ steps: list(proposed.steps, (step) => ({
245
+ description: text(step.description, "step description"),
246
+ ...step.protocol ? { protocol: text(step.protocol, "protocol", 128) } : {},
247
+ ...step.target ? { target: text(step.target, "target") } : {}
248
+ })),
249
+ summary: text(proposed.summary, "summary")
250
+ };
251
+ await options.plans.save(plan2);
252
+ const { input: _input, ...visible } = plan2;
253
+ return Response.json(visible, {
254
+ status: 201,
255
+ headers: { "cache-control": "no-store" }
256
+ });
257
+ }
258
+ const execute = new RegExp(`^${base.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&")}/api/plans/([^/]+)/execute$`, "u").exec(url.pathname);
259
+ if (request.method !== "POST" || !execute?.[1])
260
+ return new Response(null, { status: 405 });
261
+ if (!mutationAllowed(request, url))
262
+ return new Response("Cross-site mutation denied", { status: 403 });
263
+ const operationId = request.headers.get("idempotency-key");
264
+ if (!operationId)
265
+ return Response.json({ error: "Idempotency-Key is required" }, { status: 400 });
266
+ const plan = await options.plans.get(decodeURIComponent(execute[1]), operator.id);
267
+ if (!plan)
268
+ return Response.json({ error: "Plan not found or expired" }, { status: 404 });
269
+ if (plan.status !== "ready")
270
+ return Response.json({ error: `Plan is ${plan.status}` }, { status: 409 });
271
+ const binding = await digest({
272
+ inputDigest: plan.inputDigest,
273
+ planId: plan.planId
274
+ });
275
+ let claim;
276
+ try {
277
+ claim = await options.operations.claim(`playground:${plan.planId}`, binding, now(), 30000);
278
+ } catch (error) {
279
+ return Response.json({ error: error instanceof Error ? error.message : "conflict" }, { status: 409 });
280
+ }
281
+ if (claim.response !== undefined)
282
+ return Response.json(claim.response);
283
+ if (!claim.acquired)
284
+ return Response.json({ error: "operation in progress" }, { status: 409, headers: { "retry-after": "5" } });
285
+ const result = await options.adapter.execute(structuredClone(plan), operator);
286
+ if (!await options.operations.complete(`playground:${plan.planId}`, binding, result))
287
+ throw new Error("Playground execution completion lost");
288
+ return Response.json(result);
289
+ };
290
+ };
291
+
2
292
  // src/index.ts
3
293
  var createMemoryOperationStore = () => {
4
- const rows = new Map;
294
+ const rows2 = new Map;
5
295
  return {
6
- claim: async (key, digest, now, leaseMs) => {
7
- const row = rows.get(key);
8
- if (row?.digest !== undefined && row.digest !== digest)
296
+ claim: async (key, digest2, now, leaseMs) => {
297
+ const row = rows2.get(key);
298
+ if (row?.digest !== undefined && row.digest !== digest2)
9
299
  throw new Error("Idempotency key reused with different input");
10
300
  if (row?.response !== undefined)
11
301
  return { acquired: false, response: structuredClone(row.response) };
12
302
  if (row && row.leaseUntil > now)
13
303
  return { acquired: false };
14
- rows.set(key, { digest, key, leaseUntil: now + leaseMs });
304
+ rows2.set(key, { digest: digest2, key, leaseUntil: now + leaseMs });
15
305
  return { acquired: true };
16
306
  },
17
- complete: async (key, digest, response) => {
18
- const row = rows.get(key);
19
- if (!row || row.digest !== digest)
307
+ complete: async (key, digest2, response) => {
308
+ const row = rows2.get(key);
309
+ if (!row || row.digest !== digest2)
20
310
  return false;
21
- rows.set(key, { ...row, response: structuredClone(response) });
311
+ rows2.set(key, { ...row, response: structuredClone(response) });
22
312
  return true;
23
313
  }
24
314
  };
25
315
  };
26
- var hash = async (value) => [
316
+ var hash2 = async (value) => [
27
317
  ...new Uint8Array(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(JSON.stringify(value))))
28
318
  ].map((byte) => byte.toString(16).padStart(2, "0")).join("");
29
319
  var createAgentControlHandler = ({
@@ -54,10 +344,10 @@ var createAgentControlHandler = ({
54
344
  });
55
345
  if (!body?.operationId || !body.reason)
56
346
  return Response.json({ error: "operationId and reason are required" }, { status: 400 });
57
- const digest = await hash({ action, agentId, reason: body.reason });
347
+ const digest2 = await hash2({ action, agentId, reason: body.reason });
58
348
  let claim;
59
349
  try {
60
- claim = await operations.claim(body.operationId, digest, now(), 30000);
350
+ claim = await operations.claim(body.operationId, digest2, now(), 30000);
61
351
  } catch (error) {
62
352
  return Response.json({ error: error instanceof Error ? error.message : "conflict" }, { status: 409 });
63
353
  }
@@ -70,7 +360,7 @@ var createAgentControlHandler = ({
70
360
  agentId,
71
361
  reason: body.reason
72
362
  }) : (await control.restore(agentId), { agentId, restored: true, restoredBy: operator.id });
73
- if (!await operations.complete(body.operationId, digest, response))
363
+ if (!await operations.complete(body.operationId, digest2, response))
74
364
  throw new Error("Control operation completion lost");
75
365
  return Response.json(response);
76
366
  };
@@ -89,12 +379,12 @@ var createPostgresOperationStore = ({
89
379
  }) => {
90
380
  const ns = nsOf(namespace);
91
381
  return {
92
- claim: async (key, digest, now, leaseMs) => {
93
- const result = await client.query(`INSERT INTO ${ns}.operations (operation_key,digest,lease_until) VALUES ($1,$2,$3) ON CONFLICT (operation_key) DO UPDATE SET lease_until=$3 WHERE ${ns}.operations.digest=$2 AND ${ns}.operations.response IS NULL AND ${ns}.operations.lease_until <= $4 RETURNING digest,response`, [key, digest, now + leaseMs, now]);
382
+ claim: async (key, digest2, now, leaseMs) => {
383
+ const result = await client.query(`INSERT INTO ${ns}.operations (operation_key,digest,lease_until) VALUES ($1,$2,$3) ON CONFLICT (operation_key) DO UPDATE SET lease_until=$3 WHERE ${ns}.operations.digest=$2 AND ${ns}.operations.response IS NULL AND ${ns}.operations.lease_until <= $4 RETURNING digest,response`, [key, digest2, now + leaseMs, now]);
94
384
  const row = result.rows[0];
95
385
  if (!row) {
96
386
  const existing = (await client.query(`SELECT digest,response FROM ${ns}.operations WHERE operation_key=$1`, [key])).rows[0];
97
- if (existing?.digest !== undefined && existing.digest !== digest)
387
+ if (existing?.digest !== undefined && existing.digest !== digest2)
98
388
  throw new Error("Idempotency key reused with different input");
99
389
  return {
100
390
  acquired: false,
@@ -103,12 +393,16 @@ var createPostgresOperationStore = ({
103
393
  }
104
394
  return { acquired: row.response === null || row.response === undefined };
105
395
  },
106
- complete: async (key, digest, response) => (await client.query(`UPDATE ${ns}.operations SET response=$3::jsonb WHERE operation_key=$1 AND digest=$2 AND response IS NULL RETURNING operation_key`, [key, digest, JSON.stringify(response)])).rows.length === 1
396
+ complete: async (key, digest2, response) => (await client.query(`UPDATE ${ns}.operations SET response=$3::jsonb WHERE operation_key=$1 AND digest=$2 AND response IS NULL RETURNING operation_key`, [key, digest2, JSON.stringify(response)])).rows.length === 1
107
397
  };
108
398
  };
109
399
  export {
400
+ sanitizeAgentControlSnapshot,
110
401
  createPostgresOperationStore,
111
402
  createMemoryOperationStore,
403
+ createMemoryAgentPlaygroundPlanStore,
404
+ createAgentPlaygroundHandler,
112
405
  createAgentControlHandler,
406
+ createAgentControlConsoleHandler,
113
407
  agentControlPostgresSchemaSql
114
408
  };
package/dist/manifest.js CHANGED
@@ -5901,6 +5901,14 @@ var serializedTool = Type.Object({
5901
5901
  });
5902
5902
  var manifestSchema = Type.Object({
5903
5903
  contract: Type.Union([Type.Literal(1), Type.Literal(2)]),
5904
+ discovery: Type.Optional(Type.Object({
5905
+ audiences: Type.Optional(Type.Array(Type.String({ minLength: 1 }))),
5906
+ certificationUrl: Type.Optional(Type.String()),
5907
+ intents: Type.Optional(Type.Array(Type.String({ minLength: 1 }))),
5908
+ keywords: Type.Optional(Type.Array(Type.String({ minLength: 1 }))),
5909
+ protocols: Type.Optional(Type.Array(Type.String({ minLength: 1 }))),
5910
+ url: Type.Optional(Type.String())
5911
+ })),
5904
5912
  identity: Type.Object({
5905
5913
  accent: Type.Optional(Type.String({ pattern: "^#[0-9a-fA-F]{3,8}$" })),
5906
5914
  category: Type.String({ minLength: 1 }),
@@ -5935,7 +5943,7 @@ var manifest = defineManifest()({
5935
5943
  identity: {
5936
5944
  accent: "#dc2626",
5937
5945
  category: "security",
5938
- description: "Authenticated operator API for agent inventory, durable kill switches, source revocation, restoration, and leased idempotent operations.",
5946
+ description: "Authenticated operator API, CSP-hardened console, and bound plan-then-execute playground for approvals, runs, budgets, delegations, memory metadata, reputation, durable kill switches, and leased idempotent operations.",
5939
5947
  docsUrl: "https://github.com/absolutejs/agent-control",
5940
5948
  name: "@absolutejs/agent-control",
5941
5949
  tagline: "See and stop every capability an agent holds."
@@ -3,7 +3,7 @@
3
3
  "identity": {
4
4
  "accent": "#dc2626",
5
5
  "category": "security",
6
- "description": "Authenticated operator API for agent inventory, durable kill switches, source revocation, restoration, and leased idempotent operations.",
6
+ "description": "Authenticated operator API, CSP-hardened console, and bound plan-then-execute playground for approvals, runs, budgets, delegations, memory metadata, reputation, durable kill switches, and leased idempotent operations.",
7
7
  "docsUrl": "https://github.com/absolutejs/agent-control",
8
8
  "name": "@absolutejs/agent-control",
9
9
  "tagline": "See and stop every capability an agent holds."
@@ -0,0 +1,53 @@
1
+ import type { OperationStore, Operator } from "./index";
2
+ export type AgentPlaygroundCatalogItem = {
3
+ description?: string;
4
+ id: string;
5
+ name: string;
6
+ operations: string[];
7
+ protocols: string[];
8
+ };
9
+ export type AgentPlaygroundPlan = {
10
+ agentId: string;
11
+ effects: string[];
12
+ expiresAt: number;
13
+ input: unknown;
14
+ inputDigest: string;
15
+ operation: string;
16
+ operatorId: string;
17
+ planId: string;
18
+ spend?: {
19
+ amountMinor: number;
20
+ currency: string;
21
+ };
22
+ status: "ready" | "approval-required" | "denied";
23
+ steps: Array<{
24
+ description: string;
25
+ protocol?: string;
26
+ target?: string;
27
+ }>;
28
+ summary: string;
29
+ };
30
+ export type AgentPlaygroundPlanStore = {
31
+ get: (planId: string, operatorId: string) => Promise<AgentPlaygroundPlan | undefined>;
32
+ save: (plan: AgentPlaygroundPlan) => Promise<void>;
33
+ };
34
+ export type AgentPlaygroundAdapter = {
35
+ catalog: (operator: Operator) => Promise<AgentPlaygroundCatalogItem[]> | AgentPlaygroundCatalogItem[];
36
+ /** Must not produce external effects. Return only a proposed plan. */
37
+ plan: (request: {
38
+ agentId: string;
39
+ input: unknown;
40
+ operation: string;
41
+ }, operator: Operator) => Promise<Omit<AgentPlaygroundPlan, "agentId" | "expiresAt" | "input" | "inputDigest" | "operation" | "operatorId" | "planId">> | Omit<AgentPlaygroundPlan, "agentId" | "expiresAt" | "input" | "inputDigest" | "operation" | "operatorId" | "planId">;
42
+ execute: (plan: AgentPlaygroundPlan, operator: Operator) => Promise<unknown>;
43
+ };
44
+ export declare const createMemoryAgentPlaygroundPlanStore: (now?: () => number) => AgentPlaygroundPlanStore;
45
+ export declare const createAgentPlaygroundHandler: (options: {
46
+ adapter: AgentPlaygroundAdapter;
47
+ authorize: (request: Request) => Promise<Operator | undefined> | Operator | undefined;
48
+ basePath?: string;
49
+ now?: () => number;
50
+ operations: OperationStore;
51
+ plans: AgentPlaygroundPlanStore;
52
+ planTtlMs?: number;
53
+ }) => (request: Request) => Promise<Response | null>;
package/package.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@absolutejs/agent-control",
3
- "version": "0.1.0",
4
- "description": "Authenticated operator API for AI agent inventory, kill switches, revocation, and restoration.",
3
+ "version": "0.3.0",
4
+ "description": "Authenticated AI agent operator API, console, and bound plan-then-execute playground.",
5
5
  "type": "module",
6
6
  "license": "MIT",
7
7
  "repository": {
8
8
  "type": "git",
9
- "url": "https://github.com/absolutejs/agent-control.git"
9
+ "url": "git+https://github.com/absolutejs/agent-control.git"
10
10
  },
11
11
  "publishConfig": {
12
12
  "access": "public"
@@ -41,7 +41,7 @@
41
41
  },
42
42
  "dependencies": {
43
43
  "@absolutejs/agency": "^0.4.0",
44
- "@absolutejs/manifest": "^0.2.0",
44
+ "@absolutejs/manifest": "^0.3.0",
45
45
  "@sinclair/typebox": "^0.34.0"
46
46
  },
47
47
  "devDependencies": {