@twin3-ai/agent-id 0.1.0 → 0.2.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/artifact-bundle.js +92 -0
- package/bin/agent-id.js +75 -3
- package/enterprise-identity.js +27 -0
- package/installer.js +115 -4
- package/local-policy.js +4 -2
- package/optimization-loop.js +48 -0
- package/package.json +5 -2
- package/repository-connector.js +30 -3
- package/site-agent.js +2 -2
- package/static-edge-adapter.js +115 -0
- package/sync-service.js +1 -1
- package/task-executor.js +80 -10
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const crypto = require("node:crypto");
|
|
4
|
+
const { AUTO_MANAGED_PATHS } = require("./repository-connector.js");
|
|
5
|
+
|
|
6
|
+
const SCHEMA = "agentx-signed-artifact-bundle-v0.2";
|
|
7
|
+
const MAX_LIFETIME_SECONDS = 7 * 24 * 60 * 60;
|
|
8
|
+
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;
|
|
9
|
+
|
|
10
|
+
function bundleError(code) {
|
|
11
|
+
const error = new Error(code);
|
|
12
|
+
error.code = code;
|
|
13
|
+
return error;
|
|
14
|
+
}
|
|
15
|
+
function stableValue(value) {
|
|
16
|
+
if (Array.isArray(value)) return value.map(stableValue);
|
|
17
|
+
if (!value || typeof value !== "object") return value;
|
|
18
|
+
return Object.fromEntries(Object.keys(value).sort().map((key) => [key, stableValue(value[key])]));
|
|
19
|
+
}
|
|
20
|
+
function canonical(value) { return Buffer.from(JSON.stringify(stableValue(value)), "utf8"); }
|
|
21
|
+
function hash(value) { return `sha256:${crypto.createHash("sha256").update(value).digest("hex")}`; }
|
|
22
|
+
function sameSubject(left, right) {
|
|
23
|
+
return ["tenant_id", "host", "environment", "agent_id"].every((field) => String(left && left[field] || "") === String(right && right[field] || ""));
|
|
24
|
+
}
|
|
25
|
+
function keyId(key) {
|
|
26
|
+
const publicKey = key && key.type === "public" ? key : crypto.createPublicKey(key);
|
|
27
|
+
const der = publicKey.export({ type: "spki", format: "der" });
|
|
28
|
+
return `ed25519:${crypto.createHash("sha256").update(der).digest("hex").slice(0, 24)}`;
|
|
29
|
+
}
|
|
30
|
+
function normalizeArtifacts(artifacts) {
|
|
31
|
+
if (!Array.isArray(artifacts) || artifacts.length === 0) throw bundleError("ARTIFACT_BUNDLE_ARTIFACTS_REQUIRED");
|
|
32
|
+
const seen = new Set();
|
|
33
|
+
return artifacts.map((artifact) => {
|
|
34
|
+
const path = String(artifact && artifact.path || "").replace(/^\/+/, "");
|
|
35
|
+
const content = artifact && artifact.content;
|
|
36
|
+
if (!AUTO_MANAGED_PATHS.has(path)) throw bundleError("ARTIFACT_BUNDLE_PATH_NOT_ALLOWED");
|
|
37
|
+
if (seen.has(path)) throw bundleError("ARTIFACT_BUNDLE_DUPLICATE_PATH");
|
|
38
|
+
if (typeof content !== "string" || Buffer.byteLength(content, "utf8") > 1024 * 1024 || SECRET_CONTENT.test(content)) throw bundleError("ARTIFACT_BUNDLE_INVALID_CONTENT");
|
|
39
|
+
if (path.endsWith(".json")) {
|
|
40
|
+
try { JSON.parse(content); } catch (_error) { throw bundleError("ARTIFACT_BUNDLE_INVALID_JSON"); }
|
|
41
|
+
}
|
|
42
|
+
seen.add(path);
|
|
43
|
+
return {
|
|
44
|
+
path,
|
|
45
|
+
content_type: String(artifact.content_type || "text/plain; charset=utf-8").slice(0, 120),
|
|
46
|
+
content,
|
|
47
|
+
content_hash: hash(Buffer.from(content, "utf8"))
|
|
48
|
+
};
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
function unsignedBundle(bundle) {
|
|
52
|
+
const copy = { ...bundle };
|
|
53
|
+
delete copy.signature;
|
|
54
|
+
return copy;
|
|
55
|
+
}
|
|
56
|
+
function createArtifactBundle({ subject, artifacts, issuerPrivateKey, issuerPublicKey, source = {}, issuedAt = Math.floor(Date.now() / 1000), expiresAt = issuedAt + 3600 } = {}) {
|
|
57
|
+
if (!subject || !issuerPrivateKey || !issuerPublicKey) throw bundleError("ARTIFACT_BUNDLE_CONFIGURATION_INVALID");
|
|
58
|
+
if (!["tenant_id", "host", "environment", "agent_id"].every((field) => typeof subject[field] === "string" && subject[field])) throw bundleError("ARTIFACT_BUNDLE_SUBJECT_INVALID");
|
|
59
|
+
if (typeof source.run_id !== "string" || !source.run_id || typeof source.receipt_hash !== "string" || !/^sha256:[a-f0-9]{6,}$/i.test(source.receipt_hash)) throw bundleError("ARTIFACT_BUNDLE_SOURCE_INVALID");
|
|
60
|
+
if (!Number.isInteger(issuedAt) || !Number.isInteger(expiresAt) || expiresAt <= issuedAt || expiresAt - issuedAt > MAX_LIFETIME_SECONDS) throw bundleError("ARTIFACT_BUNDLE_TIME_INVALID");
|
|
61
|
+
const bundle = {
|
|
62
|
+
schema: SCHEMA,
|
|
63
|
+
bundle_version: 2,
|
|
64
|
+
subject: stableValue(subject),
|
|
65
|
+
source: { run_id: source.run_id, receipt_hash: source.receipt_hash },
|
|
66
|
+
issued_at: issuedAt,
|
|
67
|
+
expires_at: expiresAt,
|
|
68
|
+
issuer_key_id: keyId(issuerPublicKey),
|
|
69
|
+
artifacts: normalizeArtifacts(artifacts)
|
|
70
|
+
};
|
|
71
|
+
bundle.bundle_hash = hash(canonical(bundle));
|
|
72
|
+
bundle.signature = crypto.sign(null, canonical(bundle), issuerPrivateKey).toString("base64url");
|
|
73
|
+
return bundle;
|
|
74
|
+
}
|
|
75
|
+
function verifyArtifactBundle(bundle, { issuerPublicKey, expectedSubject, now = Math.floor(Date.now() / 1000) } = {}) {
|
|
76
|
+
if (!bundle || bundle.schema !== SCHEMA || bundle.bundle_version !== 2 || !issuerPublicKey || !bundle.signature) throw bundleError("ARTIFACT_BUNDLE_INVALID");
|
|
77
|
+
if (!sameSubject(bundle.subject, expectedSubject)) throw bundleError("ARTIFACT_BUNDLE_SUBJECT_MISMATCH");
|
|
78
|
+
if (!Number.isInteger(bundle.issued_at) || !Number.isInteger(bundle.expires_at) || now < bundle.issued_at || now >= bundle.expires_at || bundle.expires_at - bundle.issued_at > MAX_LIFETIME_SECONDS) throw bundleError("ARTIFACT_BUNDLE_EXPIRED");
|
|
79
|
+
if (bundle.issuer_key_id !== keyId(issuerPublicKey)) throw bundleError("ARTIFACT_BUNDLE_SIGNATURE_INVALID");
|
|
80
|
+
const artifacts = normalizeArtifacts(bundle.artifacts);
|
|
81
|
+
if (artifacts.some((artifact, index) => artifact.content_hash !== bundle.artifacts[index].content_hash)) throw bundleError("ARTIFACT_BUNDLE_SIGNATURE_INVALID");
|
|
82
|
+
const unsigned = unsignedBundle(bundle);
|
|
83
|
+
const claimedHash = unsigned.bundle_hash;
|
|
84
|
+
delete unsigned.bundle_hash;
|
|
85
|
+
if (claimedHash !== hash(canonical(unsigned))) throw bundleError("ARTIFACT_BUNDLE_SIGNATURE_INVALID");
|
|
86
|
+
const verificationKey = issuerPublicKey && issuerPublicKey.type === "public" ? issuerPublicKey : crypto.createPublicKey(issuerPublicKey);
|
|
87
|
+
const verified = crypto.verify(null, canonical({ ...unsigned, bundle_hash: claimedHash }), verificationKey, Buffer.from(bundle.signature, "base64url"));
|
|
88
|
+
if (!verified) throw bundleError("ARTIFACT_BUNDLE_SIGNATURE_INVALID");
|
|
89
|
+
return { verified: true, bundle_hash: claimedHash, subject: bundle.subject, artifacts: artifacts.map(({ content, ...item }) => item) };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
module.exports = { SCHEMA, MAX_LIFETIME_SECONDS, createArtifactBundle, verifyArtifactBundle };
|
package/bin/agent-id.js
CHANGED
|
@@ -3,11 +3,15 @@ const { configuredEndpoint, TRUSTED_ISSUER_KEY_SHA256 } = require("../runtime-co
|
|
|
3
3
|
const endpoint = configuredEndpoint();
|
|
4
4
|
const fs = require("fs");
|
|
5
5
|
const path = require("path");
|
|
6
|
-
const { buildInstallPlan, resumeInstall, uninstallInstall, enterpriseInstallStatus, prepareEnterpriseSecretDirectory, writeEnterpriseBootstrapCredential, readEnterpriseBootstrapCredential, replaceEnterpriseBootstrapCredential, removeEnterpriseBootstrapCredential, writeEnterpriseEnrollmentState } = require("../installer.js");
|
|
6
|
+
const { buildInstallPlan, applyInstallPlan, resumeInstall, uninstallInstall, enterpriseInstallStatus, prepareEnterpriseSecretDirectory, writeEnterpriseBootstrapCredential, readEnterpriseBootstrapCredential, replaceEnterpriseBootstrapCredential, removeEnterpriseBootstrapCredential, writeEnterpriseEnrollmentState } = require("../installer.js");
|
|
7
7
|
const { buildRepositoryPatch, applyRepositoryPatch, rollbackRepositoryPatch } = require("../repository-connector.js");
|
|
8
|
+
const { createRepositoryConnector } = require("../repository-connector.js");
|
|
8
9
|
const { generateSiteAgentIdentity, createEnterpriseSession } = require("../enterprise-identity.js");
|
|
9
10
|
const { buildWellKnownProofDocument, resolveProofTarget, writeWellKnownProofDocument } = require("../domain-proof.js");
|
|
10
11
|
const { initializeLocalPolicy, loadLocalPolicy, approvePolicyRule } = require("../local-policy.js");
|
|
12
|
+
const { createTaskExecutor } = require("../task-executor.js");
|
|
13
|
+
const { createOptimizationLoop } = require("../optimization-loop.js");
|
|
14
|
+
const crypto = require("node:crypto");
|
|
11
15
|
const { verifyPortableBundle } = require("../trust-verifier.js");
|
|
12
16
|
const { verifyReleaseManifest } = require("../release-verifier.js");
|
|
13
17
|
const { runProductionPreflight } = require("../production-preflight.js");
|
|
@@ -62,7 +66,17 @@ async function enterpriseSessionForProject(projectRoot) {
|
|
|
62
66
|
credential: existing.credential,
|
|
63
67
|
identity
|
|
64
68
|
});
|
|
65
|
-
return { root, state, session };
|
|
69
|
+
return { root, state, session, identity, credential: existing.credential };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async function trustedIssuerPublicKey() {
|
|
73
|
+
const response = await fetch(endpoint + "/.well-known/agentx-issuer-key.json");
|
|
74
|
+
if (!response.ok) throw new Error("enterprise issuer key unavailable");
|
|
75
|
+
const descriptor = await response.json();
|
|
76
|
+
const publicKey = String(descriptor.public_key_pem || "");
|
|
77
|
+
const actual = `sha256:${crypto.createHash("sha256").update(publicKey).digest("hex")}`;
|
|
78
|
+
if (!publicKey || actual !== TRUSTED_ISSUER_KEY_SHA256) throw new Error("enterprise issuer key pin mismatch");
|
|
79
|
+
return publicKey;
|
|
66
80
|
}
|
|
67
81
|
|
|
68
82
|
function printManagedResult(value) {
|
|
@@ -1364,6 +1378,8 @@ async function runCli(argv) {
|
|
|
1364
1378
|
" agent-id enterprise-managed-pause [--project-root <dir>] [--json]",
|
|
1365
1379
|
" agent-id enterprise-managed-resume [--project-root <dir>] [--json]",
|
|
1366
1380
|
" agent-id enterprise-managed-runs [--project-root <dir>] [--limit <1-100>] [--json]",
|
|
1381
|
+
" agent-id enterprise-optimize-once [--project-root <dir>] [--approve-task <task-id>] [--json]",
|
|
1382
|
+
" agent-id enterprise-rollback-once --task-id <task-id> --project-root <dir> --approve [--json]",
|
|
1367
1383
|
" agent-id enterprise-contract",
|
|
1368
1384
|
" agent-id repository-patch-plan <root> <changes-json-file>",
|
|
1369
1385
|
" agent-id repository-patch-apply <plan-json-file> --approve",
|
|
@@ -2533,12 +2549,19 @@ async function runCli(argv) {
|
|
|
2533
2549
|
applyProof: true,
|
|
2534
2550
|
});
|
|
2535
2551
|
if (!enrollment.ok) throw new Error(enrollment.error || "enterprise_enrollment_failed");
|
|
2552
|
+
// Enrollment creates the customer's key and publishes the proof, but the
|
|
2553
|
+
// runtime that polls for work is a separate set of files. Without this the
|
|
2554
|
+
// installer that writes them was never called from the CLI, so nothing was
|
|
2555
|
+
// installed, install-resume always reported not_installed, and
|
|
2556
|
+
// install-uninstall removed nothing because there was no state to find.
|
|
2557
|
+
const receipt = await applyInstallPlan(detectedPlan, { approved: true });
|
|
2536
2558
|
return printSignedInstallResult({
|
|
2537
2559
|
ok: true,
|
|
2538
2560
|
dry_run: false,
|
|
2539
2561
|
plan,
|
|
2540
2562
|
release: gate,
|
|
2541
2563
|
enrollment,
|
|
2564
|
+
runtime_install: receipt,
|
|
2542
2565
|
non_claims: ["domain_verification_still_required", "shared_site_agent_key_not_used", "does_not_modify_customer_content"]
|
|
2543
2566
|
});
|
|
2544
2567
|
}
|
|
@@ -2737,6 +2760,55 @@ async function runCli(argv) {
|
|
|
2737
2760
|
session.clear();
|
|
2738
2761
|
}
|
|
2739
2762
|
}
|
|
2763
|
+
if (cmd === "enterprise-optimize-once") {
|
|
2764
|
+
const projectRoot = readFlagValue(argv, "--project-root") || process.cwd();
|
|
2765
|
+
const { root, state, session, identity, credential } = await enterpriseSessionForProject(projectRoot);
|
|
2766
|
+
const policy = loadLocalPolicy(path.join(root, ".agent-id", "enterprise-policy.json"));
|
|
2767
|
+
identity.subject = { tenant_id: credential.tenant_id, host: credential.host, environment: state.environment, agent_id: credential.agent_id };
|
|
2768
|
+
const executor = createTaskExecutor({
|
|
2769
|
+
issuerPublicKey: await trustedIssuerPublicKey(),
|
|
2770
|
+
identity,
|
|
2771
|
+
policy,
|
|
2772
|
+
connectors: { repository_patch: createRepositoryConnector({ repositoryRoot: root }) },
|
|
2773
|
+
statePath: path.join(root, ".agent-id", "executor-state.json")
|
|
2774
|
+
});
|
|
2775
|
+
const approvedTask = readFlagValue(argv, "--approve-task");
|
|
2776
|
+
try {
|
|
2777
|
+
return print(await createOptimizationLoop({ session, executor }).runOnce({ approvedTaskIds: approvedTask ? [approvedTask] : [] }));
|
|
2778
|
+
} finally {
|
|
2779
|
+
session.clear();
|
|
2780
|
+
}
|
|
2781
|
+
}
|
|
2782
|
+
if (cmd === "enterprise-rollback-once") {
|
|
2783
|
+
const projectRoot = readFlagValue(argv, "--project-root") || process.cwd();
|
|
2784
|
+
const taskId = readFlagValue(argv, "--task-id");
|
|
2785
|
+
if (!taskId) throw new Error("enterprise-rollback-once requires --task-id");
|
|
2786
|
+
if (!argv.includes("--approve")) throw new Error("enterprise rollback requires --approve");
|
|
2787
|
+
const { root, state, session, identity, credential } = await enterpriseSessionForProject(projectRoot);
|
|
2788
|
+
const policy = loadLocalPolicy(path.join(root, ".agent-id", "enterprise-policy.json"));
|
|
2789
|
+
identity.subject = { tenant_id: credential.tenant_id, host: credential.host, environment: state.environment, agent_id: credential.agent_id };
|
|
2790
|
+
const executor = createTaskExecutor({
|
|
2791
|
+
issuerPublicKey: await trustedIssuerPublicKey(),
|
|
2792
|
+
identity,
|
|
2793
|
+
policy,
|
|
2794
|
+
connectors: { repository_patch: createRepositoryConnector({ repositoryRoot: root }) },
|
|
2795
|
+
statePath: path.join(root, ".agent-id", "executor-state.json")
|
|
2796
|
+
});
|
|
2797
|
+
try {
|
|
2798
|
+
const listed = await session.listTasks();
|
|
2799
|
+
let task = (listed.tasks || []).find((item) => item.task_id === taskId);
|
|
2800
|
+
if (!task) throw new Error("enterprise_task_not_found");
|
|
2801
|
+
if (["deployed", "failed"].includes(task.state)) {
|
|
2802
|
+
task = (await session.transitionTask(taskId, "rollback", task.version)).task;
|
|
2803
|
+
}
|
|
2804
|
+
if (task.state !== "rollback_pending") throw new Error(`enterprise_task_not_rollbackable:${task.state}`);
|
|
2805
|
+
const rollbackReceipt = await executor.rollback(taskId, { approved: true, task_id: taskId });
|
|
2806
|
+
const completed = await session.transitionTask(taskId, "rollback-receipt", task.version, { rollback_receipt: rollbackReceipt });
|
|
2807
|
+
return print({ ok: true, task: completed.task, rollback_receipt: rollbackReceipt });
|
|
2808
|
+
} finally {
|
|
2809
|
+
session.clear();
|
|
2810
|
+
}
|
|
2811
|
+
}
|
|
2740
2812
|
if (cmd === "repository-patch-plan") {
|
|
2741
2813
|
const root = url;
|
|
2742
2814
|
const changesFile = argv[2];
|
|
@@ -2790,7 +2862,7 @@ function runMcp() {
|
|
|
2790
2862
|
|
|
2791
2863
|
async function handleMcpLine(line) {
|
|
2792
2864
|
const msg = JSON.parse(line);
|
|
2793
|
-
if (msg.method === "initialize") return send(msg.id, { protocolVersion: "2024-11-05", serverInfo: { name: "agent-id", version: "0.
|
|
2865
|
+
if (msg.method === "initialize") return send(msg.id, { protocolVersion: "2024-11-05", serverInfo: { name: "agent-id", version: "0.2.0" } });
|
|
2794
2866
|
if (msg.method === "tools/list") {
|
|
2795
2867
|
const tools = [
|
|
2796
2868
|
{ name: "audit_url", description: "Audit a website for SEO/AEO/GEO/Agent readiness.", inputSchema: { type: "object", properties: { url: { type: "string" } }, required: ["url"] } },
|
package/enterprise-identity.js
CHANGED
|
@@ -145,6 +145,13 @@ function createEnterpriseSession({ serviceOrigin, siteUrl, environment, credenti
|
|
|
145
145
|
return responseJson(await fetcher(`${origin}/api/site_agents/v1/managed/${pathname}`, request));
|
|
146
146
|
}
|
|
147
147
|
|
|
148
|
+
async function taskRequest(pathname, { method = "GET", body = null, scopes = ["tasks:read"] } = {}) {
|
|
149
|
+
const accessToken = await session.getAccessToken(scopes);
|
|
150
|
+
const request = { method, headers: { "content-type": "application/json", authorization: `Bearer ${accessToken}` } };
|
|
151
|
+
if (body !== null) request.body = JSON.stringify(body);
|
|
152
|
+
return responseJson(await fetcher(`${origin}/api/site_agents/v1/tasks${pathname}`, request));
|
|
153
|
+
}
|
|
154
|
+
|
|
148
155
|
const session = {
|
|
149
156
|
schema: "agentx-enterprise-session-v1",
|
|
150
157
|
site_url: site.url,
|
|
@@ -194,6 +201,26 @@ function createEnterpriseSession({ serviceOrigin, siteUrl, environment, credenti
|
|
|
194
201
|
const bounded = Math.max(1, Math.min(Number(limit) || 20, 100));
|
|
195
202
|
return managedRequest(`runs?limit=${encodeURIComponent(Math.trunc(bounded))}`);
|
|
196
203
|
},
|
|
204
|
+
async listTasks() {
|
|
205
|
+
return taskRequest("");
|
|
206
|
+
},
|
|
207
|
+
async getTaskComparison(taskId) {
|
|
208
|
+
const id = encodeURIComponent(String(taskId || ""));
|
|
209
|
+
if (!id) throw enterpriseError("enterprise_task_id_required");
|
|
210
|
+
return taskRequest(`/${id}/comparison`);
|
|
211
|
+
},
|
|
212
|
+
async createTaskComparison(taskId) {
|
|
213
|
+
const id = encodeURIComponent(String(taskId || ""));
|
|
214
|
+
if (!id) throw enterpriseError("enterprise_task_id_required");
|
|
215
|
+
return taskRequest(`/${id}/comparison`, { method: "POST", scopes: ["tasks:read"], body: {} });
|
|
216
|
+
},
|
|
217
|
+
async transitionTask(taskId, action, expectedVersion, payload = {}) {
|
|
218
|
+
const id = encodeURIComponent(String(taskId || ""));
|
|
219
|
+
const supported = new Set(["propose", "approve", "execute", "reject", "receipt", "verify", "complete", "rollback", "rollback-receipt"]);
|
|
220
|
+
if (!id || !supported.has(action) || !Number.isInteger(expectedVersion)) throw enterpriseError("enterprise_task_transition_invalid");
|
|
221
|
+
const scopes = ["receipt", "rollback-receipt"].includes(action) ? ["evidence:write"] : (["verify", "complete"].includes(action) ? ["tasks:read"] : ["tasks:execute"]);
|
|
222
|
+
return taskRequest(`/${id}/${action}`, { method: "POST", scopes, body: { ...payload, expected_version: expectedVersion } });
|
|
223
|
+
},
|
|
197
224
|
clear() { cachedToken = ""; cachedExpiry = 0; cachedScopes = []; }
|
|
198
225
|
};
|
|
199
226
|
return Object.freeze(session);
|
package/installer.js
CHANGED
|
@@ -169,7 +169,7 @@ async function main() {
|
|
|
169
169
|
const observedAt = new Date().toISOString();
|
|
170
170
|
const heartbeat = SYNC_MODE === "monthly" ? { skipped: true, reason: "monthly_insights_run" } : await call("site_agent_events", {
|
|
171
171
|
source: "customer_owned_github_actions_runner",
|
|
172
|
-
events: [{ event: "heartbeat", url: SITE_URL, ts: Math.floor(Date.now() / 1000), data: { runtime: "github_actions", sdk_version: "0.
|
|
172
|
+
events: [{ event: "heartbeat", url: SITE_URL, ts: Math.floor(Date.now() / 1000), data: { runtime: "github_actions", sdk_version: "0.2.0" } }]
|
|
173
173
|
});
|
|
174
174
|
const insights = SYNC_MODE === "heartbeat" ? { skipped: true, reason: "daily_heartbeat_only" } : await call("insights_feed", {
|
|
175
175
|
source: "customer_owned_github_actions_runner",
|
|
@@ -300,6 +300,90 @@ function stateTemplate(plan) {
|
|
|
300
300
|
};
|
|
301
301
|
}
|
|
302
302
|
|
|
303
|
+
const OPPORTUNITY_CHECKS = Object.freeze([
|
|
304
|
+
{ id: "package_manifest", paths: ["package.json"] },
|
|
305
|
+
{ id: "llms_txt", paths: ["llms.txt", "public/llms.txt"] },
|
|
306
|
+
{ id: "robots_txt", paths: ["robots.txt", "public/robots.txt"] },
|
|
307
|
+
{ id: "agent_card", paths: [".well-known/agent-card.json", "public/.well-known/agent-card.json"] },
|
|
308
|
+
{ id: "agent_id", paths: [".well-known/agent-id.json", "public/.well-known/agent-id.json"] },
|
|
309
|
+
{ id: "aeo_agent", paths: [".well-known/aeo-agent.json", "public/.well-known/aeo-agent.json"] },
|
|
310
|
+
{ id: "agent_knowledge", paths: [".well-known/agent-knowledge.json", "public/.well-known/agent-knowledge.json"] }
|
|
311
|
+
]);
|
|
312
|
+
|
|
313
|
+
async function inspectAgentIdOpportunities({ projectRoot = process.cwd(), fsImpl = fs, checks = OPPORTUNITY_CHECKS } = {}) {
|
|
314
|
+
const root = normalizeProjectRoot(projectRoot);
|
|
315
|
+
const reportChecks = [];
|
|
316
|
+
for (const definition of checks) {
|
|
317
|
+
const presentPaths = [];
|
|
318
|
+
const unavailablePaths = [];
|
|
319
|
+
const unavailableErrors = {};
|
|
320
|
+
const symlinkPaths = [];
|
|
321
|
+
for (const relativePath of definition.paths) {
|
|
322
|
+
if (typeof fsImpl.lstat !== "function") {
|
|
323
|
+
unavailablePaths.push(relativePath);
|
|
324
|
+
unavailableErrors[relativePath] = "LSTAT_UNAVAILABLE";
|
|
325
|
+
continue;
|
|
326
|
+
}
|
|
327
|
+
try {
|
|
328
|
+
const candidate = path.resolve(root, relativePath);
|
|
329
|
+
const withinRoot = path.relative(root, candidate);
|
|
330
|
+
if (withinRoot === ".." || withinRoot.startsWith(`..${path.sep}`) || path.isAbsolute(withinRoot)) {
|
|
331
|
+
unavailablePaths.push(relativePath);
|
|
332
|
+
unavailableErrors[relativePath] = "PATH_ESCAPE";
|
|
333
|
+
continue;
|
|
334
|
+
}
|
|
335
|
+
const stat = await fsImpl.lstat(candidate);
|
|
336
|
+
if (stat.isSymbolicLink()) {
|
|
337
|
+
symlinkPaths.push(relativePath);
|
|
338
|
+
unavailablePaths.push(relativePath);
|
|
339
|
+
unavailableErrors[relativePath] = "SYMLINK_REJECTED";
|
|
340
|
+
continue;
|
|
341
|
+
}
|
|
342
|
+
if (typeof stat.isFile === "function" && !stat.isFile()) {
|
|
343
|
+
unavailablePaths.push(relativePath);
|
|
344
|
+
unavailableErrors[relativePath] = "NOT_REGULAR_FILE";
|
|
345
|
+
continue;
|
|
346
|
+
}
|
|
347
|
+
presentPaths.push(relativePath);
|
|
348
|
+
} catch (error) {
|
|
349
|
+
if (!error || error.code !== "ENOENT") {
|
|
350
|
+
unavailablePaths.push(relativePath);
|
|
351
|
+
unavailableErrors[relativePath] = (error && ["EACCES", "EPERM"].includes(error.code))
|
|
352
|
+
? "PERMISSION_DENIED"
|
|
353
|
+
: "UNAVAILABLE";
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
reportChecks.push({
|
|
358
|
+
id: definition.id,
|
|
359
|
+
status: presentPaths.length ? "present" : unavailablePaths.length ? "unavailable" : "missing",
|
|
360
|
+
present_paths: presentPaths,
|
|
361
|
+
unavailable_paths: unavailablePaths,
|
|
362
|
+
unavailable_errors: unavailableErrors,
|
|
363
|
+
symlink_paths: symlinkPaths,
|
|
364
|
+
candidate_paths: [...definition.paths]
|
|
365
|
+
});
|
|
366
|
+
}
|
|
367
|
+
const present = reportChecks.filter((item) => item.status === "present").length;
|
|
368
|
+
const unavailable = reportChecks.filter((item) => item.status === "unavailable").length;
|
|
369
|
+
return {
|
|
370
|
+
schema: "agentx-local-opportunity-report-v1",
|
|
371
|
+
local_only: true,
|
|
372
|
+
source_uploaded: false,
|
|
373
|
+
secrets_scanned: false,
|
|
374
|
+
content_validated: false,
|
|
375
|
+
verified: 0,
|
|
376
|
+
checks: reportChecks,
|
|
377
|
+
symlink_policy: "skip_and_exclude",
|
|
378
|
+
summary: {
|
|
379
|
+
total: reportChecks.length,
|
|
380
|
+
present,
|
|
381
|
+
missing: reportChecks.length - present - unavailable,
|
|
382
|
+
unavailable
|
|
383
|
+
}
|
|
384
|
+
};
|
|
385
|
+
}
|
|
386
|
+
|
|
303
387
|
function buildInstallPlan({ url, projectRoot = process.cwd(), endpoint = DEFAULT_ENDPOINT, framework, packageManager, detected } = {}) {
|
|
304
388
|
return Promise.resolve().then(async () => {
|
|
305
389
|
const siteUrl = normalizeSiteUrl(url);
|
|
@@ -307,6 +391,7 @@ function buildInstallPlan({ url, projectRoot = process.cwd(), endpoint = DEFAULT
|
|
|
307
391
|
const detectedProject = detected || await detectProject({ projectRoot: root });
|
|
308
392
|
const selectedFramework = framework || detectedProject.framework;
|
|
309
393
|
const selectedPackageManager = packageManager || detectedProject.package_manager;
|
|
394
|
+
const opportunityReport = await inspectAgentIdOpportunities({ projectRoot: root });
|
|
310
395
|
if (!/^[a-z][a-z0-9._-]{0,31}$/.test(String(selectedFramework))) throw installError("INVALID_FRAMEWORK", "Framework identifier is invalid.");
|
|
311
396
|
if (!/^[a-z][a-z0-9._-]{0,31}$/.test(String(selectedPackageManager))) throw installError("INVALID_PACKAGE_MANAGER", "Package manager identifier is invalid.");
|
|
312
397
|
const parentEndpoint = String(endpoint || DEFAULT_ENDPOINT).replace(/\/+$/, "");
|
|
@@ -328,6 +413,7 @@ function buildInstallPlan({ url, projectRoot = process.cwd(), endpoint = DEFAULT
|
|
|
328
413
|
project_root: root,
|
|
329
414
|
framework: selectedFramework,
|
|
330
415
|
package_manager: selectedPackageManager,
|
|
416
|
+
opportunity_report: opportunityReport,
|
|
331
417
|
approval_required: true,
|
|
332
418
|
side_effects: selectedFramework === "static"
|
|
333
419
|
? ["write_agent_id_namespace", "write_dedicated_github_actions_workflow"]
|
|
@@ -338,9 +424,33 @@ function buildInstallPlan({ url, projectRoot = process.cwd(), endpoint = DEFAULT
|
|
|
338
424
|
: ["verify_domain_ownership", "install_server_package", "store_SITE_AGENT_KEY_in_server_secret_store", "start_site_agent_sync"],
|
|
339
425
|
files: files.map(({ path: filePath, mode, content }) => ({ path: filePath, mode, sha256: hashText(content) }))
|
|
340
426
|
};
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
427
|
+
// install_id identifies the deterministic install plan, not the mutable
|
|
428
|
+
// state file that is generated from that plan at apply time.
|
|
429
|
+
const installIdentity = {
|
|
430
|
+
schema: base.schema,
|
|
431
|
+
version: base.version,
|
|
432
|
+
site_url: base.site_url,
|
|
433
|
+
parent_endpoint: base.parent_endpoint,
|
|
434
|
+
framework: base.framework,
|
|
435
|
+
package_manager: base.package_manager,
|
|
436
|
+
approval_required: base.approval_required,
|
|
437
|
+
side_effects: base.side_effects,
|
|
438
|
+
excluded_side_effects: base.excluded_side_effects,
|
|
439
|
+
next_steps: base.next_steps,
|
|
440
|
+
files: base.files.filter((file) => file.path !== `${MANAGED_DIR}/install-state.json`)
|
|
441
|
+
};
|
|
442
|
+
base.install_id = hashText(stableJson(installIdentity)).slice(0, 24);
|
|
443
|
+
const stateContent = JSON.stringify(stateTemplate({ ...base, files: base.files }), null, 2) + "\n";
|
|
444
|
+
const stateFileIndex = base.files.findIndex((file) => file.path === `${MANAGED_DIR}/install-state.json`);
|
|
445
|
+
if (stateFileIndex < 0) throw installError("INSTALL_STATE_FILE_MISSING", "The install plan must include its managed state file.");
|
|
446
|
+
base.files[stateFileIndex].sha256 = hashText(stateContent);
|
|
447
|
+
return Object.freeze({
|
|
448
|
+
...base,
|
|
449
|
+
files: files.map((file, index) => ({
|
|
450
|
+
...base.files[index],
|
|
451
|
+
content: file.path === `${MANAGED_DIR}/install-state.json` ? stateContent : file.content
|
|
452
|
+
}))
|
|
453
|
+
});
|
|
344
454
|
});
|
|
345
455
|
}
|
|
346
456
|
|
|
@@ -708,6 +818,7 @@ module.exports = {
|
|
|
708
818
|
buildInstallPlan,
|
|
709
819
|
preflightInstallPlan,
|
|
710
820
|
detectProject,
|
|
821
|
+
inspectAgentIdOpportunities,
|
|
711
822
|
applyInstallPlan,
|
|
712
823
|
resumeInstall,
|
|
713
824
|
uninstallInstall,
|
package/local-policy.js
CHANGED
|
@@ -7,6 +7,7 @@ const crypto = require("node:crypto");
|
|
|
7
7
|
const POLICY_SCHEMA = "agentx-local-policy-v1";
|
|
8
8
|
const MAX_BYTES = 1024 * 1024;
|
|
9
9
|
const OPERATIONS = new Set(["upsert", "replace", "delete"]);
|
|
10
|
+
const APPROVAL_MODES = new Set(["auto", "review", "explicit"]);
|
|
10
11
|
const SENSITIVE_PATHS = /(^|\/)(\.env(?:\.|$)|\.git|id_rsa|id_ed25519|secrets?|credentials?|private[-_.]?key|package-lock\.json)(\/|$)/i;
|
|
11
12
|
const EXECUTABLE_PATHS = /\.(?:sh|bash|zsh|fish|cmd|bat|ps1|exe|dll|so|dylib|js|mjs|cjs|py|rb|php|pl)$/i;
|
|
12
13
|
const SECRET_CONTENT = /(?:-----BEGIN [A-Z ]*PRIVATE KEY-----|\b(?:SITE_AGENT_KEY|AEO_AGENT_KEY|API_KEY|ACCESS_TOKEN|SECRET)\s*[:=]|\beyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\b)/i;
|
|
@@ -57,7 +58,7 @@ function validRule(rule) {
|
|
|
57
58
|
&& safeRelative(rule.path_prefix) && !rule.path_prefix.includes("*")
|
|
58
59
|
&& Array.isArray(rule.allowed_operations) && rule.allowed_operations.length > 0 && rule.allowed_operations.every((item) => OPERATIONS.has(item))
|
|
59
60
|
&& Number.isInteger(rule.max_bytes) && rule.max_bytes > 0 && rule.max_bytes <= MAX_BYTES
|
|
60
|
-
&& rule.approval_mode
|
|
61
|
+
&& APPROVAL_MODES.has(rule.approval_mode) && Number.isInteger(rule.version) && rule.version > 0;
|
|
61
62
|
}
|
|
62
63
|
function approvePolicyRule(policy, rule, { approved = false, approvedBy = "", now = Math.floor(Date.now() / 1000) } = {}) {
|
|
63
64
|
if (approved !== true || typeof approvedBy !== "string" || !approvedBy.trim()) throw policyError("policy_approval_required");
|
|
@@ -112,7 +113,8 @@ function authorizeTask(policy, task, currentResource) {
|
|
|
112
113
|
for (const operation of task.operations) {
|
|
113
114
|
if (!rule.allowed_operations.includes(operation.operation) || !pathWithinPrefix(operation.path, rule.path_prefix) || Buffer.byteLength(operation.value || "", "utf8") > rule.max_bytes) throw policyError("policy_denied");
|
|
114
115
|
}
|
|
115
|
-
|
|
116
|
+
const decision = rule.approval_mode === "auto" ? "auto" : "review";
|
|
117
|
+
return { authorized: true, decision, policy_version: policy.version, policy_digest: policy.digest, rule_version: rule.version, approval_mode: rule.approval_mode };
|
|
116
118
|
}
|
|
117
119
|
|
|
118
120
|
module.exports = { POLICY_SCHEMA, initializeLocalPolicy, loadLocalPolicy, approvePolicyRule, authorizeTask };
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
function loopError(code) { const error = new Error(code); error.code = code; return error; }
|
|
4
|
+
|
|
5
|
+
function createOptimizationLoop({ session, executor } = {}) {
|
|
6
|
+
if (!session || !executor || typeof session.listTasks !== "function") throw loopError("OPTIMIZATION_LOOP_CONFIGURATION_INVALID");
|
|
7
|
+
async function advance(input, approvals) {
|
|
8
|
+
let task = input;
|
|
9
|
+
if (task.state === "draft") task = (await session.transitionTask(task.task_id, "propose", task.version)).task;
|
|
10
|
+
if (task.state === "proposed") {
|
|
11
|
+
const plan = await executor.plan(task);
|
|
12
|
+
if (plan.approval_required && !approvals.has(task.task_id)) return { task_id: task.task_id, status: "pending_customer_review", plan };
|
|
13
|
+
task = (await session.transitionTask(task.task_id, "approve", task.version)).task;
|
|
14
|
+
}
|
|
15
|
+
if (task.state === "approved") task = (await session.transitionTask(task.task_id, "execute", task.version)).task;
|
|
16
|
+
if (task.state === "executing") {
|
|
17
|
+
const approval = { approved: true, task_id: task.task_id, policy_digest: task.policy_digest };
|
|
18
|
+
const receipt = await executor.apply(task, approval);
|
|
19
|
+
task = (await session.transitionTask(task.task_id, "receipt", task.version, { receipt })).task;
|
|
20
|
+
}
|
|
21
|
+
if (task.state === "deployed") {
|
|
22
|
+
const result = await session.transitionTask(task.task_id, "verify", task.version, { attempt: 0 });
|
|
23
|
+
if (result.pending) return { task_id: task.task_id, status: "public_verification_pending", retry_after_seconds: result.public_result && result.public_result.retry_after_seconds || 30 };
|
|
24
|
+
task = result.task;
|
|
25
|
+
}
|
|
26
|
+
if (task.state === "verifying") task = (await session.transitionTask(task.task_id, "complete", task.version)).task;
|
|
27
|
+
let comparison = null;
|
|
28
|
+
if (task.state === "completed" && typeof session.createTaskComparison === "function") {
|
|
29
|
+
comparison = await session.createTaskComparison(task.task_id);
|
|
30
|
+
}
|
|
31
|
+
return { task_id: task.task_id, status: task.state, version: task.version, comparison_receipt_id: comparison && comparison.receipt && comparison.receipt.receipt_id || null };
|
|
32
|
+
}
|
|
33
|
+
return Object.freeze({
|
|
34
|
+
async runOnce({ approvedTaskIds = [] } = {}) {
|
|
35
|
+
const payload = await session.listTasks();
|
|
36
|
+
const approvals = new Set(approvedTaskIds.map(String));
|
|
37
|
+
const results = [];
|
|
38
|
+
for (const task of payload.tasks || []) {
|
|
39
|
+
if (["rejected", "expired", "rolled_back", "rollback_failed", "failed"].includes(task.state)) continue;
|
|
40
|
+
try { results.push(await advance(task, approvals)); }
|
|
41
|
+
catch (error) { results.push({ task_id: task.task_id, status: "error", error: String(error.code || error.message || "optimization_loop_failed") }); }
|
|
42
|
+
}
|
|
43
|
+
return { schema: "agentx-optimization-loop-run-v0.2", processed: results.length, results };
|
|
44
|
+
}
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
module.exports = { createOptimizationLoop };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@twin3-ai/agent-id",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Domain-bound Agent identity, AEO evidence, and website-Agent integration SDK.",
|
|
5
5
|
"license": "UNLICENSED",
|
|
6
6
|
"private": false,
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
"npm": ">=10"
|
|
17
17
|
},
|
|
18
18
|
"scripts": {
|
|
19
|
-
"prepublishOnly": "node --check bin/agent-id.js && node --check installer.js && node --check enterprise-identity.js && node --check release-verifier.js && node --check production-preflight.js"
|
|
19
|
+
"prepublishOnly": "node --check bin/agent-id.js && node --check installer.js && node --check enterprise-identity.js && node --check release-verifier.js && node --check production-preflight.js && node --check static-edge-adapter.js && node --check optimization-loop.js && node --check artifact-bundle.js"
|
|
20
20
|
},
|
|
21
21
|
"main": "site-agent.js",
|
|
22
22
|
"exports": {
|
|
@@ -34,6 +34,9 @@
|
|
|
34
34
|
"./trust-verifier": "./trust-verifier.js",
|
|
35
35
|
"./repository-connector": "./repository-connector.js",
|
|
36
36
|
"./cloudflare-worker-adapter": "./cloudflare-worker-adapter.js",
|
|
37
|
+
"./static-edge-adapter": "./static-edge-adapter.js",
|
|
38
|
+
"./optimization-loop": "./optimization-loop.js",
|
|
39
|
+
"./artifact-bundle": "./artifact-bundle.js",
|
|
37
40
|
"./release-verifier": "./release-verifier.js",
|
|
38
41
|
"./production-preflight": "./production-preflight.js"
|
|
39
42
|
},
|
package/repository-connector.js
CHANGED
|
@@ -4,14 +4,15 @@ const fs = require("node:fs/promises");
|
|
|
4
4
|
const path = require("node:path");
|
|
5
5
|
const crypto = require("node:crypto");
|
|
6
6
|
|
|
7
|
-
const
|
|
7
|
+
const AUTO_MANAGED_PATHS = new Set([
|
|
8
8
|
"llms.txt",
|
|
9
|
-
"robots.txt",
|
|
10
9
|
".well-known/agent-card.json",
|
|
11
10
|
".well-known/agent-id.json",
|
|
12
11
|
".well-known/aeo-agent.json",
|
|
13
12
|
".well-known/agent-knowledge.json"
|
|
14
13
|
]);
|
|
14
|
+
const REVIEW_ONLY_PATHS = new Set(["robots.txt"]);
|
|
15
|
+
const ALLOWED_PATHS = AUTO_MANAGED_PATHS;
|
|
15
16
|
const SECRET_PATTERNS = [
|
|
16
17
|
/\bak_aeo_[A-Za-z0-9_-]{6,}\b/,
|
|
17
18
|
/\bav_[A-Za-z0-9_-]{8,}\b/,
|
|
@@ -35,6 +36,7 @@ function rootPath(value = process.cwd()) {
|
|
|
35
36
|
|
|
36
37
|
function normalizeRelativePath(value) {
|
|
37
38
|
const relative = String(value || "").replace(/\\/g, "/").replace(/^\.\//, "");
|
|
39
|
+
if (REVIEW_ONLY_PATHS.has(relative)) throw connectorError("CONNECTOR_REVIEW_ONLY_PATH", `Path requires customer-owned review and is not automatically managed: ${relative}`);
|
|
38
40
|
if (!ALLOWED_PATHS.has(relative)) throw connectorError("CONNECTOR_PATH_NOT_ALLOWED", `Path is not on the connector allowlist: ${relative}`);
|
|
39
41
|
return relative;
|
|
40
42
|
}
|
|
@@ -157,6 +159,25 @@ async function buildImplementationPatch({ repositoryRoot = process.cwd(), implem
|
|
|
157
159
|
});
|
|
158
160
|
}
|
|
159
161
|
|
|
162
|
+
async function buildVerifiedArtifactPatch({ repositoryRoot = process.cwd(), artifactBundle, issuerPublicKey, expectedSubject, now } = {}) {
|
|
163
|
+
const { verifyArtifactBundle } = require("./artifact-bundle.js");
|
|
164
|
+
const verification = verifyArtifactBundle(artifactBundle, { issuerPublicKey, expectedSubject, now });
|
|
165
|
+
const plan = await buildRepositoryPatch({
|
|
166
|
+
repositoryRoot,
|
|
167
|
+
changes: artifactBundle.artifacts.map((artifact) => ({
|
|
168
|
+
path: artifact.path,
|
|
169
|
+
content: artifact.content,
|
|
170
|
+
reason: `Signed Agent ID artifact bundle ${verification.bundle_hash}`
|
|
171
|
+
}))
|
|
172
|
+
});
|
|
173
|
+
return {
|
|
174
|
+
...plan,
|
|
175
|
+
artifact_bundle_hash: verification.bundle_hash,
|
|
176
|
+
artifact_bundle_source: { ...artifactBundle.source },
|
|
177
|
+
artifact_bundle_expires_at: artifactBundle.expires_at
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
|
|
160
181
|
function validatePlan(plan, expectedRoot) {
|
|
161
182
|
if (!plan || plan.schema !== "agentx-repository-patch-plan-v1" || plan.approval_required !== true) throw connectorError("CONNECTOR_INVALID_PLAN", "Invalid repository patch plan.");
|
|
162
183
|
const root = rootPath(plan.repository_root);
|
|
@@ -216,6 +237,9 @@ async function applyRepositoryPatch(plan, { approved = false } = {}) {
|
|
|
216
237
|
repository_root: root,
|
|
217
238
|
applied: true,
|
|
218
239
|
default_branch_push: false,
|
|
240
|
+
artifact_bundle_hash: String(plan.artifact_bundle_hash || ""),
|
|
241
|
+
artifact_bundle_source: plan.artifact_bundle_source ? { ...plan.artifact_bundle_source } : null,
|
|
242
|
+
artifact_bundle_expires_at: Number(plan.artifact_bundle_expires_at || 0),
|
|
219
243
|
applied_changes: appliedChanges,
|
|
220
244
|
rollback_entries: rollbackEntries
|
|
221
245
|
};
|
|
@@ -249,13 +273,16 @@ async function rollbackRepositoryPatch(receiptOrRoot, { approved = false } = {})
|
|
|
249
273
|
else await fs.rm(absolute, { force: true });
|
|
250
274
|
restored.push({ path: relative, before_sha256: item.before_sha256 });
|
|
251
275
|
}
|
|
252
|
-
return { schema: "agentx-repository-rollback-receipt-v1", connector: "repository_patch", plan_id: receipt.plan_id || "", rolled_back: true, files: restored };
|
|
276
|
+
return { schema: "agentx-repository-rollback-receipt-v1", connector: "repository_patch", plan_id: receipt.plan_id || "", artifact_bundle_hash: String(receipt.artifact_bundle_hash || ""), rolled_back: true, files: restored };
|
|
253
277
|
}
|
|
254
278
|
|
|
255
279
|
module.exports = {
|
|
256
280
|
ALLOWED_PATHS,
|
|
281
|
+
AUTO_MANAGED_PATHS,
|
|
282
|
+
REVIEW_ONLY_PATHS,
|
|
257
283
|
buildRepositoryPatch,
|
|
258
284
|
buildImplementationPatch,
|
|
285
|
+
buildVerifiedArtifactPatch,
|
|
259
286
|
deploymentChanges,
|
|
260
287
|
applyRepositoryPatch,
|
|
261
288
|
rollbackRepositoryPatch,
|
package/site-agent.js
CHANGED
|
@@ -362,7 +362,7 @@ function createSiteAgentClient(options = {}) {
|
|
|
362
362
|
ts: payload.ts == null ? Math.floor(Date.now() / 1000) : payload.ts,
|
|
363
363
|
data: {
|
|
364
364
|
runtime: payload.runtime || "server_side_site_agent",
|
|
365
|
-
sdk_version: payload.sdk_version || payload.sdkVersion || "0.
|
|
365
|
+
sdk_version: payload.sdk_version || payload.sdkVersion || "0.2.0"
|
|
366
366
|
}
|
|
367
367
|
}]
|
|
368
368
|
});
|
|
@@ -379,7 +379,7 @@ function createSiteAgentClient(options = {}) {
|
|
|
379
379
|
ts: payload.ts == null ? Math.floor(Date.now() / 1000) : payload.ts,
|
|
380
380
|
data: {
|
|
381
381
|
runtime: payload.runtime || "server_side_site_agent",
|
|
382
|
-
sdk_version: payload.sdk_version || payload.sdkVersion || "0.
|
|
382
|
+
sdk_version: payload.sdk_version || payload.sdkVersion || "0.2.0"
|
|
383
383
|
}
|
|
384
384
|
}]
|
|
385
385
|
});
|
|
@@ -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.
|
|
128
|
+
const runtimeVersion = String(options.runtimeVersion || process.env.AGENT_ID_RUNTIME_VERSION || "0.2.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:
|
|
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)
|
|
87
|
-
|
|
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] = {
|
|
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
|
|
121
|
-
|
|
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
|
},
|