@twin3-ai/agent-id 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.
@@ -0,0 +1,115 @@
1
+ "use strict";
2
+
3
+ const crypto = require("node:crypto");
4
+ const { AUTO_MANAGED_PATHS } = require("./repository-connector.js");
5
+ const SECRET_CONTENT = /(?:-----BEGIN [A-Z ]*PRIVATE KEY-----|\bak_aeo_[A-Za-z0-9_-]{6,}\b|\bav_[A-Za-z0-9_-]{8,}\b|\beyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\b)/i;
6
+
7
+ function edgeError(code) { const error = new Error(code); error.code = code; return error; }
8
+ function hashText(value) { return `sha256:${crypto.createHash("sha256").update(value).digest("hex")}`; }
9
+ function normalizePath(value) {
10
+ const path = String(value || "").replace(/^\/+/, "");
11
+ if (!AUTO_MANAGED_PATHS.has(path)) throw edgeError("EDGE_PATH_NOT_ALLOWED");
12
+ return path;
13
+ }
14
+ function assertPublicAsset(path, content) {
15
+ if (typeof content !== "string" || Buffer.byteLength(content, "utf8") > 1024 * 1024 || SECRET_CONTENT.test(content)) throw edgeError("EDGE_CONTENT_INVALID");
16
+ if (path.endsWith(".json")) {
17
+ try { JSON.parse(content); } catch (_error) { throw edgeError("EDGE_JSON_INVALID"); }
18
+ }
19
+ }
20
+ function createMemoryAssetStore(initial = {}) {
21
+ const values = new Map(Object.entries(initial));
22
+ return Object.freeze({
23
+ async get(key) { return values.has(key) ? structuredClone(values.get(key)) : null; },
24
+ async put(key, value) { values.set(key, structuredClone(value)); },
25
+ async delete(key) { values.delete(key); },
26
+ async list() { return [...values.keys()].sort(); }
27
+ });
28
+ }
29
+ function createStaticEdgeConnector({ store, verifiedRoutes = [...AUTO_MANAGED_PATHS] } = {}) {
30
+ if (!store || !["get", "put", "delete"].every((name) => typeof store[name] === "function")) throw edgeError("EDGE_STORE_REQUIRED");
31
+ const allowed = new Set(verifiedRoutes.map(normalizePath));
32
+ return Object.freeze({
33
+ describeCapabilities() {
34
+ return { connector: "static_edge", operations: ["upsert", "replace", "delete"], paths: [...allowed].sort(), customer_site_write_access: false };
35
+ },
36
+ async readResource(resource) {
37
+ const path = normalizePath(resource && resource.path);
38
+ if (!allowed.has(path)) throw edgeError("EDGE_ROUTE_NOT_VERIFIED");
39
+ const current = await store.get(path);
40
+ return { connector: "static_edge", environment: String(resource.environment || "production"), resource_type: "route", resource_id: String(resource.resource_id || path), path, content: current && current.content || null, sha256: current && current.sha256 || "" };
41
+ },
42
+ async planOperations(task, currentResource) {
43
+ if (!task || !Array.isArray(task.operations) || !task.operations.length) throw edgeError("EDGE_OPERATIONS_REQUIRED");
44
+ const changes = task.operations.map((operation) => {
45
+ const path = normalizePath(operation.path);
46
+ if (!allowed.has(path) || path !== currentResource.path) throw edgeError("EDGE_ROUTE_NOT_VERIFIED");
47
+ if (!["upsert", "replace", "delete"].includes(operation.operation)) throw edgeError("EDGE_OPERATION_NOT_SUPPORTED");
48
+ const content = operation.operation === "delete" ? "" : operation.value;
49
+ assertPublicAsset(path, content);
50
+ if (operation.expected_hash !== currentResource.sha256) throw edgeError("EDGE_STALE_RESOURCE");
51
+ return { path, operation: operation.operation, before_sha256: currentResource.sha256, after_sha256: operation.operation === "delete" ? "" : hashText(content), after_content: content, content_type: path.endsWith(".json") ? "application/json; charset=utf-8" : "text/plain; charset=utf-8" };
52
+ });
53
+ return { schema: "agentx-static-edge-plan-v1", approval_required: true, customer_site_write_access: false, changes };
54
+ },
55
+ async applyOperations(plan) {
56
+ if (!plan || plan.schema !== "agentx-static-edge-plan-v1") throw edgeError("EDGE_PLAN_INVALID");
57
+ const rollbackEntries = [];
58
+ for (const change of plan.changes) {
59
+ const current = await store.get(change.path);
60
+ const currentHash = current && current.sha256 || "";
61
+ if (currentHash !== change.before_sha256) throw edgeError("EDGE_STALE_RESOURCE");
62
+ rollbackEntries.push({ path: change.path, before: current, after_sha256: change.after_sha256 });
63
+ }
64
+ const applied = [];
65
+ try {
66
+ for (const change of plan.changes) {
67
+ if (change.operation === "delete") await store.delete(change.path);
68
+ else await store.put(change.path, { content: change.after_content, content_type: change.content_type, sha256: change.after_sha256, updated_at: Date.now() });
69
+ applied.push(change.path);
70
+ }
71
+ } catch (error) {
72
+ for (const item of rollbackEntries.filter((entry) => applied.includes(entry.path)).reverse()) {
73
+ if (item.before) await store.put(item.path, item.before);
74
+ else await store.delete(item.path);
75
+ }
76
+ throw error;
77
+ }
78
+ return { schema: "agentx-static-edge-receipt-v1", connector: "static_edge", applied_changes: plan.changes.map(({ after_content, ...change }) => change), rollback_entries: rollbackEntries, customer_site_write_access: false };
79
+ },
80
+ async verifyLocalResult(receipt) {
81
+ for (const change of receipt.applied_changes || []) {
82
+ const current = await store.get(change.path);
83
+ const currentHash = current && current.sha256 || "";
84
+ if (currentHash !== change.after_sha256) throw edgeError("EDGE_VERIFY_FAILED");
85
+ }
86
+ return { verified: true };
87
+ },
88
+ async rollback(receipt) {
89
+ if (!receipt || receipt.schema !== "agentx-static-edge-receipt-v1") throw edgeError("EDGE_ROLLBACK_RECEIPT_INVALID");
90
+ for (const item of receipt.rollback_entries || []) {
91
+ const current = await store.get(item.path);
92
+ if ((current && current.sha256 || "") !== item.after_sha256) throw edgeError("EDGE_ROLLBACK_CONFLICT");
93
+ }
94
+ for (const item of [...(receipt.rollback_entries || [])].reverse()) {
95
+ if (item.before) await store.put(item.path, item.before);
96
+ else await store.delete(item.path);
97
+ }
98
+ return { schema: "agentx-static-edge-rollback-receipt-v1", connector: "static_edge", rolled_back: true, paths: (receipt.rollback_entries || []).map((item) => item.path) };
99
+ }
100
+ });
101
+ }
102
+ function createStaticEdgeHandler({ store } = {}) {
103
+ if (!store || typeof store.get !== "function") throw edgeError("EDGE_STORE_REQUIRED");
104
+ return async function handle(request) {
105
+ const method = String(request && request.method || "GET").toUpperCase();
106
+ if (!['GET', 'HEAD'].includes(method)) return new Response("Method Not Allowed", { status: 405, headers: { Allow: "GET, HEAD" } });
107
+ let path;
108
+ try { path = normalizePath(new URL(request.url).pathname); } catch (_error) { return new Response("Not Found", { status: 404 }); }
109
+ const asset = await store.get(path);
110
+ if (!asset) return new Response("Not Found", { status: 404 });
111
+ return new Response(method === "HEAD" ? null : asset.content, { status: 200, headers: { "Content-Type": asset.content_type, "Cache-Control": "public, max-age=300", "ETag": `\"${asset.sha256}\"`, "X-Agent-ID-Managed": "true", "X-Content-Type-Options": "nosniff" } });
112
+ };
113
+ }
114
+
115
+ module.exports = { createMemoryAssetStore, createStaticEdgeConnector, createStaticEdgeHandler };
package/sync-service.js CHANGED
@@ -125,7 +125,7 @@ function createSiteAgentSyncService(options = {}) {
125
125
  const intervalMs = Math.max(1000, Number(options.intervalMs || process.env.AGENT_ID_SYNC_INTERVAL_MS || 900000));
126
126
  const heartbeatIntervalMs = Math.max(300000, Number(options.heartbeatIntervalMs || process.env.AGENT_ID_HEARTBEAT_INTERVAL_MS || options.intervalMs || 900000));
127
127
  const collector = options.collector || null;
128
- const runtimeVersion = String(options.runtimeVersion || process.env.AGENT_ID_RUNTIME_VERSION || "0.1.0");
128
+ const runtimeVersion = String(options.runtimeVersion || process.env.AGENT_ID_RUNTIME_VERSION || "0.3.0");
129
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
130
  const now = options.now || Date.now;
131
131
  const sleep = options.sleep || ((ms, signal) => new Promise((resolve) => {
package/task-executor.js CHANGED
@@ -15,6 +15,44 @@ function stableValue(value) {
15
15
  }
16
16
  function canonical(value) { return Buffer.from(JSON.stringify(stableValue(value)), "utf8"); }
17
17
  function hash(value) { return `sha256:${crypto.createHash("sha256").update(value).digest("hex")}`; }
18
+ function rollbackEvidence(connectorReceipt) {
19
+ if (!connectorReceipt || typeof connectorReceipt !== "object" || connectorReceipt.rolled_back !== true) {
20
+ throw executorError("rollback_not_confirmed");
21
+ }
22
+ return {
23
+ schema: String(connectorReceipt.schema || ""),
24
+ connector: String(connectorReceipt.connector || ""),
25
+ plan_id: String(connectorReceipt.plan_id || ""),
26
+ artifact_bundle_hash: String(connectorReceipt.artifact_bundle_hash || ""),
27
+ rolled_back: true,
28
+ files: Array.isArray(connectorReceipt.files)
29
+ ? connectorReceipt.files.map((item) => ({
30
+ path: String(item && (item.path || item.relative_path) || ""),
31
+ before_sha256: String(item && item.before_sha256 || "")
32
+ }))
33
+ : [],
34
+ paths: Array.isArray(connectorReceipt.paths) ? connectorReceipt.paths.map((item) => String(item)) : []
35
+ };
36
+ }
37
+ function signedRollbackReceipt({ task, subject, originalReceipt, connectorReceipt, privateKey, now }) {
38
+ const connectorRollbackReceipt = rollbackEvidence(connectorReceipt);
39
+ const payload = {
40
+ schema: "agentx-customer-rollback-receipt-v1",
41
+ receipt_nonce: crypto.randomUUID(),
42
+ task_id: task.task_id,
43
+ subject,
44
+ connector: task.connector,
45
+ policy_digest: task.policy_digest,
46
+ resource: task.resource,
47
+ original_receipt_hash: String(originalReceipt && originalReceipt.receipt_hash || ""),
48
+ connector_rollback_hash: hash(canonical(connectorRollbackReceipt)),
49
+ connector_rollback_receipt: connectorRollbackReceipt,
50
+ rolled_back: true,
51
+ rolled_back_at: Number(now),
52
+ };
53
+ const signature = crypto.sign(null, canonical(payload), privateKey).toString("base64url");
54
+ return { ...payload, signature, receipt_hash: hash(canonical({ ...payload, signature })) };
55
+ }
18
56
  function exactSubject(left, right) {
19
57
  return ["tenant_id","host","environment","agent_id"].every((field) => String(left && left[field] || "") === String(right && right[field] || ""));
20
58
  }
@@ -69,23 +107,28 @@ function createTaskExecutor({ issuerPublicKey, identity, policy, connectors, sta
69
107
  environment: task.subject.environment,
70
108
  operations: task.operations.map((operation) => ({ ...operation, operation: operation.op }))
71
109
  };
72
- authorizeTask(policy, policyTask, current);
110
+ const authorization = authorizeTask(policy, policyTask, current);
73
111
  const plan = await connector.planOperations(policyTask, current);
74
- return { connector, current, plan, policyTask };
112
+ return { connector, current, plan, policyTask, authorization };
75
113
  }
76
114
  return Object.freeze({
77
115
  async plan(task) {
78
116
  const prepared = await prepare(task);
79
117
  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]])))) };
118
+ state.tasks[task.task_id] = { task_id: task.task_id, connector: task.connector, idempotency_key: task.idempotency_key, resource: task.resource, state: "planned", policy_digest: task.policy_digest, envelope_hash: hash(canonical(Object.fromEntries(SIGNED_FIELDS.map((field) => [field, task[field]])))) };
81
119
  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) };
120
+ return { schema: "agentx-local-execution-plan-v1", task_id: task.task_id, connector: task.connector, decision: prepared.authorization.decision, approval_required: prepared.authorization.decision !== "auto", plan_hash: hash(canonical(prepared.plan)), changes: prepared.plan.changes.map(({ after_content, rollback_entries, ...item }) => item) };
83
121
  },
84
122
  async apply(task, approval = {}) {
85
123
  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");
124
+ if (existing && existing.state === "deployed" && existing.result) {
125
+ const connector = connectors && connectors[task.connector];
126
+ if (!connector) throw executorError("unknown_connector");
127
+ if (existing.connector_receipt) runtime.set(task.task_id, { task, connector, connectorReceipt: existing.connector_receipt });
128
+ return existing.result;
129
+ }
88
130
  const prepared = runtime.get(task.task_id) || { task, ...(await prepare(task)) };
131
+ if (prepared.authorization.decision !== "auto" && (approval.approved !== true || approval.task_id !== task.task_id || approval.policy_digest !== policy.digest)) throw executorError("approval_required");
89
132
  const receipt = await prepared.connector.applyOperations(prepared.plan);
90
133
  await prepared.connector.verifyLocalResult(receipt);
91
134
  const appliedResource = (receipt.applied_changes || []).find((item) => item.path === task.resource.path) || {};
@@ -105,20 +148,47 @@ function createTaskExecutor({ issuerPublicKey, identity, policy, connectors, sta
105
148
  const privateKey = crypto.createPrivateKey(fs.readFileSync(identity.key_path, "utf8"));
106
149
  publicReceipt.signature = crypto.sign(null, canonical(publicReceipt), privateKey).toString("base64url");
107
150
  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 };
151
+ state.tasks[task.task_id] = {
152
+ ...state.tasks[task.task_id],
153
+ state: "deployed",
154
+ receipt_hash: publicReceipt.receipt_hash,
155
+ result: publicReceipt,
156
+ connector_receipt: receipt,
157
+ };
109
158
  runtime.set(task.task_id, { ...prepared, connectorReceipt: receipt });
110
159
  persist();
111
160
  return publicReceipt;
112
161
  },
113
162
  async rollback(taskId, approval = {}) {
114
163
  if (approval.approved !== true || approval.task_id !== taskId) throw executorError("approval_required");
115
- const prepared = runtime.get(taskId);
116
164
  const taskState = state.tasks[taskId];
165
+ const prepared = runtime.get(taskId) || {
166
+ task: {
167
+ task_id: taskId,
168
+ connector: taskState && taskState.connector,
169
+ policy_digest: taskState && taskState.policy_digest,
170
+ subject: identity.subject,
171
+ resource: taskState && taskState.resource,
172
+ },
173
+ connector: connectors && connectors[taskState && taskState.connector],
174
+ connectorReceipt: taskState && taskState.connector_receipt,
175
+ };
117
176
  if (!taskState || taskState.state !== "deployed") throw executorError("rollback_receipt_unavailable");
177
+ if (!taskState.connector_receipt) throw executorError("rollback_receipt_unavailable");
118
178
  const connector = prepared && prepared.connector || connectors && connectors[taskState.connector];
119
179
  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)) };
180
+ const connectorReceipt = await connector.rollback(prepared && prepared.connectorReceipt);
181
+ rollbackEvidence(connectorReceipt);
182
+ const privateKey = crypto.createPrivateKey(fs.readFileSync(identity.key_path, "utf8"));
183
+ const receipt = signedRollbackReceipt({
184
+ task: prepared.task,
185
+ subject: identity.subject,
186
+ originalReceipt: taskState.result,
187
+ connectorReceipt,
188
+ privateKey,
189
+ now: now(),
190
+ });
191
+ state.tasks[taskId] = { ...taskState, state: "rolled_back", rollback_receipt_hash: receipt.receipt_hash };
122
192
  persist();
123
193
  return receipt;
124
194
  },