@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
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,9 +372,21 @@ 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;
|
|
383
|
+
}
|
|
384
|
+
|
|
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));
|
|
301
390
|
}
|
|
302
391
|
|
|
303
392
|
const OPPORTUNITY_CHECKS = Object.freeze([
|
|
@@ -384,10 +473,11 @@ async function inspectAgentIdOpportunities({ projectRoot = process.cwd(), fsImpl
|
|
|
384
473
|
};
|
|
385
474
|
}
|
|
386
475
|
|
|
387
|
-
function buildInstallPlan({ url, projectRoot = process.cwd(), endpoint = DEFAULT_ENDPOINT, framework, packageManager, detected } = {}) {
|
|
476
|
+
function buildInstallPlan({ url, projectRoot = process.cwd(), endpoint = DEFAULT_ENDPOINT, framework, packageManager, detected, modules, grantedPermissions } = {}) {
|
|
388
477
|
return Promise.resolve().then(async () => {
|
|
389
478
|
const siteUrl = normalizeSiteUrl(url);
|
|
390
479
|
const root = normalizeProjectRoot(projectRoot);
|
|
480
|
+
const selection = normalizeInstallSelection(modules, grantedPermissions);
|
|
391
481
|
const detectedProject = detected || await detectProject({ projectRoot: root });
|
|
392
482
|
const selectedFramework = framework || detectedProject.framework;
|
|
393
483
|
const selectedPackageManager = packageManager || detectedProject.package_manager;
|
|
@@ -397,7 +487,7 @@ function buildInstallPlan({ url, projectRoot = process.cwd(), endpoint = DEFAULT
|
|
|
397
487
|
const parentEndpoint = String(endpoint || DEFAULT_ENDPOINT).replace(/\/+$/, "");
|
|
398
488
|
const files = selectedFramework === "static" ? [
|
|
399
489
|
{ path: `${MANAGED_DIR}/install-state.json`, mode: "private", content: "" },
|
|
400
|
-
{ 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) },
|
|
401
491
|
{ path: `${MANAGED_DIR}/README.md`, mode: "documentation", content: readmeTemplate(siteUrl, selectedFramework, selectedPackageManager, parentEndpoint) },
|
|
402
492
|
{ path: ".github/workflows/agent-id-sync.yml", mode: "customer_runner_workflow", content: staticWorkflowTemplate(siteUrl, parentEndpoint) }
|
|
403
493
|
] : [
|
|
@@ -413,6 +503,9 @@ function buildInstallPlan({ url, projectRoot = process.cwd(), endpoint = DEFAULT
|
|
|
413
503
|
project_root: root,
|
|
414
504
|
framework: selectedFramework,
|
|
415
505
|
package_manager: selectedPackageManager,
|
|
506
|
+
modules: selection.modules,
|
|
507
|
+
granted_permissions: selection.granted_permissions,
|
|
508
|
+
module_catalog: INSTALL_MODULES,
|
|
416
509
|
opportunity_report: opportunityReport,
|
|
417
510
|
approval_required: true,
|
|
418
511
|
side_effects: selectedFramework === "static"
|
|
@@ -433,6 +526,8 @@ function buildInstallPlan({ url, projectRoot = process.cwd(), endpoint = DEFAULT
|
|
|
433
526
|
parent_endpoint: base.parent_endpoint,
|
|
434
527
|
framework: base.framework,
|
|
435
528
|
package_manager: base.package_manager,
|
|
529
|
+
modules: base.modules,
|
|
530
|
+
granted_permissions: base.granted_permissions,
|
|
436
531
|
approval_required: base.approval_required,
|
|
437
532
|
side_effects: base.side_effects,
|
|
438
533
|
excluded_side_effects: base.excluded_side_effects,
|
|
@@ -468,15 +563,34 @@ function validatePlan(plan) {
|
|
|
468
563
|
return root;
|
|
469
564
|
}
|
|
470
565
|
|
|
471
|
-
async function
|
|
472
|
-
|
|
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 });
|
|
473
580
|
const temp = `${filePath}.${process.pid}.${crypto.randomUUID()}.tmp`;
|
|
581
|
+
let handle;
|
|
474
582
|
try {
|
|
475
|
-
await
|
|
476
|
-
await
|
|
477
|
-
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);
|
|
478
591
|
} finally {
|
|
479
|
-
await
|
|
592
|
+
if (handle) await handle.close().catch(() => {});
|
|
593
|
+
await fsImpl.rm(temp, { force: true }).catch(() => {});
|
|
480
594
|
}
|
|
481
595
|
}
|
|
482
596
|
|
|
@@ -670,14 +784,14 @@ function installContents(plan) {
|
|
|
670
784
|
contents: {
|
|
671
785
|
[`${MANAGED_DIR}/install-state.json`]: JSON.stringify(state, null, 2) + "\n",
|
|
672
786
|
[`${MANAGED_DIR}/site-agent.cjs`]: runtimeTemplate(),
|
|
673
|
-
[`${MANAGED_DIR}/runner.cjs`]: staticRunnerTemplate(plan.site_url),
|
|
787
|
+
[`${MANAGED_DIR}/runner.cjs`]: staticRunnerTemplate(plan.site_url, plan.modules),
|
|
674
788
|
[`${MANAGED_DIR}/README.md`]: readmeTemplate(plan.site_url, plan.framework, plan.package_manager, plan.parent_endpoint),
|
|
675
789
|
".github/workflows/agent-id-sync.yml": staticWorkflowTemplate(plan.site_url)
|
|
676
790
|
}
|
|
677
791
|
};
|
|
678
792
|
}
|
|
679
793
|
|
|
680
|
-
async function inspectInstallPlan(plan) {
|
|
794
|
+
async function inspectInstallPlan(plan, { fsImpl = fs } = {}) {
|
|
681
795
|
const root = validatePlan(plan);
|
|
682
796
|
const { state, contents } = installContents(plan);
|
|
683
797
|
const prepared = [];
|
|
@@ -687,7 +801,7 @@ async function inspectInstallPlan(plan) {
|
|
|
687
801
|
const content = contents[file.path];
|
|
688
802
|
if (typeof content !== "string" || hashText(content) !== file.sha256) throw installError("PLAN_CONTENT_MISMATCH", `Install plan content mismatch for ${file.path}.`);
|
|
689
803
|
let existing = null;
|
|
690
|
-
try { existing = await
|
|
804
|
+
try { existing = await fsImpl.readFile(target, "utf8"); } catch (error) { if (error.code !== "ENOENT") throw error; }
|
|
691
805
|
if (existing !== null && existing !== content) throw installError("INSTALL_CONFLICT", `Refusing to overwrite an existing file: ${file.path}`);
|
|
692
806
|
prepared.push({ file, target, content, existing });
|
|
693
807
|
}
|
|
@@ -700,30 +814,392 @@ async function preflightInstallPlan(plan) {
|
|
|
700
814
|
schema: "agentx-install-preflight-v1",
|
|
701
815
|
ok: true,
|
|
702
816
|
install_id: plan.install_id,
|
|
703
|
-
files: prepared.map(({ file, existing }) => ({ path: file.path, action: existing === null ? "create" : "unchanged" })),
|
|
704
|
-
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,
|
|
705
1045
|
};
|
|
706
1046
|
}
|
|
707
1047
|
|
|
708
|
-
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 } = {}) {
|
|
709
1083
|
if (approved !== true) throw installError("INSTALL_APPROVAL_REQUIRED", "Installation requires explicit approved=true.");
|
|
710
|
-
const
|
|
711
|
-
const
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
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
|
+
}
|
|
1160
|
+
}
|
|
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" };
|
|
716
1171
|
}
|
|
717
|
-
|
|
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();
|
|
718
1187
|
}
|
|
719
|
-
return result;
|
|
720
1188
|
}
|
|
721
1189
|
|
|
722
1190
|
async function resumeInstall(projectRoot = process.cwd()) {
|
|
723
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
|
+
}
|
|
724
1199
|
const statePath = path.join(root, MANAGED_DIR, "install-state.json");
|
|
725
1200
|
const state = await readJson(statePath);
|
|
726
|
-
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.");
|
|
727
1203
|
return {
|
|
728
1204
|
schema: "agentx-install-status-v1",
|
|
729
1205
|
state: state.status || "unknown",
|
|
@@ -731,6 +1207,7 @@ async function resumeInstall(projectRoot = process.cwd()) {
|
|
|
731
1207
|
site_url: state.site_url || "",
|
|
732
1208
|
install_id: state.install_id || "",
|
|
733
1209
|
project_root: root,
|
|
1210
|
+
recovery,
|
|
734
1211
|
next_steps: state.framework === "static"
|
|
735
1212
|
? (state.verification_status === "verified"
|
|
736
1213
|
? ["store_SITE_AGENT_KEY_as_GitHub_Actions_secret", "run_customer_owned_sync_workflow"]
|
|
@@ -793,28 +1270,73 @@ async function writeEnterpriseEnrollmentState(projectRoot, state) {
|
|
|
793
1270
|
return allowed;
|
|
794
1271
|
}
|
|
795
1272
|
|
|
796
|
-
async function uninstallInstall(projectRoot = process.cwd(), { approved = false } = {}) {
|
|
1273
|
+
async function uninstallInstall(projectRoot = process.cwd(), { approved = false, fsImpl = fs } = {}) {
|
|
797
1274
|
if (approved !== true) throw installError("INSTALL_APPROVAL_REQUIRED", "Uninstall requires explicit approved=true.");
|
|
798
1275
|
const root = normalizeProjectRoot(projectRoot);
|
|
799
|
-
const
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
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();
|
|
810
1332
|
}
|
|
811
|
-
await fs.rm(path.join(root, MANAGED_DIR), { recursive: false, force: true }).catch(() => {});
|
|
812
|
-
return { schema: "agentx-uninstall-receipt-v1", removed: true, files: removed, install_id: state.install_id || "" };
|
|
813
1333
|
}
|
|
814
1334
|
|
|
815
1335
|
module.exports = {
|
|
816
1336
|
INSTALL_SCHEMA,
|
|
817
1337
|
STATE_SCHEMA,
|
|
1338
|
+
INSTALL_MODULES,
|
|
1339
|
+
normalizeInstallSelection,
|
|
818
1340
|
buildInstallPlan,
|
|
819
1341
|
preflightInstallPlan,
|
|
820
1342
|
detectProject,
|