@twin3-ai/agent-id 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/b1-sync.js +85 -0
- package/bin/agent-id-b1-sync.js +44 -0
- package/bin/agent-id-cloudflare-a1.js +53 -0
- package/bin/agent-id.js +328 -75
- package/cloudflare-a1-sync.js +105 -0
- package/domain-proof.js +193 -0
- package/edge-html-injection.js +258 -0
- package/enterprise-identity.js +21 -0
- package/installer.js +565 -43
- package/package.json +12 -3
- package/production-preflight.js +35 -4
- package/release-verifier.js +46 -1
- package/repository-connector.js +123 -5
- package/site-agent.js +55 -3
- package/sync-service.js +1 -1
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const fs = require("node:fs");
|
|
4
|
+
const path = require("node:path");
|
|
5
|
+
const crypto = require("node:crypto");
|
|
6
|
+
|
|
7
|
+
const ENDPOINT = "https://api.cloudflare.com/client/v4/graphql";
|
|
8
|
+
const DEFAULT_USER_AGENTS = Object.freeze(["GPTBot", "ChatGPT-User", "OAI-SearchBot", "ClaudeBot", "Claude-User", "Google-Extended", "Googlebot", "PerplexityBot", "Amazonbot", "Bytespider"]);
|
|
9
|
+
|
|
10
|
+
function emptyState() {
|
|
11
|
+
return { schema: "agentx-cloudflare-a1-sync-state-v1", cursor_end: "", pending: null, retry_at: 0, attempts: 0, next_run_at: 0, last_sync_at: 0, last_error: null };
|
|
12
|
+
}
|
|
13
|
+
function loadState(statePath) {
|
|
14
|
+
try {
|
|
15
|
+
const parsed = JSON.parse(fs.readFileSync(statePath, "utf8"));
|
|
16
|
+
return { ...emptyState(), ...parsed };
|
|
17
|
+
} catch (error) {
|
|
18
|
+
if (error.code === "ENOENT") return emptyState();
|
|
19
|
+
throw new Error("cloudflare_a1_state_invalid");
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
function saveState(statePath, state) {
|
|
23
|
+
const target = path.resolve(statePath);
|
|
24
|
+
fs.mkdirSync(path.dirname(target), { recursive: true, mode: 0o700 });
|
|
25
|
+
const temporary = `${target}.${process.pid}.${crypto.randomUUID()}.tmp`;
|
|
26
|
+
try {
|
|
27
|
+
fs.writeFileSync(temporary, JSON.stringify(state, null, 2) + "\n", { mode: 0o600, flag: "wx" });
|
|
28
|
+
fs.renameSync(temporary, target);
|
|
29
|
+
fs.chmodSync(target, 0o600);
|
|
30
|
+
} finally { try { fs.rmSync(temporary, { force: true }); } catch (_error) {} }
|
|
31
|
+
}
|
|
32
|
+
function cleanPath(value) {
|
|
33
|
+
const raw = String(value || "/");
|
|
34
|
+
try { return (new URL(raw, "https://redacted.invalid").pathname || "/").slice(0, 240); }
|
|
35
|
+
catch (_error) { return (raw.split(/[?#]/, 1)[0] || "/").slice(0, 240); }
|
|
36
|
+
}
|
|
37
|
+
function userAgentProduct(value) {
|
|
38
|
+
const match = String(value || "").trim().match(/^([A-Za-z][A-Za-z0-9_-]{0,63})(?:\/[^\s]+)?/);
|
|
39
|
+
return match ? match[1] : "";
|
|
40
|
+
}
|
|
41
|
+
function buildQuery(zoneId, start, end, userAgents) {
|
|
42
|
+
const filters = userAgents.map((item) => `{userAgent_like:${JSON.stringify(`%${item}%`)}}`).join(" ");
|
|
43
|
+
return `query AgentIdA1 { viewer { zones(filter:{zoneTag:${JSON.stringify(zoneId)}}) { a1:httpRequestsAdaptiveGroups(filter:{datetime_geq:${JSON.stringify(start)} datetime_leq:${JSON.stringify(end)} requestSource:"eyeball" OR:[${filters}]} limit:5000 orderBy:[count_DESC]) { count dimensions { userAgent clientRequestPath edgeResponseStatus } sum { edgeResponseBytes } } } } }`;
|
|
44
|
+
}
|
|
45
|
+
async function defaultFetchCloudflare({ token, query, fetchImpl = globalThis.fetch }) {
|
|
46
|
+
if (typeof fetchImpl !== "function") throw new Error("cloudflare_a1_fetch_unavailable");
|
|
47
|
+
const response = await fetchImpl(ENDPOINT, { method: "POST", headers: { authorization: `Bearer ${token}`, "content-type": "application/json" }, body: JSON.stringify({ query }), redirect: "error" });
|
|
48
|
+
if (!response.ok) throw new Error(`cloudflare_a1_http_${response.status}`);
|
|
49
|
+
const body = await response.json();
|
|
50
|
+
if (Array.isArray(body.errors) && body.errors.length) throw new Error("cloudflare_a1_provider_error");
|
|
51
|
+
return body;
|
|
52
|
+
}
|
|
53
|
+
function responseRows(response, siteUrl, observedAt, windowKey = "") {
|
|
54
|
+
const groups = response && response.data && response.data.viewer && Array.isArray(response.data.viewer.zones) ? response.data.viewer.zones[0] && response.data.viewer.zones[0].a1 : null;
|
|
55
|
+
if (!Array.isArray(groups)) throw new Error("cloudflare_a1_response_invalid");
|
|
56
|
+
const host = new URL(siteUrl).hostname.toLowerCase();
|
|
57
|
+
return groups.slice(0, 5000).flatMap((group) => {
|
|
58
|
+
const dimensions = group && group.dimensions || {};
|
|
59
|
+
const userAgent = userAgentProduct(dimensions.userAgent);
|
|
60
|
+
const count = Math.max(0, Math.floor(Number(group && group.count || 0)));
|
|
61
|
+
const status = Math.floor(Number(dimensions.edgeResponseStatus || 0));
|
|
62
|
+
if (!userAgent || count < 1 || status < 100 || status > 599) return [];
|
|
63
|
+
const clean = cleanPath(dimensions.clientRequestPath);
|
|
64
|
+
const eventId = `cfa1_${crypto.createHash("sha256").update(JSON.stringify([host, windowKey, userAgent, clean, status])).digest("hex").slice(0, 32)}`;
|
|
65
|
+
return [{ event_id: eventId, host, path: clean, user_agent: userAgent, status, bytes: Math.max(0, Math.floor(Number(group && group.sum && group.sum.edgeResponseBytes || 0))), request_count: count, timestamp: observedAt, source: "cloudflare_ai_crawl_control" }];
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
function createCloudflareA1Sync({ client, statePath, siteUrl, zoneId, apiToken, userAgents = DEFAULT_USER_AGENTS, lookbackMs = 3600000, intervalMs = 900000, maxPendingRows = 5000, fetchCloudflare = defaultFetchCloudflare, now = Date.now, random = Math.random } = {}) {
|
|
69
|
+
if (!client || typeof client.aiReads !== "function" || !statePath || !siteUrl || !zoneId || !apiToken) throw new Error("cloudflare_a1_sync_configuration_invalid");
|
|
70
|
+
const normalizedUrl = new URL(siteUrl).origin;
|
|
71
|
+
const agents = [...new Set((userAgents || []).map(userAgentProduct).filter(Boolean))].slice(0, 50);
|
|
72
|
+
if (!agents.length) throw new Error("cloudflare_a1_user_agents_required");
|
|
73
|
+
const state = loadState(statePath);
|
|
74
|
+
const persist = () => saveState(statePath, state);
|
|
75
|
+
async function upload(rows, windowEnd) {
|
|
76
|
+
try {
|
|
77
|
+
const result = await client.aiReads({ url: normalizedUrl, logs: rows, source: "cloudflare_a1_sync" });
|
|
78
|
+
state.pending = null; state.cursor_end = windowEnd; state.retry_at = 0; state.attempts = 0; state.last_sync_at = now(); state.next_run_at = state.last_sync_at + Math.max(60000, intervalMs); state.last_error = null; persist();
|
|
79
|
+
return { ok: true, status: "synced", rows: rows.length, requests: rows.reduce((sum, row) => sum + row.request_count, 0), stored: Number(result && result.stored || 0), cursor_end: state.cursor_end };
|
|
80
|
+
} catch (error) {
|
|
81
|
+
state.pending = { rows: rows.slice(0, maxPendingRows), window_end: windowEnd }; state.attempts += 1;
|
|
82
|
+
const backoff = Math.min(300000, 1000 * (2 ** Math.min(8, state.attempts - 1))); state.retry_at = now() + Math.round(backoff * (0.5 + random())); state.last_error = { code: String(error.code || "a1_delivery_failed").slice(0, 80) }; persist();
|
|
83
|
+
return { ok: false, status: "queued_for_retry", pending_rows: state.pending.rows.length, retry_at: state.retry_at };
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
async function runOnce({ force = false } = {}) {
|
|
87
|
+
const timestamp = now();
|
|
88
|
+
if (!force && state.retry_at > timestamp) return { ok: false, status: "retry_wait", retry_at: state.retry_at, pending_rows: state.pending ? state.pending.rows.length : 0 };
|
|
89
|
+
if (state.pending) return upload(state.pending.rows, state.pending.window_end);
|
|
90
|
+
if (!force && state.next_run_at > timestamp) return { ok: true, status: "not_due", next_run_at: state.next_run_at };
|
|
91
|
+
const bucketMs = Math.max(60000, intervalMs);
|
|
92
|
+
const endMs = Math.floor(timestamp / bucketMs) * bucketMs;
|
|
93
|
+
const end = new Date(endMs).toISOString();
|
|
94
|
+
const start = state.cursor_end || new Date(timestamp - Math.max(60000, lookbackMs)).toISOString();
|
|
95
|
+
if (Date.parse(start) >= endMs) return { ok: true, status: "not_due", next_run_at: endMs + bucketMs };
|
|
96
|
+
const response = await fetchCloudflare({ token: apiToken, query: buildQuery(zoneId, start, end, agents) });
|
|
97
|
+
return upload(responseRows(response, normalizedUrl, timestamp, `${start}/${end}`), end);
|
|
98
|
+
}
|
|
99
|
+
async function status() {
|
|
100
|
+
return { schema: state.schema, provider: "cloudflare_ai_crawl_control", host: new URL(normalizedUrl).hostname, cursor_end: state.cursor_end, pending_rows: state.pending ? state.pending.rows.length : 0, retry_at: state.retry_at, next_run_at: state.next_run_at, last_sync_at: state.last_sync_at, last_error: state.last_error };
|
|
101
|
+
}
|
|
102
|
+
return Object.freeze({ runOnce, status });
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
module.exports = { DEFAULT_USER_AGENTS, buildQuery, responseRows, createCloudflareA1Sync };
|
package/domain-proof.js
CHANGED
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
|
|
3
3
|
const fs = require("node:fs/promises");
|
|
4
|
+
const fsSync = require("node:fs");
|
|
4
5
|
const path = require("node:path");
|
|
5
6
|
const crypto = require("node:crypto");
|
|
6
7
|
|
|
7
8
|
const PROOF_SCHEMA = "agentx-domain-proof-v1";
|
|
8
9
|
const CHALLENGE_SCHEMA = "agentx-domain-challenge-v1";
|
|
9
10
|
const SITE_VERIFICATION_SCHEMA = "agentx-site-agent-verification-v1";
|
|
11
|
+
const IDENTITY_SCHEMA = "agentx-site-identity-v1";
|
|
10
12
|
const PROOF_RELATIVE_PATH = path.join(".well-known", "agent-id.json");
|
|
11
13
|
|
|
12
14
|
function proofError(code, message) {
|
|
@@ -34,6 +36,151 @@ function exactHostname(value) {
|
|
|
34
36
|
return host;
|
|
35
37
|
}
|
|
36
38
|
|
|
39
|
+
function stableValue(value) {
|
|
40
|
+
if (Array.isArray(value)) return value.map(stableValue);
|
|
41
|
+
if (!value || typeof value !== "object") return value;
|
|
42
|
+
return Object.fromEntries(Object.keys(value).sort().map(key => [key, stableValue(value[key])]));
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function canonicalBytes(value) {
|
|
46
|
+
return Buffer.from(JSON.stringify(stableValue(value)), "utf8");
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function sha256(value) {
|
|
50
|
+
return `sha256:${crypto.createHash("sha256").update(canonicalBytes(value)).digest("hex")}`;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function serviceOrigin(value) {
|
|
54
|
+
let parsed;
|
|
55
|
+
try { parsed = new URL(String(value || "")); } catch (_error) {
|
|
56
|
+
throw proofError("invalid_service_origin", "serviceOrigin must be an HTTPS origin");
|
|
57
|
+
}
|
|
58
|
+
if (parsed.protocol !== "https:" || parsed.username || parsed.password || parsed.pathname !== "/" || parsed.search || parsed.hash) {
|
|
59
|
+
throw proofError("invalid_service_origin", "serviceOrigin must be an HTTPS origin");
|
|
60
|
+
}
|
|
61
|
+
return parsed.origin;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function buildEnterpriseIdentityDocument({ state, credential, identity, serviceOrigin: issuerOrigin }) {
|
|
65
|
+
if (!state || state.status !== "verified_domain") {
|
|
66
|
+
throw proofError("identity_not_verified", "domain verification must complete before publishing identity");
|
|
67
|
+
}
|
|
68
|
+
if (!credential || credential.status !== "active") {
|
|
69
|
+
throw proofError("credential_not_active", "an active enterprise credential is required");
|
|
70
|
+
}
|
|
71
|
+
if (!identity || !identity.key_path || !identity.public_key_pem || !identity.public_key_thumbprint) {
|
|
72
|
+
throw proofError("identity_key_missing", "the customer Ed25519 identity is required");
|
|
73
|
+
}
|
|
74
|
+
const host = exactHostname(credential.host);
|
|
75
|
+
const agentId = requiredString(credential.agent_id, "credential.agent_id");
|
|
76
|
+
const environment = requiredString(credential.environment, "credential.environment");
|
|
77
|
+
const thumbprint = requiredString(credential.public_key_thumbprint, "credential.public_key_thumbprint");
|
|
78
|
+
const site = new URL(requiredString(state.site_url, "state.site_url"));
|
|
79
|
+
const issuer = serviceOrigin(issuerOrigin);
|
|
80
|
+
if (
|
|
81
|
+
site.protocol !== "https:" || site.hostname.toLowerCase() !== host ||
|
|
82
|
+
state.agent_id !== agentId || state.environment !== environment ||
|
|
83
|
+
identity.public_key_thumbprint !== thumbprint ||
|
|
84
|
+
(state.public_key_thumbprint && state.public_key_thumbprint !== thumbprint)
|
|
85
|
+
) {
|
|
86
|
+
throw proofError("identity_subject_mismatch", "state, credential, site, and signing key must identify the same Agent");
|
|
87
|
+
}
|
|
88
|
+
const publicKey = crypto.createPublicKey(identity.public_key_pem);
|
|
89
|
+
const publicKeyJwk = publicKey.export({ format: "jwk" });
|
|
90
|
+
const body = {
|
|
91
|
+
schema: IDENTITY_SCHEMA,
|
|
92
|
+
agent_id: agentId,
|
|
93
|
+
tenant_id: requiredString(credential.tenant_id, "credential.tenant_id"),
|
|
94
|
+
host,
|
|
95
|
+
environment,
|
|
96
|
+
controller: `https://${host}`,
|
|
97
|
+
issuer: { id: issuer, name: "twin3 AgentX.ID" },
|
|
98
|
+
verification_method: {
|
|
99
|
+
id: `https://${host}/.well-known/agent-id.json#site-key`,
|
|
100
|
+
type: "JsonWebKey2020",
|
|
101
|
+
controller: `https://${host}`,
|
|
102
|
+
publicKeyJwk,
|
|
103
|
+
public_key_thumbprint: thumbprint,
|
|
104
|
+
},
|
|
105
|
+
credential_status: {
|
|
106
|
+
credential_id: requiredString(credential.credential_id, "credential.credential_id"),
|
|
107
|
+
state: "active",
|
|
108
|
+
issued_at: Number(credential.issued_at || state.verified_at || 0),
|
|
109
|
+
expires_at: Number(credential.expires_at || 0),
|
|
110
|
+
status_url: `${issuer}/agentx/id/${agentId}/status.json`,
|
|
111
|
+
},
|
|
112
|
+
services: {
|
|
113
|
+
passport: `${issuer}/agentx/id/${agentId}.json`,
|
|
114
|
+
proof_graph: `${issuer}/agentx/id/${agentId}/proof.json`,
|
|
115
|
+
agent_card: `https://${host}/.well-known/agent-card.json`,
|
|
116
|
+
},
|
|
117
|
+
trust_anchors: {
|
|
118
|
+
domain: { status: "verified", method: "well_known" },
|
|
119
|
+
erc_8004: { status: "not_registered" },
|
|
120
|
+
google_marketplace: { status: "not_asserted" },
|
|
121
|
+
},
|
|
122
|
+
non_claims: [
|
|
123
|
+
"not_a_google_issued_identity",
|
|
124
|
+
"not_registered_in_erc_8004",
|
|
125
|
+
"does_not_grant_website_mutation_authority",
|
|
126
|
+
],
|
|
127
|
+
};
|
|
128
|
+
body.content_hash = sha256(body);
|
|
129
|
+
const privateKey = crypto.createPrivateKey(fsSync.readFileSync(identity.key_path, "utf8"));
|
|
130
|
+
const signature = crypto.sign(null, canonicalBytes(body), privateKey).toString("base64url");
|
|
131
|
+
return {
|
|
132
|
+
...body,
|
|
133
|
+
proof: {
|
|
134
|
+
type: "DataIntegrityProof",
|
|
135
|
+
cryptosuite: "eddsa-agentx-stable-json-v1",
|
|
136
|
+
proofPurpose: "assertionMethod",
|
|
137
|
+
verificationMethod: body.verification_method.id,
|
|
138
|
+
proofValue: `base64url:${signature}`,
|
|
139
|
+
},
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function verifyEnterpriseIdentityDocument(document, { now = Math.floor(Date.now() / 1000) } = {}) {
|
|
144
|
+
const failures = [];
|
|
145
|
+
if (!document || document.schema !== IDENTITY_SCHEMA) failures.push("unsupported_identity_schema");
|
|
146
|
+
const proof = document && document.proof || {};
|
|
147
|
+
const signed = document && typeof document === "object" ? { ...document } : {};
|
|
148
|
+
delete signed.proof;
|
|
149
|
+
const hashInput = { ...signed };
|
|
150
|
+
delete hashInput.content_hash;
|
|
151
|
+
if (!signed.content_hash || signed.content_hash !== sha256(hashInput)) failures.push("content_hash_mismatch");
|
|
152
|
+
const method = signed.verification_method || {};
|
|
153
|
+
if (signed.controller !== `https://${signed.host}` || method.controller !== signed.controller) failures.push("controller_binding_mismatch");
|
|
154
|
+
if (!/^agt_[A-Za-z0-9_-]{4,128}$/.test(String(signed.agent_id || ""))) failures.push("agent_id_invalid");
|
|
155
|
+
if (proof.verificationMethod !== method.id || proof.cryptosuite !== "eddsa-agentx-stable-json-v1") failures.push("proof_metadata_mismatch");
|
|
156
|
+
try {
|
|
157
|
+
const publicKey = crypto.createPublicKey({ key: method.publicKeyJwk, format: "jwk" });
|
|
158
|
+
const publicDer = publicKey.export({ type: "spki", format: "der" });
|
|
159
|
+
const actualThumbprint = `sha256:${crypto.createHash("sha256").update(publicDer).digest("base64url")}`;
|
|
160
|
+
if (actualThumbprint !== method.public_key_thumbprint) failures.push("public_key_thumbprint_mismatch");
|
|
161
|
+
const encoded = String(proof.proofValue || "");
|
|
162
|
+
const signature = encoded.startsWith("base64url:") ? Buffer.from(encoded.slice(10), "base64url") : null;
|
|
163
|
+
if (!signature || !crypto.verify(null, canonicalBytes(signed), publicKey, signature)) failures.push("proof_value_mismatch");
|
|
164
|
+
} catch (_error) {
|
|
165
|
+
failures.push("public_key_invalid");
|
|
166
|
+
}
|
|
167
|
+
const expiry = Number((signed.credential_status || {}).expires_at || 0);
|
|
168
|
+
if (!Number.isFinite(expiry) || expiry <= Number(now)) failures.push("credential_expired");
|
|
169
|
+
if ((signed.credential_status || {}).state !== "active") failures.push("credential_not_active");
|
|
170
|
+
return {
|
|
171
|
+
schema: "agentx-site-identity-verification-v1",
|
|
172
|
+
ok: failures.length === 0,
|
|
173
|
+
agent_id: signed.agent_id || "",
|
|
174
|
+
host: signed.host || "",
|
|
175
|
+
failure_codes: [...new Set(failures)],
|
|
176
|
+
offline_signature_valid: !failures.some(code => ["content_hash_mismatch", "controller_binding_mismatch", "proof_metadata_mismatch", "proof_value_mismatch", "public_key_invalid", "public_key_thumbprint_mismatch"].includes(code)),
|
|
177
|
+
offline_trust_level: "self_signature_only",
|
|
178
|
+
trust_established: false,
|
|
179
|
+
online_status_required: true,
|
|
180
|
+
status_url: (signed.credential_status || {}).status_url || "",
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
|
|
37
184
|
function buildWellKnownProofDocument(challenge, { now = null } = {}) {
|
|
38
185
|
const isChallenge = challenge && challenge.schema === CHALLENGE_SCHEMA && challenge.method === "well_known";
|
|
39
186
|
const isProof = challenge && challenge.schema === PROOF_SCHEMA;
|
|
@@ -196,13 +343,59 @@ async function writeSiteAgentVerificationDocument({ registration, projectRoot, p
|
|
|
196
343
|
};
|
|
197
344
|
}
|
|
198
345
|
|
|
346
|
+
async function writeEnterpriseIdentityDocument({ document, projectRoot, publicRoot, approved = false }) {
|
|
347
|
+
if (approved !== true) {
|
|
348
|
+
throw proofError("approval_required", "publishing a durable Agent identity requires explicit approval");
|
|
349
|
+
}
|
|
350
|
+
const verification = verifyEnterpriseIdentityDocument(document);
|
|
351
|
+
if (!verification.ok) {
|
|
352
|
+
throw proofError("identity_document_invalid", verification.failure_codes.join(","));
|
|
353
|
+
}
|
|
354
|
+
const { project, root, directory, target } = resolveProofTarget({ projectRoot, publicRoot });
|
|
355
|
+
const [realProject, realRoot] = await Promise.all([fs.realpath(project), fs.realpath(root)]);
|
|
356
|
+
if (realRoot !== realProject && !realRoot.startsWith(realProject + path.sep)) {
|
|
357
|
+
throw proofError("unsafe_public_root", "publicRoot cannot escape the customer project through a symlink");
|
|
358
|
+
}
|
|
359
|
+
await fs.mkdir(directory, { recursive: true, mode: 0o755 });
|
|
360
|
+
const existing = await readExisting(target);
|
|
361
|
+
if (existing !== null) {
|
|
362
|
+
let parsed;
|
|
363
|
+
try { parsed = JSON.parse(existing); } catch (_error) { parsed = null; }
|
|
364
|
+
const managed = parsed && [PROOF_SCHEMA, IDENTITY_SCHEMA].includes(parsed.schema);
|
|
365
|
+
if (!managed || parsed.host !== document.host || parsed.agent_id !== document.agent_id) {
|
|
366
|
+
throw proofError("unmanaged_proof_conflict", "refusing to overwrite a customer-managed file");
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
const serialized = `${JSON.stringify(document, null, 2)}\n`;
|
|
370
|
+
const temporary = `${target}.tmp-${process.pid}-${crypto.randomBytes(6).toString("hex")}`;
|
|
371
|
+
try {
|
|
372
|
+
await fs.writeFile(temporary, serialized, { mode: 0o644, flag: "wx" });
|
|
373
|
+
await fs.rename(temporary, target);
|
|
374
|
+
} finally {
|
|
375
|
+
await fs.rm(temporary, { force: true }).catch(() => {});
|
|
376
|
+
}
|
|
377
|
+
return {
|
|
378
|
+
schema: "agentx-site-identity-write-receipt-v1",
|
|
379
|
+
changed: existing !== serialized,
|
|
380
|
+
path: target,
|
|
381
|
+
relative_path: "/.well-known/agent-id.json",
|
|
382
|
+
sha256: `sha256:${crypto.createHash("sha256").update(serialized).digest("hex")}`,
|
|
383
|
+
agent_id: document.agent_id,
|
|
384
|
+
host: document.host,
|
|
385
|
+
};
|
|
386
|
+
}
|
|
387
|
+
|
|
199
388
|
module.exports = {
|
|
200
389
|
PROOF_SCHEMA,
|
|
201
390
|
SITE_VERIFICATION_SCHEMA,
|
|
391
|
+
IDENTITY_SCHEMA,
|
|
202
392
|
PROOF_RELATIVE_PATH,
|
|
203
393
|
buildWellKnownProofDocument,
|
|
204
394
|
buildSiteAgentVerificationDocument,
|
|
395
|
+
buildEnterpriseIdentityDocument,
|
|
396
|
+
verifyEnterpriseIdentityDocument,
|
|
205
397
|
resolveProofTarget,
|
|
206
398
|
writeWellKnownProofDocument,
|
|
207
399
|
writeSiteAgentVerificationDocument,
|
|
400
|
+
writeEnterpriseIdentityDocument,
|
|
208
401
|
};
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Edge HTML injection lane.
|
|
5
|
+
*
|
|
6
|
+
* The repository connector is deliberately limited to five allowlisted
|
|
7
|
+
* replaceable machine-readable files, which means `seo_jsonld`, `seo_og`, and
|
|
8
|
+
* `geo_org_entity` could only ever be delivered as snippets for a human to paste into
|
|
9
|
+
* HTML templates. This module closes those checks by rewriting the HTML response
|
|
10
|
+
* inside the customer's own edge worker. `geo_sameas` closes only when the customer
|
|
11
|
+
* supplies explicit external profile URLs. This structural signal does not verify
|
|
12
|
+
* ownership or authority of those third-party profiles.
|
|
13
|
+
*
|
|
14
|
+
* The trust boundary is unchanged: this code runs in the customer's
|
|
15
|
+
* infrastructure, it never writes to the customer's source tree, and the site
|
|
16
|
+
* owner's existing markup always wins over injected markup.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
const MAX_DOCUMENT_BYTES = 1024 * 1024;
|
|
20
|
+
|
|
21
|
+
const SECRET_PATTERNS = [
|
|
22
|
+
/\bak_aeo_[A-Za-z0-9_-]{6,}\b/,
|
|
23
|
+
/\bav_[A-Za-z0-9_-]{8,}\b/,
|
|
24
|
+
/\beyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\b/,
|
|
25
|
+
/-----BEGIN [A-Z ]*PRIVATE KEY-----/
|
|
26
|
+
];
|
|
27
|
+
|
|
28
|
+
const INJECTION_MARKER = "data-agent-id-injected";
|
|
29
|
+
|
|
30
|
+
function injectionError(code, message) {
|
|
31
|
+
const error = new Error(message || code);
|
|
32
|
+
error.code = code;
|
|
33
|
+
return error;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function assertNoSecret(value) {
|
|
37
|
+
const text = typeof value === "string" ? value : JSON.stringify(value || "");
|
|
38
|
+
if (SECRET_PATTERNS.some((pattern) => pattern.test(text))) {
|
|
39
|
+
throw injectionError("EDGE_HTML_SECRET_DETECTED", "Injection content contains a credential-like value and was rejected.");
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function escapeAttribute(value) {
|
|
44
|
+
return String(value == null ? "" : value)
|
|
45
|
+
.replace(/&/g, "&")
|
|
46
|
+
.replace(/"/g, """)
|
|
47
|
+
.replace(/</g, "<")
|
|
48
|
+
.replace(/>/g, ">");
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Escape a JSON-LD payload so it can never break out of its script element.
|
|
53
|
+
* `<` is escaped as `\u003c`, which is valid JSON and inert inside HTML.
|
|
54
|
+
*/
|
|
55
|
+
function serializeJsonLd(value) {
|
|
56
|
+
return JSON.stringify(value, null, 2).replace(/</g, "\\u003c");
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function httpsUrl(value) {
|
|
60
|
+
const text = String(value || "").trim();
|
|
61
|
+
if (!text) return "";
|
|
62
|
+
if (!/^https:\/\//i.test(text)) throw injectionError("EDGE_HTML_INSECURE_URL", `Injection URLs must use https: ${text}`);
|
|
63
|
+
return text;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function normalizeSameAs(list) {
|
|
67
|
+
const values = Array.isArray(list) ? list : list ? [list] : [];
|
|
68
|
+
const seen = new Set();
|
|
69
|
+
const output = [];
|
|
70
|
+
for (const item of values) {
|
|
71
|
+
const url = httpsUrl(item);
|
|
72
|
+
if (!url || seen.has(url)) continue;
|
|
73
|
+
seen.add(url);
|
|
74
|
+
output.push(url);
|
|
75
|
+
}
|
|
76
|
+
return output;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function canonicalHost(value) {
|
|
80
|
+
const text = String(value || "").trim();
|
|
81
|
+
try {
|
|
82
|
+
return new URL(`https://${text}`).hostname.toLowerCase().replace(/\.$/, "");
|
|
83
|
+
} catch (_error) {
|
|
84
|
+
throw injectionError("EDGE_HTML_IDENTITY_REQUIRED", "Identity host must be a valid hostname.");
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Build a reviewable injection plan. Nothing is applied here; the plan is the
|
|
90
|
+
* artifact an owner (or the optimization loop) approves.
|
|
91
|
+
*/
|
|
92
|
+
function createHtmlInjectionPlan({ identity, organization = {}, openGraph = {}, now } = {}) {
|
|
93
|
+
if (!identity || !identity.host || !identity.base_url) {
|
|
94
|
+
throw injectionError("EDGE_HTML_IDENTITY_REQUIRED", "An identity with host and base_url is required.");
|
|
95
|
+
}
|
|
96
|
+
assertNoSecret(identity);
|
|
97
|
+
assertNoSecret(organization);
|
|
98
|
+
assertNoSecret(openGraph);
|
|
99
|
+
|
|
100
|
+
const baseUrl = httpsUrl(identity.base_url);
|
|
101
|
+
const identityHost = canonicalHost(identity.host);
|
|
102
|
+
const baseHost = new URL(baseUrl).hostname.toLowerCase().replace(/\.$/, "");
|
|
103
|
+
if (identityHost !== baseHost) {
|
|
104
|
+
throw injectionError("EDGE_HTML_IDENTITY_HOST_MISMATCH", "Identity host must match the HTTPS base URL hostname.");
|
|
105
|
+
}
|
|
106
|
+
const sameAs = normalizeSameAs(organization.sameAs);
|
|
107
|
+
|
|
108
|
+
const jsonld = {
|
|
109
|
+
"@context": "https://schema.org",
|
|
110
|
+
"@type": String(organization.type || "Organization"),
|
|
111
|
+
name: String(organization.name || identity.host),
|
|
112
|
+
url: baseUrl,
|
|
113
|
+
identifier: {
|
|
114
|
+
"@type": "PropertyValue",
|
|
115
|
+
propertyID: "AgentX.ID",
|
|
116
|
+
value: String(identity.agent_id || "")
|
|
117
|
+
}
|
|
118
|
+
};
|
|
119
|
+
if (sameAs.length) jsonld.sameAs = sameAs;
|
|
120
|
+
if (organization.logo) jsonld.logo = httpsUrl(organization.logo);
|
|
121
|
+
if (organization.description) jsonld.description = String(organization.description);
|
|
122
|
+
|
|
123
|
+
const og = [];
|
|
124
|
+
og.push({ property: "og:title", content: String(openGraph.title || organization.name || identity.host) });
|
|
125
|
+
if (openGraph.description) og.push({ property: "og:description", content: String(openGraph.description) });
|
|
126
|
+
og.push({ property: "og:url", content: baseUrl });
|
|
127
|
+
if (openGraph.image) og.push({ property: "og:image", content: httpsUrl(openGraph.image) });
|
|
128
|
+
og.push({ property: "og:type", content: String(openGraph.type || "website") });
|
|
129
|
+
|
|
130
|
+
const closes = ["seo_jsonld", "seo_og", "geo_org_entity"];
|
|
131
|
+
if (sameAs.length) closes.push("geo_sameas");
|
|
132
|
+
|
|
133
|
+
return Object.freeze({
|
|
134
|
+
schema: "agentx-edge-html-injection-plan-v1",
|
|
135
|
+
lane: "edge_html_injection",
|
|
136
|
+
approval_required: true,
|
|
137
|
+
customer_source_write_access: false,
|
|
138
|
+
host: identity.host,
|
|
139
|
+
base_url: baseUrl,
|
|
140
|
+
agent_id: String(identity.agent_id || ""),
|
|
141
|
+
verification_status: String(identity.verification_status || "unverified_scan"),
|
|
142
|
+
created_at: Number(now || Date.now()),
|
|
143
|
+
jsonld,
|
|
144
|
+
open_graph: og,
|
|
145
|
+
sameas_present: sameAs.length > 0,
|
|
146
|
+
closes_checks: closes,
|
|
147
|
+
owner_markup_wins: true,
|
|
148
|
+
non_claims: [
|
|
149
|
+
"does_not_write_to_customer_source_tree",
|
|
150
|
+
"runs_in_customer_edge_infrastructure",
|
|
151
|
+
"never_overrides_existing_owner_markup",
|
|
152
|
+
"does_not_guarantee_ai_citation_or_ranking"
|
|
153
|
+
]
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function hasJsonLdOrganization(head) {
|
|
158
|
+
const blocks = head.match(/<script[^>]+type\s*=\s*["']application\/ld\+json["'][^>]*>([\s\S]*?)<\/script>/gi) || [];
|
|
159
|
+
return blocks.some((block) => /"@type"\s*:\s*"(Organization|LocalBusiness|Corporation)"/i.test(block));
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function hasOgProperty(head, property) {
|
|
163
|
+
const escaped = String(property).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
164
|
+
return new RegExp(`<meta[^>]+property\\s*=\\s*["']${escaped}["']`, "i").test(head);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function alreadyInjected(head) {
|
|
168
|
+
return head.includes(INJECTION_MARKER);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Rewrite a single HTML document. Returns the original string untouched whenever
|
|
173
|
+
* the document is not safely rewritable, so the lane can never break a page.
|
|
174
|
+
*/
|
|
175
|
+
function applyHtmlInjection(html, plan) {
|
|
176
|
+
if (!plan || plan.schema !== "agentx-edge-html-injection-plan-v1") {
|
|
177
|
+
throw injectionError("EDGE_HTML_PLAN_INVALID", "A valid edge HTML injection plan is required.");
|
|
178
|
+
}
|
|
179
|
+
const source = typeof html === "string" ? html : "";
|
|
180
|
+
if (Buffer.byteLength(source, "utf8") > MAX_DOCUMENT_BYTES) {
|
|
181
|
+
return { html: source, injected_checks: [], skipped: "document_too_large" };
|
|
182
|
+
}
|
|
183
|
+
const headClose = source.search(/<\/head\s*>/i);
|
|
184
|
+
if (headClose === -1) {
|
|
185
|
+
return { html: source, injected_checks: [], skipped: "no_head_element" };
|
|
186
|
+
}
|
|
187
|
+
const head = source.slice(0, headClose);
|
|
188
|
+
if (alreadyInjected(head)) {
|
|
189
|
+
return { html: source, injected_checks: [], skipped: "already_injected" };
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const parts = [];
|
|
193
|
+
const injected = [];
|
|
194
|
+
|
|
195
|
+
if (!hasJsonLdOrganization(head)) {
|
|
196
|
+
parts.push(`<script type="application/ld+json" ${INJECTION_MARKER}="seo_jsonld">${serializeJsonLd(plan.jsonld)}</script>`);
|
|
197
|
+
injected.push("seo_jsonld", "geo_org_entity");
|
|
198
|
+
if (plan.sameas_present) injected.push("geo_sameas");
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
if (!hasOgProperty(head, "og:title")) {
|
|
202
|
+
const missingTags = plan.open_graph.filter((tag) => !hasOgProperty(head, tag.property));
|
|
203
|
+
for (const tag of missingTags) {
|
|
204
|
+
parts.push(`<meta property="${escapeAttribute(tag.property)}" content="${escapeAttribute(tag.content)}" ${INJECTION_MARKER}="seo_og">`);
|
|
205
|
+
}
|
|
206
|
+
if (missingTags.some((tag) => tag.property === "og:title")) injected.push("seo_og");
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
if (!parts.length) {
|
|
210
|
+
return { html: source, injected_checks: [], skipped: "owner_markup_already_present" };
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const block = `\n${parts.join("\n")}\n`;
|
|
214
|
+
const output = source.slice(0, headClose) + block + source.slice(headClose);
|
|
215
|
+
return { html: output, injected_checks: [...new Set(injected)], skipped: null };
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const REWRITABLE_CONTENT_TYPE = /^text\/html\b/i;
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Wrap an origin fetch so HTML responses are rewritten and everything else is
|
|
222
|
+
* passed through byte-for-byte.
|
|
223
|
+
*/
|
|
224
|
+
function createHtmlInjectionHandler({ plan, origin } = {}) {
|
|
225
|
+
if (typeof origin !== "function") throw injectionError("EDGE_HTML_ORIGIN_REQUIRED", "An origin fetch function is required.");
|
|
226
|
+
if (!plan || plan.schema !== "agentx-edge-html-injection-plan-v1") {
|
|
227
|
+
throw injectionError("EDGE_HTML_PLAN_INVALID", "A valid edge HTML injection plan is required.");
|
|
228
|
+
}
|
|
229
|
+
return async function handle(request) {
|
|
230
|
+
const response = await origin(request);
|
|
231
|
+
const method = String((request && request.method) || "GET").toUpperCase();
|
|
232
|
+
if (!["GET", "HEAD"].includes(method)) return response;
|
|
233
|
+
if (!response || response.status !== 200) return response;
|
|
234
|
+
const contentType = response.headers.get("Content-Type") || "";
|
|
235
|
+
if (!REWRITABLE_CONTENT_TYPE.test(contentType)) return response;
|
|
236
|
+
|
|
237
|
+
const body = await response.text();
|
|
238
|
+
const result = applyHtmlInjection(body, plan);
|
|
239
|
+
const headers = new Headers(response.headers);
|
|
240
|
+
if (result.injected_checks.length) {
|
|
241
|
+
headers.set("X-Agent-ID-Injected", result.injected_checks.join(","));
|
|
242
|
+
}
|
|
243
|
+
headers.delete("Content-Length");
|
|
244
|
+
return new Response(method === "HEAD" ? null : result.html, {
|
|
245
|
+
status: response.status,
|
|
246
|
+
statusText: response.statusText,
|
|
247
|
+
headers
|
|
248
|
+
});
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
module.exports = {
|
|
253
|
+
MAX_DOCUMENT_BYTES,
|
|
254
|
+
INJECTION_MARKER,
|
|
255
|
+
createHtmlInjectionPlan,
|
|
256
|
+
applyHtmlInjection,
|
|
257
|
+
createHtmlInjectionHandler
|
|
258
|
+
};
|
package/enterprise-identity.js
CHANGED
|
@@ -152,6 +152,13 @@ function createEnterpriseSession({ serviceOrigin, siteUrl, environment, credenti
|
|
|
152
152
|
return responseJson(await fetcher(`${origin}/api/site_agents/v1/tasks${pathname}`, request));
|
|
153
153
|
}
|
|
154
154
|
|
|
155
|
+
async function dashboardRequest(pathname, { method = "GET", body = null } = {}) {
|
|
156
|
+
const accessToken = await session.getAccessToken(["insights:read"]);
|
|
157
|
+
const request = { method, headers: { "content-type": "application/json", authorization: `Bearer ${accessToken}` } };
|
|
158
|
+
if (body !== null) request.body = JSON.stringify(body);
|
|
159
|
+
return responseJson(await fetcher(`${origin}/api/site_agents/v1/dashboard${pathname}`, request));
|
|
160
|
+
}
|
|
161
|
+
|
|
155
162
|
const session = {
|
|
156
163
|
schema: "agentx-enterprise-session-v1",
|
|
157
164
|
site_url: site.url,
|
|
@@ -201,9 +208,23 @@ function createEnterpriseSession({ serviceOrigin, siteUrl, environment, credenti
|
|
|
201
208
|
const bounded = Math.max(1, Math.min(Number(limit) || 20, 100));
|
|
202
209
|
return managedRequest(`runs?limit=${encodeURIComponent(Math.trunc(bounded))}`);
|
|
203
210
|
},
|
|
211
|
+
async createDashboardPairing() {
|
|
212
|
+
return dashboardRequest("/pairings", { method: "POST", body: {} });
|
|
213
|
+
},
|
|
204
214
|
async listTasks() {
|
|
205
215
|
return taskRequest("");
|
|
206
216
|
},
|
|
217
|
+
async proposeTask({ path: resourcePath, content, policyDigest, idempotencyKey } = {}) {
|
|
218
|
+
if (typeof resourcePath !== "string" || !resourcePath.trim()) throw enterpriseError("enterprise_task_path_required");
|
|
219
|
+
if (typeof content !== "string" || !content) throw enterpriseError("enterprise_task_content_required");
|
|
220
|
+
if (typeof policyDigest !== "string" || !policyDigest) throw enterpriseError("enterprise_task_policy_digest_required");
|
|
221
|
+
if (typeof idempotencyKey !== "string" || !idempotencyKey.trim()) throw enterpriseError("enterprise_task_idempotency_key_required");
|
|
222
|
+
return taskRequest("", {
|
|
223
|
+
method: "POST",
|
|
224
|
+
scopes: ["tasks:execute"],
|
|
225
|
+
body: { path: resourcePath, content, policy_digest: policyDigest, idempotency_key: idempotencyKey }
|
|
226
|
+
});
|
|
227
|
+
},
|
|
207
228
|
async getTaskComparison(taskId) {
|
|
208
229
|
const id = encodeURIComponent(String(taskId || ""));
|
|
209
230
|
if (!id) throw enterpriseError("enterprise_task_id_required");
|