@twin3-ai/agent-id 0.2.0 → 0.3.1

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.2.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,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, parentEndpoint) },
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 writeAtomic(filePath, content, mode) {
472
- 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 });
473
580
  const temp = `${filePath}.${process.pid}.${crypto.randomUUID()}.tmp`;
581
+ let handle;
474
582
  try {
475
- await fs.writeFile(temp, content, { encoding: "utf8", mode: mode === "private" ? 0o600 : 0o644 });
476
- await fs.rename(temp, filePath);
477
- 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);
478
591
  } finally {
479
- await fs.rm(temp, { force: true }).catch(() => {});
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 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; }
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 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 } = {}) {
709
1083
  if (approved !== true) throw installError("INSTALL_APPROVAL_REQUIRED", "Installation requires explicit approved=true.");
710
- const { state, prepared } = await inspectInstallPlan(plan);
711
- const result = { schema: "agentx-install-receipt-v1", install_id: plan.install_id, changed: false, files: [], state: state.status };
712
- for (const { file, target, content, existing } of prepared) {
713
- if (existing === null) {
714
- await writeAtomic(target, content, file.mode);
715
- 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
+ }
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
- result.files.push({ path: file.path, changed: existing === null, sha256: file.sha256 });
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 statePath = path.join(root, MANAGED_DIR, "install-state.json");
800
- const state = await readJson(statePath);
801
- if (!state || state.schema !== STATE_SCHEMA) return { schema: "agentx-uninstall-receipt-v1", removed: false, files: [] };
802
- const files = Array.isArray(state.managed_paths) ? state.managed_paths : [];
803
- const removed = [];
804
- for (const relative of files) {
805
- const allowedWorkflow = relative === ".github/workflows/agent-id-sync.yml";
806
- 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.");
807
- const target = path.resolve(root, relative);
808
- await fs.rm(target, { force: true });
809
- 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();
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,