@twin3-ai/agent-id 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/bin/agent-id-sync.js +80 -0
- package/bin/agent-id.js +3397 -0
- package/browser-sdk.js +149 -0
- package/cloudflare-worker-adapter.js +100 -0
- package/domain-proof.js +208 -0
- package/enterprise-identity.js +202 -0
- package/installer.js +722 -0
- package/local-policy.js +118 -0
- package/package.json +44 -0
- package/production-preflight.js +123 -0
- package/release-verifier.js +83 -0
- package/repository-connector.js +306 -0
- package/runtime-config.js +10 -0
- package/site-agent.js +1010 -0
- package/sync-service.js +312 -0
- package/task-executor.js +129 -0
- package/telemetry-collector.js +163 -0
- package/trust-verifier.js +206 -0
package/sync-service.js
ADDED
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const fs = require("node:fs/promises");
|
|
4
|
+
const path = require("node:path");
|
|
5
|
+
const crypto = require("node:crypto");
|
|
6
|
+
|
|
7
|
+
const SENSITIVE_KEYS = new Set([
|
|
8
|
+
"agent_key",
|
|
9
|
+
"agentkey",
|
|
10
|
+
"site_agent_key",
|
|
11
|
+
"site_token",
|
|
12
|
+
"authorization",
|
|
13
|
+
"cookie",
|
|
14
|
+
"password",
|
|
15
|
+
"secret",
|
|
16
|
+
"access_token",
|
|
17
|
+
"refresh_token"
|
|
18
|
+
]);
|
|
19
|
+
|
|
20
|
+
function redact(value) {
|
|
21
|
+
if (Array.isArray(value)) return value.map(redact);
|
|
22
|
+
if (!value || typeof value !== "object") return value;
|
|
23
|
+
const out = {};
|
|
24
|
+
for (const [key, item] of Object.entries(value)) {
|
|
25
|
+
const normalized = key.toLowerCase().replace(/[-\s]/g, "_");
|
|
26
|
+
if (SENSITIVE_KEYS.has(normalized) || SENSITIVE_KEYS.has(normalized.replace(/_/g, ""))) continue;
|
|
27
|
+
out[key] = redact(item);
|
|
28
|
+
}
|
|
29
|
+
return out;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function emptyState() {
|
|
33
|
+
return {
|
|
34
|
+
schema: "agentx-site-agent-sync-state-v1",
|
|
35
|
+
lifecycle: "active",
|
|
36
|
+
lifecycle_reason: "",
|
|
37
|
+
lifecycle_changed_at: "",
|
|
38
|
+
cursor: "",
|
|
39
|
+
queue: [],
|
|
40
|
+
dead_letters: [],
|
|
41
|
+
last_sync_at: "",
|
|
42
|
+
last_success_at: "",
|
|
43
|
+
last_heartbeat_at: 0,
|
|
44
|
+
last_error: null
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async function readState(statePath) {
|
|
49
|
+
try {
|
|
50
|
+
const parsed = JSON.parse(await fs.readFile(statePath, "utf8"));
|
|
51
|
+
return { ...emptyState(), ...parsed, queue: parsed.queue || [], dead_letters: parsed.dead_letters || [] };
|
|
52
|
+
} catch (err) {
|
|
53
|
+
if (err.code === "ENOENT") return emptyState();
|
|
54
|
+
throw err;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async function writeState(statePath, state) {
|
|
59
|
+
await fs.mkdir(path.dirname(statePath), { recursive: true });
|
|
60
|
+
const clean = redact({ ...emptyState(), ...state });
|
|
61
|
+
const temporary = `${statePath}.${process.pid}.${crypto.randomUUID()}.tmp`;
|
|
62
|
+
try {
|
|
63
|
+
await fs.writeFile(temporary, JSON.stringify(clean, null, 2) + "\n", { mode: 0o600 });
|
|
64
|
+
await fs.rename(temporary, statePath);
|
|
65
|
+
} finally {
|
|
66
|
+
await fs.rm(temporary, { force: true }).catch(() => {});
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function publicStatus(state, statePath) {
|
|
71
|
+
return {
|
|
72
|
+
schema: state.schema,
|
|
73
|
+
state_path: statePath,
|
|
74
|
+
lifecycle: state.lifecycle || "active",
|
|
75
|
+
lifecycle_reason: state.lifecycle_reason || "",
|
|
76
|
+
lifecycle_changed_at: state.lifecycle_changed_at || "",
|
|
77
|
+
cursor: state.cursor || "",
|
|
78
|
+
queue_count: (state.queue || []).length,
|
|
79
|
+
dead_letter_count: (state.dead_letters || []).length,
|
|
80
|
+
dead_letters: (state.dead_letters || []).map((item) => redact(item)),
|
|
81
|
+
last_sync_at: state.last_sync_at || "",
|
|
82
|
+
last_success_at: state.last_success_at || "",
|
|
83
|
+
last_error: redact(state.last_error)
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function versionTuple(value) {
|
|
88
|
+
const match = String(value || "").trim().replace(/^v/i, "").match(/^(\d+)(?:\.(\d+))?(?:\.(\d+))?/);
|
|
89
|
+
return match ? [Number(match[1]), Number(match[2] || 0), Number(match[3] || 0)] : null;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function versionAtLeast(actual, required) {
|
|
93
|
+
const left = versionTuple(actual);
|
|
94
|
+
const right = versionTuple(required);
|
|
95
|
+
if (!left || !right) return false;
|
|
96
|
+
for (let index = 0; index < 3; index += 1) {
|
|
97
|
+
if (left[index] !== right[index]) return left[index] > right[index];
|
|
98
|
+
}
|
|
99
|
+
return true;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function assertCompatibility(result, runtimeVersion, capabilities) {
|
|
103
|
+
const compatibility = (result && result.compatibility) || (result && result.feed && result.feed.compatibility) || {};
|
|
104
|
+
const minimum = compatibility.min_runtime_version || compatibility.minRuntimeVersion || "";
|
|
105
|
+
const requiredCapabilities = Array.isArray(compatibility.required_capabilities)
|
|
106
|
+
? compatibility.required_capabilities.map((item) => String(item))
|
|
107
|
+
: Array.isArray(compatibility.requiredCapabilities) ? compatibility.requiredCapabilities.map((item) => String(item)) : [];
|
|
108
|
+
const missing = requiredCapabilities.filter((item) => !capabilities.includes(item));
|
|
109
|
+
if ((minimum && !versionAtLeast(runtimeVersion, minimum)) || missing.length) {
|
|
110
|
+
const error = new Error("Parent Agent runtime contract is incompatible.");
|
|
111
|
+
error.code = minimum && !versionAtLeast(runtimeVersion, minimum) ? "SYNC_VERSION_INCOMPATIBLE" : "SYNC_CAPABILITY_INCOMPATIBLE";
|
|
112
|
+
error.runtime_version = runtimeVersion;
|
|
113
|
+
error.required_version = minimum;
|
|
114
|
+
error.missing_capabilities = missing;
|
|
115
|
+
throw error;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function createSiteAgentSyncService(options = {}) {
|
|
120
|
+
const client = options.client;
|
|
121
|
+
const siteUrl = String(options.siteUrl || options.url || process.env.SITE_URL || "").trim();
|
|
122
|
+
const statePath = path.resolve(options.statePath || process.env.AGENT_ID_SYNC_STATE || path.join(process.cwd(), ".agent-id", "sync-state.json"));
|
|
123
|
+
const maxDeliveryAttempts = Math.max(1, Math.min(10, Number(options.maxDeliveryAttempts || 3)));
|
|
124
|
+
const retryBaseMs = Math.max(0, Number(options.retryBaseMs == null ? 1000 : options.retryBaseMs));
|
|
125
|
+
const intervalMs = Math.max(1000, Number(options.intervalMs || process.env.AGENT_ID_SYNC_INTERVAL_MS || 900000));
|
|
126
|
+
const heartbeatIntervalMs = Math.max(300000, Number(options.heartbeatIntervalMs || process.env.AGENT_ID_HEARTBEAT_INTERVAL_MS || options.intervalMs || 900000));
|
|
127
|
+
const collector = options.collector || null;
|
|
128
|
+
const runtimeVersion = String(options.runtimeVersion || process.env.AGENT_ID_RUNTIME_VERSION || "0.1.0");
|
|
129
|
+
const capabilities = Array.from(new Set((Array.isArray(options.capabilities) ? options.capabilities : ["heartbeat", "insights_feed"]).map((item) => String(item).trim()).filter(Boolean))).sort();
|
|
130
|
+
const now = options.now || Date.now;
|
|
131
|
+
const sleep = options.sleep || ((ms, signal) => new Promise((resolve) => {
|
|
132
|
+
if (signal && signal.aborted) return resolve();
|
|
133
|
+
const timer = setTimeout(done, ms);
|
|
134
|
+
function done() {
|
|
135
|
+
clearTimeout(timer);
|
|
136
|
+
if (signal) signal.removeEventListener("abort", done);
|
|
137
|
+
resolve();
|
|
138
|
+
}
|
|
139
|
+
if (signal) signal.addEventListener("abort", done, { once: true });
|
|
140
|
+
}));
|
|
141
|
+
if (!client || typeof client.syncOnce !== "function" || typeof client.call !== "function") {
|
|
142
|
+
throw new Error("A Site Agent client with syncOnce() and call() is required.");
|
|
143
|
+
}
|
|
144
|
+
if (collector && (typeof collector.flush !== "function" || typeof collector.status !== "function")) {
|
|
145
|
+
throw new Error("collector must expose flush() and status().");
|
|
146
|
+
}
|
|
147
|
+
if (!siteUrl) throw new Error("siteUrl or SITE_URL is required.");
|
|
148
|
+
|
|
149
|
+
async function collectorStatus() {
|
|
150
|
+
if (!collector) return null;
|
|
151
|
+
try { return await Promise.resolve().then(() => collector.status()); }
|
|
152
|
+
catch (error) { return { schema: "agentx-telemetry-status-v1", ok: false, error: redact({ code: error.code || "COLLECTOR_STATUS_FAILED", message: error.message || "Collector status failed" }) }; }
|
|
153
|
+
}
|
|
154
|
+
async function status() {
|
|
155
|
+
const result = publicStatus(await readState(statePath), statePath);
|
|
156
|
+
if (collector) result.collector = await collectorStatus();
|
|
157
|
+
return result;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
async function transition(nextLifecycle, reason = "") {
|
|
161
|
+
const state = await readState(statePath);
|
|
162
|
+
const current = state.lifecycle || "active";
|
|
163
|
+
if (current === "revoked" && nextLifecycle === "active") {
|
|
164
|
+
const error = new Error("A revoked Site Agent cannot be resumed.");
|
|
165
|
+
error.code = "SYNC_REVOKED";
|
|
166
|
+
throw error;
|
|
167
|
+
}
|
|
168
|
+
if (!["active", "paused", "disconnected", "revoked"].includes(nextLifecycle)) {
|
|
169
|
+
const error = new Error(`Unsupported lifecycle state: ${nextLifecycle}`);
|
|
170
|
+
error.code = "SYNC_INVALID_LIFECYCLE";
|
|
171
|
+
throw error;
|
|
172
|
+
}
|
|
173
|
+
state.lifecycle = nextLifecycle;
|
|
174
|
+
state.lifecycle_reason = String(reason || "").slice(0, 240);
|
|
175
|
+
state.lifecycle_changed_at = new Date(now()).toISOString();
|
|
176
|
+
await writeState(statePath, state);
|
|
177
|
+
return publicStatus(state, statePath);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
async function assertActive() {
|
|
181
|
+
const state = await readState(statePath);
|
|
182
|
+
const lifecycle = state.lifecycle || "active";
|
|
183
|
+
if (lifecycle !== "active") {
|
|
184
|
+
const error = new Error(`Site Agent sync is ${lifecycle}.`);
|
|
185
|
+
error.code = "SYNC_LIFECYCLE_BLOCKED";
|
|
186
|
+
error.lifecycle = lifecycle;
|
|
187
|
+
throw error;
|
|
188
|
+
}
|
|
189
|
+
return state;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
async function enqueue(action, payload = {}) {
|
|
193
|
+
action = String(action || "").trim();
|
|
194
|
+
if (!/^[a-z][a-z0-9_]{1,79}$/.test(action)) throw new Error("Queue action must be a private Site Agent action id.");
|
|
195
|
+
const state = await readState(statePath);
|
|
196
|
+
const item = {
|
|
197
|
+
id: crypto.randomUUID(),
|
|
198
|
+
action,
|
|
199
|
+
payload: redact({ ...payload, url: payload.url || siteUrl }),
|
|
200
|
+
attempts: 0,
|
|
201
|
+
created_at: new Date(now()).toISOString(),
|
|
202
|
+
next_attempt_at: now()
|
|
203
|
+
};
|
|
204
|
+
state.queue.push(item);
|
|
205
|
+
await writeState(statePath, state);
|
|
206
|
+
return redact(item);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
async function flushQueue({ force = false } = {}) {
|
|
210
|
+
await assertActive();
|
|
211
|
+
const state = await readState(statePath);
|
|
212
|
+
const remaining = [];
|
|
213
|
+
let delivered = 0;
|
|
214
|
+
let retrying = 0;
|
|
215
|
+
let deadLettered = 0;
|
|
216
|
+
for (const queued of state.queue) {
|
|
217
|
+
if (!force && Number(queued.next_attempt_at || 0) > now()) {
|
|
218
|
+
remaining.push(queued);
|
|
219
|
+
continue;
|
|
220
|
+
}
|
|
221
|
+
try {
|
|
222
|
+
await client.call(queued.action, redact(queued.payload || {}));
|
|
223
|
+
delivered += 1;
|
|
224
|
+
} catch (err) {
|
|
225
|
+
const failed = {
|
|
226
|
+
...queued,
|
|
227
|
+
attempts: Number(queued.attempts || 0) + 1,
|
|
228
|
+
last_error: redact({ code: err.code || "DELIVERY_FAILED", message: err.message || "Delivery failed" })
|
|
229
|
+
};
|
|
230
|
+
if (failed.attempts >= maxDeliveryAttempts) {
|
|
231
|
+
failed.failed_at = new Date(now()).toISOString();
|
|
232
|
+
state.dead_letters.push(failed);
|
|
233
|
+
deadLettered += 1;
|
|
234
|
+
} else {
|
|
235
|
+
failed.next_attempt_at = now() + Math.min(300000, retryBaseMs * (2 ** Math.max(0, failed.attempts - 1)));
|
|
236
|
+
remaining.push(failed);
|
|
237
|
+
retrying += 1;
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
state.queue = remaining;
|
|
242
|
+
state.dead_letters = state.dead_letters.slice(-100);
|
|
243
|
+
await writeState(statePath, state);
|
|
244
|
+
return { delivered, retrying, dead_lettered: deadLettered, queue_count: remaining.length };
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
async function runOnce(payload = {}) {
|
|
248
|
+
const initialState = await assertActive();
|
|
249
|
+
const delivery = await flushQueue();
|
|
250
|
+
const telemetry = collector
|
|
251
|
+
? await Promise.resolve().then(() => collector.flush()).catch((error) => ({ ok: false, delivered: 0, error: redact({ code: error.code || "COLLECTOR_FLUSH_FAILED", message: error.message || "Collector flush failed" }) }))
|
|
252
|
+
: null;
|
|
253
|
+
const state = initialState;
|
|
254
|
+
state.last_sync_at = new Date(now()).toISOString();
|
|
255
|
+
try {
|
|
256
|
+
const heartbeatDue = Number(state.last_heartbeat_at || 0) + heartbeatIntervalMs <= now();
|
|
257
|
+
const result = await client.syncOnce({
|
|
258
|
+
...payload,
|
|
259
|
+
heartbeat: payload.heartbeat === false ? false : heartbeatDue,
|
|
260
|
+
url: payload.url || siteUrl,
|
|
261
|
+
cursor: state.cursor || "",
|
|
262
|
+
runtime_version: runtimeVersion,
|
|
263
|
+
capabilities
|
|
264
|
+
});
|
|
265
|
+
assertCompatibility(result, runtimeVersion, capabilities);
|
|
266
|
+
state.cursor = result.cursor || state.cursor || "";
|
|
267
|
+
state.last_success_at = new Date(now()).toISOString();
|
|
268
|
+
if (payload.heartbeat !== false && heartbeatDue) state.last_heartbeat_at = now();
|
|
269
|
+
state.last_error = null;
|
|
270
|
+
await writeState(statePath, state);
|
|
271
|
+
return { ...result, delivery, telemetry, service: { ...publicStatus(state, statePath), ...(collector ? { collector: await collectorStatus() } : {}) } };
|
|
272
|
+
} catch (err) {
|
|
273
|
+
state.last_error = redact({ code: err.code || "SYNC_FAILED", message: err.message || "Sync failed", at: state.last_sync_at });
|
|
274
|
+
await writeState(statePath, state);
|
|
275
|
+
throw err;
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
async function run({ signal, onCycle } = {}) {
|
|
280
|
+
while (!signal || !signal.aborted) {
|
|
281
|
+
let result;
|
|
282
|
+
try {
|
|
283
|
+
result = await runOnce();
|
|
284
|
+
} catch (err) {
|
|
285
|
+
result = { ok: false, error: redact({ code: err.code || "SYNC_FAILED", message: err.message || "Sync failed" }) };
|
|
286
|
+
}
|
|
287
|
+
if (typeof onCycle === "function") await onCycle(result);
|
|
288
|
+
if (signal && signal.aborted) break;
|
|
289
|
+
await sleep(intervalMs, signal);
|
|
290
|
+
}
|
|
291
|
+
return status();
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
return Object.freeze({
|
|
295
|
+
statePath,
|
|
296
|
+
siteUrl,
|
|
297
|
+
runtimeVersion,
|
|
298
|
+
capabilities,
|
|
299
|
+
heartbeatIntervalMs,
|
|
300
|
+
status,
|
|
301
|
+
enqueue,
|
|
302
|
+
flushQueue,
|
|
303
|
+
runOnce,
|
|
304
|
+
run,
|
|
305
|
+
pause: (reason) => transition("paused", reason),
|
|
306
|
+
disconnect: (reason) => transition("disconnected", reason),
|
|
307
|
+
resume: (reason) => transition("active", reason),
|
|
308
|
+
revoke: (reason) => transition("revoked", reason)
|
|
309
|
+
});
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
module.exports = { createSiteAgentSyncService };
|
package/task-executor.js
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const fs = require("node:fs");
|
|
4
|
+
const path = require("node:path");
|
|
5
|
+
const crypto = require("node:crypto");
|
|
6
|
+
const { authorizeTask } = require("./local-policy.js");
|
|
7
|
+
|
|
8
|
+
const SIGNED_FIELDS = ["schema","task_id","subject","connector","resource","operations","expected_hash","policy_digest","idempotency_key","causation_id","task_version","issuer_key_id","issued_at","expires_at"];
|
|
9
|
+
|
|
10
|
+
function executorError(code) { const error = new Error(code); error.code = code; return error; }
|
|
11
|
+
function stableValue(value) {
|
|
12
|
+
if (Array.isArray(value)) return value.map(stableValue);
|
|
13
|
+
if (!value || typeof value !== "object") return value;
|
|
14
|
+
return Object.fromEntries(Object.keys(value).sort().map((key) => [key, stableValue(value[key])]));
|
|
15
|
+
}
|
|
16
|
+
function canonical(value) { return Buffer.from(JSON.stringify(stableValue(value)), "utf8"); }
|
|
17
|
+
function hash(value) { return `sha256:${crypto.createHash("sha256").update(value).digest("hex")}`; }
|
|
18
|
+
function exactSubject(left, right) {
|
|
19
|
+
return ["tenant_id","host","environment","agent_id"].every((field) => String(left && left[field] || "") === String(right && right[field] || ""));
|
|
20
|
+
}
|
|
21
|
+
function keyId(publicKey) {
|
|
22
|
+
const raw = publicKey.export({ type: "spki", format: "der" });
|
|
23
|
+
const prefix = Buffer.from("302a300506032b6570032100", "hex");
|
|
24
|
+
const keyBytes = raw.subarray(prefix.length);
|
|
25
|
+
return `ed25519:${crypto.createHash("sha256").update(keyBytes).digest("hex").slice(0, 24)}`;
|
|
26
|
+
}
|
|
27
|
+
function verifyTask(task, publicKey, subject, now) {
|
|
28
|
+
if (!task || task.schema !== "agentx-enterprise-task-v1" || task.task_version !== 1 || !task.signature || !exactSubject(task.subject, subject)) throw executorError("invalid_task_envelope");
|
|
29
|
+
if (now < Number(task.issued_at) || now >= Number(task.expires_at)) throw executorError("task_expired");
|
|
30
|
+
const key = crypto.createPublicKey(publicKey);
|
|
31
|
+
if (task.issuer_key_id !== keyId(key)) throw executorError("invalid_task_signature");
|
|
32
|
+
const payload = Object.fromEntries(SIGNED_FIELDS.map((field) => [field, task[field]]));
|
|
33
|
+
if (!crypto.verify(null, canonical(payload), key, Buffer.from(task.signature, "base64url"))) throw executorError("invalid_task_signature");
|
|
34
|
+
if (!task.resource || !task.operations.every((operation) => operation.path === task.resource.path)) throw executorError("task_resource_boundary_mismatch");
|
|
35
|
+
}
|
|
36
|
+
function writeState(statePath, state) {
|
|
37
|
+
const target = path.resolve(statePath);
|
|
38
|
+
fs.mkdirSync(path.dirname(target), { recursive: true, mode: 0o700 });
|
|
39
|
+
const temporary = `${target}.${process.pid}.${crypto.randomUUID()}.tmp`;
|
|
40
|
+
try {
|
|
41
|
+
fs.writeFileSync(temporary, JSON.stringify(state, null, 2) + "\n", { mode: 0o600, flag: "wx" });
|
|
42
|
+
fs.renameSync(temporary, target);
|
|
43
|
+
fs.chmodSync(target, 0o600);
|
|
44
|
+
} finally { try { fs.rmSync(temporary, { force: true }); } catch (_error) {} }
|
|
45
|
+
}
|
|
46
|
+
function loadState(statePath) {
|
|
47
|
+
try {
|
|
48
|
+
const parsed = JSON.parse(fs.readFileSync(statePath, "utf8"));
|
|
49
|
+
return parsed && parsed.schema === "agentx-local-executor-state-v1" && parsed.tasks && typeof parsed.tasks === "object"
|
|
50
|
+
? parsed : { schema: "agentx-local-executor-state-v1", tasks: {} };
|
|
51
|
+
} catch (error) {
|
|
52
|
+
if (error.code === "ENOENT") return { schema: "agentx-local-executor-state-v1", tasks: {} };
|
|
53
|
+
throw error;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
function createTaskExecutor({ issuerPublicKey, identity, policy, connectors, statePath, now = () => Math.floor(Date.now() / 1000) } = {}) {
|
|
57
|
+
if (!issuerPublicKey || !identity || !identity.key_path || !identity.subject || !policy || !statePath) throw executorError("executor_configuration_invalid");
|
|
58
|
+
const state = loadState(statePath);
|
|
59
|
+
const runtime = new Map();
|
|
60
|
+
const persist = () => writeState(statePath, state);
|
|
61
|
+
async function prepare(task) {
|
|
62
|
+
verifyTask(task, issuerPublicKey, identity.subject, now());
|
|
63
|
+
const connector = connectors && connectors[task.connector];
|
|
64
|
+
if (!connector) throw executorError("unknown_connector");
|
|
65
|
+
const resourceRequest = { ...task.resource, environment: task.subject.environment };
|
|
66
|
+
const current = await connector.readResource(resourceRequest);
|
|
67
|
+
const policyTask = {
|
|
68
|
+
...task,
|
|
69
|
+
environment: task.subject.environment,
|
|
70
|
+
operations: task.operations.map((operation) => ({ ...operation, operation: operation.op }))
|
|
71
|
+
};
|
|
72
|
+
authorizeTask(policy, policyTask, current);
|
|
73
|
+
const plan = await connector.planOperations(policyTask, current);
|
|
74
|
+
return { connector, current, plan, policyTask };
|
|
75
|
+
}
|
|
76
|
+
return Object.freeze({
|
|
77
|
+
async plan(task) {
|
|
78
|
+
const prepared = await prepare(task);
|
|
79
|
+
runtime.set(task.task_id, { task, ...prepared });
|
|
80
|
+
state.tasks[task.task_id] = { task_id: task.task_id, connector: task.connector, idempotency_key: task.idempotency_key, state: "planned", policy_digest: task.policy_digest, envelope_hash: hash(canonical(Object.fromEntries(SIGNED_FIELDS.map((field) => [field, task[field]])))) };
|
|
81
|
+
persist();
|
|
82
|
+
return { schema: "agentx-local-execution-plan-v1", task_id: task.task_id, connector: task.connector, approval_required: true, plan_hash: hash(canonical(prepared.plan)), changes: prepared.plan.changes.map(({ after_content, rollback_entries, ...item }) => item) };
|
|
83
|
+
},
|
|
84
|
+
async apply(task, approval = {}) {
|
|
85
|
+
const existing = state.tasks[task.task_id];
|
|
86
|
+
if (existing && existing.state === "deployed" && existing.result) return existing.result;
|
|
87
|
+
if (approval.approved !== true || approval.task_id !== task.task_id || approval.policy_digest !== policy.digest) throw executorError("approval_required");
|
|
88
|
+
const prepared = runtime.get(task.task_id) || { task, ...(await prepare(task)) };
|
|
89
|
+
const receipt = await prepared.connector.applyOperations(prepared.plan);
|
|
90
|
+
await prepared.connector.verifyLocalResult(receipt);
|
|
91
|
+
const appliedResource = (receipt.applied_changes || []).find((item) => item.path === task.resource.path) || {};
|
|
92
|
+
const publicReceipt = {
|
|
93
|
+
schema: "agentx-customer-execution-receipt-v1",
|
|
94
|
+
receipt_nonce: crypto.randomUUID(),
|
|
95
|
+
task_id: task.task_id,
|
|
96
|
+
subject: identity.subject,
|
|
97
|
+
connector: task.connector,
|
|
98
|
+
policy_digest: policy.digest,
|
|
99
|
+
resource: task.resource,
|
|
100
|
+
public_url: task.resource.public === false ? "" : `https://${identity.subject.host}/${String(task.resource.path || task.resource.id || "").replace(/^\/+/, "")}`,
|
|
101
|
+
result_hash: String(appliedResource.after_sha256 || ""),
|
|
102
|
+
applied_at: now(),
|
|
103
|
+
connector_receipt_hash: hash(canonical(receipt))
|
|
104
|
+
};
|
|
105
|
+
const privateKey = crypto.createPrivateKey(fs.readFileSync(identity.key_path, "utf8"));
|
|
106
|
+
publicReceipt.signature = crypto.sign(null, canonical(publicReceipt), privateKey).toString("base64url");
|
|
107
|
+
publicReceipt.receipt_hash = hash(canonical(publicReceipt));
|
|
108
|
+
state.tasks[task.task_id] = { ...state.tasks[task.task_id], state: "deployed", receipt_hash: publicReceipt.receipt_hash, result: publicReceipt };
|
|
109
|
+
runtime.set(task.task_id, { ...prepared, connectorReceipt: receipt });
|
|
110
|
+
persist();
|
|
111
|
+
return publicReceipt;
|
|
112
|
+
},
|
|
113
|
+
async rollback(taskId, approval = {}) {
|
|
114
|
+
if (approval.approved !== true || approval.task_id !== taskId) throw executorError("approval_required");
|
|
115
|
+
const prepared = runtime.get(taskId);
|
|
116
|
+
const taskState = state.tasks[taskId];
|
|
117
|
+
if (!taskState || taskState.state !== "deployed") throw executorError("rollback_receipt_unavailable");
|
|
118
|
+
const connector = prepared && prepared.connector || connectors && connectors[taskState.connector];
|
|
119
|
+
if (!connector) throw executorError("unknown_connector");
|
|
120
|
+
const receipt = await connector.rollback(prepared && prepared.connectorReceipt);
|
|
121
|
+
state.tasks[taskId] = { ...taskState, state: "rolled_back", rollback_receipt_hash: hash(canonical(receipt)) };
|
|
122
|
+
persist();
|
|
123
|
+
return receipt;
|
|
124
|
+
},
|
|
125
|
+
status(taskId) { return state.tasks[taskId] ? JSON.parse(JSON.stringify(state.tasks[taskId])) : null; }
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
module.exports = { createTaskExecutor };
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const fs = require("node:fs");
|
|
4
|
+
const path = require("node:path");
|
|
5
|
+
const crypto = require("node:crypto");
|
|
6
|
+
|
|
7
|
+
const SENSITIVE_KEYS = /^(?:authorization|cookie|set_cookie|email|e_mail|query|query_string|access_token|refresh_token|bootstrap_secret|password|secret|order_id|orderid)$/i;
|
|
8
|
+
const EMAIL = /\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b/gi;
|
|
9
|
+
|
|
10
|
+
function cleanString(value, key = "") {
|
|
11
|
+
if (/url|uri|href/i.test(key)) {
|
|
12
|
+
try {
|
|
13
|
+
const parsed = new URL(value);
|
|
14
|
+
parsed.search = "";
|
|
15
|
+
parsed.hash = "";
|
|
16
|
+
return parsed.toString();
|
|
17
|
+
} catch (_error) {}
|
|
18
|
+
}
|
|
19
|
+
return String(value).replace(EMAIL, "[redacted-email]");
|
|
20
|
+
}
|
|
21
|
+
function redact(value, key = "") {
|
|
22
|
+
if (SENSITIVE_KEYS.test(key)) return "[redacted]";
|
|
23
|
+
if (Array.isArray(value)) return value.map((item) => redact(item));
|
|
24
|
+
if (value && typeof value === "object") return Object.fromEntries(Object.entries(value).map(([childKey, item]) => [childKey, redact(item, childKey)]));
|
|
25
|
+
return typeof value === "string" ? cleanString(value, key) : value;
|
|
26
|
+
}
|
|
27
|
+
function bytes(value) { return Buffer.byteLength(JSON.stringify(value), "utf8"); }
|
|
28
|
+
function emptyState() {
|
|
29
|
+
return { schema: "agentx-telemetry-buffer-v1", events: [], dead_letters: [], retry_at: 0, attempts: 0, last_flush_at: 0, last_error: null };
|
|
30
|
+
}
|
|
31
|
+
function loadState(statePath, maxFileBytes) {
|
|
32
|
+
try {
|
|
33
|
+
if (fs.statSync(statePath).size > maxFileBytes) throw new Error("collector_state_too_large");
|
|
34
|
+
const parsed = JSON.parse(fs.readFileSync(statePath, "utf8"));
|
|
35
|
+
return { ...emptyState(), ...parsed, events: Array.isArray(parsed.events) ? parsed.events : [], dead_letters: Array.isArray(parsed.dead_letters) ? parsed.dead_letters : [] };
|
|
36
|
+
} catch (error) {
|
|
37
|
+
if (error.code === "ENOENT") return emptyState();
|
|
38
|
+
throw error;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
function writeState(statePath, state) {
|
|
42
|
+
const target = path.resolve(statePath);
|
|
43
|
+
fs.mkdirSync(path.dirname(target), { recursive: true, mode: 0o700 });
|
|
44
|
+
const temporary = `${target}.${process.pid}.${crypto.randomUUID()}.tmp`;
|
|
45
|
+
try {
|
|
46
|
+
fs.writeFileSync(temporary, JSON.stringify(state, null, 2) + "\n", { mode: 0o600, flag: "wx" });
|
|
47
|
+
fs.renameSync(temporary, target);
|
|
48
|
+
fs.chmodSync(target, 0o600);
|
|
49
|
+
} finally { try { fs.rmSync(temporary, { force: true }); } catch (_error) {} }
|
|
50
|
+
}
|
|
51
|
+
function createTelemetryCollector({
|
|
52
|
+
client,
|
|
53
|
+
statePath,
|
|
54
|
+
subject,
|
|
55
|
+
maxBytes = 50 * 1024 * 1024,
|
|
56
|
+
maxAgeMs = 24 * 60 * 60 * 1000,
|
|
57
|
+
maxEvents = 50000,
|
|
58
|
+
maxBatchBytes = 256 * 1024,
|
|
59
|
+
maxBatchEvents = 250,
|
|
60
|
+
maxDeadLetters = 100,
|
|
61
|
+
now = Date.now,
|
|
62
|
+
random = Math.random
|
|
63
|
+
} = {}) {
|
|
64
|
+
if (!client || typeof client.call !== "function" || !statePath || !subject) throw new Error("collector_configuration_invalid");
|
|
65
|
+
maxBytes = Math.max(1024, Number(maxBytes) || 50 * 1024 * 1024);
|
|
66
|
+
maxAgeMs = Math.max(1000, Number(maxAgeMs) || 24 * 60 * 60 * 1000);
|
|
67
|
+
maxEvents = Math.max(1, Number(maxEvents) || 50000);
|
|
68
|
+
maxBatchBytes = Math.max(512, Number(maxBatchBytes) || 256 * 1024);
|
|
69
|
+
maxBatchEvents = Math.max(1, Math.min(250, Number(maxBatchEvents) || 250));
|
|
70
|
+
maxDeadLetters = Math.max(1, Math.min(100, Number(maxDeadLetters) || 100));
|
|
71
|
+
const state = loadState(statePath, maxBytes + 1024 * 1024);
|
|
72
|
+
function persist() { writeState(statePath, state); }
|
|
73
|
+
function prune(timestamp = now()) {
|
|
74
|
+
const cutoff = timestamp - maxAgeMs;
|
|
75
|
+
const removed = [];
|
|
76
|
+
state.events = state.events.filter((event) => {
|
|
77
|
+
if (Number(event.ingestion_time) < cutoff) { removed.push({ ...event, dead_letter_reason: "expired_buffer_event" }); return false; }
|
|
78
|
+
return true;
|
|
79
|
+
});
|
|
80
|
+
while (state.events.length > maxEvents || bytes(state.events) > maxBytes) removed.push({ ...state.events.shift(), dead_letter_reason: "buffer_limit_pruned" });
|
|
81
|
+
state.dead_letters.push(...removed.map((event) => ({ event_id: event.event_id, source: event.source, dead_letter_reason: event.dead_letter_reason, failed_at: timestamp })));
|
|
82
|
+
state.dead_letters = state.dead_letters.slice(-maxDeadLetters);
|
|
83
|
+
persist();
|
|
84
|
+
return { removed: removed.length, event_count: state.events.length };
|
|
85
|
+
}
|
|
86
|
+
async function record(event) {
|
|
87
|
+
const timestamp = now();
|
|
88
|
+
const eventId = String(event && (event.event_id || event.id) || crypto.randomUUID());
|
|
89
|
+
if (state.events.some((item) => item.event_id === eventId) || state.dead_letters.some((item) => item.event_id === eventId)) return { recorded: false, duplicate: true, event_id: eventId };
|
|
90
|
+
const cleaned = redact(event || {});
|
|
91
|
+
const normalized = {
|
|
92
|
+
...cleaned,
|
|
93
|
+
schema_version: "1",
|
|
94
|
+
event_id: eventId,
|
|
95
|
+
event: String(cleaned.event || "unknown").slice(0, 80),
|
|
96
|
+
event_time: Number(cleaned.event_time || timestamp),
|
|
97
|
+
ingestion_time: timestamp,
|
|
98
|
+
source: String(cleaned.source || "site_agent").slice(0, 80),
|
|
99
|
+
subject: { tenant_id: subject.tenant_id, host: subject.host, environment: subject.environment, agent_id: subject.agent_id }
|
|
100
|
+
};
|
|
101
|
+
if (normalized.user_agent && !normalized.verified_bot_identity) normalized.identity_confidence = "user_agent_only_spoofable";
|
|
102
|
+
state.events.push(normalized);
|
|
103
|
+
prune(timestamp);
|
|
104
|
+
return { recorded: true, event_id: eventId };
|
|
105
|
+
}
|
|
106
|
+
function nextBatch(timestamp) {
|
|
107
|
+
const selected = [];
|
|
108
|
+
for (const event of state.events) {
|
|
109
|
+
if (event.expires_at && Number(event.expires_at) <= timestamp) continue;
|
|
110
|
+
const candidate = [...selected, event];
|
|
111
|
+
if (candidate.length > maxBatchEvents || bytes({ schema: "agentx-telemetry-batch-v1", events: candidate }) > maxBatchBytes) break;
|
|
112
|
+
selected.push(event);
|
|
113
|
+
}
|
|
114
|
+
return selected;
|
|
115
|
+
}
|
|
116
|
+
async function flush() {
|
|
117
|
+
const timestamp = now();
|
|
118
|
+
prune(timestamp);
|
|
119
|
+
const expired = state.events.filter((event) => event.expires_at && Number(event.expires_at) <= timestamp);
|
|
120
|
+
if (expired.length) {
|
|
121
|
+
const ids = new Set(expired.map((event) => event.event_id));
|
|
122
|
+
state.events = state.events.filter((event) => !ids.has(event.event_id));
|
|
123
|
+
state.dead_letters.push(...expired.map((event) => ({ event_id: event.event_id, source: event.source, dead_letter_reason: "expired_work_refused", failed_at: timestamp })));
|
|
124
|
+
state.dead_letters = state.dead_letters.slice(-maxDeadLetters);
|
|
125
|
+
}
|
|
126
|
+
if (state.retry_at > timestamp) { persist(); return { attempted: 0, delivered: 0, retry_at: state.retry_at, event_count: state.events.length }; }
|
|
127
|
+
const batch = nextBatch(timestamp);
|
|
128
|
+
if (!batch.length && state.events.length) {
|
|
129
|
+
const oversized = state.events.shift();
|
|
130
|
+
state.dead_letters.push({ event_id: oversized.event_id, source: oversized.source, dead_letter_reason: "event_exceeds_batch_limit", failed_at: timestamp });
|
|
131
|
+
state.dead_letters = state.dead_letters.slice(-maxDeadLetters);
|
|
132
|
+
persist();
|
|
133
|
+
return { attempted: 0, delivered: 0, retry_at: 0, event_count: state.events.length };
|
|
134
|
+
}
|
|
135
|
+
if (!batch.length) { persist(); return { attempted: 0, delivered: 0, retry_at: 0, event_count: 0 }; }
|
|
136
|
+
try {
|
|
137
|
+
await client.call("site_agent_events", { schema: "agentx-telemetry-batch-v1", events: batch });
|
|
138
|
+
const deliveredIds = new Set(batch.map((event) => event.event_id));
|
|
139
|
+
state.events = state.events.filter((event) => !deliveredIds.has(event.event_id));
|
|
140
|
+
state.retry_at = 0;
|
|
141
|
+
state.attempts = 0;
|
|
142
|
+
state.last_flush_at = timestamp;
|
|
143
|
+
state.last_error = null;
|
|
144
|
+
persist();
|
|
145
|
+
return { attempted: batch.length, delivered: batch.length, retry_at: 0, event_count: state.events.length };
|
|
146
|
+
} catch (error) {
|
|
147
|
+
state.attempts += 1;
|
|
148
|
+
const retryAfter = Number(error.retry_after_ms || error.retryAfterMs || 0);
|
|
149
|
+
const backoff = Math.min(300000, 1000 * (2 ** Math.min(8, state.attempts - 1)));
|
|
150
|
+
state.retry_at = timestamp + (retryAfter > 0 ? retryAfter : Math.round(backoff * (0.5 + random())));
|
|
151
|
+
state.last_error = { code: String(error.code || error.status || "delivery_failed"), message: String(error.message || "delivery_failed").slice(0, 240) };
|
|
152
|
+
persist();
|
|
153
|
+
return { attempted: batch.length, delivered: 0, retry_at: state.retry_at, event_count: state.events.length };
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
async function status() {
|
|
157
|
+
prune(now());
|
|
158
|
+
return { schema: "agentx-telemetry-status-v1", event_count: state.events.length, dead_letter_count: state.dead_letters.length, bytes: bytes(state.events), retry_at: state.retry_at, last_flush_at: state.last_flush_at, last_error: state.last_error };
|
|
159
|
+
}
|
|
160
|
+
return Object.freeze({ record, flush, status, prune });
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
module.exports = { createTelemetryCollector };
|