@forgezero/agent 0.1.22 → 0.1.24

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.
@@ -0,0 +1,185 @@
1
+ // src/signed-node-http.ts
2
+ import {
3
+ encodeSignatureHeader,
4
+ generateResponseRecipient,
5
+ openResponse,
6
+ RESPONSE_KEY_HEADER,
7
+ signRequest
8
+ } from "@forgezero/runtime/identity";
9
+
10
+ class SignedNodeHttpError extends Error {
11
+ status;
12
+ constructor(status, message) {
13
+ super(message);
14
+ this.status = status;
15
+ this.name = "SignedNodeHttpError";
16
+ }
17
+ }
18
+ async function postSignedNode(options, path, body) {
19
+ const url = new URL(options.apiUrl);
20
+ url.pathname = `${url.pathname.replace(/\/$/, "")}/${path.replace(/^\//, "")}`.replace(/\/+/g, "/");
21
+ url.search = "";
22
+ url.hash = "";
23
+ const raw = JSON.stringify(body);
24
+ const recipient = generateResponseRecipient();
25
+ const envelope = signRequest(options.keys, options.nodeKey, {
26
+ method: "POST",
27
+ path: url.pathname,
28
+ query: "",
29
+ body: raw,
30
+ responseKey: recipient.publicKey
31
+ });
32
+ const signature = encodeSignatureHeader(envelope);
33
+ const response = await (options.fetch ?? globalThis.fetch)(url, {
34
+ method: "POST",
35
+ headers: {
36
+ "content-type": "application/json",
37
+ "x-fz-node": options.nodeKey,
38
+ "x-fz-signature": signature,
39
+ [RESPONSE_KEY_HEADER]: recipient.publicKey
40
+ },
41
+ body: raw,
42
+ signal: AbortSignal.timeout(options.requestTimeoutMs ?? 15000)
43
+ });
44
+ const payload = await response.json().catch(() => null);
45
+ if (!response.ok) {
46
+ const failure = payload;
47
+ const reason = failure ? failure.error?.message ?? failure.message : undefined;
48
+ throw new SignedNodeHttpError(response.status, reason || `signed node request returned HTTP ${response.status}`);
49
+ }
50
+ try {
51
+ return await openResponse(recipient.secretKey, signature, payload);
52
+ } catch {
53
+ throw new SignedNodeHttpError(502, "The node response was not sealed to this request.");
54
+ }
55
+ }
56
+
57
+ // src/migration-pull.ts
58
+ class MigrationClaimLostError extends Error {
59
+ constructor(message) {
60
+ super(message);
61
+ this.name = "MigrationClaimLostError";
62
+ }
63
+ }
64
+ var post = (options, operation, body) => postSignedNode(options, `v1/node/migrations/${operation}`, body);
65
+ async function complete(options, body) {
66
+ const attempts = Math.max(1, Math.min(options.completionAttempts ?? 5, 10));
67
+ const sleep = options.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
68
+ let last;
69
+ for (let attempt = 1;attempt <= attempts; attempt += 1) {
70
+ try {
71
+ await post(options, "complete", body);
72
+ return;
73
+ } catch (cause) {
74
+ last = cause;
75
+ if (cause instanceof SignedNodeHttpError && cause.status < 500)
76
+ throw cause;
77
+ if (attempt < attempts)
78
+ await sleep(Math.min(2000, 250 * 2 ** (attempt - 1)));
79
+ }
80
+ }
81
+ throw last;
82
+ }
83
+ async function pullMigrationOnce(options) {
84
+ const response = await post(options, "claim", {});
85
+ if (!response.claim)
86
+ return { status: "idle" };
87
+ const claim = response.claim;
88
+ const setTimer = options.setTimer ?? ((callback, ms) => setTimeout(callback, ms));
89
+ const clearTimer = options.clearTimer ?? ((handle) => clearTimeout(handle));
90
+ const now = options.now ?? Date.now;
91
+ let expires = claim.claimExpiresAtTs;
92
+ let stopped = false;
93
+ let timer;
94
+ let renewal = null;
95
+ let lost = null;
96
+ const schedule = (override) => {
97
+ if (stopped)
98
+ return;
99
+ const delay = override ?? Math.max(1000, Math.min(5 * 60000, Math.floor(Math.max(0, expires - now()) / 3)));
100
+ timer = setTimer(() => {
101
+ if (stopped || renewal)
102
+ return;
103
+ let retry;
104
+ renewal = post(options, "renew", {
105
+ migrationKey: claim.migrationKey,
106
+ claimToken: claim.claimToken
107
+ }).then((value) => {
108
+ expires = value.claimExpiresAtTs;
109
+ }).catch((cause) => {
110
+ if (cause instanceof SignedNodeHttpError && cause.status < 500) {
111
+ lost = new MigrationClaimLostError(cause.message);
112
+ stopped = true;
113
+ } else
114
+ retry = Math.max(1000, options.renewRetryMs ?? 5000);
115
+ }).finally(() => {
116
+ renewal = null;
117
+ if (!stopped)
118
+ schedule(retry);
119
+ });
120
+ }, delay);
121
+ };
122
+ schedule();
123
+ let evidence;
124
+ try {
125
+ evidence = await options.run(claim);
126
+ } catch (cause) {
127
+ stopped = true;
128
+ clearTimer(timer);
129
+ await renewal;
130
+ if (lost)
131
+ throw lost;
132
+ const reason = (cause instanceof Error ? cause.message : String(cause)).slice(0, 2000);
133
+ await complete(options, {
134
+ migrationKey: claim.migrationKey,
135
+ claimToken: claim.claimToken,
136
+ ok: false,
137
+ detail: reason
138
+ });
139
+ return { status: "failed", claim, reason };
140
+ }
141
+ stopped = true;
142
+ clearTimer(timer);
143
+ await renewal;
144
+ if (lost)
145
+ throw lost;
146
+ await complete(options, {
147
+ migrationKey: claim.migrationKey,
148
+ claimToken: claim.claimToken,
149
+ ok: true,
150
+ evidence
151
+ });
152
+ return { status: "completed", claim, evidence };
153
+ }
154
+ function startMigrationPull(options) {
155
+ const interval = Math.max(1000, options.intervalMs ?? 5000);
156
+ const setTimer = options.setTimer ?? ((callback, ms) => setTimeout(callback, ms));
157
+ const clearTimer = options.clearTimer ?? ((handle) => clearTimeout(handle));
158
+ let stopped = false;
159
+ let timer;
160
+ let active = null;
161
+ const tick = () => {
162
+ if (stopped || active)
163
+ return;
164
+ active = pullMigrationOnce(options).then((result) => options.onEvent?.(result.status, result)).catch((cause) => options.onEvent?.("poll-failed", cause)).finally(() => {
165
+ active = null;
166
+ if (!stopped)
167
+ timer = setTimer(tick, interval);
168
+ });
169
+ };
170
+ tick();
171
+ return {
172
+ async stop() {
173
+ stopped = true;
174
+ clearTimer(timer);
175
+ await active;
176
+ },
177
+ get active() {
178
+ return !stopped;
179
+ }
180
+ };
181
+ }
182
+ export {
183
+ startMigrationPull,
184
+ pullMigrationOnce
185
+ };
@@ -92,6 +92,20 @@ export interface UnitOptions {
92
92
  deploymentCredentials?: Record<string, string>;
93
93
  /** Enable authenticated outbound deployment claims after enrolment. */
94
94
  pullDeployments?: boolean;
95
+ /** Enable PQ-authenticated lifecycle claims through a constrained root helper. */
96
+ pullMigrations?: boolean;
97
+ /** Root-owned declarative lifecycle profile; never supplied by a migration claim. */
98
+ lifecycleProfilePath?: string;
99
+ lifecycleHelperSocketPath?: string;
100
+ /** Optional cross-network overlay. All three values are required together. */
101
+ warpOrganization?: string;
102
+ warpClientIdCredentialPath?: string;
103
+ warpClientSecretCredentialPath?: string;
104
+ /** Non-secret coordinates bound through the PQ-signed enrolment request. */
105
+ cloudflareAccountId?: string;
106
+ cloudflareTunnelId?: string;
107
+ cloudflareVirtualNetworkId?: string;
108
+ cloudflareWarpPolicyId?: string;
95
109
  /** Where `fz-agent` ended up. `bun add -g` puts it on PATH. */
96
110
  binPath?: string;
97
111
  /** Package-owned binary copied to binPath before hardened units start. */
@@ -109,9 +123,17 @@ export interface UnitOptions {
109
123
  export declare const DEPLOYMENT_RUNNER_USER = "forgezero-runner";
110
124
  export declare const DEPLOYMENT_GROUP = "forgezero-deploy";
111
125
  export declare const VAULT_GROUP = "forgezero-vault";
126
+ export declare const LIFECYCLE_GROUP = "forgezero-lifecycle";
112
127
  export declare const DEPLOYMENT_RUNNER_UNIT_PATH = "/etc/systemd/system/forgezero-deploy-runner.service";
113
128
  export declare const DEPLOYMENT_RUNNER_SOCKET = "/run/forgezero-deploy/runner.sock";
114
129
  export declare const ENROLMENT_UNIT_PATH = "/etc/systemd/system/forgezero-agent-enrol.service";
130
+ export declare const LIFECYCLE_HELPER_UNIT_PATH = "/etc/systemd/system/forgezero-lifecycle-helper.service";
131
+ export declare const LIFECYCLE_HELPER_SOCKET = "/run/forgezero-lifecycle/helper.sock";
132
+ export declare const WARP_CONFIG_UNIT_PATH = "/etc/systemd/system/forgezero-warp-config.service";
133
+ export declare const WARP_SERVICE_DROP_IN_PATH = "/etc/systemd/system/warp-svc.service.d/forgezero.conf";
134
+ export declare function warpConfigUnit(options: Pick<UnitOptions, 'binPath' | 'warpOrganization' | 'warpClientIdCredentialPath' | 'warpClientSecretCredentialPath'>): string;
135
+ export declare function warpServiceDropIn(): string;
136
+ export declare function lifecycleHelperUnit(options: Pick<UnitOptions, 'binPath' | 'lifecycleProfilePath' | 'lifecycleHelperSocketPath'>): string;
115
137
  /**
116
138
  * Exchange a tenant-issued one-time capability before the durable agent starts.
117
139
  *
package/dist/provision.js CHANGED
@@ -42,9 +42,104 @@ var reasonFor = (mode) => mode === "attested" ? "SEV-SNP guest device present, s
42
42
  var DEPLOYMENT_RUNNER_USER = "forgezero-runner";
43
43
  var DEPLOYMENT_GROUP = "forgezero-deploy";
44
44
  var VAULT_GROUP = "forgezero-vault";
45
+ var LIFECYCLE_GROUP = "forgezero-lifecycle";
45
46
  var DEPLOYMENT_RUNNER_UNIT_PATH = "/etc/systemd/system/forgezero-deploy-runner.service";
46
47
  var DEPLOYMENT_RUNNER_SOCKET = "/run/forgezero-deploy/runner.sock";
47
48
  var ENROLMENT_UNIT_PATH = "/etc/systemd/system/forgezero-agent-enrol.service";
49
+ var LIFECYCLE_HELPER_UNIT_PATH = "/etc/systemd/system/forgezero-lifecycle-helper.service";
50
+ var LIFECYCLE_HELPER_SOCKET = "/run/forgezero-lifecycle/helper.sock";
51
+ var WARP_CONFIG_UNIT_PATH = "/etc/systemd/system/forgezero-warp-config.service";
52
+ var WARP_SERVICE_DROP_IN_PATH = "/etc/systemd/system/warp-svc.service.d/forgezero.conf";
53
+ var systemdPath = (value, label) => {
54
+ if (!value || !/^\/[A-Za-z0-9._@/-]+$/.test(value))
55
+ throw new Error(`invalid ${label} path`);
56
+ return value;
57
+ };
58
+ function warpConfigUnit(options) {
59
+ if (!options.warpOrganization || !/^[a-z0-9][a-z0-9-]{0,62}$/i.test(options.warpOrganization)) {
60
+ throw new Error("WARP organization is invalid");
61
+ }
62
+ if (!options.warpClientIdCredentialPath || !options.warpClientSecretCredentialPath) {
63
+ throw new Error("WARP service-token credential paths are required");
64
+ }
65
+ const clientIdPath = systemdPath(options.warpClientIdCredentialPath, "WARP client-id credential");
66
+ const clientSecretPath = systemdPath(options.warpClientSecretCredentialPath, "WARP client-secret credential");
67
+ const bin = options.binPath ?? "fz-agent";
68
+ return `[Unit]
69
+ Description=Materialize Cloudflare One enrollment in tmpfs
70
+ Documentation=https://www.forgezero.net/docs/agent
71
+ Before=warp-svc.service
72
+
73
+ [Service]
74
+ Type=oneshot
75
+ RemainAfterExit=yes
76
+ LoadCredentialEncrypted=warp-auth-client-id:${clientIdPath}
77
+ LoadCredentialEncrypted=warp-auth-client-secret:${clientSecretPath}
78
+ Environment=FZ_WARP_CLIENT_ID_CREDENTIAL=warp-auth-client-id
79
+ Environment=FZ_WARP_CLIENT_SECRET_CREDENTIAL=warp-auth-client-secret
80
+ ExecStart=${bin} warp-config --organization=${options.warpOrganization}
81
+ RuntimeDirectory=forgezero-warp
82
+ RuntimeDirectoryMode=0700
83
+ RuntimeDirectoryPreserve=yes
84
+ UMask=0077
85
+ LimitCORE=0
86
+ NoNewPrivileges=true
87
+ PrivateTmp=true
88
+ ProtectSystem=strict
89
+ ProtectHome=true
90
+ ReadWritePaths=/var/lib/cloudflare-warp
91
+
92
+ [Install]
93
+ WantedBy=multi-user.target
94
+ `;
95
+ }
96
+ function warpServiceDropIn() {
97
+ return `[Unit]
98
+ Requires=forgezero-warp-config.service
99
+ After=forgezero-warp-config.service
100
+ `;
101
+ }
102
+ function lifecycleHelperUnit(options) {
103
+ if (!options.lifecycleProfilePath)
104
+ throw new Error("lifecycle helper needs a root-owned profile");
105
+ const bin = options.binPath ?? "fz-agent";
106
+ const profile = systemdPath(options.lifecycleProfilePath, "lifecycle profile");
107
+ const socket = systemdPath(options.lifecycleHelperSocketPath ?? LIFECYCLE_HELPER_SOCKET, "lifecycle helper socket");
108
+ return `[Unit]
109
+ Description=ForgeZero fixed-operation compute lifecycle helper
110
+ Documentation=https://www.forgezero.net/docs/agent
111
+ After=network-online.target
112
+ Wants=network-online.target
113
+
114
+ [Service]
115
+ Type=simple
116
+ User=root
117
+ Group=${LIFECYCLE_GROUP}
118
+ Environment=FZ_LIFECYCLE_HELPER_SOCKET=${socket}
119
+ ExecStart=${bin} lifecycle-helper --profile=${profile}
120
+ Restart=always
121
+ RestartSec=2
122
+ RuntimeDirectory=forgezero-lifecycle
123
+ RuntimeDirectoryMode=0750
124
+ UMask=0007
125
+ LimitCORE=0
126
+ NoNewPrivileges=true
127
+ PrivateTmp=true
128
+ ProtectSystem=strict
129
+ ProtectHome=true
130
+ ProtectKernelTunables=true
131
+ ProtectKernelModules=true
132
+ ProtectControlGroups=true
133
+ RestrictSUIDSGID=true
134
+ RestrictRealtime=true
135
+ MemoryDenyWriteExecute=true
136
+ LockPersonality=true
137
+ RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
138
+
139
+ [Install]
140
+ WantedBy=multi-user.target
141
+ `;
142
+ }
48
143
  function agentEnrolmentUnit(options) {
49
144
  if (!options.apiUrl || !options.enrolTokenCredentialPath || !options.enrolStatePath) {
50
145
  throw new Error("direct enrolment needs API, credential and state paths");
@@ -56,6 +151,16 @@ function agentEnrolmentUnit(options) {
56
151
  ` : "";
57
152
  const gitPublicKey = options.gitPublicKeyPath ? `Environment=FZ_GIT_PUBLIC_KEY_FILE=${options.gitPublicKeyPath}
58
153
  ` : "";
154
+ const networkAttachment = [
155
+ options.cloudflareAccountId ? `Environment=FZ_CF_ACCOUNT_ID=${options.cloudflareAccountId}
156
+ ` : "",
157
+ options.cloudflareTunnelId ? `Environment=FZ_CF_TUNNEL_ID=${options.cloudflareTunnelId}
158
+ ` : "",
159
+ options.cloudflareVirtualNetworkId ? `Environment=FZ_CF_VIRTUAL_NETWORK_ID=${options.cloudflareVirtualNetworkId}
160
+ ` : "",
161
+ options.cloudflareWarpPolicyId ? `Environment=FZ_CF_WARP_POLICY_ID=${options.cloudflareWarpPolicyId}
162
+ ` : ""
163
+ ].join("");
59
164
  const stateDir = options.enrolStatePath.replace(/\/[^/]+$/, "");
60
165
  return `[Unit]
61
166
  Description=Bind this machine to its ForgeZero compute
@@ -74,7 +179,7 @@ Environment=FZ_SEED_CREDENTIAL=agent-seed
74
179
  Environment=FZ_ENROL_TOKEN_CREDENTIAL=enrol-token
75
180
  Environment=FZ_ENROL_STATE_FILE=${options.enrolStatePath}
76
181
  Environment=FZ_API=${options.apiUrl}
77
- ${label}${gitPublicKey}ExecStart=${bin} enrol
182
+ ${label}${gitPublicKey}${networkAttachment}ExecStart=${bin} enrol
78
183
  # A '+' fixed command runs as root solely to remove the host-bound one-time
79
184
  # ciphertext. Tenant code and the agent never receive a privilege boundary.
80
185
  ExecStartPost=+/usr/bin/rm -f ${options.enrolTokenCredentialPath}
@@ -139,6 +244,34 @@ function agentUnit(options) {
139
244
  const controlSocketPath = options.controlSocketPath ?? "/run/forgezero/control.sock";
140
245
  const deployRoot = options.deployRoot ?? "/opt/forgezero";
141
246
  const deploymentEnabled = Boolean(options.repository || options.pullDeployments);
247
+ const lifecycleEnabled = Boolean(options.pullMigrations && options.lifecycleProfilePath);
248
+ if (Boolean(options.pullMigrations) !== Boolean(options.lifecycleProfilePath)) {
249
+ throw new Error("migration pull and lifecycle profile must be supplied together");
250
+ }
251
+ const lifecycleHelperSocketPath = lifecycleEnabled ? systemdPath(options.lifecycleHelperSocketPath ?? LIFECYCLE_HELPER_SOCKET, "lifecycle helper socket") : undefined;
252
+ const warpValues = [
253
+ options.warpOrganization,
254
+ options.warpClientIdCredentialPath,
255
+ options.warpClientSecretCredentialPath
256
+ ];
257
+ const warpEnabled = warpValues.every(Boolean);
258
+ if (warpValues.some(Boolean) && !warpEnabled)
259
+ throw new Error("WARP configuration must be supplied together");
260
+ const networkAttachmentValues = [
261
+ options.cloudflareAccountId,
262
+ options.cloudflareTunnelId,
263
+ options.cloudflareVirtualNetworkId,
264
+ options.cloudflareWarpPolicyId
265
+ ];
266
+ if (networkAttachmentValues.some(Boolean)) {
267
+ if (!options.cloudflareAccountId || !options.cloudflareTunnelId || !options.cloudflareWarpPolicyId) {
268
+ throw new Error("private-network attachment requires account, Tunnel and WARP policy ids");
269
+ }
270
+ const uuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
271
+ if (!/^[a-f0-9]{32}$/i.test(options.cloudflareAccountId) || !uuid.test(options.cloudflareTunnelId) || options.cloudflareVirtualNetworkId && !uuid.test(options.cloudflareVirtualNetworkId) || !/^[A-Za-z0-9-]{1,64}$/.test(options.cloudflareWarpPolicyId)) {
272
+ throw new Error("private-network attachment coordinates are invalid");
273
+ }
274
+ }
142
275
  const enrolmentEnabled = Boolean(options.enrolTokenCredentialPath && options.enrolStatePath);
143
276
  const deploymentEnvironment = options.deploymentEnvironment ?? {};
144
277
  const deploymentCredentials = options.deploymentCredentials ?? {};
@@ -173,7 +306,9 @@ function agentUnit(options) {
173
306
  Object.keys(deploymentEnvironment).length > 0 ? `FZ_DEPLOY_ENV_NAMES=${Object.keys(deploymentEnvironment).join(",")}` : null,
174
307
  ...Object.entries(deploymentEnvironment).map(([name, value]) => `${name}=${value}`),
175
308
  options.publicApiUrl ? `FZ_PUBLIC_API_URL=${options.publicApiUrl}` : null,
176
- options.pullDeployments ? "FZ_DEPLOY_PULL=true" : null
309
+ options.pullDeployments ? "FZ_DEPLOY_PULL=true" : null,
310
+ options.pullMigrations ? "FZ_MIGRATION_PULL=true" : null,
311
+ options.pullMigrations ? `FZ_LIFECYCLE_HELPER_SOCKET=${lifecycleHelperSocketPath}` : null
177
312
  ].filter((line) => line !== null);
178
313
  if (deploymentEnabled) {
179
314
  environment.push(`HOME=${deployRoot}/agent-home`, `XDG_CACHE_HOME=${deployRoot}/cache`);
@@ -183,14 +318,22 @@ function agentUnit(options) {
183
318
  const projectCredentials = Object.entries(deploymentCredentials).map(([name, path]) => `LoadCredentialEncrypted=${name}:${path}`).join(`
184
319
  `);
185
320
  const deploymentWrites = deploymentEnabled ? `ReadWritePaths=${deployRoot}/releases ${deployRoot}/agent-home ${deployRoot}/cache` : "";
186
- const deploymentGroup = deploymentEnabled ? `SupplementaryGroups=${DEPLOYMENT_GROUP}` : "";
321
+ const supplementaryGroups = [
322
+ deploymentEnabled ? DEPLOYMENT_GROUP : null,
323
+ lifecycleEnabled ? LIFECYCLE_GROUP : null
324
+ ].filter((value) => value !== null);
325
+ const deploymentGroup = supplementaryGroups.length > 0 ? `SupplementaryGroups=${supplementaryGroups.join(" ")}` : "";
187
326
  const after = [
188
327
  "network-online.target",
189
328
  deploymentEnabled ? "forgezero-deploy-runner.service" : null,
329
+ lifecycleEnabled ? "forgezero-lifecycle-helper.service" : null,
330
+ warpEnabled ? "warp-svc.service" : null,
190
331
  enrolmentEnabled ? "forgezero-agent-enrol.service" : null
191
332
  ].filter((value) => value !== null);
192
333
  const requires = [
193
334
  deploymentEnabled ? "forgezero-deploy-runner.service" : null,
335
+ lifecycleEnabled ? "forgezero-lifecycle-helper.service" : null,
336
+ warpEnabled ? "warp-svc.service" : null,
194
337
  enrolmentEnabled ? "forgezero-agent-enrol.service" : null
195
338
  ].filter((value) => value !== null);
196
339
  const deploymentDependency = [
@@ -262,37 +405,63 @@ function planProvision(options) {
262
405
  const credentialDir = seedCredentialPath.replace(/\/[^/]+$/, "");
263
406
  const deployRoot = options.deployRoot ?? "/opt/forgezero";
264
407
  const deploymentEnabled = Boolean(options.repository || options.pullDeployments);
408
+ const lifecycleEnabled = Boolean(options.pullMigrations && options.lifecycleProfilePath);
409
+ if (Boolean(options.pullMigrations) !== Boolean(options.lifecycleProfilePath)) {
410
+ throw new Error("migration pull and lifecycle profile must be supplied together");
411
+ }
412
+ const warpValues = [
413
+ options.warpOrganization,
414
+ options.warpClientIdCredentialPath,
415
+ options.warpClientSecretCredentialPath
416
+ ];
417
+ const warpEnabled = warpValues.every(Boolean);
418
+ if (warpValues.some(Boolean) && !warpEnabled)
419
+ throw new Error("WARP configuration must be supplied together");
265
420
  const enrolmentEnabled = Boolean(options.enrolTokenSourcePath && options.enrolTokenCredentialPath && options.enrolStatePath);
266
421
  if (Boolean(options.enrolTokenSourcePath) !== Boolean(options.enrolTokenCredentialPath) || Boolean(options.enrolTokenCredentialPath) !== Boolean(options.enrolStatePath))
267
422
  throw new Error("direct enrolment paths must be supplied together");
268
- const safePath = (value, label) => {
269
- if (!value || !/^\/[A-Za-z0-9._@/-]+$/.test(value))
270
- throw new Error(`invalid ${label} path`);
271
- return value;
272
- };
273
- const enrolTokenSourcePath = enrolmentEnabled ? safePath(options.enrolTokenSourcePath, "enrolment source") : undefined;
274
- const enrolTokenCredentialPath = enrolmentEnabled ? safePath(options.enrolTokenCredentialPath, "enrolment credential") : undefined;
275
- const enrolStatePath = enrolmentEnabled ? safePath(options.enrolStatePath, "enrolment state") : undefined;
423
+ const enrolTokenSourcePath = enrolmentEnabled ? systemdPath(options.enrolTokenSourcePath, "enrolment source") : undefined;
424
+ const enrolTokenCredentialPath = enrolmentEnabled ? systemdPath(options.enrolTokenCredentialPath, "enrolment credential") : undefined;
425
+ const enrolStatePath = enrolmentEnabled ? systemdPath(options.enrolStatePath, "enrolment state") : undefined;
276
426
  const enrolStateDir = enrolStatePath?.replace(/\/[^/]+$/, "");
277
- const sourceBinPath = options.sourceBinPath ? safePath(options.sourceBinPath, "agent source binary") : undefined;
278
- const binPath = options.binPath ? safePath(options.binPath, "agent binary") : undefined;
279
- const gitCredentialPath = options.gitCredentialPath ? safePath(options.gitCredentialPath, "Git credential") : undefined;
280
- const gitPublicKeyPath = options.gitPublicKeyPath ? safePath(options.gitPublicKeyPath, "Git public key") : undefined;
427
+ const sourceBinPath = options.sourceBinPath ? systemdPath(options.sourceBinPath, "agent source binary") : undefined;
428
+ const binPath = options.binPath ? systemdPath(options.binPath, "agent binary") : undefined;
429
+ const gitCredentialPath = options.gitCredentialPath ? systemdPath(options.gitCredentialPath, "Git credential") : undefined;
430
+ const gitPublicKeyPath = options.gitPublicKeyPath ? systemdPath(options.gitPublicKeyPath, "Git public key") : undefined;
281
431
  if (options.generateGitIdentity && (!gitCredentialPath || !gitPublicKeyPath)) {
282
432
  throw new Error("generated Git identity needs credential and public-key paths");
283
433
  }
284
434
  const gitPublicKeyDir = gitPublicKeyPath?.replace(/\/[^/]+$/, "");
435
+ const lifecycleProfilePath = lifecycleEnabled ? systemdPath(options.lifecycleProfilePath, "lifecycle profile") : undefined;
436
+ const lifecycleHelperSocketPath = lifecycleEnabled ? systemdPath(options.lifecycleHelperSocketPath ?? LIFECYCLE_HELPER_SOCKET, "lifecycle helper socket") : undefined;
437
+ const warpClientIdCredentialPath = warpEnabled ? systemdPath(options.warpClientIdCredentialPath, "WARP client-id credential") : undefined;
438
+ const warpClientSecretCredentialPath = warpEnabled ? systemdPath(options.warpClientSecretCredentialPath, "WARP client-secret credential") : undefined;
285
439
  return {
286
440
  mode,
287
441
  reason: reasonFor(mode),
288
442
  unitPath: UNIT_PATH,
289
- unit: agentUnit({ ...options, mode }),
443
+ unit: agentUnit({ ...options, mode, lifecycleProfilePath, lifecycleHelperSocketPath }),
290
444
  auxiliaryUnits: [
291
445
  ...deploymentEnabled ? [
292
446
  { path: DEPLOYMENT_RUNNER_UNIT_PATH, unit: deploymentRunnerUnit(options) }
293
447
  ] : [],
294
448
  ...enrolmentEnabled ? [
295
449
  { path: ENROLMENT_UNIT_PATH, unit: agentEnrolmentUnit(options) }
450
+ ] : [],
451
+ ...lifecycleEnabled ? [
452
+ { path: LIFECYCLE_HELPER_UNIT_PATH, unit: lifecycleHelperUnit({
453
+ ...options,
454
+ lifecycleProfilePath,
455
+ lifecycleHelperSocketPath
456
+ }) }
457
+ ] : [],
458
+ ...warpEnabled ? [
459
+ { path: WARP_CONFIG_UNIT_PATH, unit: warpConfigUnit({
460
+ ...options,
461
+ warpClientIdCredentialPath,
462
+ warpClientSecretCredentialPath
463
+ }) },
464
+ { path: WARP_SERVICE_DROP_IN_PATH, unit: warpServiceDropIn() }
296
465
  ] : []
297
466
  ],
298
467
  socketPath: options.socketPath,
@@ -306,10 +475,18 @@ function planProvision(options) {
306
475
  label: "root-owned agent runtime",
307
476
  command: `install -d -o root -g root -m 0755 ${binPath.replace(/\/[^/]+$/, "")}; ` + `install -o root -g root -m 0755 ${sourceBinPath} ${binPath}`
308
477
  }] : [],
478
+ ...warpEnabled ? [{
479
+ label: "Cloudflare One client for Ubuntu 26.04",
480
+ command: `. /etc/os-release; test "$ID" = ubuntu && test "$VERSION_ID" = 26.04; ` + `install -d -m 0755 /usr/share/keyrings /etc/apt/sources.list.d /etc/systemd/system/warp-svc.service.d; ` + `curl -fsSL https://pkg.cloudflareclient.com/pubkey.gpg -o /run/cloudflare-warp-key.gpg; ` + `gpg --batch --yes --dearmor -o /usr/share/keyrings/cloudflare-warp-archive-keyring.gpg /run/cloudflare-warp-key.gpg; ` + `rm -f /run/cloudflare-warp-key.gpg; ` + `printf 'deb [signed-by=/usr/share/keyrings/cloudflare-warp-archive-keyring.gpg] https://pkg.cloudflareclient.com/ %s main\\n' "$VERSION_CODENAME" > /etc/apt/sources.list.d/cloudflare-client.list; ` + `apt-get update -qq; DEBIAN_FRONTEND=noninteractive apt-get install -y cloudflare-warp`
481
+ }] : [],
309
482
  ...deploymentEnabled ? [{
310
483
  label: "deployment isolation group",
311
484
  command: `groupadd --system ${DEPLOYMENT_GROUP} || true`
312
485
  }] : [],
486
+ ...lifecycleEnabled ? [{
487
+ label: "lifecycle helper access group",
488
+ command: `groupadd --system ${LIFECYCLE_GROUP} || true`
489
+ }] : [],
313
490
  {
314
491
  label: "service account",
315
492
  command: `useradd --system --no-create-home --shell /usr/sbin/nologin ${user} || true`
@@ -318,6 +495,10 @@ function planProvision(options) {
318
495
  label: "bind service account to vault group",
319
496
  command: `usermod -g ${VAULT_GROUP} ${user}`
320
497
  },
498
+ ...lifecycleEnabled ? [{
499
+ label: "grant lifecycle helper socket access",
500
+ command: `usermod -a -G ${LIFECYCLE_GROUP} ${user}`
501
+ }] : [],
321
502
  ...deploymentEnabled ? [{
322
503
  label: "credential-free deployment account",
323
504
  command: `useradd --system --no-create-home --shell /usr/sbin/nologin --gid ${DEPLOYMENT_GROUP} ${DEPLOYMENT_RUNNER_USER} || true; ` + `usermod -a -G ${DEPLOYMENT_GROUP} ${user}`
@@ -363,6 +544,8 @@ function planProvision(options) {
363
544
  label: "enable and start",
364
545
  command: `systemctl enable --now ${[
365
546
  ...deploymentEnabled ? ["forgezero-deploy-runner.service"] : [],
547
+ ...lifecycleEnabled ? ["forgezero-lifecycle-helper.service"] : [],
548
+ ...warpEnabled ? ["forgezero-warp-config.service", "warp-svc.service"] : [],
366
549
  ...enrolmentEnabled ? ["forgezero-agent-enrol.service"] : [],
367
550
  "forgezero-agent.service"
368
551
  ].join(" ")}`
@@ -377,6 +560,14 @@ function planProvision(options) {
377
560
  label: "prove the deployment runner socket exists",
378
561
  command: `test -S ${DEPLOYMENT_RUNNER_SOCKET}`
379
562
  }] : [],
563
+ ...lifecycleEnabled ? [{
564
+ label: "prove the lifecycle helper socket exists",
565
+ command: `test -S ${lifecycleHelperSocketPath}`
566
+ }] : [],
567
+ ...warpEnabled ? [{
568
+ label: "prove Cloudflare WARP is connected",
569
+ command: `warp-cli --accept-tos status | grep -Eiq '(^|[[:space:]])Connected([[:space:]]|$)'`
570
+ }] : [],
380
571
  ...options.repository ? [{
381
572
  label: "prove the deployment control socket exists",
382
573
  command: `test -S ${options.controlSocketPath ?? "/run/forgezero/control.sock"}`
@@ -385,15 +576,23 @@ function planProvision(options) {
385
576
  };
386
577
  }
387
578
  export {
579
+ warpServiceDropIn,
580
+ warpConfigUnit,
388
581
  reasonFor,
389
582
  planProvision,
390
583
  modeFor,
584
+ lifecycleHelperUnit,
391
585
  deploymentRunnerUnit,
392
586
  atLeast,
393
587
  agentUnit,
394
588
  agentEnrolmentUnit,
589
+ WARP_SERVICE_DROP_IN_PATH,
590
+ WARP_CONFIG_UNIT_PATH,
395
591
  VAULT_GROUP,
396
592
  UNIT_PATH,
593
+ LIFECYCLE_HELPER_UNIT_PATH,
594
+ LIFECYCLE_HELPER_SOCKET,
595
+ LIFECYCLE_GROUP,
397
596
  ENROLMENT_UNIT_PATH,
398
597
  DEPLOYMENT_RUNNER_USER,
399
598
  DEPLOYMENT_RUNNER_UNIT_PATH,
package/dist/version.d.ts CHANGED
@@ -1,2 +1,2 @@
1
1
  /** One package version shared by both public binaries. Pinned to package.json by tests. */
2
- export declare const VERSION = "0.1.22";
2
+ export declare const VERSION = "0.1.24";
@@ -0,0 +1,20 @@
1
+ /** Cloudflare One MDM document; callers keep both token values out of argv/env. */
2
+ export declare function renderWarpMdm(options: {
3
+ organization: string;
4
+ clientId: string;
5
+ clientSecret: string;
6
+ }): string;
7
+ /**
8
+ * Materialize enrollment only in /run. The durable /var/lib path is a symlink,
9
+ * so neither service-token half is left as plaintext on the filesystem.
10
+ */
11
+ export declare function materializeWarpMdm(options: {
12
+ organization: string;
13
+ clientId: string;
14
+ clientSecret: string;
15
+ runtimePath?: string;
16
+ servicePath?: string;
17
+ }): {
18
+ runtimePath: string;
19
+ servicePath: string;
20
+ };
package/package.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "//": "Publishing happens from an operator's machine, not CI \u2014 CLAUDE.md records that the absence of CI is deliberate. npm's `provenance` attests a tarball was built by a recognised CI provider from a named commit, so it cannot be produced here: it was set, and the first publish failed with `Automatic provenance generation not supported for provider: null`. A setting that can never be satisfied is worse than none, because it reads as a guarantee nobody is getting. Restore it the day this publishes from CI, and not before.",
3
3
  "name": "@forgezero/agent",
4
- "version": "0.1.22",
4
+ "version": "0.1.24",
5
5
  "type": "module",
6
6
  "scripts": {
7
7
  "check": "tsc --noEmit",
8
8
  "prebuild": "rm -rf dist",
9
- "build": "bun build src/index.ts --outfile dist/fz-agent.js --target bun --format esm --packages external && bun build src/cli/index.ts --outfile dist/fz.js --target bun --format esm --packages external && bun build src/compute.ts src/provision.ts src/subscribe.ts src/pipeline.ts src/definition.ts src/ssh-server.ts src/ssh-listen.ts src/provisioning-pull.ts src/guest-enrolment.ts src/node-vault.ts src/metal-provision.ts src/metal-helper-socket.ts src/deployment-runner.ts src/ubuntu.ts --root src --outdir dist --target browser --format esm --packages external && tsc --emitDeclarationOnly --declaration --noEmit false --outDir dist",
9
+ "build": "bun build src/index.ts --outfile dist/fz-agent.js --target bun --format esm --packages external && bun build src/cli/index.ts --outfile dist/fz.js --target bun --format esm --packages external && bun build src/compute.ts src/provision.ts src/subscribe.ts src/pipeline.ts src/definition.ts src/ssh-server.ts src/ssh-listen.ts src/provisioning-pull.ts src/migration-pull.ts src/guest-enrolment.ts src/node-vault.ts src/metal-provision.ts src/metal-helper-socket.ts src/lifecycle-helper.ts src/deployment-runner.ts src/ubuntu.ts --root src --outdir dist --target browser --format esm --packages external && tsc --emitDeclarationOnly --declaration --noEmit false --outDir dist",
10
10
  "prepublishOnly": "bun run check && bun run build"
11
11
  },
12
12
  "devDependencies": {
@@ -88,6 +88,14 @@
88
88
  "types": "./dist/provisioning-pull.d.ts",
89
89
  "default": "./dist/provisioning-pull.js"
90
90
  },
91
+ "./migration-pull": {
92
+ "types": "./dist/migration-pull.d.ts",
93
+ "default": "./dist/migration-pull.js"
94
+ },
95
+ "./lifecycle-helper": {
96
+ "types": "./dist/lifecycle-helper.d.ts",
97
+ "default": "./dist/lifecycle-helper.js"
98
+ },
91
99
  "./metal-provision": {
92
100
  "types": "./dist/metal-provision.d.ts",
93
101
  "default": "./dist/metal-provision.js"