@namewta/speculo 1.0.19 → 1.0.21

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.
Files changed (194) hide show
  1. package/README.md +8 -7
  2. package/THIRD-PARTY-NOTICES.md +33 -0
  3. package/dist/src/agent-files.js +2 -2
  4. package/dist/src/agent-files.js.map +1 -1
  5. package/dist/src/agent-skills.d.ts +81 -0
  6. package/dist/src/agent-skills.js +530 -0
  7. package/dist/src/agent-skills.js.map +1 -0
  8. package/dist/src/cli.js +11 -3
  9. package/dist/src/cli.js.map +1 -1
  10. package/dist/src/config.js +5 -3
  11. package/dist/src/config.js.map +1 -1
  12. package/dist/src/doctor.js +7 -2
  13. package/dist/src/doctor.js.map +1 -1
  14. package/dist/src/index.d.ts +8 -0
  15. package/dist/src/index.js +23 -3
  16. package/dist/src/index.js.map +1 -1
  17. package/dist/src/manifest.d.ts +9 -0
  18. package/dist/src/manifest.js +45 -7
  19. package/dist/src/manifest.js.map +1 -1
  20. package/dist/src/refresh.js +40 -0
  21. package/dist/src/refresh.js.map +1 -1
  22. package/dist/src/structured.js +15 -4
  23. package/dist/src/structured.js.map +1 -1
  24. package/dist/src/transaction.d.ts +24 -3
  25. package/dist/src/transaction.js +191 -12
  26. package/dist/src/transaction.js.map +1 -1
  27. package/package.json +7 -4
  28. package/scripts/validate-skills.mjs +56 -0
  29. package/template/AGENTS.md +1 -1
  30. package/template/CLAUDE.md +1 -1
  31. package/template/canonical/canonical-specdev-goal-plan.md +156 -28
  32. package/template/canonical/canonical-specdev-grill-with-docs.md +153 -31
  33. package/template/canonical/canonical-specdev-spec.md +153 -27
  34. package/template/canonical/canonical-specdev-tickets.md +153 -27
  35. package/template/canonical/canonical-specdev-wayfinder.md +142 -27
  36. package/template/commands/retro.md +11 -9
  37. package/template/skills/archive-and-consolidate/SKILL.md +163 -7
  38. package/template/skills/archive-and-consolidate/references/cleanup-rules.md +1 -1
  39. package/template/skills/docs-sync/references/agents/claude-redirect.md +8 -8
  40. package/template/skills/docs-sync/references/agents/content-contract.md +1 -1
  41. package/template/skills/docs-sync/references/agents/manifest-discovery.md +7 -7
  42. package/template/skills/docs-sync/references/agents/role-classification.md +4 -4
  43. package/template/skills/docs-sync/references/agents-contract.md +1 -1
  44. package/template/skills/engineering-standards-builder/SKILL.md +149 -9
  45. package/template/skills/engineering-standards-builder/agents/openai.yaml +2 -0
  46. package/template/skills/engineering-standards-builder/manifest.txt +2 -1
  47. package/template/skills/engineering-standards-builder/references/rules/09-mechanical-guardrails.md +9 -0
  48. package/template/skills/engineering-standards-builder/scripts/validate-builder.mjs +4 -7
  49. package/template/skills/git-history-squash/SKILL.md +87 -11
  50. package/template/skills/git-history-squash/agents/openai.yaml +2 -0
  51. package/template/skills/optimize-codex-config/SKILL.md +71 -11
  52. package/template/skills/{speculo-retro/references/entry-procedure.md → retrospective/SKILL.md} +12 -8
  53. package/template/skills/{speculo-retro → retrospective}/references/friction-taxonomy.md +22 -13
  54. package/template/skills/{speculo-retro → retrospective}/references/issue-drafting-sop.md +10 -20
  55. package/template/skills/source-code-zip/SKILL.md +44 -8
  56. package/template/skills/source-code-zip/references/{entry-procedure.md → cli-reference.md} +0 -43
  57. package/template/skills/upstream-fork-sync/SKILL.md +71 -8
  58. package/template/skills/{writing-great-skills → writing-for-agents}/GLOSSARY.md +1 -1
  59. package/template/skills/writing-for-agents/SKILL.md +22 -0
  60. package/template/skills/writing-for-agents/agents/openai.yaml +2 -0
  61. package/template/skills/{writing-great-skills/references/entry-procedure.md → writing-for-agents/references/authoring-principles.md} +2 -9
  62. package/template/workflows/ops/D-project-deploy/D-project-deploy.md +1 -1
  63. package/template/workflows/ops/H-host-manage/H-host-manage.md +4 -0
  64. package/template/workflows/ops/H-host-manage/references/host-profiles.md +4 -0
  65. package/template/workflows/ops/I-initialize/I-initialize.md +1 -1
  66. package/template/workflows/ops/INDEX.md +2 -0
  67. package/template/workflows/ops/README.md +7 -3
  68. package/template/workflows/ops/S-server-connect/S-server-connect.md +5 -5
  69. package/template/workflows/ops/V-inventory-view/V-inventory-view.md +2 -0
  70. package/template/workflows/ops/common/CAPABILITIES.md +2 -0
  71. package/template/workflows/ops/common/USAGE.md +5 -5
  72. package/template/workflows/ops/common/WORKSPACE-USAGE.md +29 -2
  73. package/template/workflows/ops/common/examples/README.md +1 -1
  74. package/template/workflows/ops/common/examples/register.example.json +7 -3
  75. package/template/workflows/ops/common/examples/server-connection.example.json +11 -0
  76. package/template/workflows/ops/common/rules/deployment-layout.md +1 -1
  77. package/template/workflows/ops/common/rules/host-root-and-onboarding.md +47 -0
  78. package/template/workflows/ops/common/rules/persistence-and-secrets.md +1 -1
  79. package/template/workflows/ops/common/rules/research-and-decisions.md +8 -0
  80. package/template/workflows/ops/common/rules/server-onboarding.md +3 -3
  81. package/template/workflows/ops/common/rules/workspace-and-authorization.md +2 -2
  82. package/template/workflows/ops/common/schemas/fleet-view.schema.json +64 -0
  83. package/template/workflows/ops/common/schemas/host.schema.json +19 -0
  84. package/template/workflows/ops/common/schemas/plan.schema.json +57 -0
  85. package/template/workflows/ops/common/schemas/root-confirmation.schema.json +118 -0
  86. package/template/workflows/ops/common/schemas/server-connection.schema.json +67 -0
  87. package/template/workflows/ops/common/schemas/server-discovery.schema.json +171 -0
  88. package/template/workflows/ops/common/schemas/spec.schema.json +19 -0
  89. package/template/workflows/ops/common/schemas/status.schema.json +19 -0
  90. package/template/workflows/ops/common/templates/FLEET.html +1 -1
  91. package/template/workflows/ops/common/templates/HOST-README.md +3 -1
  92. package/template/workflows/ops/common/tests/test_ops.mjs +8 -11
  93. package/template/workflows/ops/common/tests/test_ops_bootstrap.mjs +101 -16
  94. package/template/workflows/ops/common/tests/test_ops_onboarding.mjs +225 -0
  95. package/template/workflows/ops/common/tests/test_ops_workspace.mjs +17 -8
  96. package/template/workflows/ops/common/tools/demo-local.mjs +4 -0
  97. package/template/workflows/ops/common/tools/opslib/agent.mjs +26 -0
  98. package/template/workflows/ops/common/tools/opslib/bootstrap.mjs +52 -26
  99. package/template/workflows/ops/common/tools/opslib/cli.mjs +1 -0
  100. package/template/workflows/ops/common/tools/opslib/core.mjs +1 -1
  101. package/template/workflows/ops/common/tools/opslib/docs.mjs +1 -0
  102. package/template/workflows/ops/common/tools/opslib/execution.mjs +4 -0
  103. package/template/workflows/ops/common/tools/opslib/fleet.mjs +26 -4
  104. package/template/workflows/ops/common/tools/opslib/host_recipes.mjs +34 -0
  105. package/template/workflows/ops/common/tools/opslib/model.mjs +6 -0
  106. package/template/workflows/ops/common/tools/opslib/onboarding.mjs +215 -0
  107. package/template/workflows/ops/common/tools/opslib/planner.mjs +3 -0
  108. package/template/workflows/ops/common/tools/opslib/transport.mjs +10 -6
  109. package/template/workflows/ops/common/tools/opslib/workspace_cli.mjs +20 -1
  110. package/template/workflows/ops/common/tools/server-discover.sh +65 -0
  111. package/template/workflows/ops/common/tools/validate-ops.mjs +1 -1
  112. package/template/workflows/ops/manifest.json +12 -5
  113. package/template/workflows/specdev/A-archive-and-consolidate/consolidation-interview.md +3 -3
  114. package/template/workflows/specdev/C-code-review/C-code-review.md +1 -1
  115. package/template/workflows/specdev/D-diagnose-bugs/D-diagnose-bugs.md +6 -0
  116. package/template/workflows/specdev/G-grill-with-docs/references/interview-procedure.md +6 -6
  117. package/template/workflows/specdev/I-implement/references/implementation-procedure.md +1 -1
  118. package/template/workflows/specdev/I-implement/tdd-rules.md +4 -0
  119. package/template/workflows/specdev/I-init-setup/config-template.json +5 -1
  120. package/template/workflows/specdev/INDEX.md +2 -2
  121. package/template/workflows/specdev/P-goal-plan/planning-modes.md +1 -1
  122. package/template/workflows/specdev/P-prototype/P-prototype.md +7 -3
  123. package/template/workflows/specdev/P-prototype/logic-protocol.md +11 -0
  124. package/template/workflows/specdev/P-prototype/logic-template.md +27 -0
  125. package/template/workflows/specdev/P-prototype/project-preview-protocol.md +9 -0
  126. package/template/workflows/specdev/R-retro/R-retro.md +26 -0
  127. package/template/workflows/specdev/R-retro/retro-template.md +27 -0
  128. package/template/workflows/specdev/R-review-architecture/references/selected-design.md +1 -1
  129. package/template/workflows/specdev/README.md +13 -2
  130. package/template/workflows/specdev/S-spec/S-spec.md +4 -0
  131. package/template/workflows/specdev/T-tickets/T-tickets.md +4 -0
  132. package/template/workflows/specdev/T-triage/T-triage.md +31 -25
  133. package/template/workflows/specdev/T-triage/capture-protocol.md +1 -1
  134. package/template/workflows/specdev/T-triage/ci-security-protocol.md +11 -0
  135. package/template/workflows/specdev/T-triage/intake-protocol.md +10 -4
  136. package/template/workflows/specdev/T-triage/pr-delivery-protocol.md +13 -0
  137. package/template/workflows/specdev/T-triage/pr-record-template.md +39 -0
  138. package/template/workflows/specdev/T-triage/publish-protocol.md +1 -1
  139. package/template/workflows/specdev/T-triage/queue-protocol.md +7 -0
  140. package/template/workflows/specdev/T-triage/reconcile-protocol.md +1 -1
  141. package/template/workflows/specdev/T-triage/references/issue-pr-policy.md +23 -0
  142. package/template/workflows/specdev/T-triage/references/release-authentication.md +9 -0
  143. package/template/workflows/specdev/T-triage/references/release-notes-injection.md +21 -0
  144. package/template/workflows/specdev/T-triage/references/workflow-yaml-reference.md +37 -0
  145. package/template/workflows/specdev/T-triage/release-preflight.md +13 -0
  146. package/template/workflows/specdev/T-triage/release-protocol.md +12 -0
  147. package/template/workflows/specdev/T-triage/release-recovery.md +18 -0
  148. package/template/workflows/specdev/T-triage/remote-operations.md +25 -0
  149. package/template/workflows/specdev/T-triage/scripts/github-transport.mjs +208 -0
  150. package/template/workflows/specdev/T-triage/source-template.md +7 -0
  151. package/template/workflows/specdev/T-triage/triage-run-template.md +34 -0
  152. package/template/workflows/specdev/T-triage/triage-template.md +4 -1
  153. package/template/workflows/specdev/common/rules/artifact-contract.md +7 -0
  154. package/template/workflows/specdev/common/rules/change-completion.md +6 -0
  155. package/template/workflows/specdev/common/rules/workflow-routing.md +6 -0
  156. package/template/workflows/specdev/common/rules/workflow-state-and-lifecycle.md +1 -1
  157. package/template/workflows/specdev/common/schemas/config.schema.json +137 -26
  158. package/template/workflows/specdev/common/schemas/logic-prototype.schema.json +62 -0
  159. package/template/workflows/specdev/common/schemas/pull-request.schema.json +90 -0
  160. package/template/workflows/specdev/common/schemas/retrospective.schema.json +58 -0
  161. package/template/workflows/specdev/common/schemas/triage-run.schema.json +149 -0
  162. package/template/workflows/specdev/common/schemas/triage.schema.json +117 -15
  163. package/template/workflows/specdev/common/tools/delivery-artifacts.mjs +95 -0
  164. package/template/workflows/specdev/common/tools/validate-specdev.mjs +60 -19
  165. package/template/workflows/specdev/runtime-contract.json +4 -2
  166. package/template/skills/archive-and-consolidate/references/entry-procedure.md +0 -178
  167. package/template/skills/docs-sync/references/entry-procedure.md +0 -3
  168. package/template/skills/engineering-standards-builder/references/entry-procedure.md +0 -154
  169. package/template/skills/git-history-squash/references/entry-procedure.md +0 -94
  170. package/template/skills/github-npm-ops/SKILL.md +0 -19
  171. package/template/skills/github-npm-ops/references/ci-and-security-ops.md +0 -178
  172. package/template/skills/github-npm-ops/references/entry-procedure.md +0 -25
  173. package/template/skills/github-npm-ops/references/failure-recovery.md +0 -123
  174. package/template/skills/github-npm-ops/references/issue-pr-triage.md +0 -227
  175. package/template/skills/github-npm-ops/references/issue-transport.md +0 -26
  176. package/template/skills/github-npm-ops/references/package-json-checklist.md +0 -171
  177. package/template/skills/github-npm-ops/references/preflight-checklist.md +0 -39
  178. package/template/skills/github-npm-ops/references/publish-detection.md +0 -68
  179. package/template/skills/github-npm-ops/references/release-notes-injection.md +0 -238
  180. package/template/skills/github-npm-ops/references/release-pipeline.md +0 -108
  181. package/template/skills/github-npm-ops/references/setup-npm-token.md +0 -63
  182. package/template/skills/github-npm-ops/references/troubleshooting-playbook.md +0 -293
  183. package/template/skills/github-npm-ops/references/version-bump-flow.md +0 -217
  184. package/template/skills/github-npm-ops/references/workflow-yaml-reference.md +0 -268
  185. package/template/skills/github-npm-ops/scripts/issue-transport.mjs +0 -227
  186. package/template/skills/optimize-codex-config/references/entry-procedure.md +0 -78
  187. package/template/skills/speculo-retro/SKILL.md +0 -19
  188. package/template/skills/upstream-fork-sync/references/entry-procedure.md +0 -78
  189. package/template/skills/writing-great-skills/SKILL.md +0 -23
  190. /package/template/skills/{writing-great-skills → writing-for-agents}/references/document-contract.md +0 -0
  191. /package/template/workflows/specdev/{G-grill-with-docs → common/rules}/adr-format.md +0 -0
  192. /package/template/workflows/specdev/{G-grill-with-docs → common/rules}/context-format.md +0 -0
  193. /package/template/workflows/specdev/{I-implement → common/rules}/design-it-twice.md +0 -0
  194. /package/template/workflows/specdev/{G-grill-with-docs/domain-modeling-rules.md → common/rules/domain-modeling.md} +0 -0
@@ -2,7 +2,9 @@
2
2
 
3
3
  此文件是内容合同,不含真实密码;实际生成器依据成功回执填入真实值,不把占位符当交付。
4
4
 
5
- ## host_id、实际身份、host_root、更新 UTC 时间
5
+ ## host_id、实际身份、登录账号、host_root、根确认回执、更新 UTC 时间
6
+
7
+ 区分已登记、目录存在、双边文档已验证、项目运行环境就绪。新根由用户指定或接受实际登录主目录下 ops;已有根标记既有登记,换账号不换根。
6
8
 
7
9
  ## 服务一览表
8
10
 
@@ -67,7 +67,7 @@ function setupFixture() {
67
67
  project_id: "app-a", display_name: "APP A", kind: "app", service_type: null,
68
68
  source: { type: "local", location: join(tmp, "source"), revision: "v1" },
69
69
  };
70
- model.register(state, { hosts: [host], projects: [project] });
70
+ const legacy = model.load(state); legacy.hosts[host.host_id] = host; legacy.projects[project.project_id] = project; model.save(state, legacy); // pre-upgrade v3 fixture
71
71
  model.putCredential(state, { credential_id: "app-auth", version: 1, purpose: "Synthetic test only", values: { username: "app-user", password: PASSWORD } });
72
72
  const spec = (version = "v1", operation = "deploy", program = null) => {
73
73
  if (program == null) {
@@ -175,16 +175,13 @@ test("lock is not autobroken", () => {
175
175
  });
176
176
  rmSync(d, { recursive: true, force: true });
177
177
  });
178
- test("symlink rejected", () => {
178
+ test("symlink rejected", (t) => {
179
179
  const d = mkdtempSync(join(tmpdir(), "ops-link-"));
180
+ t.after(() => rmSync(d, { recursive: true, force: true }));
180
181
  mkdirSync(join(d, "real"));
181
- try { symlinkSync(join(d, "real"), join(d, "link"), "dir"); }
182
- catch (e) {
183
- if (process.platform === "win32") { rmSync(d, { recursive: true, force: true }); return; }
184
- throw e;
185
- }
182
+ symlinkSync(join(d, "real"), join(d, "link"), process.platform === "win32" ? "junction" : "dir");
186
183
  assert.throws(() => atomicWrite(join(d, "link", "a.txt"), "x"), OpsError);
187
- rmSync(d, { recursive: true, force: true });
184
+ assert.equal(existsSync(join(d, "real", "a.txt")), false);
188
185
  });
189
186
  test("file permissions", () => {
190
187
  const d = mkdtempSync(join(tmpdir(), "ops-perm-"));
@@ -443,7 +440,7 @@ test("planner contracts", async (t) => {
443
440
  });
444
441
  await each("unrelated host registration does not block approval", (fx) => {
445
442
  const [info] = fx.plan();
446
- model.register(fx.state, { hosts: [{ ...fx.host, host_id: "node-b", display_name: "Node B", root: join(fx.tmp, "server-b") }] });
443
+ const legacy = model.load(fx.state); legacy.hosts["node-b"] = { ...fx.host, host_id: "node-b", display_name: "Node B", root: join(fx.tmp, "server-b") }; model.save(fx.state, legacy); // preserve legacy multi-root executor coverage
447
444
  fx.approve(info);
448
445
  });
449
446
  await each("touched host catalog drift blocks approval", (fx) => {
@@ -891,7 +888,7 @@ test("control file helpers", () => {
891
888
  });
892
889
 
893
890
  test("disjoint host plans apply sequentially", () => runFx((fx) => {
894
- model.register(fx.state, { hosts: [{ ...fx.host, host_id: "node-b", display_name: "Node B", root: join(fx.tmp, "server-b") }] });
891
+ const legacy = model.load(fx.state); legacy.hosts["node-b"] = { ...fx.host, host_id: "node-b", display_name: "Node B", root: join(fx.tmp, "server-b") }; model.save(fx.state, legacy); // preserve legacy multi-root executor coverage
895
892
  const specA = {
896
893
  schema_version: 1, worker: "H", operation: "maintain", hosts: ["node-a"], reason: "Host A cache fixture",
897
894
  rollback_note: "Keep isolated cache until a separate purge is approved.",
@@ -994,7 +991,7 @@ test("new controllers keep secrets out of default documentation", () => runFx((f
994
991
  }
995
992
  assert.match(readFileSync(join(local,"OPERATIONS.md"),"utf8"), /secret_ref:.*app-auth@1/);
996
993
  assert.ok(readFileSync(join(remote,"env","app.env"),"utf8").includes(PASSWORD));
997
- assert.equal(plan.operations.find((o) => o.path?.endsWith("env/app.env")).mode, 0o600);
994
+ assert.equal(plan.operations.find((o) => o.path === join(remote, "env", "app.env")).mode, 0o600);
998
995
  if (process.platform !== "win32") assert.equal(statSync(join(remote,"env","app.env")).mode & 0o777, 0o600);
999
996
  assert.equal(readJson(join(local,"docs-receipt.json")).status, "both-sides-verified");
1000
997
  }));
@@ -3,7 +3,7 @@ import assert from "node:assert/strict";
3
3
  import { spawnSync } from "node:child_process";
4
4
  import { copyFileSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync, existsSync } from "node:fs";
5
5
  import { tmpdir } from "node:os";
6
- import { dirname, join } from "node:path";
6
+ import { dirname, join, posix } from "node:path";
7
7
  import { fileURLToPath } from "node:url";
8
8
  import test from "node:test";
9
9
  import { createHash } from "node:crypto";
@@ -14,10 +14,12 @@ import * as bootstrap from "../tools/opslib/bootstrap.mjs";
14
14
  import { initialize, main, cliHooks } from "../tools/opslib/cli.mjs";
15
15
  import * as agent from "../tools/opslib/agent.mjs";
16
16
  import * as model from "../tools/opslib/model.mjs";
17
+ import { onboardingHooks, discoverServer, confirmServerRoot } from "../tools/opslib/onboarding.mjs";
17
18
 
18
19
  const toolsDir = join(dirname(fileURLToPath(import.meta.url)), "../tools");
19
20
  const scriptPath = join(toolsDir, "bootstrap-volta.sh");
20
21
  const ACK = "I-APPROVE-THIS-BOOTSTRAP";
22
+ const linuxExecution = { skip: process.platform !== "linux" && "Requires Linux /bin/sh and a Linux Node executable; SSH protocol contracts run on every platform" };
21
23
 
22
24
  function sha256(p) {
23
25
  return createHash("sha256").update(readFileSync(p)).digest("hex");
@@ -58,13 +60,14 @@ function pinFor(archives, arch = "linux-x64") {
58
60
  return { ...base, archives: { ...base.archives, [arch]: slot } };
59
61
  }
60
62
 
61
- function mockTransport({ probeJson = null } = {}) {
63
+ function mockTransport({ probeJson = null, agentJson = null, applyJson = null } = {}) {
62
64
  const prev = transport.transportHooks.spawnSync;
63
65
  const log = [];
64
66
  transport.transportHooks.spawnSync = (cmd, args, kw) => {
65
67
  log.push({ cmd, args: [...args], encoding: kw.encoding, inputIsBuffer: Buffer.isBuffer(kw.input) });
66
68
  const last = args[args.length - 1];
67
69
  if (cmd === "scp") {
70
+ if (applyJson) return { status: 0, stdout: Buffer.alloc(0), stderr: Buffer.alloc(0) };
68
71
  const local = args[args.length - 2];
69
72
  const spec = args[args.length - 1];
70
73
  const remotePath = spec.slice(spec.indexOf(":") + 1);
@@ -80,11 +83,13 @@ function mockTransport({ probeJson = null } = {}) {
80
83
  return { status: 0, stdout: Buffer.from(JSON.stringify(probeJson) + "\n"), stderr: Buffer.alloc(0) };
81
84
  }
82
85
  if (String(last).includes("--input-type=module")) {
86
+ if (agentJson) return { status: 0, stdout: Buffer.from(JSON.stringify({ ok: true, result: agentJson }) + "\n"), stderr: Buffer.alloc(0) };
83
87
  const parts = splitRemote(last);
84
88
  return spawnSync(parts[0], parts.slice(1), {
85
89
  input: kw.input, encoding: "buffer", timeout: kw.timeout, maxBuffer: kw.maxBuffer,
86
90
  });
87
91
  }
92
+ if (applyJson) return { status: 0, stdout: Buffer.from(String(last).includes("--apply") ? JSON.stringify(applyJson) + "\n" : ""), stderr: Buffer.alloc(0) };
88
93
  const parts = splitRemote(last);
89
94
  return spawnSync(parts[0], parts.slice(1), {
90
95
  input: kw.input, encoding: "buffer", timeout: kw.timeout, maxBuffer: kw.maxBuffer,
@@ -182,7 +187,20 @@ test("scp uses the same host-key contract", () => {
182
187
  } finally { transport.transportHooks.spawnSync = prev; rmSync(d, { recursive: true, force: true }); }
183
188
  });
184
189
 
185
- test("bootstrap-volta apply installs wrapper and does not touch profile", () => {
190
+ test("scp rejects controller paths and unsafe Linux paths before spawning", (t) => {
191
+ const d = mkdtempSync(join(tmpdir(), "ops-scp-path-"));
192
+ t.after(() => rmSync(d, { recursive: true, force: true }));
193
+ const kh = join(d, "known_hosts"), local = join(d, "archive.tar.gz");
194
+ writeFileSync(kh, "fixture pinned key\n"); writeFileSync(local, "reviewed archive");
195
+ const prev = transport.transportHooks.spawnSync;
196
+ t.after(() => { transport.transportHooks.spawnSync = prev; });
197
+ transport.transportHooks.spawnSync = () => { assert.fail("invalid paths must not reach scp"); };
198
+ for (const path of ["C:\\server\\archive.tar.gz", "C:/server/archive.tar.gz", "\\\\server\\share\\archive.tar.gz", "/srv/ops\\archive.tar.gz", "relative.tar.gz", "/srv/../archive.tar.gz", "/srv/space name.tar.gz", "/srv/nul\0.tar.gz"]) {
199
+ assert.throws(() => transport.posixSend({ hostname: "host.invalid", username: "ops", known_hosts: kh }, local, path), /remote scp path/);
200
+ }
201
+ });
202
+
203
+ test("bootstrap-volta apply installs wrapper and does not touch profile", linuxExecution, () => {
186
204
  const d = mkdtempSync(join(tmpdir(), "ops-vol-"));
187
205
  const home = join(d, "home");
188
206
  mkdirSync(home);
@@ -205,28 +223,33 @@ test("bootstrap-volta apply installs wrapper and does not touch profile", () =>
205
223
  } finally { rmSync(d, { recursive: true, force: true }); }
206
224
  });
207
225
 
208
- test("bootstrap-volta apply without ack blocked", () => {
226
+ test("bootstrap-volta apply without ack blocked", linuxExecution, () => {
209
227
  const d = mkdtempSync(join(tmpdir(), "ops-ack-"));
210
228
  const archives = makeArchives(d);
211
229
  const p = spawnSync("/bin/sh", [scriptPath, "--apply", archives.voltaTar, archives.voltaSha, archives.nodeTar, archives.nodeSha, join(d, "volta"), "24.21.0", "nope"], { encoding: "utf8" });
212
- assert.notEqual(p.status, 0);
230
+ assert.equal(p.error, undefined);
231
+ assert.equal(p.status, 2);
232
+ assert.match(p.stderr, /acknowledgement/);
213
233
  rmSync(d, { recursive: true, force: true });
214
234
  });
215
235
 
216
- test("bootstrap-volta sha mismatch blocked", () => {
236
+ test("bootstrap-volta sha mismatch blocked", linuxExecution, () => {
217
237
  const d = mkdtempSync(join(tmpdir(), "ops-sha-"));
218
238
  const archives = makeArchives(d);
219
239
  const p = spawnSync("/bin/sh", [scriptPath, "--apply", archives.voltaTar, "0".repeat(64), archives.nodeTar, archives.nodeSha, join(d, "volta"), "24.21.0", ACK], { encoding: "utf8" });
220
- assert.notEqual(p.status, 0);
240
+ assert.equal(p.error, undefined);
241
+ assert.equal(p.status, 2);
242
+ assert.match(p.stderr, /installer changed after review/);
221
243
  assert.ok(!existsSync(join(d, "volta", "bin", "ops-node")));
222
244
  rmSync(d, { recursive: true, force: true });
223
245
  });
224
246
 
225
- function cliFixture() {
247
+ function cliFixture({ executeLinux = false } = {}) {
226
248
  const tmp = mkdtempSync(join(tmpdir(), "ops-bootcli-"));
227
249
  const state = join(tmp, "controller");
228
- const target = join(tmp, "server");
229
- mkdirSync(target, { recursive: true });
250
+ // A simulated Linux SSH target is not a path on the controller filesystem.
251
+ const target = executeLinux ? join(tmp, "server") : "/srv/speculo-bootstrap-test";
252
+ if (executeLinux) mkdirSync(target, { recursive: true });
230
253
  const kh = join(tmp, "known_hosts");
231
254
  writeFileSync(kh, "fixture pinned key\n");
232
255
  const prevProbe = cliHooks.probeLocal;
@@ -236,9 +259,13 @@ function cliFixture() {
236
259
  const archives = makeArchives(tmp);
237
260
  const prevPin = bootstrap.bootstrapHooks.loadPin;
238
261
  bootstrap.bootstrapHooks.loadPin = () => pinFor(archives);
262
+ const previousObserve = onboardingHooks.observe;
263
+ onboardingHooks.observe = (h, root) => ({ identity: executeLinux ? agent.fingerprint() : "a".repeat(64), platform: "linux", username: h.connection.username, uid: "1000", home: "/home/ops", node: null, node_version: null, ...(root ? {root_check: {exists: false, writable: true, nonempty: false, owner: null}} : {}) });
239
264
  return {
240
265
  tmp, state, target, kh, archives,
266
+ confirm(host) { const d = discoverServer(state, host.host_id, host); const r = confirmServerRoot(state, host.host_id, d.discovery.receipt_id, host.root, "fixture-user", "Confirm only this isolated simulated target root"); host.root_confirmation = r.root_confirmation; },
241
267
  cleanup() {
268
+ onboardingHooks.observe = previousObserve;
242
269
  bootstrap.bootstrapHooks.loadPin = prevPin;
243
270
  rmSync(tmp, { recursive: true, force: true });
244
271
  },
@@ -251,6 +278,49 @@ const missingProbe = {
251
278
  node_path: null, node_version: null, node_usable: false, profile_has_volta: false,
252
279
  };
253
280
 
281
+ test("Linux SSH bootstrap and enrollment preserve remote paths on every controller platform", () => {
282
+ const fx = cliFixture();
283
+ const connectionNode = posix.join(fx.target, "_host/toolchains/ops/volta/bin/ops-node");
284
+ const mock = mockTransport({ probeJson: missingProbe, applyJson: { status: "installed", connection_node: connectionNode, profile_unchanged: true } });
285
+ try {
286
+ const host = sshHost({ root: fx.target, kh: fx.kh, node: "node" });
287
+ fx.confirm(host);
288
+ const conn = writeConn(fx.tmp, host);
289
+ const r = captureMain(["bootstrap-node", "--state", fx.state, "--host-id", host.host_id, "--connection-file", conn, "--apply", "--account", "ops", "--host-root", fx.target, "--ack", ACK,
290
+ "--volta-archive", fx.archives.voltaTar, "--volta-sha256", fx.archives.voltaSha, "--node-archive", fx.archives.nodeTar, "--node-sha256", fx.archives.nodeSha]);
291
+ assert.equal(r.code, 0, r.stderr);
292
+ const boot = JSON.parse(r.stdout), pins = pinFor(fx.archives).archives["linux-x64"];
293
+ const paths = [pins.volta.filename, pins.node.filename].map(name => posix.join(fx.target, "_host/installers", name));
294
+ assert.deepEqual(mock.log.filter(e => e.cmd === "scp").map(e => e.args.at(-1)), paths.map(p => "ops@host.invalid:" + p));
295
+ const applyArgs = splitRemote(mock.log.find(e => e.cmd === "ssh" && e.args.at(-1).includes("--apply")).args.at(-1));
296
+ assert.deepEqual(applyArgs.slice(applyArgs.indexOf("--apply") + 1), [paths[0], fx.archives.voltaSha, paths[1], fx.archives.nodeSha, posix.dirname(posix.dirname(connectionNode)), fx.archives.nodeVersion, ACK]);
297
+ assert.equal(JSON.parse(readFileSync(boot.receipt, "utf8")).connection_node, connectionNode);
298
+ assert.deepEqual(Object.keys(model.load(fx.state).hosts), []);
299
+ // A new confirmation cannot reset an interrupted bootstrap's machine/root guard.
300
+ fx.confirm(host); writeConn(fx.tmp, host);
301
+ const retryArgs = ["bootstrap-node", "--state", fx.state, "--host-id", host.host_id, "--connection-file", conn, "--apply", "--account", "ops", "--host-root", fx.target, "--ack", ACK,
302
+ "--volta-archive", fx.archives.voltaTar, "--node-archive", fx.archives.nodeTar];
303
+ const blockedRetry = captureMain(retryArgs); assert.equal(blockedRetry.code, 2); assert.match(blockedRetry.stderr, /already started/);
304
+ assert.equal(mock.log.filter(e => e.cmd === "scp").length, 2);
305
+ const recovered = captureMain([...retryArgs, "--recover-ack", "I-VERIFIED-BOOTSTRAP-IS-STOPPED", "--recovery-statement", "Fixture only: the simulated old process is stopped; retry exactly the same pinned inputs"]);
306
+ assert.equal(recovered.code, 0, recovered.stderr);
307
+ assert.ok(readdirSync(join(fx.state, "hosts", host.host_id, "bootstrap")).some(n => n.startsWith("recovery-")));
308
+ mock.restore();
309
+ host.connection.node = connectionNode;
310
+ const enrollFile = join(fx.tmp, "register.json"); writeFileSync(enrollFile, JSON.stringify({ hosts: [host], projects: [] }));
311
+ const inventory = { identity: "a".repeat(64), platform: "linux", tools: { node: { path: connectionNode, version: "v24.21.0" } } };
312
+ const enrolledMock = mockTransport({ probeJson: { ...missingProbe, tools: { ...missingProbe.tools, node: connectionNode }, node_path: connectionNode, node_usable: true }, agentJson: inventory });
313
+ try {
314
+ const enrolled = captureMain(["--state", fx.state, "enroll", "--file", enrollFile]);
315
+ assert.equal(enrolled.code, 0, enrolled.stderr);
316
+ assert.equal(model.load(fx.state).hosts[host.host_id].connection.node, connectionNode);
317
+ const snapshot = JSON.parse(enrolled.stdout).hosts[0].snapshot;
318
+ assert.deepEqual(JSON.parse(readFileSync(snapshot, "utf8")), inventory);
319
+ assert.equal(enrolledMock.log.filter(e => e.args.at(-1).includes("--input-type=module")).length, 2);
320
+ } finally { enrolledMock.restore(); }
321
+ } finally { mock.restore(); fx.cleanup(); }
322
+ });
323
+
254
324
  test("discover probe without node returns node-missing", () => {
255
325
  const fx = cliFixture();
256
326
  const mock = mockTransport({ probeJson: missingProbe });
@@ -300,6 +370,18 @@ test("bootstrap-node apply without ack blocked", () => {
300
370
  } finally { mock.restore(); fx.cleanup(); }
301
371
  });
302
372
 
373
+ test("bootstrap with valid archives but no root confirmation cannot write or upload", () => {
374
+ const fx = cliFixture(), mock = mockTransport({ probeJson: missingProbe });
375
+ try {
376
+ const host = sshHost({ root: fx.target, kh: fx.kh, node: "node" }), conn = writeConn(fx.tmp, host);
377
+ const r = captureMain(["bootstrap-node", "--state", fx.state, "--host-id", host.host_id, "--connection-file", conn, "--apply", "--account", "ops", "--host-root", fx.target, "--ack", ACK,
378
+ "--volta-archive", fx.archives.voltaTar, "--node-archive", fx.archives.nodeTar]);
379
+ assert.equal(r.code, 2); assert.match(r.stderr, /root-confirm/);
380
+ assert.equal(mock.log.filter(e => e.cmd === "scp").length, 0);
381
+ assert.ok(mock.log.every(e => e.args.at(-1).includes("--probe")));
382
+ } finally { mock.restore(); fx.cleanup(); }
383
+ });
384
+
303
385
  test("bootstrap-node sha mismatch blocked", () => {
304
386
  const fx = cliFixture();
305
387
  const mock = mockTransport({ probeJson: missingProbe });
@@ -313,6 +395,7 @@ test("bootstrap-node sha mismatch blocked", () => {
313
395
  "--node-archive", fx.archives.nodeTar, "--node-sha256", fx.archives.nodeSha,
314
396
  ]);
315
397
  assert.equal(r.code, 2);
398
+ assert.match(r.stderr, /sha256 does not match the reviewed pin/i);
316
399
  assert.ok(!mock.log.some((e) => String(e.args.at(-1) || "").includes("--apply")));
317
400
  } finally { mock.restore(); fx.cleanup(); }
318
401
  });
@@ -362,8 +445,8 @@ test("bootstrap-node skips when node exists", () => {
362
445
  } finally { mock.restore(); fx.cleanup(); }
363
446
  });
364
447
 
365
- test("bootstrap-node apply then enroll writes inventory and status", () => {
366
- const fx = cliFixture();
448
+ test("bootstrap-node apply then enroll writes inventory and status", linuxExecution, () => {
449
+ const fx = cliFixture({ executeLinux: true });
367
450
  const home = join(fx.tmp, "home");
368
451
  mkdirSync(home);
369
452
  writeFileSync(join(home, ".bashrc"), "keep-me\n");
@@ -372,6 +455,7 @@ test("bootstrap-node apply then enroll writes inventory and status", () => {
372
455
  const mock = mockTransport({ probeJson: missingProbe });
373
456
  try {
374
457
  const host = sshHost({ root: fx.target, kh: fx.kh, node: "node" });
458
+ fx.confirm(host);
375
459
  const conn = writeConn(fx.tmp, host);
376
460
  const applied = captureMain([
377
461
  "bootstrap-node", "--state", fx.state, "--host-id", "wsl-a",
@@ -435,10 +519,11 @@ test("enroll without node blocked", () => {
435
519
  test("probe --connection-file still does not imply register", () => {
436
520
  const fx = cliFixture();
437
521
  const mock = mockTransport({
438
- probeJson: { ...missingProbe, tools: { ...missingProbe.tools, node: process.execPath }, node_path: process.execPath, node_usable: true },
522
+ probeJson: { ...missingProbe, tools: { ...missingProbe.tools, node: "/usr/bin/node" }, node_path: "/usr/bin/node", node_usable: true },
523
+ agentJson: { identity: "a".repeat(64), platform: "linux" },
439
524
  });
440
525
  try {
441
- const host = sshHost({ root: fx.target, kh: fx.kh, node: process.execPath, identity: "discover" });
526
+ const host = sshHost({ root: fx.target, kh: fx.kh, identity: "discover" });
442
527
  const conn = writeConn(fx.tmp, host);
443
528
  const r = captureMain(["probe", "--connection-file", conn]);
444
529
  assert.equal(r.code, 0, r.stderr);
@@ -461,7 +546,7 @@ test("environmentSpec uses managed volta path", () => {
461
546
  host_id: "node-a", display_name: "Node A", platform: process.platform === "win32" ? "windows" : "linux",
462
547
  transport: "local", root: target, identity: agent.fingerprint(), connection: {},
463
548
  };
464
- model.register(state, { hosts: [host], projects: [] });
549
+ const legacy = model.load(state); legacy.hosts[host.host_id] = host; model.save(state, legacy); // existing runtime, no fabricated confirmation
465
550
  const prev = recipes.hostRecipesHooks.call;
466
551
  const voltaBin = core.targetJoin(host, "_host/toolchains/ops/volta/bin/volta");
467
552
  recipes.hostRecipesHooks.call = (_h, req) => {
@@ -497,7 +582,7 @@ test("environmentSpec still throws when volta truly absent", () => {
497
582
  host_id: "node-a", display_name: "Node A", platform: process.platform === "win32" ? "windows" : "linux",
498
583
  transport: "local", root: target, identity: agent.fingerprint(), connection: {},
499
584
  };
500
- model.register(state, { hosts: [host], projects: [] });
585
+ const legacy = model.load(state); legacy.hosts[host.host_id] = host; model.save(state, legacy); // existing runtime, no fabricated confirmation
501
586
  const prev = recipes.hostRecipesHooks.call;
502
587
  recipes.hostRecipesHooks.call = (_h, req) => {
503
588
  if (req.action === "snapshot") return { paths: { [req.paths[0]]: { kind: "absent" } } };
@@ -0,0 +1,225 @@
1
+ /** Root-consent contracts, simulated SSH and real disposable local initialization. */
2
+ import assert from "node:assert/strict";
3
+ import test from "node:test";
4
+ import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, unlinkSync, writeFileSync } from "node:fs";
5
+ import { tmpdir } from "node:os";
6
+ import { join, resolve } from "node:path";
7
+ import { spawnSync } from "node:child_process";
8
+ import { fileURLToPath } from "node:url";
9
+ import { emptyStatus, digest, writeJson } from "../tools/opslib/core.mjs";
10
+ import { load, register, save, validate } from "../tools/opslib/model.mjs";
11
+ import { discoverServer, confirmServerRoot, validateRootConfirmation, onboardingHooks, observeServer, parseLinuxDiscovery } from "../tools/opslib/onboarding.mjs";
12
+ import { call, transportHooks } from "../tools/opslib/transport.mjs";
13
+ import { initializeHostSpec, connectionSpec, hostRecipesHooks } from "../tools/opslib/host_recipes.mjs";
14
+ import { compilePlan, plannerHooks } from "../tools/opslib/planner.mjs";
15
+ import { approval, apply } from "../tools/opslib/execution.mjs";
16
+ import { fileState } from "../tools/opslib/agent.mjs";
17
+ import { generateFleet } from "../tools/opslib/fleet.mjs";
18
+
19
+ function fixture(t, local = false) {
20
+ const dir = mkdtempSync(join(tmpdir(), "ops-onboard-")), state = join(dir, "state");
21
+ mkdirSync(state); mkdirSync(join(state, "private"));
22
+ const registry = emptyStatus();
23
+ registry.controller = { controller_id: "fixture", state_root: state, created_at: "2026-01-01T00:00:00Z" };
24
+ writeFileSync(join(state, "status.json"), JSON.stringify(registry));
25
+ writeFileSync(join(state, "private/credentials.json"), '{"schema_version":1,"entries":{}}', { mode: 0o600 });
26
+ const knownHosts = join(dir, "known_hosts"); writeFileSync(knownHosts, "synthetic pinned key\n");
27
+ const host = { host_id: "node-a", display_name: "Node A", platform: local ? process.platform === "win32" ? "windows" : process.platform : "linux",
28
+ transport: local ? "local" : "ssh", root: local ? join(dir, "target") : "/home/wta/ops", identity: "a".repeat(64),
29
+ connection: local ? {} : { hostname: "server.invalid", username: "wta", known_hosts: knownHosts, node: "/usr/bin/node", sudo: true } };
30
+ const prev = onboardingHooks.observe;
31
+ let facts = { identity: host.identity, platform: "linux", username: "wta", uid: "1000", home: "/home/wta", node: null, node_version: null };
32
+ let check = { exists: false, writable: true, nonempty: false, owner: null };
33
+ if (!local) onboardingHooks.observe = (_h, root) => ({ ...facts, ...(root ? { root_check: { ...check } } : {}) });
34
+ t.after(() => { onboardingHooks.observe = prev; rmSync(dir, { recursive: true, force: true }); });
35
+ return { dir, state, host, knownHosts, facts: (v) => { facts = { ...facts, ...v }; }, check: (v) => { check = { ...check, ...v }; },
36
+ discover: () => discoverServer(state, host.host_id, host),
37
+ confirm(root = host.root) {
38
+ const d = discoverServer(state, host.host_id, host);
39
+ const r = confirmServerRoot(state, host.host_id, d.discovery.receipt_id, root, "fixture-user", "Use this exact isolated root for this fixture");
40
+ Object.assign(host, { root: r.root, identity: r.identity, root_confirmation: r.root_confirmation });
41
+ return r;
42
+ } };
43
+ }
44
+
45
+ function wire(values) { return Object.entries(values).map(([k, v]) => k + "=" + Buffer.from(v).toString("hex")).join("\n") + "\n"; }
46
+ const accountWire = { machine: "fixture-machine", system: "Linux", username: "wta", uid: "1000", home: "/accounts/wta", node: "", node_version: "" };
47
+
48
+ test("Linux wire discovery reads account database home, rejects malformed/noisy responses", () => {
49
+ const r = parseLinuxDiscovery(wire(accountWire));
50
+ assert.equal(r.home, "/accounts/wta"); assert.equal(r.node, null);
51
+ assert.equal(r.identity, digest({ machine: "fixture-machine", platform: "Linux" }));
52
+ for (const input of ["banner\n" + wire(accountWire), wire(accountWire) + "home=00\n", "home=xyz\n"]) assert.throws(() => parseLinuxDiscovery(input));
53
+ });
54
+
55
+ test("sudo-enabled Linux SSH discovery is unprivileged and does not need Node", (t) => {
56
+ const f = fixture(t), prev = transportHooks.spawnSync;
57
+ t.after(() => { transportHooks.spawnSync = prev; });
58
+ let called = false;
59
+ transportHooks.spawnSync = (cmd, args, options) => {
60
+ called = true; assert.equal(cmd, "ssh"); assert.ok(args.includes("StrictHostKeyChecking=yes"));
61
+ assert.ok(!args.at(-1).includes("sudo")); assert.ok(!args.at(-1).includes("node"));
62
+ assert.ok(options.input.toString().includes('getent passwd "$uid"'));
63
+ return { status: 0, stdout: wire(accountWire), stderr: "" };
64
+ };
65
+ assert.equal(observeServer(f.host).username, "wta"); assert.ok(called);
66
+ });
67
+
68
+ test("root, ordinary and custom account homes generate suggestions, never confirmation", (t) => {
69
+ const f = fixture(t);
70
+ for (const [username, uid, home] of [["root", "0", "/root"], ["wta", "1000", "/home/wta"], ["service_user", "1001", "/accounts/service user"]]) {
71
+ f.host.connection.username = username; f.facts({ username, uid, home });
72
+ const d = f.discover(); assert.equal(d.suggested_root, home + "/ops");
73
+ assert.deepEqual(load(f.state).hosts, {});
74
+ assert.throws(() => register(f.state, { hosts: [f.host] }), /requires server-root-confirm/);
75
+ }
76
+ assert.equal(existsSync(join(f.dir, "target")), false);
77
+ });
78
+
79
+ test("confirmed root locks retain login ownership while approved steps can use sudo", (t) => {
80
+ const f = fixture(t), prev = transportHooks.spawnSync;
81
+ t.after(() => { transportHooks.spawnSync = prev; });
82
+ const commands = [];
83
+ transportHooks.spawnSync = (_cmd, args) => {
84
+ commands.push(args.at(-1));
85
+ return { status: 0, stdout: JSON.stringify({ ok: true, result: {} }), stderr: "" };
86
+ };
87
+ f.host.root_confirmation = { receipt_id: "root-test", digest: "b".repeat(64) };
88
+ for (const action of ["lock", "step", "unlock"]) call(f.host, { action });
89
+ assert.equal(commands[0].startsWith("sudo "), false);
90
+ assert.equal(commands[1].startsWith("sudo -n -- "), true);
91
+ assert.equal(commands[2].startsWith("sudo "), false);
92
+ delete f.host.root_confirmation;
93
+ call(f.host, { action: "lock" });
94
+ assert.equal(commands[3].startsWith("sudo -n -- "), true, "legacy privilege behavior is unchanged");
95
+ });
96
+
97
+ test("invalid endpoint and missing controller initialization fail before probing", (t) => {
98
+ const f = fixture(t); onboardingHooks.observe = () => assert.fail("invalid inputs must not reach a target");
99
+ assert.throws(() => discoverServer(f.state, "node-a", { ...f.host, connection: { ...f.host.connection, password: "synthetic-only" } }), /unknown/);
100
+ const s = load(f.state); s.controller = null; save(f.state, s);
101
+ assert.throws(() => f.discover(), /initialize the controller/);
102
+ });
103
+
104
+ test("Windows account discovery accepts SSH account casing and uses its actual home", (t) => {
105
+ const f = fixture(t);
106
+ f.host.platform = "windows"; f.host.connection.username = "WTA"; f.host.connection.shell = "powershell";
107
+ f.facts({ platform: "windows", username: "wta", uid: "-1", home: "D:\\Profiles\\wta" });
108
+ assert.equal(f.discover().suggested_root, "D:\\Profiles\\wta\\ops");
109
+ f.facts({ username: "different-user" }); assert.throws(() => f.discover(), /login account differs/);
110
+ });
111
+
112
+ test("explicit default/custom roots register with immutable evidence and repeat without new consent", (t) => {
113
+ const f = fixture(t); f.confirm("/data/ops");
114
+ const ref = structuredClone(f.host.root_confirmation);
115
+ register(f.state, { hosts: [f.host] }); register(f.state, { hosts: [f.host] });
116
+ assert.equal(load(f.state).hosts["node-a"].root, "/data/ops");
117
+ assert.deepEqual(load(f.state).hosts["node-a"].root_confirmation, ref);
118
+ assert.equal(f.discover().suggested_root, "/data/ops");
119
+ assert.throws(() => f.confirm("/new-root"), /immutable/);
120
+ const other = { ...f.host, host_id: "node-b", root: "/other/ops" };
121
+ assert.throws(() => register(f.state, { hosts: [other] }), /reuse Host/);
122
+ assert.throws(() => discoverServer(f.state, "node-b", other), /reuse Host/);
123
+ });
124
+
125
+ test("missing user statement, unsafe paths, nonempty roots, permissions and ownership block confirmation", (t) => {
126
+ const f = fixture(t), d = f.discover();
127
+ const confirm = (root = f.host.root, statement = "Use this isolated root") => confirmServerRoot(f.state, "node-a", d.discovery.receipt_id, root, "user", statement);
128
+ for (const statement of ["", " "]) assert.throws(() => confirm(f.host.root, statement), /confirmation/);
129
+ for (const root of ["relative", "/", "/root", "/home/wta", "/etc/nested", "/data/../root", "/data/ops\n", "/data\\ops"]) assert.throws(() => confirm(root));
130
+ f.check({ writable: false }); assert.throws(() => confirm(), /writable/);
131
+ f.check({ writable: true, exists: true, nonempty: true }); assert.throws(() => confirm(), /unowned/);
132
+ f.check({ owner: { host_id: "other", identity: f.host.identity } }); assert.throws(() => confirm(), /ownership/);
133
+ assert.deepEqual(load(f.state).hosts, {});
134
+ });
135
+
136
+ test("identity, home, trust and receipt drift never authorize the changed target", (t) => {
137
+ const f = fixture(t), d = f.discover();
138
+ const confirm = () => confirmServerRoot(f.state, "node-a", d.discovery.receipt_id, f.host.root, "user", "Use this exact root");
139
+ f.facts({ home: "/changed/home" }); assert.throws(confirm, /drift/);
140
+ f.facts({ home: "/home/wta", identity: "b".repeat(64) }); assert.throws(confirm, /drift/);
141
+ f.facts({ identity: "a".repeat(64) });
142
+ writeFileSync(f.knownHosts, "changed host key"); assert.throws(confirm, /trust changed/);
143
+ f.confirm();
144
+ assert.throws(() => register(f.state, { hosts: [{ ...f.host, root: "/elsewhere/ops" }] }), /mismatch/);
145
+ const receipt = join(f.state, "records/servers/node-a/onboarding", f.host.root_confirmation.receipt_id + ".json");
146
+ const data = JSON.parse(readFileSync(receipt)); data.statement = "tampered"; writeFileSync(receipt, JSON.stringify(data));
147
+ assert.throws(() => register(f.state, { hosts: [f.host] }), /mismatch/);
148
+ });
149
+
150
+ test("same-machine connection update preserves the selected root and verifies new account access", (t) => {
151
+ const f = fixture(t); f.confirm(); register(f.state, { hosts: [f.host] });
152
+ f.facts({ username: "root", uid: "0", home: "/root" });
153
+ const endpoint = { ...f.host, connection: { ...f.host.connection, username: "root" } };
154
+ const spec = connectionSpec(f.state, "node-a", endpoint);
155
+ assert.equal(spec.resource_updates.hosts[0].root, "/home/wta/ops");
156
+ assert.deepEqual(spec.resource_updates.hosts[0].root_confirmation, f.host.root_confirmation);
157
+ assert.equal(load(f.state).hosts["node-a"].connection.username, "wta");
158
+ f.check({ writable: false }); assert.throws(() => connectionSpec(f.state, "node-a", endpoint), /writable/);
159
+ });
160
+
161
+ test("legacy v3 roots and evidence stay unchanged; ambiguous reconnection is blocked", (t) => {
162
+ const f = fixture(t), s = load(f.state); s.hosts["node-a"] = { ...f.host, root: "/srv/ops" }; save(f.state, s);
163
+ const before = readFileSync(join(f.state, "status.json"));
164
+ assert.equal(f.discover().suggested_root, "/srv/ops");
165
+ assert.equal(validateRootConfirmation(f.state, s.hosts["node-a"]), null);
166
+ assert.deepEqual(readFileSync(join(f.state, "status.json")), before);
167
+ s.hosts["old-second"] = { ...s.hosts["node-a"], host_id: "old-second", root: "/srv/second" }; save(f.state, s);
168
+ assert.throws(() => f.discover(), /ambiguous legacy/);
169
+ });
170
+
171
+ test("real local root rejects symlink ancestors and existing unrelated files", (t) => {
172
+ const f = fixture(t, true), real = join(f.dir, "real"), link = join(f.dir, "link"); mkdirSync(real);
173
+ symlinkSync(real, link, process.platform === "win32" ? "junction" : "dir");
174
+ assert.throws(() => f.confirm(join(link, "ops")), /symlink|reparse/i);
175
+ mkdirSync(f.host.root); writeFileSync(join(f.host.root, "README.md"), "User file, preserve");
176
+ assert.throws(() => f.confirm(), /unowned/);
177
+ assert.equal(readFileSync(join(f.host.root, "README.md"), "utf8"), "User file, preserve");
178
+ });
179
+
180
+ test("real local onboarding and approved minimal initialization deliver both sides, then reuse directories", (t) => {
181
+ const f = fixture(t, true); f.confirm(); register(f.state, { hosts: [f.host] });
182
+ assert.equal(existsSync(f.host.root), false, "registration must not create target directories");
183
+ const pp = plannerHooks.call, hp = hostRecipesHooks.call;
184
+ const inventory = (h, req) => ({ identity: h.identity, platform: h.platform, tools: {}, defaults: {}, diagnostics: {},
185
+ snapshots: Object.fromEntries((req.paths || []).map(p => [p, fileState(p)])) });
186
+ plannerHooks.call = inventory; hostRecipesHooks.call = inventory;
187
+ t.after(() => { plannerHooks.call = pp; hostRecipesHooks.call = hp; });
188
+ const spec = initializeHostSpec(f.state, "node-a");
189
+ assert.ok(spec.host_actions.length > 0); assert.ok(spec.host_actions.every(a => a.kind === "mkdir"));
190
+ const specPath = join(f.dir, "init.json"); writeJson(specPath, spec);
191
+ const plan = compilePlan(f.state, specPath);
192
+ assert.equal(existsSync(f.host.root), false, "planning must not prepare the target");
193
+ approval(f.state, plan.run_id, plan.plan_digest, "fixture-user", "Initialize only this disposable local Host");
194
+ const redirected = join(f.dir, "redirected"); mkdirSync(redirected);
195
+ symlinkSync(redirected, f.host.root, process.platform === "win32" ? "junction" : "dir");
196
+ assert.throws(() => apply(f.state, plan.run_id), /symlink|reparse/i);
197
+ assert.equal(existsSync(join(redirected, ".ops-host.json")), false);
198
+ unlinkSync(f.host.root);
199
+ const result = apply(f.state, plan.run_id); assert.equal(result.status, "completed", JSON.stringify(result));
200
+ for (const p of ["README.md", "DEPLOYMENTS.md", "docs/standards/DEPLOYMENT-STANDARD.md", ".ops-host.json"]) assert.ok(existsSync(join(f.host.root, p)), p);
201
+ assert.equal(initializeHostSpec(f.state, "node-a").host_actions.length, 0);
202
+ assert.equal(existsSync(join(f.host.root, "_runtime/docker")), false, "minimal initialization never installs Docker");
203
+ const previous = onboardingHooks.observe; onboardingHooks.observe = () => assert.fail("V must not connect to targets");
204
+ try {
205
+ const view = generateFleet(f.state); const data = JSON.parse(readFileSync(view.json)); validate(data, "fleet-view");
206
+ assert.equal(data.servers[0].root_confirmation.status, "confirmed");
207
+ assert.equal(data.servers[0].initialization.documents, "both-sides-verified");
208
+ assert.equal(data.servers[0].initialization.runtime, "unverified");
209
+ assert.ok(!readFileSync(view.html, "utf8").includes("Use this exact isolated root"));
210
+ const s = load(f.state), last = s.releases[plan.run_id];
211
+ s.releases["pending-fixture"] = { ...last, run_id: "pending-fixture", status: "docs_pending", updated_at: new Date(Date.now() + 2000).toISOString() };
212
+ save(f.state, s);
213
+ const pending = JSON.parse(readFileSync(generateFleet(f.state).json));
214
+ assert.equal(pending.servers[0].initialization.documents, "unknown", "an older receipt cannot conceal a newer pending delivery");
215
+ } finally { onboardingHooks.observe = previous; }
216
+ });
217
+
218
+ test("Linux shell adapter matches real account identity without creating the selected root", { skip: process.platform !== "linux" && "Linux shell execution requires a Linux runner" }, (t) => {
219
+ const f = fixture(t, true), script = fileURLToPath(new URL("../tools/server-discover.sh", import.meta.url));
220
+ const run = spawnSync("/bin/sh", [script, f.host.root], { encoding: "utf8", timeout: 30000 });
221
+ assert.equal(run.status, 0, run.stderr);
222
+ const shell = parseLinuxDiscovery(run.stdout, true), native = observeServer(f.host, f.host.root);
223
+ for (const key of ["identity", "username", "uid", "home"]) assert.equal(shell[key], native[key]);
224
+ assert.equal(existsSync(f.host.root), false);
225
+ });