@namewta/speculo 1.0.20 → 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 (51) hide show
  1. package/package.json +1 -1
  2. package/template/workflows/ops/D-project-deploy/D-project-deploy.md +1 -1
  3. package/template/workflows/ops/H-host-manage/H-host-manage.md +4 -0
  4. package/template/workflows/ops/H-host-manage/references/host-profiles.md +4 -0
  5. package/template/workflows/ops/I-initialize/I-initialize.md +1 -1
  6. package/template/workflows/ops/INDEX.md +2 -0
  7. package/template/workflows/ops/README.md +7 -3
  8. package/template/workflows/ops/S-server-connect/S-server-connect.md +5 -5
  9. package/template/workflows/ops/V-inventory-view/V-inventory-view.md +2 -0
  10. package/template/workflows/ops/common/CAPABILITIES.md +2 -0
  11. package/template/workflows/ops/common/USAGE.md +5 -5
  12. package/template/workflows/ops/common/WORKSPACE-USAGE.md +29 -2
  13. package/template/workflows/ops/common/examples/README.md +1 -1
  14. package/template/workflows/ops/common/examples/register.example.json +7 -3
  15. package/template/workflows/ops/common/examples/server-connection.example.json +11 -0
  16. package/template/workflows/ops/common/rules/deployment-layout.md +1 -1
  17. package/template/workflows/ops/common/rules/host-root-and-onboarding.md +47 -0
  18. package/template/workflows/ops/common/rules/persistence-and-secrets.md +1 -1
  19. package/template/workflows/ops/common/rules/research-and-decisions.md +8 -0
  20. package/template/workflows/ops/common/rules/server-onboarding.md +3 -3
  21. package/template/workflows/ops/common/rules/workspace-and-authorization.md +2 -2
  22. package/template/workflows/ops/common/schemas/fleet-view.schema.json +64 -0
  23. package/template/workflows/ops/common/schemas/host.schema.json +19 -0
  24. package/template/workflows/ops/common/schemas/plan.schema.json +57 -0
  25. package/template/workflows/ops/common/schemas/root-confirmation.schema.json +118 -0
  26. package/template/workflows/ops/common/schemas/server-connection.schema.json +67 -0
  27. package/template/workflows/ops/common/schemas/server-discovery.schema.json +171 -0
  28. package/template/workflows/ops/common/schemas/spec.schema.json +19 -0
  29. package/template/workflows/ops/common/schemas/status.schema.json +19 -0
  30. package/template/workflows/ops/common/templates/FLEET.html +1 -1
  31. package/template/workflows/ops/common/templates/HOST-README.md +3 -1
  32. package/template/workflows/ops/common/tests/test_ops.mjs +3 -3
  33. package/template/workflows/ops/common/tests/test_ops_bootstrap.mjs +30 -2
  34. package/template/workflows/ops/common/tests/test_ops_onboarding.mjs +225 -0
  35. package/template/workflows/ops/common/tools/demo-local.mjs +4 -0
  36. package/template/workflows/ops/common/tools/opslib/agent.mjs +26 -0
  37. package/template/workflows/ops/common/tools/opslib/bootstrap.mjs +50 -24
  38. package/template/workflows/ops/common/tools/opslib/cli.mjs +1 -0
  39. package/template/workflows/ops/common/tools/opslib/core.mjs +1 -1
  40. package/template/workflows/ops/common/tools/opslib/docs.mjs +1 -0
  41. package/template/workflows/ops/common/tools/opslib/execution.mjs +4 -0
  42. package/template/workflows/ops/common/tools/opslib/fleet.mjs +26 -4
  43. package/template/workflows/ops/common/tools/opslib/host_recipes.mjs +34 -0
  44. package/template/workflows/ops/common/tools/opslib/model.mjs +6 -0
  45. package/template/workflows/ops/common/tools/opslib/onboarding.mjs +215 -0
  46. package/template/workflows/ops/common/tools/opslib/planner.mjs +3 -0
  47. package/template/workflows/ops/common/tools/opslib/transport.mjs +7 -3
  48. package/template/workflows/ops/common/tools/opslib/workspace_cli.mjs +20 -1
  49. package/template/workflows/ops/common/tools/server-discover.sh +65 -0
  50. package/template/workflows/ops/common/tools/validate-ops.mjs +1 -1
  51. package/template/workflows/ops/manifest.json +12 -5
@@ -0,0 +1,118 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "urn:speculo:ops:root-confirmation:v1",
4
+ "type": "object",
5
+ "properties": {
6
+ "schema_version": {
7
+ "const": 1
8
+ },
9
+ "artifact": {
10
+ "const": "ops-root-confirmation"
11
+ },
12
+ "receipt_id": {
13
+ "type": "string",
14
+ "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$",
15
+ "maxLength": 80
16
+ },
17
+ "host_id": {
18
+ "type": "string",
19
+ "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$",
20
+ "maxLength": 80
21
+ },
22
+ "platform": {
23
+ "enum": [
24
+ "linux",
25
+ "windows",
26
+ "darwin"
27
+ ]
28
+ },
29
+ "transport": {
30
+ "enum": [
31
+ "local",
32
+ "ssh"
33
+ ]
34
+ },
35
+ "identity": {
36
+ "type": "string",
37
+ "pattern": "^[a-f0-9]{64}$"
38
+ },
39
+ "root": {
40
+ "type": "string",
41
+ "minLength": 1
42
+ },
43
+ "account": {
44
+ "type": "object",
45
+ "properties": {
46
+ "username": {
47
+ "type": "string",
48
+ "minLength": 1
49
+ },
50
+ "uid": {
51
+ "type": "string"
52
+ },
53
+ "home": {
54
+ "type": "string",
55
+ "minLength": 1
56
+ }
57
+ },
58
+ "required": [
59
+ "username",
60
+ "uid",
61
+ "home"
62
+ ],
63
+ "additionalProperties": false
64
+ },
65
+ "endpoint_digest": {
66
+ "type": "string",
67
+ "pattern": "^[a-f0-9]{64}$"
68
+ },
69
+ "discovery": {
70
+ "type": "object",
71
+ "properties": {
72
+ "receipt_id": {
73
+ "type": "string",
74
+ "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$",
75
+ "maxLength": 80
76
+ },
77
+ "digest": {
78
+ "type": "string",
79
+ "pattern": "^[a-f0-9]{64}$"
80
+ }
81
+ },
82
+ "required": [
83
+ "receipt_id",
84
+ "digest"
85
+ ],
86
+ "additionalProperties": false
87
+ },
88
+ "confirmed_at": {
89
+ "type": "string",
90
+ "minLength": 1
91
+ },
92
+ "by": {
93
+ "type": "string",
94
+ "minLength": 1
95
+ },
96
+ "statement": {
97
+ "type": "string",
98
+ "minLength": 1
99
+ }
100
+ },
101
+ "required": [
102
+ "schema_version",
103
+ "artifact",
104
+ "receipt_id",
105
+ "host_id",
106
+ "platform",
107
+ "transport",
108
+ "identity",
109
+ "root",
110
+ "account",
111
+ "endpoint_digest",
112
+ "discovery",
113
+ "confirmed_at",
114
+ "by",
115
+ "statement"
116
+ ],
117
+ "additionalProperties": false
118
+ }
@@ -0,0 +1,67 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "urn:speculo:ops:server-connection:v1",
4
+ "type": "object",
5
+ "properties": {
6
+ "platform": {
7
+ "enum": [
8
+ "linux",
9
+ "windows",
10
+ "darwin"
11
+ ]
12
+ },
13
+ "transport": {
14
+ "enum": [
15
+ "local",
16
+ "ssh"
17
+ ]
18
+ },
19
+ "connection": {
20
+ "type": "object",
21
+ "properties": {
22
+ "hostname": {
23
+ "type": "string",
24
+ "minLength": 1
25
+ },
26
+ "username": {
27
+ "type": "string",
28
+ "minLength": 1
29
+ },
30
+ "port": {
31
+ "type": "integer",
32
+ "minimum": 1,
33
+ "maximum": 65535
34
+ },
35
+ "known_hosts": {
36
+ "type": "string",
37
+ "minLength": 1
38
+ },
39
+ "identity_file": {
40
+ "type": "string",
41
+ "minLength": 1
42
+ },
43
+ "node": {
44
+ "type": "string",
45
+ "minLength": 1
46
+ },
47
+ "sudo": {
48
+ "type": "boolean"
49
+ },
50
+ "shell": {
51
+ "enum": [
52
+ "posix",
53
+ "powershell"
54
+ ]
55
+ }
56
+ },
57
+ "required": [],
58
+ "additionalProperties": false
59
+ }
60
+ },
61
+ "required": [
62
+ "platform",
63
+ "transport",
64
+ "connection"
65
+ ],
66
+ "additionalProperties": false
67
+ }
@@ -0,0 +1,171 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "urn:speculo:ops:server-discovery:v1",
4
+ "type": "object",
5
+ "properties": {
6
+ "schema_version": {
7
+ "const": 1
8
+ },
9
+ "artifact": {
10
+ "const": "ops-server-discovery"
11
+ },
12
+ "discovery_id": {
13
+ "type": "string",
14
+ "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$",
15
+ "maxLength": 80
16
+ },
17
+ "host_id": {
18
+ "type": "string",
19
+ "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$",
20
+ "maxLength": 80
21
+ },
22
+ "discovered_at": {
23
+ "type": "string",
24
+ "minLength": 1
25
+ },
26
+ "endpoint": {
27
+ "type": "object",
28
+ "properties": {
29
+ "host_id": {
30
+ "type": "string",
31
+ "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$",
32
+ "maxLength": 80
33
+ },
34
+ "platform": {
35
+ "enum": [
36
+ "linux",
37
+ "windows",
38
+ "darwin"
39
+ ]
40
+ },
41
+ "transport": {
42
+ "enum": [
43
+ "local",
44
+ "ssh"
45
+ ]
46
+ },
47
+ "connection": {
48
+ "type": "object",
49
+ "properties": {
50
+ "hostname": {
51
+ "type": "string",
52
+ "minLength": 1
53
+ },
54
+ "username": {
55
+ "type": "string",
56
+ "minLength": 1
57
+ },
58
+ "port": {
59
+ "type": "integer",
60
+ "minimum": 1,
61
+ "maximum": 65535
62
+ },
63
+ "known_hosts": {
64
+ "type": "string",
65
+ "minLength": 1
66
+ },
67
+ "identity_file": {
68
+ "type": "string",
69
+ "minLength": 1
70
+ },
71
+ "node": {
72
+ "type": "string",
73
+ "minLength": 1
74
+ },
75
+ "sudo": {
76
+ "type": "boolean"
77
+ },
78
+ "shell": {
79
+ "enum": [
80
+ "posix",
81
+ "powershell"
82
+ ]
83
+ }
84
+ },
85
+ "required": [],
86
+ "additionalProperties": false
87
+ }
88
+ },
89
+ "required": [
90
+ "host_id",
91
+ "platform",
92
+ "transport",
93
+ "connection"
94
+ ],
95
+ "additionalProperties": false
96
+ },
97
+ "endpoint_digest": {
98
+ "type": "string",
99
+ "pattern": "^[a-f0-9]{64}$"
100
+ },
101
+ "facts": {
102
+ "type": "object",
103
+ "properties": {
104
+ "identity": {
105
+ "type": "string",
106
+ "pattern": "^[a-f0-9]{64}$"
107
+ },
108
+ "platform": {
109
+ "enum": [
110
+ "linux",
111
+ "windows",
112
+ "darwin"
113
+ ]
114
+ },
115
+ "username": {
116
+ "type": "string",
117
+ "minLength": 1
118
+ },
119
+ "uid": {
120
+ "type": "string"
121
+ },
122
+ "home": {
123
+ "type": "string",
124
+ "minLength": 1
125
+ },
126
+ "node": {
127
+ "type": [
128
+ "string",
129
+ "null"
130
+ ]
131
+ },
132
+ "node_version": {
133
+ "type": [
134
+ "string",
135
+ "null"
136
+ ]
137
+ }
138
+ },
139
+ "required": [
140
+ "identity",
141
+ "platform",
142
+ "username",
143
+ "uid",
144
+ "home",
145
+ "node",
146
+ "node_version"
147
+ ],
148
+ "additionalProperties": false
149
+ },
150
+ "suggested_root": {
151
+ "type": "string",
152
+ "minLength": 1
153
+ },
154
+ "registered": {
155
+ "type": "boolean"
156
+ }
157
+ },
158
+ "required": [
159
+ "schema_version",
160
+ "artifact",
161
+ "discovery_id",
162
+ "host_id",
163
+ "discovered_at",
164
+ "endpoint",
165
+ "endpoint_digest",
166
+ "facts",
167
+ "suggested_root",
168
+ "registered"
169
+ ],
170
+ "additionalProperties": false
171
+ }
@@ -160,6 +160,25 @@
160
160
  ],
161
161
  "additionalProperties": false
162
162
  }
163
+ },
164
+ "root_confirmation": {
165
+ "type": "object",
166
+ "properties": {
167
+ "receipt_id": {
168
+ "type": "string",
169
+ "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$",
170
+ "maxLength": 80
171
+ },
172
+ "digest": {
173
+ "type": "string",
174
+ "pattern": "^[a-f0-9]{64}$"
175
+ }
176
+ },
177
+ "required": [
178
+ "receipt_id",
179
+ "digest"
180
+ ],
181
+ "additionalProperties": false
163
182
  }
164
183
  },
165
184
  "required": [
@@ -362,6 +362,25 @@
362
362
  ],
363
363
  "additionalProperties": false
364
364
  }
365
+ },
366
+ "root_confirmation": {
367
+ "type": "object",
368
+ "properties": {
369
+ "receipt_id": {
370
+ "type": "string",
371
+ "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$",
372
+ "maxLength": 80
373
+ },
374
+ "digest": {
375
+ "type": "string",
376
+ "pattern": "^[a-f0-9]{64}$"
377
+ }
378
+ },
379
+ "required": [
380
+ "receipt_id",
381
+ "digest"
382
+ ],
383
+ "additionalProperties": false
365
384
  }
366
385
  },
367
386
  "required": [
@@ -47,7 +47,7 @@ function render(){const server=$("server").value,project=$("project").value,st=$
47
47
  const ds=data.deployments.filter(d=>(!server||d.server_id===server)&&(!project||d.project_id===project)&&(!st||d.status===st)&&match(d));
48
48
  const ss=data.servers.filter(s=>(!server||s.server_id===server)&&(!project||s.deployment_ids.some(id=>byDeployment.get(id)?.project_id===project))&&(!st||ds.some(d=>d.server_id===s.server_id))&&(match(s)||ds.some(d=>d.server_id===s.server_id)));
49
49
  const ps=data.projects.filter(p=>(!project||p.project_id===project)&&(!server||p.deployment_ids.some(id=>byDeployment.get(id)?.server_id===server))&&(!st||ds.some(d=>d.project_id===p.project_id))&&(match(p)||ds.some(d=>d.project_id===p.project_id)));
50
- table("servers",["服务器 / 名称","地址 / 系统","检查结果","检查详情(* 必需项)","项目","部署根"],ss.map(s=>[links("server",[s.server_id]),`${s.name} · ${s.address}${s.port?":"+s.port:""} / ${s.platform}`,badge(s.readiness),checks(s),links("project",s.project_ids),s.root]));
50
+ table("servers",["服务器 / 名称","地址 / 系统","检查结果","检查详情(* 必需项)","项目","账号 / 持久化根 / 确认 / 文档"],ss.map(s=>[links("server",[s.server_id]),`${s.name} · ${s.address}${s.port?":"+s.port:""} / ${s.platform}`,badge(s.readiness),checks(s),links("project",s.project_ids),`${s.login_account} / ${s.root} / ${s.root_confirmation.status} / ${s.initialization.documents}`]));
51
51
  table("projects",["项目","名称","类别 / 服务类型","所在服务器","部署数量"],ps.map(p=>[link("project",p.project_id),p.name,`${p.kind} / ${p.service_type||"—"}`,links("server",p.server_ids),p.deployment_ids.length]));
52
52
  table("deployments",["部署实例","项目 / 服务器","环境 / 方法","记录状态","计划版本","验证版本","根目录 / 更新时间"],ds.map(d=>{const where=node("div");where.append(link("project",d.project_id),node("div"),link("server",d.server_id));return[d.deployment_id,where,`${d.environment} / ${d.instance} / ${d.method}`,badge(d.status),d.desired_version,d.observed_version,`${d.root} / ${d.updated_at||"未验证"}`];}));
53
53
  table("services",["服务器","主机级服务","类型","systemd 单元","监听端口"],ss.flatMap(s=>s.host_services.map(v=>[link("server",s.server_id),v.service_id,v.kind,v.unit,v.port])));
@@ -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) {
@@ -440,7 +440,7 @@ test("planner contracts", async (t) => {
440
440
  });
441
441
  await each("unrelated host registration does not block approval", (fx) => {
442
442
  const [info] = fx.plan();
443
- 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
444
444
  fx.approve(info);
445
445
  });
446
446
  await each("touched host catalog drift blocks approval", (fx) => {
@@ -888,7 +888,7 @@ test("control file helpers", () => {
888
888
  });
889
889
 
890
890
  test("disjoint host plans apply sequentially", () => runFx((fx) => {
891
- 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
892
892
  const specA = {
893
893
  schema_version: 1, worker: "H", operation: "maintain", hosts: ["node-a"], reason: "Host A cache fixture",
894
894
  rollback_note: "Keep isolated cache until a separate purge is approved.",
@@ -14,6 +14,7 @@ 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");
@@ -258,9 +259,13 @@ function cliFixture({ executeLinux = false } = {}) {
258
259
  const archives = makeArchives(tmp);
259
260
  const prevPin = bootstrap.bootstrapHooks.loadPin;
260
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}} : {}) });
261
264
  return {
262
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; },
263
267
  cleanup() {
268
+ onboardingHooks.observe = previousObserve;
264
269
  bootstrap.bootstrapHooks.loadPin = prevPin;
265
270
  rmSync(tmp, { recursive: true, force: true });
266
271
  },
@@ -279,6 +284,7 @@ test("Linux SSH bootstrap and enrollment preserve remote paths on every controll
279
284
  const mock = mockTransport({ probeJson: missingProbe, applyJson: { status: "installed", connection_node: connectionNode, profile_unchanged: true } });
280
285
  try {
281
286
  const host = sshHost({ root: fx.target, kh: fx.kh, node: "node" });
287
+ fx.confirm(host);
282
288
  const conn = writeConn(fx.tmp, host);
283
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,
284
290
  "--volta-archive", fx.archives.voltaTar, "--volta-sha256", fx.archives.voltaSha, "--node-archive", fx.archives.nodeTar, "--node-sha256", fx.archives.nodeSha]);
@@ -290,6 +296,15 @@ test("Linux SSH bootstrap and enrollment preserve remote paths on every controll
290
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]);
291
297
  assert.equal(JSON.parse(readFileSync(boot.receipt, "utf8")).connection_node, connectionNode);
292
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-")));
293
308
  mock.restore();
294
309
  host.connection.node = connectionNode;
295
310
  const enrollFile = join(fx.tmp, "register.json"); writeFileSync(enrollFile, JSON.stringify({ hosts: [host], projects: [] }));
@@ -355,6 +370,18 @@ test("bootstrap-node apply without ack blocked", () => {
355
370
  } finally { mock.restore(); fx.cleanup(); }
356
371
  });
357
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
+
358
385
  test("bootstrap-node sha mismatch blocked", () => {
359
386
  const fx = cliFixture();
360
387
  const mock = mockTransport({ probeJson: missingProbe });
@@ -428,6 +455,7 @@ test("bootstrap-node apply then enroll writes inventory and status", linuxExecut
428
455
  const mock = mockTransport({ probeJson: missingProbe });
429
456
  try {
430
457
  const host = sshHost({ root: fx.target, kh: fx.kh, node: "node" });
458
+ fx.confirm(host);
431
459
  const conn = writeConn(fx.tmp, host);
432
460
  const applied = captureMain([
433
461
  "bootstrap-node", "--state", fx.state, "--host-id", "wsl-a",
@@ -518,7 +546,7 @@ test("environmentSpec uses managed volta path", () => {
518
546
  host_id: "node-a", display_name: "Node A", platform: process.platform === "win32" ? "windows" : "linux",
519
547
  transport: "local", root: target, identity: agent.fingerprint(), connection: {},
520
548
  };
521
- 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
522
550
  const prev = recipes.hostRecipesHooks.call;
523
551
  const voltaBin = core.targetJoin(host, "_host/toolchains/ops/volta/bin/volta");
524
552
  recipes.hostRecipesHooks.call = (_h, req) => {
@@ -554,7 +582,7 @@ test("environmentSpec still throws when volta truly absent", () => {
554
582
  host_id: "node-a", display_name: "Node A", platform: process.platform === "win32" ? "windows" : "linux",
555
583
  transport: "local", root: target, identity: agent.fingerprint(), connection: {},
556
584
  };
557
- 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
558
586
  const prev = recipes.hostRecipesHooks.call;
559
587
  recipes.hostRecipesHooks.call = (_h, req) => {
560
588
  if (req.action === "snapshot") return { paths: { [req.paths[0]]: { kind: "absent" } } };