@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/browser-sdk.js
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
(function () {
|
|
2
|
+
"use strict";
|
|
3
|
+
var w = window;
|
|
4
|
+
var d = document;
|
|
5
|
+
var script = d.currentScript || {};
|
|
6
|
+
var previous = w.twin3AEO || {};
|
|
7
|
+
var endpoint = previous.endpoint || (script.getAttribute && script.getAttribute("data-endpoint")) || "/api/agent/sdk_event";
|
|
8
|
+
var agentId = previous.agentId || (script.getAttribute && script.getAttribute("data-agent-id")) || "";
|
|
9
|
+
var defaultEvent = previous.event || "page_view";
|
|
10
|
+
var aiReferrer = /(chatgpt|openai|perplexity|gemini|bard|claude|anthropic|copilot|bing|you\.com|poe\.com)/i;
|
|
11
|
+
|
|
12
|
+
function clip(value, limit) {
|
|
13
|
+
value = value == null ? "" : String(value);
|
|
14
|
+
return value.length > limit ? value.slice(0, limit) : value;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function attr(element, name) {
|
|
18
|
+
return element && element.getAttribute ? (element.getAttribute(name) || "") : "";
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function text(element) {
|
|
22
|
+
return clip((element && element.textContent || "").replace(/\s+/g, " ").trim(), 160);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function meta(name) {
|
|
26
|
+
var element = d.querySelector('meta[name="' + name + '"],meta[property="' + name + '"]');
|
|
27
|
+
return attr(element, "content");
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function hasAccessibleName(element) {
|
|
31
|
+
if (!element) return false;
|
|
32
|
+
return !!(attr(element, "aria-label") || attr(element, "title") || attr(element, "alt") || attr(element, "placeholder") || attr(element, "name") || text(element));
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function cleanData(value, depth) {
|
|
36
|
+
if (!value || typeof value !== "object" || depth > 2) return value;
|
|
37
|
+
var output = Array.isArray(value) ? [] : {};
|
|
38
|
+
Object.keys(value).slice(0, 40).forEach(function (key) {
|
|
39
|
+
var low = key.toLowerCase();
|
|
40
|
+
if (/email|phone|tel|name|note|message|token|key|secret|password/.test(low)) {
|
|
41
|
+
output[key] = "[redacted]";
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
var item = value[key];
|
|
45
|
+
if (typeof item === "string") output[key] = clip(item, 160);
|
|
46
|
+
else if (typeof item === "number" || typeof item === "boolean") output[key] = item;
|
|
47
|
+
else if (item && typeof item === "object") output[key] = cleanData(item, depth + 1);
|
|
48
|
+
});
|
|
49
|
+
return output;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function collectSignals() {
|
|
53
|
+
var forms = Array.prototype.slice.call(d.querySelectorAll("form"));
|
|
54
|
+
var webmcpForms = forms.filter(function (form) { return attr(form, "toolname") && attr(form, "tooldescription"); });
|
|
55
|
+
var badToolInputs = 0;
|
|
56
|
+
webmcpForms.forEach(function (form) {
|
|
57
|
+
Array.prototype.slice.call(form.querySelectorAll("input,select,textarea")).forEach(function (element) {
|
|
58
|
+
if ((attr(element, "type") || "").toLowerCase() === "hidden") return;
|
|
59
|
+
if (!attr(element, "name")) badToolInputs += 1;
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
var interactive = Array.prototype.slice.call(d.querySelectorAll("a,button,input,select,textarea"));
|
|
63
|
+
var namedInteractive = interactive.filter(function (element) {
|
|
64
|
+
if (element.tagName === "INPUT" && (attr(element, "type") || "").toLowerCase() === "hidden") return true;
|
|
65
|
+
return hasAccessibleName(element);
|
|
66
|
+
});
|
|
67
|
+
var images = Array.prototype.slice.call(d.querySelectorAll("img"));
|
|
68
|
+
var missingImageSize = images.filter(function (image) { return !(attr(image, "width") && attr(image, "height")); });
|
|
69
|
+
var referrer = d.referrer || "";
|
|
70
|
+
var referrerHost = "";
|
|
71
|
+
try { referrerHost = referrer ? (new URL(referrer)).hostname : ""; } catch (_) { referrerHost = ""; }
|
|
72
|
+
return {
|
|
73
|
+
title_len: (d.title || "").length,
|
|
74
|
+
description_len: meta("description").length,
|
|
75
|
+
canonical: !!d.querySelector('link[rel~="canonical"]'),
|
|
76
|
+
viewport: !!meta("viewport"),
|
|
77
|
+
lang: clip(d.documentElement && d.documentElement.lang || "", 24),
|
|
78
|
+
h1_count: d.querySelectorAll("h1").length,
|
|
79
|
+
jsonld_count: d.querySelectorAll('script[type="application/ld+json"]').length,
|
|
80
|
+
links: d.querySelectorAll("a[href]").length,
|
|
81
|
+
forms: forms.length,
|
|
82
|
+
webmcp_forms: webmcpForms.length,
|
|
83
|
+
webmcp_bad_inputs: badToolInputs,
|
|
84
|
+
interactive: interactive.length,
|
|
85
|
+
named_interactive: namedInteractive.length,
|
|
86
|
+
images: images.length,
|
|
87
|
+
images_missing_size: missingImageSize.length,
|
|
88
|
+
referrer_host: clip(referrerHost, 80),
|
|
89
|
+
ai_referrer: aiReferrer.test(referrer)
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function payload(event, data) {
|
|
94
|
+
var output = {
|
|
95
|
+
sdk: "agent-id-browser-sdk",
|
|
96
|
+
sdk_version: "0.3.0",
|
|
97
|
+
event: event || defaultEvent,
|
|
98
|
+
agent_id: agentId,
|
|
99
|
+
url: location.href,
|
|
100
|
+
path: location.pathname
|
|
101
|
+
};
|
|
102
|
+
if (data) output.data = cleanData(data, 0);
|
|
103
|
+
return JSON.stringify(output);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function post(body) {
|
|
107
|
+
if (navigator.sendBeacon) {
|
|
108
|
+
var accepted = navigator.sendBeacon(endpoint, new Blob([body], { type: "application/json" }));
|
|
109
|
+
if (accepted) return;
|
|
110
|
+
}
|
|
111
|
+
fetch(endpoint, {
|
|
112
|
+
method: "POST",
|
|
113
|
+
headers: { "content-type": "application/json" },
|
|
114
|
+
body: body,
|
|
115
|
+
keepalive: true,
|
|
116
|
+
credentials: "omit"
|
|
117
|
+
}).catch(function () {});
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function track(event, data) {
|
|
121
|
+
post(payload(event, data));
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function signal(extra) {
|
|
125
|
+
track("page_signal", { signals: collectSignals(), extra: extra || {} });
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function conversion(name, data) {
|
|
129
|
+
track("conversion", { conversion: clip(name || "conversion", 80), value: cleanData(data || {}, 0) });
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
w.twin3AEO = {
|
|
133
|
+
agentId: agentId,
|
|
134
|
+
endpoint: endpoint,
|
|
135
|
+
version: "0.3.0",
|
|
136
|
+
track: track,
|
|
137
|
+
signal: signal,
|
|
138
|
+
conversion: conversion,
|
|
139
|
+
collectSignals: collectSignals
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
function boot() {
|
|
143
|
+
track("page_view");
|
|
144
|
+
setTimeout(function () { signal(); }, 700);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
if (d.readyState === "loading") d.addEventListener("DOMContentLoaded", boot, { once: true });
|
|
148
|
+
else boot();
|
|
149
|
+
})();
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const { deploymentChanges } = require("./repository-connector.js");
|
|
4
|
+
const { ALLOWED_PATHS } = require("./repository-connector.js");
|
|
5
|
+
|
|
6
|
+
function normalizeVerifiedRoute(value) {
|
|
7
|
+
const route = "/" + String(value || "").replace(/^\/+/, "");
|
|
8
|
+
const relative = route.slice(1);
|
|
9
|
+
if (!ALLOWED_PATHS.has(relative) || relative.includes("..")) throw new Error("cloudflare_route_not_verified");
|
|
10
|
+
return route;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function buildWorkerAssetManifest({ implementationBundle, knowledgePack } = {}) {
|
|
14
|
+
const changes = deploymentChanges({ implementationBundle, knowledgePack });
|
|
15
|
+
const assets = {};
|
|
16
|
+
for (const change of changes) {
|
|
17
|
+
const route = "/" + change.path;
|
|
18
|
+
assets[route] = {
|
|
19
|
+
content_type: change.content_type || "text/plain; charset=utf-8",
|
|
20
|
+
content: change.content
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
return {
|
|
24
|
+
schema: "agentx-cloudflare-worker-assets-v1",
|
|
25
|
+
status: "ready_for_external_deployment",
|
|
26
|
+
deployment_claim: "not_deployed",
|
|
27
|
+
routes: Object.keys(assets).sort(),
|
|
28
|
+
assets,
|
|
29
|
+
security: {
|
|
30
|
+
credentials_in_manifest: false,
|
|
31
|
+
exact_path_get_or_head_only: true,
|
|
32
|
+
owner_deployment_required: true
|
|
33
|
+
},
|
|
34
|
+
non_claims: [
|
|
35
|
+
"adapter_does_not_call_cloudflare_api",
|
|
36
|
+
"manifest_is_not_deployment_receipt",
|
|
37
|
+
"owner_must_deploy_and_verify_routes"
|
|
38
|
+
]
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function buildWorkerModule(manifest) {
|
|
43
|
+
if (!manifest || manifest.schema !== "agentx-cloudflare-worker-assets-v1") {
|
|
44
|
+
throw new Error("Invalid Worker asset manifest.");
|
|
45
|
+
}
|
|
46
|
+
const assets = JSON.stringify(manifest.assets).replace(/</g, "\\u003c");
|
|
47
|
+
return `const ASSETS = ${assets};
|
|
48
|
+
export default {
|
|
49
|
+
async fetch(request) {
|
|
50
|
+
const method = String(request && request.method || "GET").toUpperCase();
|
|
51
|
+
if (!['GET', 'HEAD'].includes(method)) {
|
|
52
|
+
return new Response("Method Not Allowed", { status: 405, headers: { Allow: "GET, HEAD" } });
|
|
53
|
+
}
|
|
54
|
+
const pathname = new URL(request.url).pathname;
|
|
55
|
+
const asset = ASSETS[pathname];
|
|
56
|
+
if (!asset) return new Response("Not Found", { status: 404 });
|
|
57
|
+
return new Response(method === "HEAD" ? null : asset.content, {
|
|
58
|
+
status: 200,
|
|
59
|
+
headers: {
|
|
60
|
+
"Content-Type": asset.content_type,
|
|
61
|
+
"Cache-Control": "public, max-age=300",
|
|
62
|
+
"X-Content-Type-Options": "nosniff"
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
};
|
|
67
|
+
`;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function createCloudflareWorkerConnector({ verifiedRoutes = [], deployVerifiedRoutes, verifyDeployment, rollbackDeployment } = {}) {
|
|
71
|
+
const allowed = new Set(verifiedRoutes.map(normalizeVerifiedRoute));
|
|
72
|
+
return Object.freeze({
|
|
73
|
+
describeCapabilities() {
|
|
74
|
+
return { connector: "cloudflare_worker", operations: ["upsert", "replace"], verified_routes: [...allowed].sort(), owner_callback_required: true };
|
|
75
|
+
},
|
|
76
|
+
async readResource(resource) {
|
|
77
|
+
const route = normalizeVerifiedRoute(resource && resource.path);
|
|
78
|
+
if (!allowed.has(route)) throw new Error("cloudflare_route_not_verified");
|
|
79
|
+
return { connector: "cloudflare_worker", environment: String(resource.environment || "production"), resource_type: "route", resource_id: String(resource.resource_id || route), path: route, sha256: String(resource.sha256 || "") };
|
|
80
|
+
},
|
|
81
|
+
async planOperations(task) {
|
|
82
|
+
if (!task.operations.every((operation) => allowed.has(normalizeVerifiedRoute(operation.path)))) throw new Error("cloudflare_route_not_verified");
|
|
83
|
+
return { schema: "agentx-cloudflare-owner-plan-v1", approval_required: true, operations: task.operations.map((operation) => ({ operation: operation.operation, route: normalizeVerifiedRoute(operation.path), content: operation.value })) };
|
|
84
|
+
},
|
|
85
|
+
async applyOperations(plan) {
|
|
86
|
+
if (typeof deployVerifiedRoutes !== "function") throw new Error("cloudflare_owner_deployment_required");
|
|
87
|
+
return deployVerifiedRoutes(plan);
|
|
88
|
+
},
|
|
89
|
+
async verifyLocalResult(receipt) {
|
|
90
|
+
if (typeof verifyDeployment !== "function") throw new Error("cloudflare_owner_verification_required");
|
|
91
|
+
return verifyDeployment(receipt);
|
|
92
|
+
},
|
|
93
|
+
async rollback(receipt) {
|
|
94
|
+
if (typeof rollbackDeployment !== "function") throw new Error("cloudflare_owner_rollback_required");
|
|
95
|
+
return rollbackDeployment(receipt);
|
|
96
|
+
}
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
module.exports = { buildWorkerAssetManifest, buildWorkerModule, createCloudflareWorkerConnector };
|
package/domain-proof.js
ADDED
|
@@ -0,0 +1,208 @@
|
|
|
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 PROOF_SCHEMA = "agentx-domain-proof-v1";
|
|
8
|
+
const CHALLENGE_SCHEMA = "agentx-domain-challenge-v1";
|
|
9
|
+
const SITE_VERIFICATION_SCHEMA = "agentx-site-agent-verification-v1";
|
|
10
|
+
const PROOF_RELATIVE_PATH = path.join(".well-known", "agent-id.json");
|
|
11
|
+
|
|
12
|
+
function proofError(code, message) {
|
|
13
|
+
const error = new Error(`${code}: ${message}`);
|
|
14
|
+
error.code = code;
|
|
15
|
+
return error;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function requiredString(value, field) {
|
|
19
|
+
if (typeof value !== "string" || !value.trim()) {
|
|
20
|
+
throw proofError("invalid_domain_challenge", `${field} is required`);
|
|
21
|
+
}
|
|
22
|
+
return value.trim();
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function exactHostname(value) {
|
|
26
|
+
const host = requiredString(value, "host").toLowerCase();
|
|
27
|
+
if (host.length > 253 || !/^[a-z0-9.-]+$/.test(host)) {
|
|
28
|
+
throw proofError("invalid_domain_challenge", "host must be an ASCII hostname");
|
|
29
|
+
}
|
|
30
|
+
const labels = host.split(".");
|
|
31
|
+
if (labels.some(label => !label || label.length > 63 || !/^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/.test(label))) {
|
|
32
|
+
throw proofError("invalid_domain_challenge", "host contains an invalid DNS label");
|
|
33
|
+
}
|
|
34
|
+
return host;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function buildWellKnownProofDocument(challenge, { now = null } = {}) {
|
|
38
|
+
const isChallenge = challenge && challenge.schema === CHALLENGE_SCHEMA && challenge.method === "well_known";
|
|
39
|
+
const isProof = challenge && challenge.schema === PROOF_SCHEMA;
|
|
40
|
+
if (!isChallenge && !isProof) {
|
|
41
|
+
throw proofError("invalid_domain_challenge", "a well_known challenge is required");
|
|
42
|
+
}
|
|
43
|
+
const host = exactHostname(challenge.host);
|
|
44
|
+
const expiresAt = Number(challenge.expires_at);
|
|
45
|
+
if (!Number.isInteger(expiresAt) || expiresAt <= 0) {
|
|
46
|
+
throw proofError("invalid_domain_challenge", "expires_at must be an integer timestamp");
|
|
47
|
+
}
|
|
48
|
+
if (now !== null && expiresAt <= Number(now)) {
|
|
49
|
+
throw proofError("domain_challenge_expired", "request a new challenge before publishing proof");
|
|
50
|
+
}
|
|
51
|
+
return {
|
|
52
|
+
schema: PROOF_SCHEMA,
|
|
53
|
+
challenge_id: requiredString(challenge.challenge_id, "challenge_id"),
|
|
54
|
+
proof: requiredString(challenge.proof, "proof"),
|
|
55
|
+
agent_id: requiredString(challenge.agent_id, "agent_id"),
|
|
56
|
+
tenant_id: requiredString(challenge.tenant_id, "tenant_id"),
|
|
57
|
+
host,
|
|
58
|
+
environment: requiredString(challenge.environment, "environment"),
|
|
59
|
+
public_key_thumbprint: requiredString(challenge.public_key_thumbprint, "public_key_thumbprint"),
|
|
60
|
+
expires_at: expiresAt,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function buildSiteAgentVerificationDocument(registration) {
|
|
65
|
+
if (!registration || registration.ok !== true || !registration.verification) {
|
|
66
|
+
throw proofError("invalid_site_registration", "a successful Site Agent registration is required");
|
|
67
|
+
}
|
|
68
|
+
const host = exactHostname(registration.host);
|
|
69
|
+
const verificationToken = requiredString(registration.verification.token, "verification.token");
|
|
70
|
+
if (!verificationToken.startsWith("av_")) {
|
|
71
|
+
throw proofError("invalid_site_registration", "verification.token must be an Agent ID verification token");
|
|
72
|
+
}
|
|
73
|
+
return {
|
|
74
|
+
schema: SITE_VERIFICATION_SCHEMA,
|
|
75
|
+
agent_id: requiredString(registration.agent_id, "agent_id"),
|
|
76
|
+
host,
|
|
77
|
+
verification_token: verificationToken,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function resolveProofTarget({ projectRoot, publicRoot }) {
|
|
82
|
+
const project = path.resolve(String(projectRoot || publicRoot || ""));
|
|
83
|
+
const root = path.resolve(String(publicRoot || projectRoot || ""));
|
|
84
|
+
if (root !== project && !root.startsWith(project + path.sep)) {
|
|
85
|
+
throw proofError("unsafe_public_root", "publicRoot must be inside the customer project");
|
|
86
|
+
}
|
|
87
|
+
return { project, root, directory: path.join(root, ".well-known"), target: path.join(root, PROOF_RELATIVE_PATH) };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async function readExisting(target) {
|
|
91
|
+
try {
|
|
92
|
+
const stat = await fs.lstat(target);
|
|
93
|
+
if (stat.isSymbolicLink() || !stat.isFile()) {
|
|
94
|
+
throw proofError("unsafe_proof_target", "proof target must be a regular file");
|
|
95
|
+
}
|
|
96
|
+
return await fs.readFile(target, "utf8");
|
|
97
|
+
} catch (error) {
|
|
98
|
+
if (error.code === "ENOENT") return null;
|
|
99
|
+
throw error;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async function writeWellKnownProofDocument({ challenge, projectRoot, publicRoot, approved = false, now }) {
|
|
104
|
+
if (approved !== true) {
|
|
105
|
+
throw proofError("approval_required", "writing a public domain proof requires explicit approval");
|
|
106
|
+
}
|
|
107
|
+
const document = buildWellKnownProofDocument(challenge, {
|
|
108
|
+
now: now === undefined ? Math.floor(Date.now() / 1000) : now,
|
|
109
|
+
});
|
|
110
|
+
const { project, root, directory, target } = resolveProofTarget({ projectRoot, publicRoot });
|
|
111
|
+
let realProject;
|
|
112
|
+
let realRoot;
|
|
113
|
+
try {
|
|
114
|
+
[realProject, realRoot] = await Promise.all([fs.realpath(project), fs.realpath(root)]);
|
|
115
|
+
} catch (error) {
|
|
116
|
+
throw proofError("invalid_public_root", "projectRoot and publicRoot must already exist");
|
|
117
|
+
}
|
|
118
|
+
if (realRoot !== realProject && !realRoot.startsWith(realProject + path.sep)) {
|
|
119
|
+
throw proofError("unsafe_public_root", "publicRoot cannot escape the customer project through a symlink");
|
|
120
|
+
}
|
|
121
|
+
await fs.mkdir(directory, { recursive: true, mode: 0o755 });
|
|
122
|
+
const directoryStat = await fs.lstat(directory);
|
|
123
|
+
if (directoryStat.isSymbolicLink() || !directoryStat.isDirectory()) {
|
|
124
|
+
throw proofError("unsafe_proof_target", ".well-known must be a real directory");
|
|
125
|
+
}
|
|
126
|
+
const existing = await readExisting(target);
|
|
127
|
+
if (existing !== null) {
|
|
128
|
+
let parsed;
|
|
129
|
+
try { parsed = JSON.parse(existing); } catch (_error) { parsed = null; }
|
|
130
|
+
if (!parsed || parsed.schema !== PROOF_SCHEMA || parsed.host !== document.host || parsed.agent_id !== document.agent_id) {
|
|
131
|
+
throw proofError("unmanaged_proof_conflict", "refusing to overwrite a customer-managed file");
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
const serialized = `${JSON.stringify(document, null, 2)}\n`;
|
|
135
|
+
const temporary = `${target}.tmp-${process.pid}-${crypto.randomBytes(6).toString("hex")}`;
|
|
136
|
+
try {
|
|
137
|
+
await fs.writeFile(temporary, serialized, { mode: 0o644, flag: "wx" });
|
|
138
|
+
await fs.rename(temporary, target);
|
|
139
|
+
} finally {
|
|
140
|
+
await fs.rm(temporary, { force: true }).catch(() => {});
|
|
141
|
+
}
|
|
142
|
+
return {
|
|
143
|
+
schema: "agentx-domain-proof-write-receipt-v1",
|
|
144
|
+
changed: existing !== serialized,
|
|
145
|
+
path: target,
|
|
146
|
+
relative_path: "/.well-known/agent-id.json",
|
|
147
|
+
sha256: `sha256:${crypto.createHash("sha256").update(serialized).digest("hex")}`,
|
|
148
|
+
document,
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
async function writeSiteAgentVerificationDocument({ registration, projectRoot, publicRoot, approved = false }) {
|
|
153
|
+
if (approved !== true) {
|
|
154
|
+
throw proofError("approval_required", "writing a public domain verification document requires explicit approval");
|
|
155
|
+
}
|
|
156
|
+
const document = buildSiteAgentVerificationDocument(registration);
|
|
157
|
+
const { project, root, directory, target } = resolveProofTarget({ projectRoot, publicRoot });
|
|
158
|
+
let realProject;
|
|
159
|
+
let realRoot;
|
|
160
|
+
try {
|
|
161
|
+
[realProject, realRoot] = await Promise.all([fs.realpath(project), fs.realpath(root)]);
|
|
162
|
+
} catch (_error) {
|
|
163
|
+
throw proofError("invalid_public_root", "projectRoot and publicRoot must already exist");
|
|
164
|
+
}
|
|
165
|
+
if (realRoot !== realProject && !realRoot.startsWith(realProject + path.sep)) {
|
|
166
|
+
throw proofError("unsafe_public_root", "publicRoot cannot escape the customer project through a symlink");
|
|
167
|
+
}
|
|
168
|
+
await fs.mkdir(directory, { recursive: true, mode: 0o755 });
|
|
169
|
+
const directoryStat = await fs.lstat(directory);
|
|
170
|
+
if (directoryStat.isSymbolicLink() || !directoryStat.isDirectory()) {
|
|
171
|
+
throw proofError("unsafe_proof_target", ".well-known must be a real directory");
|
|
172
|
+
}
|
|
173
|
+
const existing = await readExisting(target);
|
|
174
|
+
if (existing !== null) {
|
|
175
|
+
let parsed;
|
|
176
|
+
try { parsed = JSON.parse(existing); } catch (_error) { parsed = null; }
|
|
177
|
+
if (!parsed || parsed.schema !== SITE_VERIFICATION_SCHEMA || parsed.host !== document.host || parsed.agent_id !== document.agent_id) {
|
|
178
|
+
throw proofError("unmanaged_proof_conflict", "refusing to overwrite a customer-managed file");
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
const serialized = `${JSON.stringify(document, null, 2)}\n`;
|
|
182
|
+
const temporary = `${target}.tmp-${process.pid}-${crypto.randomBytes(6).toString("hex")}`;
|
|
183
|
+
try {
|
|
184
|
+
await fs.writeFile(temporary, serialized, { mode: 0o644, flag: "wx" });
|
|
185
|
+
await fs.rename(temporary, target);
|
|
186
|
+
} finally {
|
|
187
|
+
await fs.rm(temporary, { force: true }).catch(() => {});
|
|
188
|
+
}
|
|
189
|
+
return {
|
|
190
|
+
schema: "agentx-site-agent-verification-write-receipt-v1",
|
|
191
|
+
changed: existing !== serialized,
|
|
192
|
+
path: target,
|
|
193
|
+
relative_path: "/.well-known/agent-id.json",
|
|
194
|
+
sha256: `sha256:${crypto.createHash("sha256").update(serialized).digest("hex")}`,
|
|
195
|
+
document,
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
module.exports = {
|
|
200
|
+
PROOF_SCHEMA,
|
|
201
|
+
SITE_VERIFICATION_SCHEMA,
|
|
202
|
+
PROOF_RELATIVE_PATH,
|
|
203
|
+
buildWellKnownProofDocument,
|
|
204
|
+
buildSiteAgentVerificationDocument,
|
|
205
|
+
resolveProofTarget,
|
|
206
|
+
writeWellKnownProofDocument,
|
|
207
|
+
writeSiteAgentVerificationDocument,
|
|
208
|
+
};
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const fs = require("node:fs");
|
|
4
|
+
const path = require("node:path");
|
|
5
|
+
const crypto = require("node:crypto");
|
|
6
|
+
|
|
7
|
+
function enterpriseError(code) {
|
|
8
|
+
const error = new Error(code);
|
|
9
|
+
error.code = code;
|
|
10
|
+
return error;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function normalizeOrigin(value) {
|
|
14
|
+
let parsed;
|
|
15
|
+
try { parsed = new URL(String(value || "")); } catch (_error) { throw enterpriseError("invalid_service_origin"); }
|
|
16
|
+
if (!['http:', 'https:'].includes(parsed.protocol) || parsed.username || parsed.password || parsed.pathname !== "/" || parsed.search || parsed.hash) {
|
|
17
|
+
throw enterpriseError("invalid_service_origin");
|
|
18
|
+
}
|
|
19
|
+
return parsed.origin;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function normalizeSite(value) {
|
|
23
|
+
let parsed;
|
|
24
|
+
try { parsed = new URL(String(value || "")); } catch (_error) { throw enterpriseError("invalid_site_url"); }
|
|
25
|
+
if (parsed.protocol !== 'https:' || parsed.username || parsed.password || !parsed.hostname || (parsed.port && parsed.port !== "443")) throw enterpriseError("invalid_site_url");
|
|
26
|
+
return { url: parsed.toString(), host: parsed.hostname.toLowerCase() };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function atomicPrivateWrite(keyPath, privatePem) {
|
|
30
|
+
const target = path.resolve(String(keyPath || ""));
|
|
31
|
+
if (!keyPath) throw enterpriseError("key_path_required");
|
|
32
|
+
const directory = path.dirname(target);
|
|
33
|
+
fs.mkdirSync(directory, { recursive: true, mode: 0o700 });
|
|
34
|
+
if (fs.lstatSync(directory).isSymbolicLink()) throw enterpriseError("private_key_directory_invalid");
|
|
35
|
+
fs.chmodSync(directory, 0o700);
|
|
36
|
+
const temporary = `${target}.${process.pid}.${crypto.randomUUID()}.tmp`;
|
|
37
|
+
try {
|
|
38
|
+
fs.writeFileSync(temporary, privatePem, { encoding: "utf8", mode: 0o600, flag: "wx" });
|
|
39
|
+
fs.renameSync(temporary, target);
|
|
40
|
+
fs.chmodSync(target, 0o600);
|
|
41
|
+
} finally {
|
|
42
|
+
try { fs.rmSync(temporary, { force: true }); } catch (_error) {}
|
|
43
|
+
}
|
|
44
|
+
return target;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function generateSiteAgentIdentity({ keyPath } = {}) {
|
|
48
|
+
const target = path.resolve(String(keyPath || ""));
|
|
49
|
+
if (!keyPath) throw enterpriseError("key_path_required");
|
|
50
|
+
let publicKey;
|
|
51
|
+
if (fs.existsSync(target)) {
|
|
52
|
+
const mode = fs.statSync(target).mode & 0o777;
|
|
53
|
+
if (mode !== 0o600) throw enterpriseError("private_key_permissions_invalid");
|
|
54
|
+
publicKey = crypto.createPublicKey(crypto.createPrivateKey(fs.readFileSync(target, "utf8")));
|
|
55
|
+
} else {
|
|
56
|
+
const pair = crypto.generateKeyPairSync("ed25519");
|
|
57
|
+
atomicPrivateWrite(target, pair.privateKey.export({ type: "pkcs8", format: "pem" }));
|
|
58
|
+
publicKey = pair.publicKey;
|
|
59
|
+
}
|
|
60
|
+
const publicPem = publicKey.export({ type: "spki", format: "pem" });
|
|
61
|
+
const publicDer = publicKey.export({ type: "spki", format: "der" });
|
|
62
|
+
return {
|
|
63
|
+
schema: "agentx-customer-identity-v1",
|
|
64
|
+
algorithm: "Ed25519",
|
|
65
|
+
public_key_pem: publicPem,
|
|
66
|
+
public_key_thumbprint: `sha256:${crypto.createHash("sha256").update(publicDer).digest("base64url")}`,
|
|
67
|
+
private_key_persisted_locally: true,
|
|
68
|
+
key_path: target
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function stableValue(value) {
|
|
73
|
+
if (Array.isArray(value)) return value.map(stableValue);
|
|
74
|
+
if (!value || typeof value !== "object") return value;
|
|
75
|
+
return Object.fromEntries(Object.keys(value).sort().map((key) => [key, stableValue(value[key])]));
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function signaturePayload(challenge) {
|
|
79
|
+
const fields = ["challenge_id", "nonce", "credential_id", "requested_scopes", "tenant_id", "host", "environment", "agent_id", "issued_at", "expires_at"];
|
|
80
|
+
const payload = Object.fromEntries(fields.map((field) => [field, challenge[field]]));
|
|
81
|
+
return Buffer.from(JSON.stringify(stableValue(payload)), "utf8");
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function signChallenge(identity, challenge, { now = Math.floor(Date.now() / 1000) } = {}) {
|
|
85
|
+
if (!identity || !identity.key_path || !identity.subject || !challenge || challenge.schema !== "agentx-token-exchange-challenge-v1") throw enterpriseError("challenge_binding_mismatch");
|
|
86
|
+
const subject = identity.subject;
|
|
87
|
+
const exactFields = ["tenant_id", "host", "environment", "agent_id", "credential_id"];
|
|
88
|
+
if (exactFields.some((field) => challenge[field] !== subject[field])) throw enterpriseError("challenge_binding_mismatch");
|
|
89
|
+
const challengeScopes = [...new Set(challenge.requested_scopes || [])].sort();
|
|
90
|
+
const subjectScopes = [...new Set(subject.requested_scopes || [])].sort();
|
|
91
|
+
if (JSON.stringify(challengeScopes) !== JSON.stringify(subjectScopes) || normalizeOrigin(challenge.service_origin) !== normalizeOrigin(identity.service_origin)) {
|
|
92
|
+
throw enterpriseError("challenge_binding_mismatch");
|
|
93
|
+
}
|
|
94
|
+
if (!Number.isFinite(challenge.issued_at) || !Number.isFinite(challenge.expires_at) || now < challenge.issued_at || now >= challenge.expires_at) {
|
|
95
|
+
throw enterpriseError("challenge_time_invalid");
|
|
96
|
+
}
|
|
97
|
+
const privateKey = crypto.createPrivateKey(fs.readFileSync(identity.key_path, "utf8"));
|
|
98
|
+
return {
|
|
99
|
+
challenge_id: challenge.challenge_id,
|
|
100
|
+
signature: crypto.sign(null, signaturePayload(challenge), privateKey).toString("base64url")
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
async function responseJson(response) {
|
|
105
|
+
const payload = await response.json();
|
|
106
|
+
if (!response.ok) throw enterpriseError(payload && payload.error ? payload.error : `enterprise_http_${response.status}`);
|
|
107
|
+
return payload;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function createEnterpriseSession({ serviceOrigin, siteUrl, environment, credential, identity, fetch: fetcher = globalThis.fetch, now = () => Math.floor(Date.now() / 1000) } = {}) {
|
|
111
|
+
const origin = normalizeOrigin(serviceOrigin);
|
|
112
|
+
const site = normalizeSite(siteUrl);
|
|
113
|
+
if (!credential || !identity || typeof fetcher !== "function") throw enterpriseError("enterprise_session_invalid");
|
|
114
|
+
const subject = {
|
|
115
|
+
tenant_id: credential.tenant_id,
|
|
116
|
+
host: credential.host,
|
|
117
|
+
environment,
|
|
118
|
+
agent_id: credential.agent_id,
|
|
119
|
+
credential_id: credential.credential_id,
|
|
120
|
+
requested_scopes: []
|
|
121
|
+
};
|
|
122
|
+
if (site.host !== subject.host || credential.environment && credential.environment !== environment) throw enterpriseError("enterprise_session_subject_mismatch");
|
|
123
|
+
let cachedToken = "";
|
|
124
|
+
let cachedExpiry = 0;
|
|
125
|
+
let cachedScopes = [];
|
|
126
|
+
|
|
127
|
+
async function lifecycleRequest(operation, body = {}) {
|
|
128
|
+
const scope = `credentials:${operation}`;
|
|
129
|
+
const accessToken = await session.getAccessToken([scope]);
|
|
130
|
+
return responseJson(await fetcher(`${origin}/api/site_agents/v1/credentials/${operation}`, {
|
|
131
|
+
method: "POST",
|
|
132
|
+
headers: { "content-type": "application/json", authorization: `Bearer ${accessToken}` },
|
|
133
|
+
body: JSON.stringify(body)
|
|
134
|
+
}));
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
async function managedRequest(pathname, { method = "GET", body = null } = {}) {
|
|
138
|
+
const scope = method === "GET" ? "insights:read" : "managed:write";
|
|
139
|
+
const accessToken = await session.getAccessToken([scope]);
|
|
140
|
+
const request = {
|
|
141
|
+
method,
|
|
142
|
+
headers: { "content-type": "application/json", authorization: `Bearer ${accessToken}` }
|
|
143
|
+
};
|
|
144
|
+
if (body !== null) request.body = JSON.stringify(body);
|
|
145
|
+
return responseJson(await fetcher(`${origin}/api/site_agents/v1/managed/${pathname}`, request));
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const session = {
|
|
149
|
+
schema: "agentx-enterprise-session-v1",
|
|
150
|
+
site_url: site.url,
|
|
151
|
+
environment,
|
|
152
|
+
async getAccessToken(scopes = ["tasks:read"]) {
|
|
153
|
+
if (!Array.isArray(scopes) || !scopes.length || scopes.some((scope) => typeof scope !== "string" || !scope)) throw enterpriseError("invalid_scope_set");
|
|
154
|
+
const requested = [...new Set(scopes)].sort();
|
|
155
|
+
if (cachedToken && now() + 60 < cachedExpiry && requested.every((scope) => cachedScopes.includes(scope))) return cachedToken;
|
|
156
|
+
const challengePayload = await responseJson(await fetcher(`${origin}/api/site_agents/v1/token/challenge`, {
|
|
157
|
+
method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ credential_id: credential.credential_id, scopes: requested })
|
|
158
|
+
}));
|
|
159
|
+
const challenge = { ...challengePayload.challenge, service_origin: origin };
|
|
160
|
+
const signed = signChallenge({ ...identity, subject: { ...subject, requested_scopes: requested }, service_origin: origin }, challenge, { now: now() });
|
|
161
|
+
const tokenPayload = await responseJson(await fetcher(`${origin}/api/site_agents/v1/token`, {
|
|
162
|
+
method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ credential_id: credential.credential_id, challenge_id: signed.challenge_id, signature: signed.signature, bootstrap_secret: credential.bootstrap_secret, scopes: requested })
|
|
163
|
+
}));
|
|
164
|
+
cachedToken = tokenPayload.access_token;
|
|
165
|
+
cachedExpiry = Number(tokenPayload.claims && tokenPayload.claims.exp || 0);
|
|
166
|
+
cachedScopes = requested;
|
|
167
|
+
return cachedToken;
|
|
168
|
+
},
|
|
169
|
+
async rotateCredential() {
|
|
170
|
+
return lifecycleRequest("rotate");
|
|
171
|
+
},
|
|
172
|
+
async revokeCredential(reason) {
|
|
173
|
+
const normalizedReason = String(reason || "").trim();
|
|
174
|
+
if (!normalizedReason) throw enterpriseError("revocation_reason_required");
|
|
175
|
+
return lifecycleRequest("revoke", { reason: normalizedReason });
|
|
176
|
+
},
|
|
177
|
+
async enableManagedInspection() {
|
|
178
|
+
return managedRequest("subscription", { method: "POST", body: { cadence: "monthly" } });
|
|
179
|
+
},
|
|
180
|
+
async getManagedInspection() {
|
|
181
|
+
return managedRequest("subscription");
|
|
182
|
+
},
|
|
183
|
+
async pauseManagedInspection(expectedVersion) {
|
|
184
|
+
const version = Number(expectedVersion);
|
|
185
|
+
if (!Number.isInteger(version) || version < 1) throw enterpriseError("managed_subscription_version_required");
|
|
186
|
+
return managedRequest("subscription/pause", { method: "POST", body: { expected_version: version } });
|
|
187
|
+
},
|
|
188
|
+
async resumeManagedInspection(expectedVersion) {
|
|
189
|
+
const version = Number(expectedVersion);
|
|
190
|
+
if (!Number.isInteger(version) || version < 1) throw enterpriseError("managed_subscription_version_required");
|
|
191
|
+
return managedRequest("subscription/resume", { method: "POST", body: { expected_version: version } });
|
|
192
|
+
},
|
|
193
|
+
async listManagedRuns(limit = 20) {
|
|
194
|
+
const bounded = Math.max(1, Math.min(Number(limit) || 20, 100));
|
|
195
|
+
return managedRequest(`runs?limit=${encodeURIComponent(Math.trunc(bounded))}`);
|
|
196
|
+
},
|
|
197
|
+
clear() { cachedToken = ""; cachedExpiry = 0; cachedScopes = []; }
|
|
198
|
+
};
|
|
199
|
+
return Object.freeze(session);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
module.exports = { generateSiteAgentIdentity, signChallenge, createEnterpriseSession };
|