@serfab/cadre-host 0.7.1 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (193) hide show
  1. package/README.md +296 -101
  2. package/dist/auth/trust-circle.d.ts +45 -7
  3. package/dist/auth/trust-circle.js +116 -59
  4. package/dist/auth/trust-circle.js.map +1 -1
  5. package/dist/auth/types.d.ts +4 -1
  6. package/dist/auth/types.js.map +1 -1
  7. package/dist/bin/host.js +426 -112
  8. package/dist/bin/host.js.map +1 -1
  9. package/dist/donation/donation-service.d.ts +136 -0
  10. package/dist/donation/donation-service.js +306 -0
  11. package/dist/donation/donation-service.js.map +1 -0
  12. package/dist/donation/donation-store.d.ts +42 -0
  13. package/dist/donation/donation-store.js +141 -0
  14. package/dist/donation/donation-store.js.map +1 -0
  15. package/dist/donation/grant-service.d.ts +71 -0
  16. package/dist/donation/grant-service.js +163 -0
  17. package/dist/donation/grant-service.js.map +1 -0
  18. package/dist/donation/grant-store.d.ts +38 -0
  19. package/dist/donation/grant-store.js +135 -0
  20. package/dist/donation/grant-store.js.map +1 -0
  21. package/dist/donation/index.d.ts +18 -0
  22. package/dist/donation/index.js +16 -0
  23. package/dist/donation/index.js.map +1 -0
  24. package/dist/donation/types.d.ts +175 -0
  25. package/dist/donation/types.js +40 -0
  26. package/dist/donation/types.js.map +1 -0
  27. package/dist/index.d.ts +12 -6
  28. package/dist/index.js +8 -3
  29. package/dist/index.js.map +1 -1
  30. package/dist/installer/browser.js +16 -17
  31. package/dist/installer/browser.js.map +1 -1
  32. package/dist/installer/config.d.ts +51 -0
  33. package/dist/installer/config.js +14 -1
  34. package/dist/installer/config.js.map +1 -1
  35. package/dist/installer/index.d.ts +7 -2
  36. package/dist/installer/index.js +17 -10
  37. package/dist/installer/index.js.map +1 -1
  38. package/dist/installer/service-host/launchd.js +1 -1
  39. package/dist/installer/service-host/launchd.js.map +1 -1
  40. package/dist/installer/service-host/systemd.js +1 -1
  41. package/dist/installer/service-host/systemd.js.map +1 -1
  42. package/dist/installer/wizard.d.ts +4 -0
  43. package/dist/installer/wizard.js +5 -1
  44. package/dist/installer/wizard.js.map +1 -1
  45. package/dist/nat/index.d.ts +1 -1
  46. package/dist/nat/nat-service.d.ts +45 -3
  47. package/dist/nat/nat-service.js +116 -3
  48. package/dist/nat/nat-service.js.map +1 -1
  49. package/dist/nat/secrets/index.d.ts +7 -0
  50. package/dist/nat/secrets/index.js +28 -2
  51. package/dist/nat/secrets/index.js.map +1 -1
  52. package/dist/nat/types.d.ts +3 -1
  53. package/dist/nat/types.js.map +1 -1
  54. package/dist/orchestrator/host-process-orchestrator.d.ts +68 -1
  55. package/dist/orchestrator/host-process-orchestrator.js +272 -39
  56. package/dist/orchestrator/host-process-orchestrator.js.map +1 -1
  57. package/dist/orchestrator/index.d.ts +3 -2
  58. package/dist/orchestrator/index.js +1 -1
  59. package/dist/orchestrator/index.js.map +1 -1
  60. package/dist/orchestrator/state-store.d.ts +6 -5
  61. package/dist/orchestrator/state-store.js +10 -2
  62. package/dist/orchestrator/state-store.js.map +1 -1
  63. package/dist/orchestrator/types.d.ts +45 -10
  64. package/dist/orchestrator/types.js.map +1 -1
  65. package/dist/owner/index.d.ts +11 -0
  66. package/dist/owner/index.js +11 -0
  67. package/dist/owner/index.js.map +1 -0
  68. package/dist/owner/owner-node-client.d.ts +95 -0
  69. package/dist/owner/owner-node-client.js +167 -0
  70. package/dist/owner/owner-node-client.js.map +1 -0
  71. package/dist/push/index.d.ts +67 -0
  72. package/dist/push/index.js +101 -0
  73. package/dist/push/index.js.map +1 -0
  74. package/dist/server/error-handler.d.ts +17 -0
  75. package/dist/server/error-handler.js +45 -0
  76. package/dist/server/error-handler.js.map +1 -1
  77. package/dist/server/index.d.ts +26 -2
  78. package/dist/server/index.js +37 -16
  79. package/dist/server/index.js.map +1 -1
  80. package/dist/server/routes/grants-admin.d.ts +17 -0
  81. package/dist/server/routes/grants-admin.js +32 -0
  82. package/dist/server/routes/grants-admin.js.map +1 -0
  83. package/dist/server/routes/grants.d.ts +28 -0
  84. package/dist/server/routes/grants.js +163 -0
  85. package/dist/server/routes/grants.js.map +1 -0
  86. package/dist/server/routes/nodes.d.ts +5 -3
  87. package/dist/server/routes/nodes.js +39 -24
  88. package/dist/server/routes/nodes.js.map +1 -1
  89. package/dist/server/routes/settings.d.ts +6 -1
  90. package/dist/server/routes/settings.js +5 -2
  91. package/dist/server/routes/settings.js.map +1 -1
  92. package/dist/server/routes/status.d.ts +8 -4
  93. package/dist/server/routes/status.js +9 -4
  94. package/dist/server/routes/status.js.map +1 -1
  95. package/dist/server/static.d.ts +12 -0
  96. package/dist/server/static.js +24 -7
  97. package/dist/server/static.js.map +1 -1
  98. package/dist/ui/assets/index-BlMXQPSw.js +14 -0
  99. package/dist/ui/assets/{index-C9uChk2P.js.map → index-BlMXQPSw.js.map} +1 -1
  100. package/dist/ui/index.html +1 -1
  101. package/dist/update/apply.d.ts +23 -1
  102. package/dist/update/apply.js +46 -4
  103. package/dist/update/apply.js.map +1 -1
  104. package/dist/update/index.d.ts +14 -3
  105. package/dist/update/index.js +35 -2
  106. package/dist/update/index.js.map +1 -1
  107. package/dist/update/manifest.d.ts +28 -9
  108. package/dist/update/manifest.js +56 -29
  109. package/dist/update/manifest.js.map +1 -1
  110. package/dist/update/release-key.d.ts +18 -7
  111. package/dist/update/release-key.js +29 -14
  112. package/dist/update/release-key.js.map +1 -1
  113. package/dist/update/sign.d.ts +46 -0
  114. package/dist/update/sign.js +53 -0
  115. package/dist/update/sign.js.map +1 -0
  116. package/package.json +9 -4
  117. package/service/README.md +95 -86
  118. package/src/__tests__/cli-invite.smoke.test.ts +6 -1
  119. package/src/__tests__/cli-nat.smoke.test.ts +6 -1
  120. package/src/__tests__/cli.smoke.test.ts +6 -1
  121. package/src/__tests__/host-owner.smoke.test.ts +123 -0
  122. package/src/__tests__/orchestrator-owner.test.ts +198 -0
  123. package/src/__tests__/orchestrator-pin-keys.test.ts +153 -0
  124. package/src/__tests__/orchestrator-push.test.ts +145 -0
  125. package/src/auth/__tests__/trust-circle-integration.test.ts +44 -25
  126. package/src/auth/__tests__/trust-circle.test.ts +91 -24
  127. package/src/auth/trust-circle.ts +363 -304
  128. package/src/auth/types.ts +4 -1
  129. package/src/bin/host.ts +1258 -905
  130. package/src/donation/__tests__/donation-service.test.ts +290 -0
  131. package/src/donation/__tests__/grant-service.test.ts +213 -0
  132. package/src/donation/__tests__/grant-store.test.ts +113 -0
  133. package/src/donation/donation-service.ts +392 -0
  134. package/src/donation/donation-store.ts +164 -0
  135. package/src/donation/grant-service.ts +200 -0
  136. package/src/donation/grant-store.ts +152 -0
  137. package/src/donation/index.ts +49 -0
  138. package/src/donation/types.ts +220 -0
  139. package/src/index.ts +66 -3
  140. package/src/installer/__tests__/config.test.ts +27 -1
  141. package/src/installer/__tests__/installer.smoke.test.ts +94 -0
  142. package/src/installer/__tests__/wizard.test.ts +5 -1
  143. package/src/installer/browser.ts +40 -42
  144. package/src/installer/config.ts +61 -1
  145. package/src/installer/index.ts +23 -12
  146. package/src/installer/service-host/launchd.ts +1 -1
  147. package/src/installer/service-host/systemd.ts +1 -1
  148. package/src/installer/wizard.ts +12 -1
  149. package/src/nat/__tests__/ddns-updater.test.ts +1 -1
  150. package/src/nat/__tests__/nat-service.test.ts +192 -0
  151. package/src/nat/__tests__/port-mapper.test.ts +1 -1
  152. package/src/nat/__tests__/secrets.test.ts +23 -1
  153. package/src/nat/index.ts +1 -1
  154. package/src/nat/nat-service.ts +131 -5
  155. package/src/nat/secrets/index.ts +32 -2
  156. package/src/nat/types.ts +3 -1
  157. package/src/orchestrator/host-process-orchestrator.ts +322 -54
  158. package/src/orchestrator/index.ts +9 -2
  159. package/src/orchestrator/state-store.ts +21 -3
  160. package/src/orchestrator/types.ts +48 -2
  161. package/src/owner/__tests__/owner-node-client.test.ts +194 -0
  162. package/src/owner/index.ts +12 -0
  163. package/src/owner/owner-node-client.ts +233 -0
  164. package/src/push/__tests__/push.test.ts +90 -0
  165. package/src/push/index.ts +146 -0
  166. package/src/server/__tests__/error-handler.test.ts +26 -4
  167. package/src/server/__tests__/grants-admin-route.test.ts +73 -0
  168. package/src/server/__tests__/grants-route.test.ts +189 -0
  169. package/src/server/__tests__/nodes-route.test.ts +90 -10
  170. package/src/server/__tests__/publishers.test.ts +1 -1
  171. package/src/server/__tests__/server.smoke.test.ts +75 -0
  172. package/src/server/__tests__/settings-route.test.ts +17 -0
  173. package/src/server/__tests__/static.test.ts +145 -0
  174. package/src/server/__tests__/status-route.test.ts +23 -2
  175. package/src/server/error-handler.ts +47 -0
  176. package/src/server/index.ts +62 -17
  177. package/src/server/routes/grants-admin.ts +41 -0
  178. package/src/server/routes/grants.ts +186 -0
  179. package/src/server/routes/nodes.ts +49 -26
  180. package/src/server/routes/settings.ts +11 -3
  181. package/src/server/routes/status.ts +18 -8
  182. package/src/server/static.ts +24 -8
  183. package/src/update/__tests__/apply.test.ts +58 -1
  184. package/src/update/__tests__/manifest.test.ts +41 -6
  185. package/src/update/__tests__/release-key.test.ts +42 -0
  186. package/src/update/__tests__/sign.test.ts +78 -0
  187. package/src/update/__tests__/update-service.integration.test.ts +44 -9
  188. package/src/update/apply.ts +46 -4
  189. package/src/update/index.ts +41 -3
  190. package/src/update/manifest.ts +214 -172
  191. package/src/update/release-key.ts +31 -15
  192. package/src/update/sign.ts +74 -0
  193. package/dist/ui/assets/index-C9uChk2P.js +0 -14
@@ -0,0 +1,78 @@
1
+ import { afterEach, describe, expect, it } from 'vitest';
2
+ import { generateKeyPairSync, type KeyObject } from 'node:crypto';
3
+
4
+ import { buildManifest, derivePublicKeyBase64, signAndSelfVerify, type ManifestFields } from '../sign.js';
5
+ import { verifyManifest } from '../manifest.js';
6
+
7
+ function freshKeypair(): { privateKey: KeyObject; publicRawB64: string } {
8
+ const { publicKey, privateKey } = generateKeyPairSync('ed25519');
9
+ const spki = publicKey.export({ format: 'der', type: 'spki' });
10
+ return { privateKey, publicRawB64: Buffer.from(spki.subarray(12)).toString('base64') };
11
+ }
12
+
13
+ const validFields: ManifestFields = {
14
+ version: '0.7.0',
15
+ package: '@serfab/cadre-host',
16
+ tag: 'latest',
17
+ publishedAt: '2026-05-15T18:00:00.000Z',
18
+ releaseNotesUrl: 'https://example.com/notes',
19
+ minPreviousVersion: '0.6.0',
20
+ };
21
+
22
+ describe('buildManifest', () => {
23
+ it('constructs a well-formed manifest from flat fields', () => {
24
+ expect(buildManifest(validFields)).toEqual({
25
+ v: 1,
26
+ version: '0.7.0',
27
+ publishedAt: '2026-05-15T18:00:00.000Z',
28
+ channels: { npm: { package: '@serfab/cadre-host', tag: 'latest' } },
29
+ releaseNotesUrl: 'https://example.com/notes',
30
+ minPreviousVersion: '0.6.0',
31
+ });
32
+ });
33
+
34
+ it('omits optional fields when not provided', () => {
35
+ const m = buildManifest({ version: '1.0.0', package: 'pkg', tag: 'latest', publishedAt: '2026-01-01T00:00:00.000Z' });
36
+ expect(m.releaseNotesUrl).toBeUndefined();
37
+ expect(m.minPreviousVersion).toBeUndefined();
38
+ });
39
+
40
+ it('rejects a bad semver version', () => {
41
+ expect(() => buildManifest({ ...validFields, version: 'not-semver' })).toThrow(/not a valid semver/i);
42
+ });
43
+
44
+ it('rejects a bad npm package name', () => {
45
+ expect(() => buildManifest({ ...validFields, package: 'BAD CHARS!' })).toThrow(/not a valid npm package name/i);
46
+ });
47
+
48
+ it('rejects a non-ISO publishedAt', () => {
49
+ expect(() => buildManifest({ ...validFields, publishedAt: 'last tuesday' })).toThrow(/ISO-8601/i);
50
+ });
51
+
52
+ it('rejects a bad minPreviousVersion', () => {
53
+ expect(() => buildManifest({ ...validFields, minPreviousVersion: '0.6' })).toThrow(/minPreviousVersion.*not a valid semver/i);
54
+ });
55
+ });
56
+
57
+ describe('signAndSelfVerify', () => {
58
+ afterEach(() => { delete process.env.CADRE_HOST_UPDATE_DEV_KEY; });
59
+
60
+ it('produces an envelope that verifies against the derived public key', () => {
61
+ const kp = freshKeypair();
62
+ const manifest = buildManifest(validFields);
63
+ const envelope = signAndSelfVerify(manifest, kp.privateKey);
64
+ // derivePublicKeyBase64 must agree with the independently-extracted raw key.
65
+ expect(derivePublicKeyBase64(kp.privateKey)).toBe(kp.publicRawB64);
66
+ // Independent verification against the derived key round-trips the manifest.
67
+ process.env.CADRE_HOST_UPDATE_DEV_KEY = kp.publicRawB64;
68
+ expect(verifyManifest(envelope)).toEqual(manifest);
69
+ });
70
+
71
+ it('the self-verify rejects verification against a different key', () => {
72
+ const kp = freshKeypair();
73
+ const other = freshKeypair();
74
+ const envelope = signAndSelfVerify(buildManifest(validFields), kp.privateKey);
75
+ process.env.CADRE_HOST_UPDATE_DEV_KEY = other.publicRawB64;
76
+ expect(() => verifyManifest(envelope)).toThrow(/signature/i);
77
+ });
78
+ });
@@ -4,8 +4,9 @@ import { tmpdir } from 'node:os';
4
4
  import { join } from 'node:path';
5
5
  import { generateKeyPairSync, type KeyObject } from 'node:crypto';
6
6
 
7
- import { signManifestForTesting } from '../manifest.js';
7
+ import { signManifest } from '../manifest.js';
8
8
  import { UpdateService } from '../index.js';
9
+ import { UpdateStateStore } from '../store.js';
9
10
  import type { UpdateManifest } from '../types.js';
10
11
 
11
12
  function freshKeypair(): { privateKey: KeyObject; publicRawB64: string } {
@@ -50,7 +51,7 @@ describe('UpdateService', () => {
50
51
  });
51
52
 
52
53
  it('check(): records an available update when manifest > current', async () => {
53
- const envelope = signManifestForTesting(manifest('0.7.0', { releaseNotesUrl: 'https://x' }), kp.privateKey);
54
+ const envelope = signManifest(manifest('0.7.0', { releaseNotesUrl: 'https://x' }), kp.privateKey);
54
55
  const svc = new UpdateService({
55
56
  dataDir: tmp,
56
57
  currentVersion: '0.6.0',
@@ -69,7 +70,7 @@ describe('UpdateService', () => {
69
70
  });
70
71
 
71
72
  it('check(): clears stale available when up-to-date', async () => {
72
- const envelope = signManifestForTesting(manifest('0.6.0'), kp.privateKey);
73
+ const envelope = signManifest(manifest('0.6.0'), kp.privateKey);
73
74
  const svc = new UpdateService({
74
75
  dataDir: tmp,
75
76
  currentVersion: '0.7.0',
@@ -96,7 +97,7 @@ describe('UpdateService', () => {
96
97
  });
97
98
 
98
99
  it('check(): records signature_invalid as lastError', async () => {
99
- const envelope = signManifestForTesting(manifest('0.7.0'), kp.privateKey);
100
+ const envelope = signManifest(manifest('0.7.0'), kp.privateKey);
100
101
  // Swap the trusted key after signing.
101
102
  const other = freshKeypair();
102
103
  process.env.CADRE_HOST_UPDATE_DEV_KEY = other.publicRawB64;
@@ -113,7 +114,7 @@ describe('UpdateService', () => {
113
114
  });
114
115
 
115
116
  it('apply(): records applyInProgress, calls npm + restart, clears on success', async () => {
116
- const envelope = signManifestForTesting(manifest('0.7.0'), kp.privateKey);
117
+ const envelope = signManifest(manifest('0.7.0'), kp.privateKey);
117
118
  const calls: string[][] = [];
118
119
  const svc = new UpdateService({
119
120
  dataDir: tmp,
@@ -132,7 +133,7 @@ describe('UpdateService', () => {
132
133
  });
133
134
 
134
135
  it('apply(): rejects when current is below minPreviousVersion', async () => {
135
- const envelope = signManifestForTesting(manifest('0.8.0', { minPreviousVersion: '0.7.0' }), kp.privateKey);
136
+ const envelope = signManifest(manifest('0.8.0', { minPreviousVersion: '0.7.0' }), kp.privateKey);
136
137
  const svc = new UpdateService({
137
138
  dataDir: tmp,
138
139
  currentVersion: '0.6.0',
@@ -143,7 +144,7 @@ describe('UpdateService', () => {
143
144
  });
144
145
 
145
146
  it('apply(): rejects when current >= latest', async () => {
146
- const envelope = signManifestForTesting(manifest('0.6.0'), kp.privateKey);
147
+ const envelope = signManifest(manifest('0.6.0'), kp.privateKey);
147
148
  const svc = new UpdateService({
148
149
  dataDir: tmp,
149
150
  currentVersion: '0.7.0',
@@ -154,7 +155,7 @@ describe('UpdateService', () => {
154
155
  });
155
156
 
156
157
  it('apply(): records lastError + clears applyInProgress when npm install fails', async () => {
157
- const envelope = signManifestForTesting(manifest('0.7.0'), kp.privateKey);
158
+ const envelope = signManifest(manifest('0.7.0'), kp.privateKey);
158
159
  let installCount = 0;
159
160
  const svc = new UpdateService({
160
161
  dataDir: tmp,
@@ -188,7 +189,7 @@ describe('UpdateService', () => {
188
189
  });
189
190
 
190
191
  it('autoApply: check() triggers apply when settings.autoApply is true', async () => {
191
- const envelope = signManifestForTesting(manifest('0.7.0'), kp.privateKey);
192
+ const envelope = signManifest(manifest('0.7.0'), kp.privateKey);
192
193
  const calls: string[][] = [];
193
194
  const svc = new UpdateService({
194
195
  dataDir: tmp,
@@ -203,6 +204,40 @@ describe('UpdateService', () => {
203
204
  expect(calls.some((c) => c[2] === '@serfab/cadre-host@0.7.0')).toBe(true);
204
205
  });
205
206
 
207
+ it('recovers from a stale applyInProgress at construction time', async () => {
208
+ // Simulate a crash: write a state file that records an in-flight apply.
209
+ const seed = new UpdateStateStore(tmp);
210
+ seed.update({
211
+ applyInProgress: {
212
+ fromVersion: '0.6.0',
213
+ toVersion: '0.7.0',
214
+ startedAt: '2026-05-15T18:00:00.000Z',
215
+ },
216
+ });
217
+
218
+ const svc = new UpdateService({
219
+ dataDir: tmp,
220
+ currentVersion: '0.6.0',
221
+ fetcher: fakeFetch({}),
222
+ manifestUrl: 'https://example.com/m',
223
+ });
224
+ const state = await svc.getState();
225
+ expect(state.applyInProgress).toBeUndefined();
226
+ expect(state.lastError?.code).toBe('apply_failed');
227
+ expect(state.lastError?.message).toMatch(/interrupted/i);
228
+
229
+ // Now apply() must not short-circuit on apply_in_progress; with a stub
230
+ // manifest mismatch we expect the usual no_update_available path instead.
231
+ const envelope = signManifest(manifest('0.6.0'), kp.privateKey);
232
+ const svc2 = new UpdateService({
233
+ dataDir: tmp,
234
+ currentVersion: '0.7.0',
235
+ fetcher: fakeFetch(envelope),
236
+ manifestUrl: 'https://example.com/m',
237
+ });
238
+ await expect(svc2.apply()).rejects.toMatchObject({ code: 'no_update_available' });
239
+ });
240
+
206
241
  it('start()/stop(): timer can be armed and cleared', async () => {
207
242
  const svc = new UpdateService({
208
243
  dataDir: tmp,
@@ -116,12 +116,25 @@ async function tryRollback(
116
116
  }
117
117
  }
118
118
 
119
- /** Default executor: spawn `npm` with the given args. Adds a hard timeout. */
119
+ /**
120
+ * Default executor: spawn `npm` with the given args. Adds a hard timeout.
121
+ *
122
+ * On Windows, npm ships as `npm.cmd` (a batch file). CreateProcess can't run
123
+ * batch files directly, so a shell is required — but Node's DEP0190 warns
124
+ * against `spawn(cmd, args, { shell: true })` because args are concatenated
125
+ * (not escaped) into the resulting command line. To dodge the deprecation we
126
+ * pre-quote each arg ourselves and pass the result as a single command string
127
+ * with an empty args array. Inputs come from a signed manifest after
128
+ * regex-validated package + semver checks, so injection surface is minimal;
129
+ * quoting is defense in depth.
130
+ */
120
131
  export function defaultNpmExecutor(timeoutMs: number): NpmExecutor {
121
132
  return (args: string[]) => new Promise<NpmResult>((resolveExec) => {
122
- // Windows resolves `npm` via `npm.cmd`; using shell:true is the
123
- // simplest cross-platform answer that still works with PATH lookup.
124
- const child = spawn('npm', args, { shell: process.platform === 'win32', windowsHide: true });
133
+ const spawnSpec = buildNpmSpawnSpec(args);
134
+ const child = spawn(spawnSpec.command, spawnSpec.args, {
135
+ shell: spawnSpec.shell,
136
+ windowsHide: true,
137
+ });
125
138
  let stdout = '';
126
139
  let stderr = '';
127
140
  child.stdout?.on('data', (chunk: Buffer | string) => { stdout += String(chunk); });
@@ -143,6 +156,35 @@ export function defaultNpmExecutor(timeoutMs: number): NpmExecutor {
143
156
  });
144
157
  }
145
158
 
159
+ /**
160
+ * Build the `(command, args, shell)` triple passed to `spawn`. Exported for
161
+ * tests — the spawn shape differs per platform and the quoting matters.
162
+ */
163
+ export function buildNpmSpawnSpec(args: string[]): { command: string; args: string[]; shell: boolean } {
164
+ if (process.platform === 'win32') {
165
+ const commandLine = ['npm', ...args.map(quoteWindowsArg)].join(' ');
166
+ return { command: commandLine, args: [], shell: true };
167
+ }
168
+ return { command: 'npm', args, shell: false };
169
+ }
170
+
171
+ /** cmd.exe quoting — wrap args containing whitespace or shell metachars. */
172
+ export function quoteWindowsArg(arg: string): string {
173
+ // \x00 is an intentional security guard: reject NUL/CR/LF in args before they
174
+ // reach the cmd.exe command line. The control char is the point of the check.
175
+ // eslint-disable-next-line no-control-regex
176
+ if (/[\x00\r\n]/.test(arg)) {
177
+ throw new Error('refusing to spawn npm with control character in argument');
178
+ }
179
+ if (arg.length === 0) return '""';
180
+ if (!/[\s"&|<>^()%!]/.test(arg)) return arg;
181
+ // Escape backslashes preceding a quote per MSVCRT/cmd rules, then double the
182
+ // quote itself (the cmd-friendly form rather than backslash-quote).
183
+ const escaped = arg.replace(/(\\*)"/g, (_m, slashes: string) => slashes + slashes + '\\"');
184
+ const trailing = escaped.replace(/(\\*)$/, (_m, slashes: string) => slashes + slashes);
185
+ return `"${trailing}"`;
186
+ }
187
+
146
188
  function tail(s: string, n: number): string {
147
189
  return s.length <= n ? s : '…' + s.slice(-n);
148
190
  }
@@ -23,6 +23,7 @@ import {
23
23
  DEFAULT_MANIFEST_URL,
24
24
  fetchManifest,
25
25
  } from './manifest.js';
26
+ import { isPlaceholderReleaseKey } from './release-key.js';
26
27
  import { UpdateStateStore } from './store.js';
27
28
  import {
28
29
  UpdateErrorException,
@@ -94,6 +95,31 @@ export class UpdateService {
94
95
  this.checkIntervalMs = opts.checkIntervalMs ?? DEFAULT_CHECK_INTERVAL_MS;
95
96
  this.settings = opts.settings ?? { autoApply: false };
96
97
  this.manifestUrl = resolveManifestUrl(opts.manifestUrl, this.settings.manifestUrl);
98
+ this.recoverInterruptedApply();
99
+ }
100
+
101
+ /**
102
+ * If `update-state.json` still records an in-flight apply at construction
103
+ * time, the previous cadre-host process died mid-apply (OOM, power loss,
104
+ * sigkill). Without intervention, every subsequent `apply()` would short-
105
+ * circuit on `apply_in_progress` forever. We clear the marker and surface a
106
+ * `lastError` so the UI banner can flag that an apply was interrupted —
107
+ * the npm install may or may not have completed, so the operator should
108
+ * verify with `npm ls -g`.
109
+ */
110
+ private recoverInterruptedApply(): void {
111
+ const state = this.store.load();
112
+ if (!state.applyInProgress) return;
113
+ const { fromVersion, toVersion } = state.applyInProgress;
114
+ log('clearing stale applyInProgress (%s → %s); previous apply was interrupted', fromVersion, toVersion);
115
+ this.store.update({
116
+ applyInProgress: undefined,
117
+ lastError: {
118
+ code: 'apply_failed',
119
+ message: `previous apply (${fromVersion} → ${toVersion}) was interrupted (cadre-host restarted before it completed); the install may or may not have succeeded — verify with 'npm ls -g'`,
120
+ at: new Date().toISOString(),
121
+ },
122
+ });
97
123
  }
98
124
 
99
125
  /** Read the persisted state for UI consumption. */
@@ -128,6 +154,12 @@ export class UpdateService {
128
154
  * debug; signature failures are recorded as `lastError` so the UI can warn.
129
155
  */
130
156
  async check(): Promise<void> {
157
+ if (isPlaceholderReleaseKey()) {
158
+ // A build that shipped the all-zeros placeholder rejects every authentic
159
+ // manifest as signature_invalid; surface that here so it's diagnosable
160
+ // from logs rather than only as an opaque verification failure.
161
+ log('release verification key is the all-zeros placeholder; production manifests will fail signature_invalid (set CADRE_HOST_UPDATE_DEV_KEY for dev/CI only)');
162
+ }
131
163
  let manifest: UpdateManifest;
132
164
  try {
133
165
  manifest = await fetchManifest({
@@ -294,10 +326,16 @@ function resolveManifestUrl(explicit: string | undefined, fromSettings: string |
294
326
  return explicit ?? fromSettings ?? DEFAULT_MANIFEST_URL;
295
327
  }
296
328
 
297
- export type { UpdateState, UpdateApplyResult, UpdateSettings, UpdateManifest } from './types.js';
329
+ export type { UpdateState, UpdateApplyResult, UpdateSettings, UpdateManifest, SignedManifest } from './types.js';
298
330
  export { UpdateErrorException, type UpdateErrorCode } from './types.js';
299
331
  export { compareVersions, parseVersion } from './version.js';
300
- export { fetchManifest, verifyManifest, canonicalJson, signManifestForTesting } from './manifest.js';
332
+ export { fetchManifest, verifyManifest, canonicalJson, signManifest, validateManifestFields } from './manifest.js';
301
333
  export { UpdateStateStore } from './store.js';
302
334
  export { applyUpdate, defaultNpmExecutor, type NpmExecutor, type ServiceRestarter } from './apply.js';
303
- export { getReleasePublicKey, getReleasePublicKeyBase64, ed25519FromRaw } from './release-key.js';
335
+ export {
336
+ getReleasePublicKey,
337
+ getReleasePublicKeyBase64,
338
+ ed25519FromRaw,
339
+ isPlaceholderReleaseKey,
340
+ } from './release-key.js';
341
+ export { buildManifest, derivePublicKeyBase64, signAndSelfVerify, type ManifestFields } from './sign.js';