@absolutejs/agent-control 0.2.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
@@ -32,3 +32,10 @@ all remote data with DOM `textContent`, cannot be framed, stores no cache, and
32
32
  has no third-party assets. The data adapter keeps it provider-neutral and lets
33
33
  applications compose `@absolutejs/agency`, `agent-runtime`, `agent-memory`, and
34
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.
package/dist/index.d.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import type { AgentControlPlane } from "@absolutejs/agency";
2
2
  export * from "./console";
3
+ export * from "./playground";
3
4
  export type Operator = {
4
5
  id: string;
5
6
  scopes: readonly string[];
package/dist/index.js CHANGED
@@ -135,25 +135,178 @@ var createAgentControlConsoleHandler = (options) => {
135
135
  return Response.json(response);
136
136
  };
137
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
+ };
138
291
 
139
292
  // src/index.ts
140
293
  var createMemoryOperationStore = () => {
141
294
  const rows2 = new Map;
142
295
  return {
143
- claim: async (key, digest, now, leaseMs) => {
296
+ claim: async (key, digest2, now, leaseMs) => {
144
297
  const row = rows2.get(key);
145
- if (row?.digest !== undefined && row.digest !== digest)
298
+ if (row?.digest !== undefined && row.digest !== digest2)
146
299
  throw new Error("Idempotency key reused with different input");
147
300
  if (row?.response !== undefined)
148
301
  return { acquired: false, response: structuredClone(row.response) };
149
302
  if (row && row.leaseUntil > now)
150
303
  return { acquired: false };
151
- rows2.set(key, { digest, key, leaseUntil: now + leaseMs });
304
+ rows2.set(key, { digest: digest2, key, leaseUntil: now + leaseMs });
152
305
  return { acquired: true };
153
306
  },
154
- complete: async (key, digest, response) => {
307
+ complete: async (key, digest2, response) => {
155
308
  const row = rows2.get(key);
156
- if (!row || row.digest !== digest)
309
+ if (!row || row.digest !== digest2)
157
310
  return false;
158
311
  rows2.set(key, { ...row, response: structuredClone(response) });
159
312
  return true;
@@ -191,10 +344,10 @@ var createAgentControlHandler = ({
191
344
  });
192
345
  if (!body?.operationId || !body.reason)
193
346
  return Response.json({ error: "operationId and reason are required" }, { status: 400 });
194
- const digest = await hash2({ action, agentId, reason: body.reason });
347
+ const digest2 = await hash2({ action, agentId, reason: body.reason });
195
348
  let claim;
196
349
  try {
197
- claim = await operations.claim(body.operationId, digest, now(), 30000);
350
+ claim = await operations.claim(body.operationId, digest2, now(), 30000);
198
351
  } catch (error) {
199
352
  return Response.json({ error: error instanceof Error ? error.message : "conflict" }, { status: 409 });
200
353
  }
@@ -207,7 +360,7 @@ var createAgentControlHandler = ({
207
360
  agentId,
208
361
  reason: body.reason
209
362
  }) : (await control.restore(agentId), { agentId, restored: true, restoredBy: operator.id });
210
- if (!await operations.complete(body.operationId, digest, response))
363
+ if (!await operations.complete(body.operationId, digest2, response))
211
364
  throw new Error("Control operation completion lost");
212
365
  return Response.json(response);
213
366
  };
@@ -226,12 +379,12 @@ var createPostgresOperationStore = ({
226
379
  }) => {
227
380
  const ns = nsOf(namespace);
228
381
  return {
229
- claim: async (key, digest, now, leaseMs) => {
230
- 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]);
231
384
  const row = result.rows[0];
232
385
  if (!row) {
233
386
  const existing = (await client.query(`SELECT digest,response FROM ${ns}.operations WHERE operation_key=$1`, [key])).rows[0];
234
- if (existing?.digest !== undefined && existing.digest !== digest)
387
+ if (existing?.digest !== undefined && existing.digest !== digest2)
235
388
  throw new Error("Idempotency key reused with different input");
236
389
  return {
237
390
  acquired: false,
@@ -240,13 +393,15 @@ var createPostgresOperationStore = ({
240
393
  }
241
394
  return { acquired: row.response === null || row.response === undefined };
242
395
  },
243
- 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
244
397
  };
245
398
  };
246
399
  export {
247
400
  sanitizeAgentControlSnapshot,
248
401
  createPostgresOperationStore,
249
402
  createMemoryOperationStore,
403
+ createMemoryAgentPlaygroundPlanStore,
404
+ createAgentPlaygroundHandler,
250
405
  createAgentControlHandler,
251
406
  createAgentControlConsoleHandler,
252
407
  agentControlPostgresSchemaSql
package/dist/manifest.js CHANGED
@@ -5943,7 +5943,7 @@ var manifest = defineManifest()({
5943
5943
  identity: {
5944
5944
  accent: "#dc2626",
5945
5945
  category: "security",
5946
- description: "Authenticated operator API and CSP-hardened console for agent inventory, approvals, runs, budgets, delegations, memory metadata, reputation, durable kill switches, 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.",
5947
5947
  docsUrl: "https://github.com/absolutejs/agent-control",
5948
5948
  name: "@absolutejs/agent-control",
5949
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 and CSP-hardened console for agent inventory, approvals, runs, budgets, delegations, memory metadata, reputation, durable kill switches, 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,7 +1,7 @@
1
1
  {
2
2
  "name": "@absolutejs/agent-control",
3
- "version": "0.2.0",
4
- "description": "Authenticated AI agent operator API and console for approvals, runs, delegations, memory, reputation, and kill switches.",
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": {