@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/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 fs.readFile(filePath, "utf8"));
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 = SYNC_MODE === "monthly" ? { skipped: true, reason: "monthly_insights_run" } : await call("site_agent_events", {
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.1.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 = SYNC_MODE === "heartbeat" ? { skipped: true, reason: "daily_heartbeat_only" } : await call("insights_feed", {
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
- return {
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 buildInstallPlan({ url, projectRoot = process.cwd(), endpoint = DEFAULT_ENDPOINT, framework, packageManager, detected } = {}) {
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, parentEndpoint) },
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
- base.install_id = hashText(stableJson(base)).slice(0, 24);
342
- base.files[0].sha256 = hashText(JSON.stringify(stateTemplate({ ...base, files: base.files }), null, 2) + "\n");
343
- return Object.freeze({ ...base, files: files.map((file, index) => ({ ...base.files[index], content: file.content })) });
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 writeAtomic(filePath, content, mode) {
362
- await fs.mkdir(path.dirname(filePath), { recursive: true, mode: mode === "private" ? 0o700 : 0o755 });
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 fs.writeFile(temp, content, { encoding: "utf8", mode: mode === "private" ? 0o600 : 0o644 });
366
- await fs.rename(temp, filePath);
367
- await fs.chmod(filePath, mode === "private" ? 0o600 : 0o644);
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 fs.rm(temp, { force: true }).catch(() => {});
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 fs.readFile(target, "utf8"); } catch (error) { if (error.code !== "ENOENT") throw error; }
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 applyInstallPlan(plan, { approved = false } = {}) {
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 { state, prepared } = await inspectInstallPlan(plan);
601
- const result = { schema: "agentx-install-receipt-v1", install_id: plan.install_id, changed: false, files: [], state: state.status };
602
- for (const { file, target, content, existing } of prepared) {
603
- if (existing === null) {
604
- await writeAtomic(target, content, file.mode);
605
- result.changed = true;
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
- result.files.push({ path: file.path, changed: existing === null, sha256: file.sha256 });
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 statePath = path.join(root, MANAGED_DIR, "install-state.json");
690
- const state = await readJson(statePath);
691
- if (!state || state.schema !== STATE_SCHEMA) return { schema: "agentx-uninstall-receipt-v1", removed: false, files: [] };
692
- const files = Array.isArray(state.managed_paths) ? state.managed_paths : [];
693
- const removed = [];
694
- for (const relative of files) {
695
- const allowedWorkflow = relative === ".github/workflows/agent-id-sync.yml";
696
- if (typeof relative !== "string" || path.isAbsolute(relative) || (!relative.startsWith(`${MANAGED_DIR}/`) && !allowedWorkflow) || relative.split(/[\\/]+/).includes("..")) throw installError("UNSAFE_INSTALL_PATH", "Refusing to remove a file outside managed Agent ID paths.");
697
- const target = path.resolve(root, relative);
698
- await fs.rm(target, { force: true });
699
- removed.push(relative);
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,