@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 +31 -0
- package/dist/console.d.ts +63 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +311 -17
- package/dist/manifest.js +9 -1
- package/dist/manifest.json +1 -1
- package/dist/playground.d.ts +53 -0
- package/package.json +4 -4
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
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
|
|
294
|
+
const rows2 = new Map;
|
|
5
295
|
return {
|
|
6
|
-
claim: async (key,
|
|
7
|
-
const row =
|
|
8
|
-
if (row?.digest !== undefined && row.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
|
-
|
|
304
|
+
rows2.set(key, { digest: digest2, key, leaseUntil: now + leaseMs });
|
|
15
305
|
return { acquired: true };
|
|
16
306
|
},
|
|
17
|
-
complete: async (key,
|
|
18
|
-
const row =
|
|
19
|
-
if (!row || row.digest !==
|
|
307
|
+
complete: async (key, digest2, response) => {
|
|
308
|
+
const row = rows2.get(key);
|
|
309
|
+
if (!row || row.digest !== digest2)
|
|
20
310
|
return false;
|
|
21
|
-
|
|
311
|
+
rows2.set(key, { ...row, response: structuredClone(response) });
|
|
22
312
|
return true;
|
|
23
313
|
}
|
|
24
314
|
};
|
|
25
315
|
};
|
|
26
|
-
var
|
|
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
|
|
347
|
+
const digest2 = await hash2({ action, agentId, reason: body.reason });
|
|
58
348
|
let claim;
|
|
59
349
|
try {
|
|
60
|
-
claim = await operations.claim(body.operationId,
|
|
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,
|
|
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,
|
|
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,
|
|
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 !==
|
|
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,
|
|
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
|
|
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."
|
package/dist/manifest.json
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
"identity": {
|
|
4
4
|
"accent": "#dc2626",
|
|
5
5
|
"category": "security",
|
|
6
|
-
"description": "Authenticated operator API for
|
|
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.
|
|
4
|
-
"description": "Authenticated
|
|
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.
|
|
44
|
+
"@absolutejs/manifest": "^0.3.0",
|
|
45
45
|
"@sinclair/typebox": "^0.34.0"
|
|
46
46
|
},
|
|
47
47
|
"devDependencies": {
|