@absolutejs/agent-control 0.1.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/LICENSE +3 -0
- package/README.md +10 -0
- package/dist/index.d.ts +37 -0
- package/dist/index.js +114 -0
- package/dist/manifest.d.ts +1 -0
- package/dist/manifest.js +5948 -0
- package/dist/manifest.json +16 -0
- package/package.json +52 -0
package/LICENSE
ADDED
package/README.md
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# @absolutejs/agent-control
|
|
2
|
+
|
|
3
|
+
Authenticated web-standard operator API over `@absolutejs/agency`'s control
|
|
4
|
+
plane. It inventories every registered source, activates the durable kill switch
|
|
5
|
+
before cleanup, reports partial source failures, restores deliberately, and uses
|
|
6
|
+
leased idempotency records so operator retries cannot change the requested input.
|
|
7
|
+
|
|
8
|
+
Scopes are `agents:read`, `agents:revoke`, and `agents:restore`. Mutations require
|
|
9
|
+
an `operationId` and `reason`; PostgreSQL operations can be safely reclaimed
|
|
10
|
+
after a crashed operator process's lease expires.
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { AgentControlPlane } from "@absolutejs/agency";
|
|
2
|
+
export type Operator = {
|
|
3
|
+
id: string;
|
|
4
|
+
scopes: readonly string[];
|
|
5
|
+
};
|
|
6
|
+
export type OperationRecord = {
|
|
7
|
+
digest: string;
|
|
8
|
+
key: string;
|
|
9
|
+
leaseUntil: number;
|
|
10
|
+
response?: unknown;
|
|
11
|
+
};
|
|
12
|
+
export type OperationStore = {
|
|
13
|
+
claim: (key: string, digest: string, now: number, leaseMs: number) => Promise<{
|
|
14
|
+
acquired: boolean;
|
|
15
|
+
response?: unknown;
|
|
16
|
+
}>;
|
|
17
|
+
complete: (key: string, digest: string, response: unknown) => Promise<boolean>;
|
|
18
|
+
};
|
|
19
|
+
export declare const createMemoryOperationStore: () => OperationStore;
|
|
20
|
+
export declare const createAgentControlHandler: ({ authorize, basePath, control, operations, now, }: {
|
|
21
|
+
authorize: (request: Request) => Promise<Operator | undefined> | Operator | undefined;
|
|
22
|
+
basePath?: string;
|
|
23
|
+
control: AgentControlPlane;
|
|
24
|
+
operations: OperationStore;
|
|
25
|
+
now?: () => number;
|
|
26
|
+
}) => (request: Request) => Promise<Response | null>;
|
|
27
|
+
export type ControlSqlResult<Row> = {
|
|
28
|
+
rows: Row[];
|
|
29
|
+
};
|
|
30
|
+
export type ControlSqlClient = {
|
|
31
|
+
query: <Row = Record<string, unknown>>(text: string, values?: readonly unknown[]) => Promise<ControlSqlResult<Row>>;
|
|
32
|
+
};
|
|
33
|
+
export declare const agentControlPostgresSchemaSql: (namespace?: string) => string;
|
|
34
|
+
export declare const createPostgresOperationStore: ({ client, namespace, }: {
|
|
35
|
+
client: ControlSqlClient;
|
|
36
|
+
namespace?: string;
|
|
37
|
+
}) => OperationStore;
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
// @bun
|
|
2
|
+
// src/index.ts
|
|
3
|
+
var createMemoryOperationStore = () => {
|
|
4
|
+
const rows = new Map;
|
|
5
|
+
return {
|
|
6
|
+
claim: async (key, digest, now, leaseMs) => {
|
|
7
|
+
const row = rows.get(key);
|
|
8
|
+
if (row?.digest !== undefined && row.digest !== digest)
|
|
9
|
+
throw new Error("Idempotency key reused with different input");
|
|
10
|
+
if (row?.response !== undefined)
|
|
11
|
+
return { acquired: false, response: structuredClone(row.response) };
|
|
12
|
+
if (row && row.leaseUntil > now)
|
|
13
|
+
return { acquired: false };
|
|
14
|
+
rows.set(key, { digest, key, leaseUntil: now + leaseMs });
|
|
15
|
+
return { acquired: true };
|
|
16
|
+
},
|
|
17
|
+
complete: async (key, digest, response) => {
|
|
18
|
+
const row = rows.get(key);
|
|
19
|
+
if (!row || row.digest !== digest)
|
|
20
|
+
return false;
|
|
21
|
+
rows.set(key, { ...row, response: structuredClone(response) });
|
|
22
|
+
return true;
|
|
23
|
+
}
|
|
24
|
+
};
|
|
25
|
+
};
|
|
26
|
+
var hash = async (value) => [
|
|
27
|
+
...new Uint8Array(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(JSON.stringify(value))))
|
|
28
|
+
].map((byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
29
|
+
var createAgentControlHandler = ({
|
|
30
|
+
authorize,
|
|
31
|
+
basePath = "/agent-control",
|
|
32
|
+
control,
|
|
33
|
+
operations,
|
|
34
|
+
now = Date.now
|
|
35
|
+
}) => async (request) => {
|
|
36
|
+
const url = new URL(request.url);
|
|
37
|
+
if (!url.pathname.startsWith(`${basePath}/agents/`))
|
|
38
|
+
return null;
|
|
39
|
+
const operator = await authorize(request);
|
|
40
|
+
if (!operator)
|
|
41
|
+
return new Response("Unauthorized", { status: 401 });
|
|
42
|
+
const rest = url.pathname.slice(`${basePath}/agents/`.length).split("/");
|
|
43
|
+
const agentId = decodeURIComponent(rest[0] ?? "");
|
|
44
|
+
const action = rest[1];
|
|
45
|
+
const required = request.method === "GET" ? "agents:read" : action === "restore" ? "agents:restore" : "agents:revoke";
|
|
46
|
+
if (!operator.scopes.includes(required))
|
|
47
|
+
return new Response("Forbidden", { status: 403 });
|
|
48
|
+
if (request.method === "GET" && !action)
|
|
49
|
+
return Response.json(await control.inventory(agentId));
|
|
50
|
+
if (request.method !== "POST" || action !== "revoke" && action !== "restore")
|
|
51
|
+
return new Response(null, { status: 405 });
|
|
52
|
+
const body = await request.json().catch(() => {
|
|
53
|
+
return;
|
|
54
|
+
});
|
|
55
|
+
if (!body?.operationId || !body.reason)
|
|
56
|
+
return Response.json({ error: "operationId and reason are required" }, { status: 400 });
|
|
57
|
+
const digest = await hash({ action, agentId, reason: body.reason });
|
|
58
|
+
let claim;
|
|
59
|
+
try {
|
|
60
|
+
claim = await operations.claim(body.operationId, digest, now(), 30000);
|
|
61
|
+
} catch (error) {
|
|
62
|
+
return Response.json({ error: error instanceof Error ? error.message : "conflict" }, { status: 409 });
|
|
63
|
+
}
|
|
64
|
+
if (claim.response !== undefined)
|
|
65
|
+
return Response.json(claim.response);
|
|
66
|
+
if (!claim.acquired)
|
|
67
|
+
return Response.json({ error: "operation in progress" }, { status: 409, headers: { "retry-after": "5" } });
|
|
68
|
+
const response = action === "revoke" ? await control.revoke({
|
|
69
|
+
activatedBy: operator.id,
|
|
70
|
+
agentId,
|
|
71
|
+
reason: body.reason
|
|
72
|
+
}) : (await control.restore(agentId), { agentId, restored: true, restoredBy: operator.id });
|
|
73
|
+
if (!await operations.complete(body.operationId, digest, response))
|
|
74
|
+
throw new Error("Control operation completion lost");
|
|
75
|
+
return Response.json(response);
|
|
76
|
+
};
|
|
77
|
+
var nsOf = (value) => {
|
|
78
|
+
if (!/^[a-z_][a-z0-9_]*$/.test(value))
|
|
79
|
+
throw new Error("Control namespace must be a simple identifier");
|
|
80
|
+
return value;
|
|
81
|
+
};
|
|
82
|
+
var agentControlPostgresSchemaSql = (namespace = "agent_control") => {
|
|
83
|
+
const ns = nsOf(namespace);
|
|
84
|
+
return `CREATE SCHEMA IF NOT EXISTS ${ns}; CREATE TABLE IF NOT EXISTS ${ns}.operations (operation_key text PRIMARY KEY, digest text NOT NULL, lease_until bigint NOT NULL, response jsonb);`;
|
|
85
|
+
};
|
|
86
|
+
var createPostgresOperationStore = ({
|
|
87
|
+
client,
|
|
88
|
+
namespace = "agent_control"
|
|
89
|
+
}) => {
|
|
90
|
+
const ns = nsOf(namespace);
|
|
91
|
+
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]);
|
|
94
|
+
const row = result.rows[0];
|
|
95
|
+
if (!row) {
|
|
96
|
+
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)
|
|
98
|
+
throw new Error("Idempotency key reused with different input");
|
|
99
|
+
return {
|
|
100
|
+
acquired: false,
|
|
101
|
+
...existing?.response === null || existing?.response === undefined ? {} : { response: existing.response }
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
return { acquired: row.response === null || row.response === undefined };
|
|
105
|
+
},
|
|
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
|
|
107
|
+
};
|
|
108
|
+
};
|
|
109
|
+
export {
|
|
110
|
+
createPostgresOperationStore,
|
|
111
|
+
createMemoryOperationStore,
|
|
112
|
+
createAgentControlHandler,
|
|
113
|
+
agentControlPostgresSchemaSql
|
|
114
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare const manifest: import("@absolutejs/manifest").PackageManifest<Record<string, never>, never>;
|