@absolutejs/agent-control 0.2.0 → 0.4.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
@@ -6,7 +6,8 @@ before cleanup, reports partial source failures, restores deliberately, and uses
6
6
  leased idempotency records so operator retries cannot change the requested input.
7
7
 
8
8
  Scopes are `agents:read`, `agents:revoke`, and `agents:restore`. Mutations require
9
- an `operationId` and `reason`; PostgreSQL operations can be safely reclaimed
9
+ an `operationId`, bounded `reason`, same-origin `Origin` header, and
10
+ `X-Agent-Control-Intent: mutate`; PostgreSQL operations can be safely reclaimed
10
11
  after a crashed operator process's lease expires.
11
12
 
12
13
  `createAgentControlConsoleHandler()` adds a dependency-free authenticated
@@ -32,3 +33,10 @@ all remote data with DOM `textContent`, cannot be framed, stores no cache, and
32
33
  has no third-party assets. The data adapter keeps it provider-neutral and lets
33
34
  applications compose `@absolutejs/agency`, `agent-runtime`, `agent-memory`, and
34
35
  `agent-reputation` without creating hard dependencies.
36
+
37
+ `createAgentPlaygroundHandler()` adds a provider-neutral plan-then-execute UI
38
+ and API. Planning is a separate, explicitly effect-free adapter operation. The
39
+ server stores the full input and only returns its digest, expires plans quickly,
40
+ binds them to the operator, refuses denied or approval-pending plans, and uses
41
+ the same leased idempotency store for execution. The browser cannot mutate a
42
+ 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;
@@ -186,15 +339,24 @@ var createAgentControlHandler = ({
186
339
  return Response.json(await control.inventory(agentId));
187
340
  if (request.method !== "POST" || action !== "revoke" && action !== "restore")
188
341
  return new Response(null, { status: 405 });
189
- const body = await request.json().catch(() => {
190
- return;
191
- });
192
- if (!body?.operationId || !body.reason)
193
- return Response.json({ error: "operationId and reason are required" }, { status: 400 });
194
- const digest = await hash2({ action, agentId, reason: body.reason });
342
+ if (request.headers.get("origin") !== url.origin || request.headers.get("x-agent-control-intent") !== "mutate")
343
+ return new Response("Cross-site mutation denied", { status: 403 });
344
+ const raw = await request.text();
345
+ if (new TextEncoder().encode(raw).byteLength > 4096)
346
+ return Response.json({ error: "Request body is too large" }, { status: 413 });
347
+ const body = (() => {
348
+ try {
349
+ return JSON.parse(raw);
350
+ } catch {
351
+ return;
352
+ }
353
+ })();
354
+ if (!body?.operationId || body.operationId.length > 200 || !body.reason || body.reason.length > 1000)
355
+ return Response.json({ error: "A bounded operationId and reason are required" }, { status: 400 });
356
+ const digest2 = await hash2({ action, agentId, reason: body.reason });
195
357
  let claim;
196
358
  try {
197
- claim = await operations.claim(body.operationId, digest, now(), 30000);
359
+ claim = await operations.claim(body.operationId, digest2, now(), 30000);
198
360
  } catch (error) {
199
361
  return Response.json({ error: error instanceof Error ? error.message : "conflict" }, { status: 409 });
200
362
  }
@@ -207,7 +369,7 @@ var createAgentControlHandler = ({
207
369
  agentId,
208
370
  reason: body.reason
209
371
  }) : (await control.restore(agentId), { agentId, restored: true, restoredBy: operator.id });
210
- if (!await operations.complete(body.operationId, digest, response))
372
+ if (!await operations.complete(body.operationId, digest2, response))
211
373
  throw new Error("Control operation completion lost");
212
374
  return Response.json(response);
213
375
  };
@@ -226,12 +388,12 @@ var createPostgresOperationStore = ({
226
388
  }) => {
227
389
  const ns = nsOf(namespace);
228
390
  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]);
391
+ claim: async (key, digest2, now, leaseMs) => {
392
+ 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
393
  const row = result.rows[0];
232
394
  if (!row) {
233
395
  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)
396
+ if (existing?.digest !== undefined && existing.digest !== digest2)
235
397
  throw new Error("Idempotency key reused with different input");
236
398
  return {
237
399
  acquired: false,
@@ -240,13 +402,15 @@ var createPostgresOperationStore = ({
240
402
  }
241
403
  return { acquired: row.response === null || row.response === undefined };
242
404
  },
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
405
+ 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
406
  };
245
407
  };
246
408
  export {
247
409
  sanitizeAgentControlSnapshot,
248
410
  createPostgresOperationStore,
249
411
  createMemoryOperationStore,
412
+ createMemoryAgentPlaygroundPlanStore,
413
+ createAgentPlaygroundHandler,
250
414
  createAgentControlHandler,
251
415
  createAgentControlConsoleHandler,
252
416
  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.4.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": {
@@ -40,7 +40,7 @@
40
40
  "check:package": "bun run format && bun run typecheck && bun run test && bun run build"
41
41
  },
42
42
  "dependencies": {
43
- "@absolutejs/agency": "^0.4.0",
43
+ "@absolutejs/agency": "^0.5.0",
44
44
  "@absolutejs/manifest": "^0.3.0",
45
45
  "@sinclair/typebox": "^0.34.0"
46
46
  },