@twin3-ai/agent-id 0.1.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/artifact-bundle.js +92 -0
- 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 +400 -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 +48 -0
- package/installer.js +679 -46
- package/local-policy.js +4 -2
- package/optimization-loop.js +48 -0
- package/package.json +15 -3
- package/production-preflight.js +35 -4
- package/release-verifier.js +46 -1
- package/repository-connector.js +150 -5
- package/site-agent.js +55 -3
- package/static-edge-adapter.js +115 -0
- package/sync-service.js +1 -1
- package/task-executor.js +80 -10
package/installer.js
CHANGED
|
@@ -1,14 +1,46 @@
|
|
|
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");
|
|
6
|
+
const os = require("node:os");
|
|
5
7
|
const crypto = require("node:crypto");
|
|
6
8
|
const { DEFAULT_ENDPOINT } = require("./runtime-config.js");
|
|
7
9
|
|
|
8
10
|
const INSTALL_SCHEMA = "agentx-install-plan-v1";
|
|
9
11
|
const STATE_SCHEMA = "agentx-site-agent-install-state-v1";
|
|
10
12
|
const MANAGED_DIR = ".agent-id";
|
|
13
|
+
const TRANSACTION_SCHEMA = "agentx-install-transaction-journal-v1";
|
|
14
|
+
const COMMIT_SCHEMA = "agentx-install-commit-marker-v1";
|
|
15
|
+
const TRANSACTION_DIR = `${MANAGED_DIR}/.transactions`;
|
|
16
|
+
const TRANSACTION_JOURNAL = `${TRANSACTION_DIR}/active.json`;
|
|
17
|
+
const TRANSACTION_LOCK = `${TRANSACTION_DIR}/active.lock`;
|
|
18
|
+
const COMMIT_MARKER = `${MANAGED_DIR}/install-commit.json`;
|
|
11
19
|
const SECRET_IGNORE = "*\n!.gitignore\n";
|
|
20
|
+
const INSTALL_MODULES = Object.freeze({
|
|
21
|
+
identity: Object.freeze({ permission: "identity_publish", label: "Domain-bound Agent identity" }),
|
|
22
|
+
audit: Object.freeze({ permission: "public_read", label: "SEO / AEO / GEO / AGO public audit" }),
|
|
23
|
+
measurement: Object.freeze({ permission: "telemetry_submit", label: "AI reads, citations, and conversion evidence" }),
|
|
24
|
+
optimization: Object.freeze({ permission: "insights_read", label: "Evidence-bound optimization insights" }),
|
|
25
|
+
automation: Object.freeze({ permission: "ai_files_write", label: "Locally approved AI-file updates and rollback" }),
|
|
26
|
+
});
|
|
27
|
+
const DEFAULT_INSTALL_MODULES = Object.freeze(["identity", "audit", "optimization"]);
|
|
28
|
+
|
|
29
|
+
function normalizeInstallSelection(modules, grantedPermissions) {
|
|
30
|
+
const selected = [...new Set((Array.isArray(modules) && modules.length ? modules : DEFAULT_INSTALL_MODULES).map(String))];
|
|
31
|
+
const unknown = selected.filter((name) => !INSTALL_MODULES[name]);
|
|
32
|
+
if (unknown.length) throw installError("INVALID_INSTALL_MODULE", `Unknown install modules: ${unknown.join(", ")}`);
|
|
33
|
+
if (!selected.includes("identity")) throw installError("INSTALL_IDENTITY_REQUIRED", "The identity module is required for every Agent ID installation.");
|
|
34
|
+
const granted = [...new Set((Array.isArray(grantedPermissions) ? grantedPermissions : selected.map((name) => INSTALL_MODULES[name].permission)).map(String))];
|
|
35
|
+
const missing = selected.filter((name) => !granted.includes(INSTALL_MODULES[name].permission));
|
|
36
|
+
if (missing.length) throw installError("INSTALL_PERMISSION_REQUIRED", `Missing permissions for modules: ${missing.join(", ")}`);
|
|
37
|
+
const knownPermissions = new Set(Object.values(INSTALL_MODULES).map((item) => item.permission));
|
|
38
|
+
const unsupported = granted.filter((permission) => !knownPermissions.has(permission));
|
|
39
|
+
if (unsupported.length) throw installError("INVALID_INSTALL_PERMISSION", `Unknown install permissions: ${unsupported.join(", ")}`);
|
|
40
|
+
const unused = granted.filter((permission) => !selected.some((name) => INSTALL_MODULES[name].permission === permission));
|
|
41
|
+
if (unused.length) throw installError("UNUSED_INSTALL_PERMISSION", `Permissions without a selected module are not allowed: ${unused.join(", ")}`);
|
|
42
|
+
return { modules: selected, granted_permissions: granted };
|
|
43
|
+
}
|
|
12
44
|
|
|
13
45
|
function installError(code, message) {
|
|
14
46
|
const error = new Error(message);
|
|
@@ -19,6 +51,20 @@ function installError(code, message) {
|
|
|
19
51
|
function normalizeProjectRoot(projectRoot = process.cwd()) {
|
|
20
52
|
const root = path.resolve(String(projectRoot));
|
|
21
53
|
if (!path.isAbsolute(root)) throw installError("INVALID_PROJECT_ROOT", "Project root must be an absolute path.");
|
|
54
|
+
if (root === path.parse(root).root) {
|
|
55
|
+
throw installError("UNSAFE_PROJECT_ROOT_FILESYSTEM", "UNSAFE_PROJECT_ROOT_FILESYSTEM: Refusing to install at the filesystem root.");
|
|
56
|
+
}
|
|
57
|
+
if (root === path.resolve(os.homedir())) {
|
|
58
|
+
throw installError("UNSAFE_PROJECT_ROOT_HOME", "UNSAFE_PROJECT_ROOT_HOME: Refusing to install in the user home directory. Select the website project or published static-site directory.");
|
|
59
|
+
}
|
|
60
|
+
try {
|
|
61
|
+
if (fsSync.realpathSync.native(root) === fsSync.realpathSync.native(os.homedir())) {
|
|
62
|
+
throw installError("UNSAFE_PROJECT_ROOT_HOME", "UNSAFE_PROJECT_ROOT_HOME: Refusing to install through a path that resolves to the user home directory.");
|
|
63
|
+
}
|
|
64
|
+
} catch (error) {
|
|
65
|
+
if (error && error.code === "UNSAFE_PROJECT_ROOT_HOME") throw error;
|
|
66
|
+
if (!error || error.code !== "ENOENT") throw error;
|
|
67
|
+
}
|
|
22
68
|
return root;
|
|
23
69
|
}
|
|
24
70
|
|
|
@@ -38,8 +84,12 @@ function normalizeSiteUrl(value) {
|
|
|
38
84
|
}
|
|
39
85
|
|
|
40
86
|
async function readJson(filePath) {
|
|
87
|
+
return readJsonWith(fs, filePath);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async function readJsonWith(fsImpl, filePath) {
|
|
41
91
|
try {
|
|
42
|
-
return JSON.parse(await
|
|
92
|
+
return JSON.parse(await fsImpl.readFile(filePath, "utf8"));
|
|
43
93
|
} catch (error) {
|
|
44
94
|
if (error.code === "ENOENT") return null;
|
|
45
95
|
throw error;
|
|
@@ -62,6 +112,24 @@ async function detectProject({ projectRoot = process.cwd(), fsImpl = fs } = {})
|
|
|
62
112
|
};
|
|
63
113
|
const dependencyNames = Object.keys(dependencies);
|
|
64
114
|
const hasStaticEntry = await exists("index.html") || await exists("public/index.html");
|
|
115
|
+
const frameworkMarkers = [
|
|
116
|
+
"next.config.js", "next.config.mjs", "next.config.ts",
|
|
117
|
+
"nuxt.config.js", "nuxt.config.ts", "astro.config.js", "astro.config.mjs",
|
|
118
|
+
"vite.config.js", "vite.config.mjs", "vite.config.ts", "gatsby-config.js",
|
|
119
|
+
"svelte.config.js"
|
|
120
|
+
];
|
|
121
|
+
const detectedMarkers = [];
|
|
122
|
+
if (hasStaticEntry) detectedMarkers.push("html_entry");
|
|
123
|
+
if (await exists(".git")) detectedMarkers.push("git_repository");
|
|
124
|
+
if ((await Promise.all(frameworkMarkers.map(exists))).some(Boolean)) detectedMarkers.push("framework_config");
|
|
125
|
+
const packageHasProjectIdentity = Boolean(packageJson && (
|
|
126
|
+
(packageJson.scripts && Object.keys(packageJson.scripts).length)
|
|
127
|
+
|| dependencyNames.some((name) => name !== "@twin3-ai/agent-id")
|
|
128
|
+
));
|
|
129
|
+
if (packageHasProjectIdentity) detectedMarkers.push("project_package_manifest");
|
|
130
|
+
if (!detectedMarkers.length) {
|
|
131
|
+
throw installError("PROJECT_ROOT_NOT_SITE", "PROJECT_ROOT_NOT_SITE: The selected directory is not a recognizable website project. Provide a directory with an HTML entry, repository metadata, framework config, or real project package manifest.");
|
|
132
|
+
}
|
|
65
133
|
const cliOnlyStaticSite = Boolean(
|
|
66
134
|
packageJson
|
|
67
135
|
&& hasStaticEntry
|
|
@@ -89,6 +157,7 @@ async function detectProject({ projectRoot = process.cwd(), fsImpl = fs } = {})
|
|
|
89
157
|
framework,
|
|
90
158
|
package_manager: packageManager,
|
|
91
159
|
package_name: packageJson && typeof packageJson.name === "string" ? packageJson.name : "",
|
|
160
|
+
project_markers: detectedMarkers,
|
|
92
161
|
detected_files: detectedFiles
|
|
93
162
|
};
|
|
94
163
|
}
|
|
@@ -124,7 +193,7 @@ module.exports = { createAgentIdRuntime };
|
|
|
124
193
|
`;
|
|
125
194
|
}
|
|
126
195
|
|
|
127
|
-
function staticRunnerTemplate(siteUrl) {
|
|
196
|
+
function staticRunnerTemplate(siteUrl, modules = DEFAULT_INSTALL_MODULES) {
|
|
128
197
|
return `"use strict";
|
|
129
198
|
|
|
130
199
|
const fs = require("node:fs/promises");
|
|
@@ -134,6 +203,7 @@ const SITE_URL = process.env.SITE_URL || ${JSON.stringify(siteUrl)};
|
|
|
134
203
|
const AGENT_ID_ENDPOINT = String(process.env.AGENT_ID_ENDPOINT || "").replace(/\\/+$/, "");
|
|
135
204
|
const SITE_AGENT_KEY = process.env.SITE_AGENT_KEY || "";
|
|
136
205
|
const SYNC_MODE = process.env.AGENT_ID_SYNC_MODE || "full";
|
|
206
|
+
const ENABLED_MODULES = new Set(${JSON.stringify(modules)});
|
|
137
207
|
|
|
138
208
|
function redact(value) {
|
|
139
209
|
if (Array.isArray(value)) return value.map(redact);
|
|
@@ -167,11 +237,15 @@ async function main() {
|
|
|
167
237
|
if (!SITE_AGENT_KEY) throw new Error("SITE_AGENT_KEY is required in the customer-owned runner environment.");
|
|
168
238
|
if (!["heartbeat", "monthly", "full"].includes(SYNC_MODE)) throw new Error("AGENT_ID_SYNC_MODE must be heartbeat, monthly, or full.");
|
|
169
239
|
const observedAt = new Date().toISOString();
|
|
170
|
-
const heartbeat =
|
|
240
|
+
const heartbeat = !ENABLED_MODULES.has("measurement")
|
|
241
|
+
? { skipped: true, reason: "measurement_module_not_granted" }
|
|
242
|
+
: SYNC_MODE === "monthly" ? { skipped: true, reason: "monthly_insights_run" } : await call("site_agent_events", {
|
|
171
243
|
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.
|
|
244
|
+
events: [{ event: "heartbeat", url: SITE_URL, ts: Math.floor(Date.now() / 1000), data: { runtime: "github_actions", sdk_version: "0.3.0" } }]
|
|
173
245
|
});
|
|
174
|
-
const insights =
|
|
246
|
+
const insights = !ENABLED_MODULES.has("optimization")
|
|
247
|
+
? { skipped: true, reason: "optimization_module_not_granted" }
|
|
248
|
+
: SYNC_MODE === "heartbeat" ? { skipped: true, reason: "daily_heartbeat_only" } : await call("insights_feed", {
|
|
175
249
|
source: "customer_owned_github_actions_runner",
|
|
176
250
|
audit_url: true,
|
|
177
251
|
cadence: "monthly"
|
|
@@ -182,6 +256,7 @@ async function main() {
|
|
|
182
256
|
parent_endpoint: AGENT_ID_ENDPOINT,
|
|
183
257
|
observed_at: observedAt,
|
|
184
258
|
sync_mode: SYNC_MODE,
|
|
259
|
+
enabled_modules: [...ENABLED_MODULES],
|
|
185
260
|
heartbeat,
|
|
186
261
|
insights,
|
|
187
262
|
authority: "advisory_only",
|
|
@@ -280,7 +355,7 @@ This directory contains only the local install state and a server-side helper te
|
|
|
280
355
|
}
|
|
281
356
|
|
|
282
357
|
function stateTemplate(plan) {
|
|
283
|
-
|
|
358
|
+
const state = {
|
|
284
359
|
schema: STATE_SCHEMA,
|
|
285
360
|
install_id: plan.install_id,
|
|
286
361
|
site_url: plan.site_url,
|
|
@@ -288,6 +363,8 @@ function stateTemplate(plan) {
|
|
|
288
363
|
parent_endpoint: plan.parent_endpoint,
|
|
289
364
|
framework: plan.framework,
|
|
290
365
|
package_manager: plan.package_manager,
|
|
366
|
+
modules: [...(plan.modules || [])],
|
|
367
|
+
granted_permissions: [...(plan.granted_permissions || [])],
|
|
291
368
|
status: "installed",
|
|
292
369
|
verification_status: "pending_domain_verification",
|
|
293
370
|
requires_site_agent_key: true,
|
|
@@ -295,24 +372,122 @@ function stateTemplate(plan) {
|
|
|
295
372
|
? "github_actions_secret:SITE_AGENT_KEY"
|
|
296
373
|
: "server_secret_store:SITE_AGENT_KEY",
|
|
297
374
|
managed_paths: plan.files.map((file) => file.path),
|
|
375
|
+
managed_files: plan.files
|
|
376
|
+
.filter((file) => file.path !== `${MANAGED_DIR}/install-state.json`)
|
|
377
|
+
.map((file) => ({ path: file.path, sha256: file.sha256 })),
|
|
298
378
|
no_direct_customer_site_mutation: true,
|
|
299
379
|
no_secret_in_state: true
|
|
300
380
|
};
|
|
381
|
+
state.state_hash = hashText(stableJson(state));
|
|
382
|
+
return state;
|
|
301
383
|
}
|
|
302
384
|
|
|
303
|
-
function
|
|
385
|
+
function verifyInstallState(state) {
|
|
386
|
+
if (!state || state.schema !== STATE_SCHEMA || typeof state.state_hash !== "string") return false;
|
|
387
|
+
const unsigned = { ...state };
|
|
388
|
+
delete unsigned.state_hash;
|
|
389
|
+
return state.state_hash === hashText(stableJson(unsigned));
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
const OPPORTUNITY_CHECKS = Object.freeze([
|
|
393
|
+
{ id: "package_manifest", paths: ["package.json"] },
|
|
394
|
+
{ id: "llms_txt", paths: ["llms.txt", "public/llms.txt"] },
|
|
395
|
+
{ id: "robots_txt", paths: ["robots.txt", "public/robots.txt"] },
|
|
396
|
+
{ id: "agent_card", paths: [".well-known/agent-card.json", "public/.well-known/agent-card.json"] },
|
|
397
|
+
{ id: "agent_id", paths: [".well-known/agent-id.json", "public/.well-known/agent-id.json"] },
|
|
398
|
+
{ id: "aeo_agent", paths: [".well-known/aeo-agent.json", "public/.well-known/aeo-agent.json"] },
|
|
399
|
+
{ id: "agent_knowledge", paths: [".well-known/agent-knowledge.json", "public/.well-known/agent-knowledge.json"] }
|
|
400
|
+
]);
|
|
401
|
+
|
|
402
|
+
async function inspectAgentIdOpportunities({ projectRoot = process.cwd(), fsImpl = fs, checks = OPPORTUNITY_CHECKS } = {}) {
|
|
403
|
+
const root = normalizeProjectRoot(projectRoot);
|
|
404
|
+
const reportChecks = [];
|
|
405
|
+
for (const definition of checks) {
|
|
406
|
+
const presentPaths = [];
|
|
407
|
+
const unavailablePaths = [];
|
|
408
|
+
const unavailableErrors = {};
|
|
409
|
+
const symlinkPaths = [];
|
|
410
|
+
for (const relativePath of definition.paths) {
|
|
411
|
+
if (typeof fsImpl.lstat !== "function") {
|
|
412
|
+
unavailablePaths.push(relativePath);
|
|
413
|
+
unavailableErrors[relativePath] = "LSTAT_UNAVAILABLE";
|
|
414
|
+
continue;
|
|
415
|
+
}
|
|
416
|
+
try {
|
|
417
|
+
const candidate = path.resolve(root, relativePath);
|
|
418
|
+
const withinRoot = path.relative(root, candidate);
|
|
419
|
+
if (withinRoot === ".." || withinRoot.startsWith(`..${path.sep}`) || path.isAbsolute(withinRoot)) {
|
|
420
|
+
unavailablePaths.push(relativePath);
|
|
421
|
+
unavailableErrors[relativePath] = "PATH_ESCAPE";
|
|
422
|
+
continue;
|
|
423
|
+
}
|
|
424
|
+
const stat = await fsImpl.lstat(candidate);
|
|
425
|
+
if (stat.isSymbolicLink()) {
|
|
426
|
+
symlinkPaths.push(relativePath);
|
|
427
|
+
unavailablePaths.push(relativePath);
|
|
428
|
+
unavailableErrors[relativePath] = "SYMLINK_REJECTED";
|
|
429
|
+
continue;
|
|
430
|
+
}
|
|
431
|
+
if (typeof stat.isFile === "function" && !stat.isFile()) {
|
|
432
|
+
unavailablePaths.push(relativePath);
|
|
433
|
+
unavailableErrors[relativePath] = "NOT_REGULAR_FILE";
|
|
434
|
+
continue;
|
|
435
|
+
}
|
|
436
|
+
presentPaths.push(relativePath);
|
|
437
|
+
} catch (error) {
|
|
438
|
+
if (!error || error.code !== "ENOENT") {
|
|
439
|
+
unavailablePaths.push(relativePath);
|
|
440
|
+
unavailableErrors[relativePath] = (error && ["EACCES", "EPERM"].includes(error.code))
|
|
441
|
+
? "PERMISSION_DENIED"
|
|
442
|
+
: "UNAVAILABLE";
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
reportChecks.push({
|
|
447
|
+
id: definition.id,
|
|
448
|
+
status: presentPaths.length ? "present" : unavailablePaths.length ? "unavailable" : "missing",
|
|
449
|
+
present_paths: presentPaths,
|
|
450
|
+
unavailable_paths: unavailablePaths,
|
|
451
|
+
unavailable_errors: unavailableErrors,
|
|
452
|
+
symlink_paths: symlinkPaths,
|
|
453
|
+
candidate_paths: [...definition.paths]
|
|
454
|
+
});
|
|
455
|
+
}
|
|
456
|
+
const present = reportChecks.filter((item) => item.status === "present").length;
|
|
457
|
+
const unavailable = reportChecks.filter((item) => item.status === "unavailable").length;
|
|
458
|
+
return {
|
|
459
|
+
schema: "agentx-local-opportunity-report-v1",
|
|
460
|
+
local_only: true,
|
|
461
|
+
source_uploaded: false,
|
|
462
|
+
secrets_scanned: false,
|
|
463
|
+
content_validated: false,
|
|
464
|
+
verified: 0,
|
|
465
|
+
checks: reportChecks,
|
|
466
|
+
symlink_policy: "skip_and_exclude",
|
|
467
|
+
summary: {
|
|
468
|
+
total: reportChecks.length,
|
|
469
|
+
present,
|
|
470
|
+
missing: reportChecks.length - present - unavailable,
|
|
471
|
+
unavailable
|
|
472
|
+
}
|
|
473
|
+
};
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
function buildInstallPlan({ url, projectRoot = process.cwd(), endpoint = DEFAULT_ENDPOINT, framework, packageManager, detected, modules, grantedPermissions } = {}) {
|
|
304
477
|
return Promise.resolve().then(async () => {
|
|
305
478
|
const siteUrl = normalizeSiteUrl(url);
|
|
306
479
|
const root = normalizeProjectRoot(projectRoot);
|
|
480
|
+
const selection = normalizeInstallSelection(modules, grantedPermissions);
|
|
307
481
|
const detectedProject = detected || await detectProject({ projectRoot: root });
|
|
308
482
|
const selectedFramework = framework || detectedProject.framework;
|
|
309
483
|
const selectedPackageManager = packageManager || detectedProject.package_manager;
|
|
484
|
+
const opportunityReport = await inspectAgentIdOpportunities({ projectRoot: root });
|
|
310
485
|
if (!/^[a-z][a-z0-9._-]{0,31}$/.test(String(selectedFramework))) throw installError("INVALID_FRAMEWORK", "Framework identifier is invalid.");
|
|
311
486
|
if (!/^[a-z][a-z0-9._-]{0,31}$/.test(String(selectedPackageManager))) throw installError("INVALID_PACKAGE_MANAGER", "Package manager identifier is invalid.");
|
|
312
487
|
const parentEndpoint = String(endpoint || DEFAULT_ENDPOINT).replace(/\/+$/, "");
|
|
313
488
|
const files = selectedFramework === "static" ? [
|
|
314
489
|
{ path: `${MANAGED_DIR}/install-state.json`, mode: "private", content: "" },
|
|
315
|
-
{ path: `${MANAGED_DIR}/runner.cjs`, mode: "customer_runner", content: staticRunnerTemplate(siteUrl,
|
|
490
|
+
{ path: `${MANAGED_DIR}/runner.cjs`, mode: "customer_runner", content: staticRunnerTemplate(siteUrl, selection.modules) },
|
|
316
491
|
{ path: `${MANAGED_DIR}/README.md`, mode: "documentation", content: readmeTemplate(siteUrl, selectedFramework, selectedPackageManager, parentEndpoint) },
|
|
317
492
|
{ path: ".github/workflows/agent-id-sync.yml", mode: "customer_runner_workflow", content: staticWorkflowTemplate(siteUrl, parentEndpoint) }
|
|
318
493
|
] : [
|
|
@@ -328,6 +503,10 @@ function buildInstallPlan({ url, projectRoot = process.cwd(), endpoint = DEFAULT
|
|
|
328
503
|
project_root: root,
|
|
329
504
|
framework: selectedFramework,
|
|
330
505
|
package_manager: selectedPackageManager,
|
|
506
|
+
modules: selection.modules,
|
|
507
|
+
granted_permissions: selection.granted_permissions,
|
|
508
|
+
module_catalog: INSTALL_MODULES,
|
|
509
|
+
opportunity_report: opportunityReport,
|
|
331
510
|
approval_required: true,
|
|
332
511
|
side_effects: selectedFramework === "static"
|
|
333
512
|
? ["write_agent_id_namespace", "write_dedicated_github_actions_workflow"]
|
|
@@ -338,9 +517,35 @@ function buildInstallPlan({ url, projectRoot = process.cwd(), endpoint = DEFAULT
|
|
|
338
517
|
: ["verify_domain_ownership", "install_server_package", "store_SITE_AGENT_KEY_in_server_secret_store", "start_site_agent_sync"],
|
|
339
518
|
files: files.map(({ path: filePath, mode, content }) => ({ path: filePath, mode, sha256: hashText(content) }))
|
|
340
519
|
};
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
520
|
+
// install_id identifies the deterministic install plan, not the mutable
|
|
521
|
+
// state file that is generated from that plan at apply time.
|
|
522
|
+
const installIdentity = {
|
|
523
|
+
schema: base.schema,
|
|
524
|
+
version: base.version,
|
|
525
|
+
site_url: base.site_url,
|
|
526
|
+
parent_endpoint: base.parent_endpoint,
|
|
527
|
+
framework: base.framework,
|
|
528
|
+
package_manager: base.package_manager,
|
|
529
|
+
modules: base.modules,
|
|
530
|
+
granted_permissions: base.granted_permissions,
|
|
531
|
+
approval_required: base.approval_required,
|
|
532
|
+
side_effects: base.side_effects,
|
|
533
|
+
excluded_side_effects: base.excluded_side_effects,
|
|
534
|
+
next_steps: base.next_steps,
|
|
535
|
+
files: base.files.filter((file) => file.path !== `${MANAGED_DIR}/install-state.json`)
|
|
536
|
+
};
|
|
537
|
+
base.install_id = hashText(stableJson(installIdentity)).slice(0, 24);
|
|
538
|
+
const stateContent = JSON.stringify(stateTemplate({ ...base, files: base.files }), null, 2) + "\n";
|
|
539
|
+
const stateFileIndex = base.files.findIndex((file) => file.path === `${MANAGED_DIR}/install-state.json`);
|
|
540
|
+
if (stateFileIndex < 0) throw installError("INSTALL_STATE_FILE_MISSING", "The install plan must include its managed state file.");
|
|
541
|
+
base.files[stateFileIndex].sha256 = hashText(stateContent);
|
|
542
|
+
return Object.freeze({
|
|
543
|
+
...base,
|
|
544
|
+
files: files.map((file, index) => ({
|
|
545
|
+
...base.files[index],
|
|
546
|
+
content: file.path === `${MANAGED_DIR}/install-state.json` ? stateContent : file.content
|
|
547
|
+
}))
|
|
548
|
+
});
|
|
344
549
|
});
|
|
345
550
|
}
|
|
346
551
|
|
|
@@ -358,15 +563,34 @@ function validatePlan(plan) {
|
|
|
358
563
|
return root;
|
|
359
564
|
}
|
|
360
565
|
|
|
361
|
-
async function
|
|
362
|
-
|
|
566
|
+
async function syncDirectory(directory, fsImpl = fs) {
|
|
567
|
+
let handle;
|
|
568
|
+
try {
|
|
569
|
+
handle = await fsImpl.open(directory, "r");
|
|
570
|
+
await handle.sync();
|
|
571
|
+
} catch (error) {
|
|
572
|
+
if (!error || !["EINVAL", "ENOTSUP", "EISDIR", "EPERM"].includes(error.code)) throw error;
|
|
573
|
+
} finally {
|
|
574
|
+
if (handle) await handle.close();
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
async function writeAtomic(filePath, content, mode, fsImpl = fs) {
|
|
579
|
+
await fsImpl.mkdir(path.dirname(filePath), { recursive: true, mode: mode === "private" ? 0o700 : 0o755 });
|
|
363
580
|
const temp = `${filePath}.${process.pid}.${crypto.randomUUID()}.tmp`;
|
|
581
|
+
let handle;
|
|
364
582
|
try {
|
|
365
|
-
await
|
|
366
|
-
await
|
|
367
|
-
await
|
|
583
|
+
handle = await fsImpl.open(temp, "wx", mode === "private" ? 0o600 : 0o644);
|
|
584
|
+
await handle.writeFile(content, { encoding: "utf8" });
|
|
585
|
+
await handle.sync();
|
|
586
|
+
await handle.close();
|
|
587
|
+
handle = null;
|
|
588
|
+
await fsImpl.rename(temp, filePath);
|
|
589
|
+
await fsImpl.chmod(filePath, mode === "private" ? 0o600 : 0o644);
|
|
590
|
+
await syncDirectory(path.dirname(filePath), fsImpl);
|
|
368
591
|
} finally {
|
|
369
|
-
await
|
|
592
|
+
if (handle) await handle.close().catch(() => {});
|
|
593
|
+
await fsImpl.rm(temp, { force: true }).catch(() => {});
|
|
370
594
|
}
|
|
371
595
|
}
|
|
372
596
|
|
|
@@ -560,14 +784,14 @@ function installContents(plan) {
|
|
|
560
784
|
contents: {
|
|
561
785
|
[`${MANAGED_DIR}/install-state.json`]: JSON.stringify(state, null, 2) + "\n",
|
|
562
786
|
[`${MANAGED_DIR}/site-agent.cjs`]: runtimeTemplate(),
|
|
563
|
-
[`${MANAGED_DIR}/runner.cjs`]: staticRunnerTemplate(plan.site_url),
|
|
787
|
+
[`${MANAGED_DIR}/runner.cjs`]: staticRunnerTemplate(plan.site_url, plan.modules),
|
|
564
788
|
[`${MANAGED_DIR}/README.md`]: readmeTemplate(plan.site_url, plan.framework, plan.package_manager, plan.parent_endpoint),
|
|
565
789
|
".github/workflows/agent-id-sync.yml": staticWorkflowTemplate(plan.site_url)
|
|
566
790
|
}
|
|
567
791
|
};
|
|
568
792
|
}
|
|
569
793
|
|
|
570
|
-
async function inspectInstallPlan(plan) {
|
|
794
|
+
async function inspectInstallPlan(plan, { fsImpl = fs } = {}) {
|
|
571
795
|
const root = validatePlan(plan);
|
|
572
796
|
const { state, contents } = installContents(plan);
|
|
573
797
|
const prepared = [];
|
|
@@ -577,7 +801,7 @@ async function inspectInstallPlan(plan) {
|
|
|
577
801
|
const content = contents[file.path];
|
|
578
802
|
if (typeof content !== "string" || hashText(content) !== file.sha256) throw installError("PLAN_CONTENT_MISMATCH", `Install plan content mismatch for ${file.path}.`);
|
|
579
803
|
let existing = null;
|
|
580
|
-
try { existing = await
|
|
804
|
+
try { existing = await fsImpl.readFile(target, "utf8"); } catch (error) { if (error.code !== "ENOENT") throw error; }
|
|
581
805
|
if (existing !== null && existing !== content) throw installError("INSTALL_CONFLICT", `Refusing to overwrite an existing file: ${file.path}`);
|
|
582
806
|
prepared.push({ file, target, content, existing });
|
|
583
807
|
}
|
|
@@ -590,30 +814,392 @@ async function preflightInstallPlan(plan) {
|
|
|
590
814
|
schema: "agentx-install-preflight-v1",
|
|
591
815
|
ok: true,
|
|
592
816
|
install_id: plan.install_id,
|
|
593
|
-
files: prepared.map(({ file, existing }) => ({ path: file.path, action: existing === null ? "create" : "unchanged" })),
|
|
594
|
-
side_effects: [],
|
|
817
|
+
files: prepared.map(({ file, existing }) => ({ path: file.path, action: existing === null ? "create" : "unchanged", sha256: file.sha256 })),
|
|
818
|
+
side_effects: [...(plan.side_effects || [])],
|
|
819
|
+
excluded_side_effects: [...(plan.excluded_side_effects || [])],
|
|
820
|
+
transaction: { atomic_apply: true, rollback_on_failure: true },
|
|
821
|
+
};
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
async function pathStatus(fsImpl, target) {
|
|
825
|
+
try { return await fsImpl.lstat(target); }
|
|
826
|
+
catch (error) { if (error.code === "ENOENT") return null; throw error; }
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
async function ensureInstallDirectory(root, target, fsImpl, createdDirectories) {
|
|
830
|
+
const relative = path.relative(root, target);
|
|
831
|
+
if (relative === "" || relative === ".") return;
|
|
832
|
+
if (relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) {
|
|
833
|
+
throw installError("UNSAFE_INSTALL_PATH", "Installation directory escaped the project root.");
|
|
834
|
+
}
|
|
835
|
+
let current = root;
|
|
836
|
+
for (const part of relative.split(path.sep).filter(Boolean)) {
|
|
837
|
+
current = path.join(current, part);
|
|
838
|
+
const stat = await pathStatus(fsImpl, current);
|
|
839
|
+
if (stat) {
|
|
840
|
+
if (stat.isSymbolicLink() || !stat.isDirectory()) {
|
|
841
|
+
throw installError("UNSAFE_INSTALL_DIRECTORY", `Refusing to traverse a non-directory or symlink: ${path.relative(root, current)}`);
|
|
842
|
+
}
|
|
843
|
+
continue;
|
|
844
|
+
}
|
|
845
|
+
await fsImpl.mkdir(current, { mode: 0o755 });
|
|
846
|
+
await syncDirectory(path.dirname(current), fsImpl);
|
|
847
|
+
createdDirectories.push(current);
|
|
848
|
+
}
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
function recordHash(record, field) {
|
|
852
|
+
const unsigned = { ...record };
|
|
853
|
+
delete unsigned[field];
|
|
854
|
+
return hashText(stableJson(unsigned));
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
function sealRecord(record, field) {
|
|
858
|
+
const sealed = { ...record };
|
|
859
|
+
sealed[field] = recordHash(sealed, field);
|
|
860
|
+
return sealed;
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
function verifyRecord(record, schema, field) {
|
|
864
|
+
return Boolean(
|
|
865
|
+
record
|
|
866
|
+
&& record.schema === schema
|
|
867
|
+
&& typeof record[field] === "string"
|
|
868
|
+
&& record[field] === recordHash(record, field)
|
|
869
|
+
);
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
function transactionPaths(root) {
|
|
873
|
+
return {
|
|
874
|
+
directory: path.join(root, TRANSACTION_DIR),
|
|
875
|
+
journal: path.join(root, TRANSACTION_JOURNAL),
|
|
876
|
+
lock: path.join(root, TRANSACTION_LOCK),
|
|
877
|
+
marker: path.join(root, COMMIT_MARKER),
|
|
878
|
+
};
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
async function removeDurable(target, options = { force: true }, fsImpl = fs) {
|
|
882
|
+
const stat = await pathStatus(fsImpl, target);
|
|
883
|
+
if (!stat) return false;
|
|
884
|
+
await fsImpl.rm(target, options);
|
|
885
|
+
await syncDirectory(path.dirname(target), fsImpl);
|
|
886
|
+
return true;
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
async function fileHash(target, fsImpl = fs) {
|
|
890
|
+
try { return hashText(await fsImpl.readFile(target)); }
|
|
891
|
+
catch (error) { if (error.code === "ENOENT") return ""; throw error; }
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
function processIsAlive(pid) {
|
|
895
|
+
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
896
|
+
try { process.kill(pid, 0); return true; }
|
|
897
|
+
catch (error) { return error && error.code === "EPERM"; }
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
async function acquireTransactionLock(root, fsImpl = fs) {
|
|
901
|
+
const paths = transactionPaths(root);
|
|
902
|
+
await ensureInstallDirectory(root, paths.directory, fsImpl, []);
|
|
903
|
+
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
904
|
+
let handle;
|
|
905
|
+
try {
|
|
906
|
+
handle = await fsImpl.open(paths.lock, "wx", 0o600);
|
|
907
|
+
await handle.writeFile(JSON.stringify({ pid: process.pid, host: os.hostname(), created_at: Date.now() }) + "\n", "utf8");
|
|
908
|
+
await handle.sync();
|
|
909
|
+
await handle.close();
|
|
910
|
+
await syncDirectory(paths.directory, fsImpl);
|
|
911
|
+
return async () => {
|
|
912
|
+
await removeDurable(paths.lock, { force: true }, fsImpl).catch(() => {});
|
|
913
|
+
await fsImpl.rmdir(paths.directory).catch((error) => {
|
|
914
|
+
if (!error || !["ENOENT", "ENOTEMPTY"].includes(error.code)) throw error;
|
|
915
|
+
});
|
|
916
|
+
};
|
|
917
|
+
} catch (error) {
|
|
918
|
+
if (handle) await handle.close().catch(() => {});
|
|
919
|
+
if (!error || error.code !== "EEXIST" || attempt > 0) throw error;
|
|
920
|
+
const existing = await readJsonWith(fsImpl, paths.lock).catch(() => null);
|
|
921
|
+
const sameHost = existing && existing.host === os.hostname();
|
|
922
|
+
const recent = existing && Number.isFinite(existing.created_at) && Date.now() - existing.created_at < 60 * 60 * 1000;
|
|
923
|
+
if ((sameHost && processIsAlive(existing.pid)) || (!sameHost && recent)) {
|
|
924
|
+
throw installError("INSTALL_TRANSACTION_BUSY", "Another installer transaction is active for this project.");
|
|
925
|
+
}
|
|
926
|
+
await removeDurable(paths.lock, { force: true }, fsImpl);
|
|
927
|
+
}
|
|
928
|
+
}
|
|
929
|
+
throw installError("INSTALL_TRANSACTION_BUSY", "Unable to acquire the installer transaction lock.");
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
async function writeJournal(root, journal, fsImpl = fs) {
|
|
933
|
+
const sealed = sealRecord(journal, "journal_hash");
|
|
934
|
+
await writeAtomic(transactionPaths(root).journal, JSON.stringify(sealed, null, 2) + "\n", "private", fsImpl);
|
|
935
|
+
return sealed;
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
async function readJournal(root, fsImpl = fs) {
|
|
939
|
+
const journal = await readJsonWith(fsImpl, transactionPaths(root).journal);
|
|
940
|
+
if (!journal) return null;
|
|
941
|
+
if (!verifyRecord(journal, TRANSACTION_SCHEMA, "journal_hash")) {
|
|
942
|
+
throw installError("INSTALL_TRANSACTION_JOURNAL_INVALID", "Installer transaction journal failed integrity validation.");
|
|
943
|
+
}
|
|
944
|
+
return journal;
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
async function writeCommitMarker(root, journal, fsImpl = fs) {
|
|
948
|
+
const marker = sealRecord({
|
|
949
|
+
schema: COMMIT_SCHEMA,
|
|
950
|
+
transaction_id: journal.transaction_id,
|
|
951
|
+
install_id: journal.install_id,
|
|
952
|
+
files: journal.files.map(({ path: relative, sha256 }) => ({ path: relative, sha256 })),
|
|
953
|
+
committed_at: Date.now(),
|
|
954
|
+
}, "marker_hash");
|
|
955
|
+
await writeAtomic(transactionPaths(root).marker, JSON.stringify(marker, null, 2) + "\n", "private", fsImpl);
|
|
956
|
+
return marker;
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
async function committedMarkerMatches(root, journal, fsImpl = fs) {
|
|
960
|
+
const marker = await readJsonWith(fsImpl, transactionPaths(root).marker);
|
|
961
|
+
if (!verifyRecord(marker, COMMIT_SCHEMA, "marker_hash")) return false;
|
|
962
|
+
if (marker.transaction_id !== journal.transaction_id || marker.install_id !== journal.install_id) return false;
|
|
963
|
+
if (stableJson(marker.files) !== stableJson(journal.files.map(({ path: relative, sha256 }) => ({ path: relative, sha256 })))) return false;
|
|
964
|
+
for (const item of marker.files) {
|
|
965
|
+
if (await fileHash(path.join(root, item.path), fsImpl) !== item.sha256) return false;
|
|
966
|
+
}
|
|
967
|
+
return true;
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
async function cleanupJournal(root, journal, fsImpl = fs) {
|
|
971
|
+
const paths = transactionPaths(root);
|
|
972
|
+
if (journal && journal.staging_path) {
|
|
973
|
+
await removeDurable(path.join(root, journal.staging_path), { recursive: true, force: true }, fsImpl).catch(() => {});
|
|
974
|
+
}
|
|
975
|
+
await removeDurable(paths.journal, { force: true }, fsImpl).catch(() => {});
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
function safeManagedPath(root, relative) {
|
|
979
|
+
const allowedWorkflow = relative === ".github/workflows/agent-id-sync.yml";
|
|
980
|
+
if (
|
|
981
|
+
typeof relative !== "string"
|
|
982
|
+
|| path.isAbsolute(relative)
|
|
983
|
+
|| (!relative.startsWith(`${MANAGED_DIR}/`) && !allowedWorkflow)
|
|
984
|
+
|| relative.split(/[\\/]+/).includes("..")
|
|
985
|
+
) {
|
|
986
|
+
throw installError("UNSAFE_INSTALL_PATH", "Installer transaction contains an unsafe managed path.");
|
|
987
|
+
}
|
|
988
|
+
const target = path.resolve(root, relative);
|
|
989
|
+
if (target !== root && !target.startsWith(root + path.sep)) {
|
|
990
|
+
throw installError("UNSAFE_INSTALL_PATH", "Installer transaction path escaped the project root.");
|
|
991
|
+
}
|
|
992
|
+
return target;
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
function retainedState(state, preserved) {
|
|
996
|
+
const retained = {
|
|
997
|
+
...state,
|
|
998
|
+
status: "uninstall_blocked_modified_files",
|
|
999
|
+
managed_paths: preserved.map((item) => item.path),
|
|
1000
|
+
managed_files: state.managed_files.filter((item) => preserved.some((entry) => entry.path === item.path)),
|
|
1001
|
+
};
|
|
1002
|
+
delete retained.state_hash;
|
|
1003
|
+
retained.state_hash = hashText(stableJson(retained));
|
|
1004
|
+
return retained;
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
async function finishUninstallJournal(root, journal, fsImpl = fs) {
|
|
1008
|
+
const removed = [];
|
|
1009
|
+
const preserved = [];
|
|
1010
|
+
const missing = [];
|
|
1011
|
+
for (const managed of journal.files) {
|
|
1012
|
+
const relative = managed.path;
|
|
1013
|
+
const target = safeManagedPath(root, relative);
|
|
1014
|
+
const stat = await pathStatus(fsImpl, target);
|
|
1015
|
+
if (!stat) {
|
|
1016
|
+
missing.push(relative);
|
|
1017
|
+
continue;
|
|
1018
|
+
}
|
|
1019
|
+
if (stat.isSymbolicLink() || !stat.isFile()) {
|
|
1020
|
+
preserved.push({ path: relative, reason: "not_regular_file" });
|
|
1021
|
+
continue;
|
|
1022
|
+
}
|
|
1023
|
+
if (await fileHash(target, fsImpl) !== String(managed.sha256 || "")) {
|
|
1024
|
+
preserved.push({ path: relative, reason: "content_modified" });
|
|
1025
|
+
continue;
|
|
1026
|
+
}
|
|
1027
|
+
await removeDurable(target, { force: true }, fsImpl);
|
|
1028
|
+
removed.push(relative);
|
|
1029
|
+
}
|
|
1030
|
+
const statePath = path.join(root, MANAGED_DIR, "install-state.json");
|
|
1031
|
+
if (preserved.length) {
|
|
1032
|
+
const retained = retainedState(journal.state_snapshot, preserved);
|
|
1033
|
+
await writeAtomic(statePath, JSON.stringify(retained, null, 2) + "\n", "private", fsImpl);
|
|
1034
|
+
} else {
|
|
1035
|
+
await removeDurable(statePath, { force: true }, fsImpl);
|
|
1036
|
+
}
|
|
1037
|
+
await removeDurable(transactionPaths(root).marker, { force: true }, fsImpl).catch(() => {});
|
|
1038
|
+
await cleanupJournal(root, journal, fsImpl);
|
|
1039
|
+
return {
|
|
1040
|
+
status: "uninstalled",
|
|
1041
|
+
complete: preserved.length === 0,
|
|
1042
|
+
removed_files: removed,
|
|
1043
|
+
preserved_files: preserved,
|
|
1044
|
+
missing_files: missing,
|
|
595
1045
|
};
|
|
596
1046
|
}
|
|
597
1047
|
|
|
598
|
-
async function
|
|
1048
|
+
async function recoverInstallTransaction(root, fsImpl = fs) {
|
|
1049
|
+
const journal = await readJournal(root, fsImpl);
|
|
1050
|
+
if (!journal) return null;
|
|
1051
|
+
if (journal.operation === "uninstall") {
|
|
1052
|
+
return finishUninstallJournal(root, journal, fsImpl);
|
|
1053
|
+
}
|
|
1054
|
+
if (journal.operation !== "install" || !Array.isArray(journal.files)) {
|
|
1055
|
+
throw installError("INSTALL_TRANSACTION_JOURNAL_INVALID", "Installer transaction operation is invalid.");
|
|
1056
|
+
}
|
|
1057
|
+
if (await committedMarkerMatches(root, journal, fsImpl)) {
|
|
1058
|
+
await cleanupJournal(root, journal, fsImpl);
|
|
1059
|
+
return { status: "committed", transaction_id: journal.transaction_id };
|
|
1060
|
+
}
|
|
1061
|
+
const removed = [];
|
|
1062
|
+
const preserved = [];
|
|
1063
|
+
for (const item of journal.files.filter((entry) => entry.action === "create")) {
|
|
1064
|
+
const target = safeManagedPath(root, item.path);
|
|
1065
|
+
const currentHash = await fileHash(target, fsImpl);
|
|
1066
|
+
if (!currentHash) continue;
|
|
1067
|
+
if (currentHash !== item.sha256) {
|
|
1068
|
+
preserved.push(item.path);
|
|
1069
|
+
continue;
|
|
1070
|
+
}
|
|
1071
|
+
await removeDurable(target, { force: true }, fsImpl);
|
|
1072
|
+
removed.push(item.path);
|
|
1073
|
+
}
|
|
1074
|
+
await removeDurable(transactionPaths(root).marker, { force: true }, fsImpl).catch(() => {});
|
|
1075
|
+
if (preserved.length) {
|
|
1076
|
+
return { status: "rollback_incomplete", transaction_id: journal.transaction_id, removed_paths: removed, preserved_paths: preserved };
|
|
1077
|
+
}
|
|
1078
|
+
await cleanupJournal(root, journal, fsImpl);
|
|
1079
|
+
return { status: "rolled_back", transaction_id: journal.transaction_id, removed_paths: removed, preserved_paths: [] };
|
|
1080
|
+
}
|
|
1081
|
+
|
|
1082
|
+
async function applyInstallPlan(plan, { approved = false, fsImpl = fs } = {}) {
|
|
599
1083
|
if (approved !== true) throw installError("INSTALL_APPROVAL_REQUIRED", "Installation requires explicit approved=true.");
|
|
600
|
-
const
|
|
601
|
-
const
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
1084
|
+
const root = validatePlan(plan);
|
|
1085
|
+
const releaseLock = await acquireTransactionLock(root, fsImpl);
|
|
1086
|
+
let journal = null;
|
|
1087
|
+
let result = null;
|
|
1088
|
+
try {
|
|
1089
|
+
const recovery = await recoverInstallTransaction(root, fsImpl);
|
|
1090
|
+
if (recovery && recovery.status === "rollback_incomplete") {
|
|
1091
|
+
throw installError("INSTALL_RECOVERY_REQUIRED", "A previous installation could not be safely rolled back.");
|
|
1092
|
+
}
|
|
1093
|
+
const { state, prepared } = await inspectInstallPlan(plan, { fsImpl });
|
|
1094
|
+
const transactionId = `itx_${crypto.randomUUID().replace(/-/g, "")}`;
|
|
1095
|
+
const stagingRelative = `${TRANSACTION_DIR}/${transactionId}`;
|
|
1096
|
+
const stagingRoot = path.join(root, stagingRelative);
|
|
1097
|
+
result = {
|
|
1098
|
+
schema: "agentx-install-receipt-v1",
|
|
1099
|
+
install_id: plan.install_id,
|
|
1100
|
+
transaction_id: transactionId,
|
|
1101
|
+
changed: prepared.some((item) => item.existing === null),
|
|
1102
|
+
files: prepared.map(({ file, existing }) => ({ path: file.path, changed: existing === null, sha256: file.sha256 })),
|
|
1103
|
+
state: state.status,
|
|
1104
|
+
atomic_apply: true,
|
|
1105
|
+
rollback_on_failure: true,
|
|
1106
|
+
durability: "transaction_journal_commit_marker_fsync",
|
|
1107
|
+
};
|
|
1108
|
+
if (!result.changed) return result;
|
|
1109
|
+
|
|
1110
|
+
journal = await writeJournal(root, {
|
|
1111
|
+
schema: TRANSACTION_SCHEMA,
|
|
1112
|
+
transaction_id: transactionId,
|
|
1113
|
+
operation: "install",
|
|
1114
|
+
phase: "staging",
|
|
1115
|
+
install_id: plan.install_id,
|
|
1116
|
+
staging_path: stagingRelative,
|
|
1117
|
+
files: prepared.map(({ file, existing }, index) => ({
|
|
1118
|
+
path: file.path,
|
|
1119
|
+
sha256: file.sha256,
|
|
1120
|
+
mode: file.mode,
|
|
1121
|
+
action: existing === null ? "create" : "unchanged",
|
|
1122
|
+
stage_name: `${String(index).padStart(3, "0")}.stage`,
|
|
1123
|
+
})),
|
|
1124
|
+
applied_paths: [],
|
|
1125
|
+
started_at: Date.now(),
|
|
1126
|
+
}, fsImpl);
|
|
1127
|
+
await ensureInstallDirectory(root, stagingRoot, fsImpl, []);
|
|
1128
|
+
for (let index = 0; index < prepared.length; index += 1) {
|
|
1129
|
+
const item = prepared[index];
|
|
1130
|
+
if (item.existing !== null) continue;
|
|
1131
|
+
const stagePath = path.join(stagingRoot, `${String(index).padStart(3, "0")}.stage`);
|
|
1132
|
+
await writeAtomic(stagePath, item.content, item.file.mode, fsImpl);
|
|
1133
|
+
const stagedContent = await fsImpl.readFile(stagePath, "utf8");
|
|
1134
|
+
if (hashText(stagedContent) !== item.file.sha256) {
|
|
1135
|
+
throw installError("INSTALL_STAGE_HASH_MISMATCH", `Staged content hash mismatch for ${item.file.path}.`);
|
|
1136
|
+
}
|
|
1137
|
+
}
|
|
1138
|
+
journal = await writeJournal(root, { ...journal, phase: "prepared" }, fsImpl);
|
|
1139
|
+
journal = await writeJournal(root, { ...journal, phase: "committing" }, fsImpl);
|
|
1140
|
+
for (let index = 0; index < prepared.length; index += 1) {
|
|
1141
|
+
const item = prepared[index];
|
|
1142
|
+
if (item.existing !== null) continue;
|
|
1143
|
+
const stagePath = path.join(stagingRoot, `${String(index).padStart(3, "0")}.stage`);
|
|
1144
|
+
await ensureInstallDirectory(root, path.dirname(item.target), fsImpl, []);
|
|
1145
|
+
if (await pathStatus(fsImpl, item.target)) {
|
|
1146
|
+
throw installError("INSTALL_CONFLICT", `Refusing to overwrite a file created during installation: ${item.file.path}`);
|
|
1147
|
+
}
|
|
1148
|
+
await fsImpl.rename(stagePath, item.target);
|
|
1149
|
+
await fsImpl.chmod(item.target, item.file.mode === "private" ? 0o600 : 0o644);
|
|
1150
|
+
await syncDirectory(path.dirname(item.target), fsImpl);
|
|
1151
|
+
journal = await writeJournal(root, {
|
|
1152
|
+
...journal,
|
|
1153
|
+
applied_paths: [...journal.applied_paths, item.file.path],
|
|
1154
|
+
}, fsImpl);
|
|
1155
|
+
}
|
|
1156
|
+
for (const { file, target } of prepared) {
|
|
1157
|
+
if (await fileHash(target, fsImpl) !== file.sha256) {
|
|
1158
|
+
throw installError("INSTALL_COMMIT_HASH_MISMATCH", `Committed content hash mismatch for ${file.path}.`);
|
|
1159
|
+
}
|
|
606
1160
|
}
|
|
607
|
-
|
|
1161
|
+
await writeCommitMarker(root, journal, fsImpl);
|
|
1162
|
+
await cleanupJournal(root, journal, fsImpl);
|
|
1163
|
+
return result;
|
|
1164
|
+
} catch (cause) {
|
|
1165
|
+
if (["INSTALL_RECOVERY_REQUIRED", "INSTALL_TRANSACTION_JOURNAL_INVALID"].includes(cause.code)) throw cause;
|
|
1166
|
+
if (!journal) throw cause;
|
|
1167
|
+
let rollback = { status: "rolled_back", removed_paths: [], preserved_paths: [] };
|
|
1168
|
+
try { rollback = await recoverInstallTransaction(root, fsImpl) || rollback; }
|
|
1169
|
+
catch (error) {
|
|
1170
|
+
rollback = { status: "rollback_incomplete", removed_paths: [], preserved_paths: [], error: error.code || "ROLLBACK_FAILED" };
|
|
1171
|
+
}
|
|
1172
|
+
if (rollback.status === "committed" && result) {
|
|
1173
|
+
return { ...result, recovered_after_commit: true };
|
|
1174
|
+
}
|
|
1175
|
+
const error = installError("INSTALL_TRANSACTION_FAILED", `Installation transaction failed: ${cause.code || cause.message || "unknown error"}`);
|
|
1176
|
+
error.cause_code = cause.code || "INSTALL_COMMIT_FAILED";
|
|
1177
|
+
error.rollback_receipt = {
|
|
1178
|
+
schema: "agentx-install-failure-rollback-receipt-v1",
|
|
1179
|
+
transaction_id: journal ? journal.transaction_id : "",
|
|
1180
|
+
status: rollback.status,
|
|
1181
|
+
removed_paths: rollback.removed_paths || [],
|
|
1182
|
+
preserved_paths: rollback.preserved_paths || [],
|
|
1183
|
+
};
|
|
1184
|
+
throw error;
|
|
1185
|
+
} finally {
|
|
1186
|
+
await releaseLock();
|
|
608
1187
|
}
|
|
609
|
-
return result;
|
|
610
1188
|
}
|
|
611
1189
|
|
|
612
1190
|
async function resumeInstall(projectRoot = process.cwd()) {
|
|
613
1191
|
const root = normalizeProjectRoot(projectRoot);
|
|
1192
|
+
const releaseLock = await acquireTransactionLock(root, fs);
|
|
1193
|
+
let recovery = null;
|
|
1194
|
+
try { recovery = await recoverInstallTransaction(root, fs); }
|
|
1195
|
+
finally { await releaseLock(); }
|
|
1196
|
+
if (recovery && recovery.status === "rollback_incomplete") {
|
|
1197
|
+
return { schema: "agentx-install-status-v1", state: "recovery_required", project_root: root, recovery, next_steps: ["review_preserved_transaction_files"] };
|
|
1198
|
+
}
|
|
614
1199
|
const statePath = path.join(root, MANAGED_DIR, "install-state.json");
|
|
615
1200
|
const state = await readJson(statePath);
|
|
616
|
-
if (!state || state.schema !== STATE_SCHEMA) return { schema: "agentx-install-status-v1", state: "not_installed", project_root: root, next_steps: ["build_install_plan"] };
|
|
1201
|
+
if (!state || state.schema !== STATE_SCHEMA) return { schema: "agentx-install-status-v1", state: "not_installed", project_root: root, recovery, next_steps: ["build_install_plan"] };
|
|
1202
|
+
if (!verifyInstallState(state)) throw installError("INSTALL_STATE_INTEGRITY_INVALID", "Installed state failed integrity validation.");
|
|
617
1203
|
return {
|
|
618
1204
|
schema: "agentx-install-status-v1",
|
|
619
1205
|
state: state.status || "unknown",
|
|
@@ -621,6 +1207,7 @@ async function resumeInstall(projectRoot = process.cwd()) {
|
|
|
621
1207
|
site_url: state.site_url || "",
|
|
622
1208
|
install_id: state.install_id || "",
|
|
623
1209
|
project_root: root,
|
|
1210
|
+
recovery,
|
|
624
1211
|
next_steps: state.framework === "static"
|
|
625
1212
|
? (state.verification_status === "verified"
|
|
626
1213
|
? ["store_SITE_AGENT_KEY_as_GitHub_Actions_secret", "run_customer_owned_sync_workflow"]
|
|
@@ -683,31 +1270,77 @@ async function writeEnterpriseEnrollmentState(projectRoot, state) {
|
|
|
683
1270
|
return allowed;
|
|
684
1271
|
}
|
|
685
1272
|
|
|
686
|
-
async function uninstallInstall(projectRoot = process.cwd(), { approved = false } = {}) {
|
|
1273
|
+
async function uninstallInstall(projectRoot = process.cwd(), { approved = false, fsImpl = fs } = {}) {
|
|
687
1274
|
if (approved !== true) throw installError("INSTALL_APPROVAL_REQUIRED", "Uninstall requires explicit approved=true.");
|
|
688
1275
|
const root = normalizeProjectRoot(projectRoot);
|
|
689
|
-
const
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
1276
|
+
const releaseLock = await acquireTransactionLock(root, fsImpl);
|
|
1277
|
+
try {
|
|
1278
|
+
const priorRecovery = await recoverInstallTransaction(root, fsImpl);
|
|
1279
|
+
const statePath = path.join(root, MANAGED_DIR, "install-state.json");
|
|
1280
|
+
const state = await readJsonWith(fsImpl, statePath);
|
|
1281
|
+
if (!state || state.schema !== STATE_SCHEMA) {
|
|
1282
|
+
return {
|
|
1283
|
+
schema: "agentx-uninstall-receipt-v1",
|
|
1284
|
+
removed: Boolean(priorRecovery && priorRecovery.status === "uninstalled"),
|
|
1285
|
+
complete: true,
|
|
1286
|
+
files: priorRecovery ? priorRecovery.removed_files || [] : [],
|
|
1287
|
+
recovery: priorRecovery,
|
|
1288
|
+
};
|
|
1289
|
+
}
|
|
1290
|
+
if (!verifyInstallState(state)) {
|
|
1291
|
+
throw installError("INSTALL_STATE_INTEGRITY_INVALID", "Refusing to uninstall because the managed state hash is missing or invalid.");
|
|
1292
|
+
}
|
|
1293
|
+
const files = Array.isArray(state.managed_files) ? state.managed_files : [];
|
|
1294
|
+
for (const managed of files) safeManagedPath(root, managed && managed.path);
|
|
1295
|
+
const transactionId = `utx_${crypto.randomUUID().replace(/-/g, "")}`;
|
|
1296
|
+
const journal = await writeJournal(root, {
|
|
1297
|
+
schema: TRANSACTION_SCHEMA,
|
|
1298
|
+
transaction_id: transactionId,
|
|
1299
|
+
operation: "uninstall",
|
|
1300
|
+
phase: "deleting",
|
|
1301
|
+
install_id: state.install_id || "",
|
|
1302
|
+
files: files.map((item) => ({ path: item.path, sha256: item.sha256 })),
|
|
1303
|
+
state_snapshot: state,
|
|
1304
|
+
approved: true,
|
|
1305
|
+
started_at: Date.now(),
|
|
1306
|
+
}, fsImpl);
|
|
1307
|
+
let completed;
|
|
1308
|
+
try {
|
|
1309
|
+
completed = await finishUninstallJournal(root, journal, fsImpl);
|
|
1310
|
+
} catch (cause) {
|
|
1311
|
+
const error = installError("UNINSTALL_TRANSACTION_FAILED", `Uninstall transaction interrupted: ${cause.code || cause.message || "unknown error"}`);
|
|
1312
|
+
error.cause_code = cause.code || "UNINSTALL_FAILED";
|
|
1313
|
+
error.transaction_id = transactionId;
|
|
1314
|
+
error.recovery = "resumeInstall_or_retry_uninstall";
|
|
1315
|
+
throw error;
|
|
1316
|
+
}
|
|
1317
|
+
return {
|
|
1318
|
+
schema: "agentx-uninstall-receipt-v1",
|
|
1319
|
+
removed: true,
|
|
1320
|
+
complete: completed.complete,
|
|
1321
|
+
files: completed.removed_files,
|
|
1322
|
+
removed_files: completed.removed_files,
|
|
1323
|
+
preserved_files: completed.preserved_files,
|
|
1324
|
+
missing_files: completed.missing_files,
|
|
1325
|
+
retryable_after_review: completed.preserved_files.length > 0,
|
|
1326
|
+
transaction_id: transactionId,
|
|
1327
|
+
durability: "transaction_journal_fsync",
|
|
1328
|
+
install_id: state.install_id || "",
|
|
1329
|
+
};
|
|
1330
|
+
} finally {
|
|
1331
|
+
await releaseLock();
|
|
700
1332
|
}
|
|
701
|
-
await fs.rm(path.join(root, MANAGED_DIR), { recursive: false, force: true }).catch(() => {});
|
|
702
|
-
return { schema: "agentx-uninstall-receipt-v1", removed: true, files: removed, install_id: state.install_id || "" };
|
|
703
1333
|
}
|
|
704
1334
|
|
|
705
1335
|
module.exports = {
|
|
706
1336
|
INSTALL_SCHEMA,
|
|
707
1337
|
STATE_SCHEMA,
|
|
1338
|
+
INSTALL_MODULES,
|
|
1339
|
+
normalizeInstallSelection,
|
|
708
1340
|
buildInstallPlan,
|
|
709
1341
|
preflightInstallPlan,
|
|
710
1342
|
detectProject,
|
|
1343
|
+
inspectAgentIdOpportunities,
|
|
711
1344
|
applyInstallPlan,
|
|
712
1345
|
resumeInstall,
|
|
713
1346
|
uninstallInstall,
|