@forgezero/agent 0.1.32 → 0.1.33

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/provision.js CHANGED
@@ -229,6 +229,7 @@ var AGENT_UPDATE_JOURNAL = "/var/lib/forgezero/agent-update.json";
229
229
  var AGENT_UPDATE_RECEIPT = "/var/lib/forgezero/agent-update-receipt.json";
230
230
  var MAX_REQUEST_BYTES = 8 * 1024;
231
231
  var COMPUTE_HELPER_UNITS = [
232
+ "forgezero-agent-egress.service",
232
233
  "forgezero-deploy-runner.service",
233
234
  "forgezero-lifecycle-helper.service",
234
235
  "forgezero-software-helper.service"
@@ -883,9 +884,71 @@ function requestSoftware(requirements, socketPath = DEFAULT_SOFTWARE_HELPER_SOCK
883
884
  }
884
885
 
885
886
  // src/version.ts
886
- var VERSION3 = "0.1.32";
887
+ var VERSION3 = "0.1.33";
888
+
889
+ // src/egress-policy.ts
890
+ import { realpathSync } from "node:fs";
891
+ var AGENT_EGRESS_TABLE = "forgezero_agent_egress";
892
+ var SYSTEMD_RESOLVED_ADDRESS = "127.0.0.53";
893
+ var BLOCKED_IPV4 = [
894
+ "0.0.0.0/8",
895
+ "10.0.0.0/8",
896
+ "100.64.0.0/10",
897
+ "127.0.0.0/8",
898
+ "168.63.129.16/32",
899
+ "169.254.0.0/16",
900
+ "172.16.0.0/12",
901
+ "192.0.0.0/24",
902
+ "192.0.2.0/24",
903
+ "192.88.99.0/24",
904
+ "192.168.0.0/16",
905
+ "198.18.0.0/15",
906
+ "198.51.100.0/24",
907
+ "203.0.113.0/24",
908
+ "224.0.0.0/4",
909
+ "240.0.0.0/4"
910
+ ];
911
+ var BLOCKED_IPV6 = [
912
+ "::/128",
913
+ "::1/128",
914
+ "::ffff:0:0/96",
915
+ "64:ff9b::/96",
916
+ "64:ff9b:1::/48",
917
+ "100::/64",
918
+ "fc00::/7",
919
+ "fec0::/10",
920
+ "fe80::/10",
921
+ "ff00::/8",
922
+ "2001::/32",
923
+ "2001:2::/48",
924
+ "2001:10::/28",
925
+ "2001:20::/28",
926
+ "2001:db8::/32",
927
+ "2002::/16",
928
+ "3fff::/20"
929
+ ];
930
+ var normalizeEgressTcpPorts = (ports) => {
931
+ for (const port of ports) {
932
+ if (!Number.isSafeInteger(port) || port < 1 || port > 65535) {
933
+ throw new Error("Agent egress policy refuses an invalid loopback TCP port.");
934
+ }
935
+ }
936
+ return [...new Set(ports)].sort((left, right) => left - right);
937
+ };
938
+ function systemdAgentEgressDirectives(loopbackTcpPorts = []) {
939
+ const ports = normalizeEgressTcpPorts(loopbackTcpPorts);
940
+ return [
941
+ "RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6",
942
+ `IPAddressAllow=${SYSTEMD_RESOLVED_ADDRESS}/32`,
943
+ ...ports.length > 0 ? ["IPAddressAllow=127.0.0.1/32", "IPAddressAllow=::1/128"] : [],
944
+ ...BLOCKED_IPV4.map((network) => `IPAddressDeny=${network}`),
945
+ ...BLOCKED_IPV6.map((network) => `IPAddressDeny=${network}`)
946
+ ].join(`
947
+ `);
948
+ }
887
949
 
888
950
  // src/provision.ts
951
+ import { isIP } from "node:net";
889
952
  function atLeast(version, floor) {
890
953
  const parse = (value) => (value.trim().replace(/^v/, "").match(/\d+/g) ?? []).slice(0, 3).map(Number);
891
954
  const got = parse(version);
@@ -939,6 +1002,63 @@ var LIFECYCLE_HELPER_UNIT_PATH = "/etc/systemd/system/forgezero-lifecycle-helper
939
1002
  var LIFECYCLE_HELPER_SOCKET = "/run/forgezero-lifecycle/helper.sock";
940
1003
  var WARP_CONFIG_UNIT_PATH = "/etc/systemd/system/forgezero-warp-config.service";
941
1004
  var WARP_SERVICE_DROP_IN_PATH = "/etc/systemd/system/warp-svc.service.d/forgezero.conf";
1005
+ var AGENT_EGRESS_UNIT_PATH = "/etc/systemd/system/forgezero-agent-egress.service";
1006
+ var DEFAULT_RUNNER_PUBLIC_TCP_PORTS = [443];
1007
+ function agentEgressUnit(options) {
1008
+ const bin = options.binPath ?? "fz-agent";
1009
+ const user = options.user ?? "forgezero";
1010
+ if (!/^[a-z_][a-z0-9_-]{0,30}$/.test(user))
1011
+ throw new Error("invalid Agent service user");
1012
+ const deploymentEnabled = Boolean(options.repository || options.pullDeployments);
1013
+ const runnerLoopbackPorts = normalizeEgressTcpPorts(options.runnerLoopbackPorts ?? []);
1014
+ const runnerPublicTcpPorts = normalizeEgressTcpPorts(options.runnerPublicTcpPorts ?? DEFAULT_RUNNER_PUBLIC_TCP_PORTS);
1015
+ if (deploymentEnabled && runnerPublicTcpPorts.length < 1) {
1016
+ throw new Error("deployed project runner needs at least one vetted public TCP port");
1017
+ }
1018
+ if (deploymentEnabled)
1019
+ systemdAgentEgressDirectives(runnerLoopbackPorts);
1020
+ const users = [user, ...deploymentEnabled ? [DEPLOYMENT_RUNNER_USER] : []];
1021
+ const runnerGrant = deploymentEnabled ? ` --loopback-user=${DEPLOYMENT_RUNNER_USER}` + runnerLoopbackPorts.map((port) => ` --loopback-tcp-port=${port}`).join("") + runnerPublicTcpPorts.map((port) => ` --public-tcp-port=${port}`).join("") : "";
1022
+ const policyProofs = deploymentEnabled ? [
1023
+ ...runnerLoopbackPorts.length > 0 ? [`loopback=.*:${runnerLoopbackPorts.join(",")}`] : [],
1024
+ `public-tcp=${runnerPublicTcpPorts.join(",")}`
1025
+ ].map((pattern) => `ExecStartPost=/bin/sh -c '/usr/sbin/nft --numeric list table inet ${AGENT_EGRESS_TABLE} | /usr/bin/grep -q "${pattern}"'`).join(`
1026
+ `) : "";
1027
+ return `[Unit]
1028
+ Description=ForgeZero Agent host egress policy
1029
+ Documentation=https://www.forgezero.net/docs/agent
1030
+ After=systemd-resolved.service nftables.service
1031
+ Requires=systemd-resolved.service
1032
+ Before=forgezero-agent-enrol.service forgezero-agent.service
1033
+
1034
+ [Service]
1035
+ Type=notify
1036
+ NotifyAccess=all
1037
+ User=root
1038
+ Group=root
1039
+ ExecStart=${bin} egress-policy ${users.map((name) => `--user=${name}`).join(" ")}${runnerGrant}
1040
+ ${policyProofs}
1041
+ Restart=on-failure
1042
+ RestartSec=2
1043
+ LimitCORE=0
1044
+ NoNewPrivileges=true
1045
+ PrivateTmp=true
1046
+ ProtectSystem=strict
1047
+ ProtectHome=true
1048
+ ProtectKernelTunables=true
1049
+ ProtectKernelModules=true
1050
+ ProtectControlGroups=true
1051
+ RestrictSUIDSGID=true
1052
+ RestrictRealtime=true
1053
+ MemoryDenyWriteExecute=true
1054
+ LockPersonality=true
1055
+ CapabilityBoundingSet=CAP_NET_ADMIN
1056
+ RestrictAddressFamilies=AF_UNIX AF_NETLINK
1057
+
1058
+ [Install]
1059
+ WantedBy=multi-user.target
1060
+ `;
1061
+ }
942
1062
  function softwareHelperUnit(options) {
943
1063
  const bin = options.binPath ?? "fz-agent";
944
1064
  return `[Unit]
@@ -1183,11 +1303,16 @@ function agentEnrolmentUnit(options) {
1183
1303
  ` : ""
1184
1304
  ].join("");
1185
1305
  const stateDir = options.enrolStatePath.replace(/\/[^/]+$/, "");
1306
+ const egressDependency = options.enforceEgress ? `After=forgezero-agent-egress.service
1307
+ Requires=forgezero-agent-egress.service
1308
+ BindsTo=forgezero-agent-egress.service
1309
+ ` : "";
1310
+ const egressDirectives = options.enforceEgress ? systemdAgentEgressDirectives() : "";
1186
1311
  return `[Unit]
1187
1312
  Description=Bind this machine to its ForgeZero compute
1188
1313
  After=network-online.target
1189
1314
  Wants=network-online.target
1190
- Before=forgezero-agent.service
1315
+ ${egressDependency}Before=forgezero-agent.service
1191
1316
  ConditionPathExists=!${options.enrolStatePath}
1192
1317
 
1193
1318
  [Service]
@@ -1210,6 +1335,7 @@ ProtectSystem=strict
1210
1335
  ProtectHome=true
1211
1336
  ReadWritePaths=${stateDir}
1212
1337
  LimitCORE=0
1338
+ ${egressDirectives}
1213
1339
 
1214
1340
  [Install]
1215
1341
  WantedBy=multi-user.target
@@ -1219,9 +1345,15 @@ function deploymentRunnerUnit(options) {
1219
1345
  const bin = options.binPath ?? "fz-agent";
1220
1346
  const root = options.deployRoot ?? "/opt/forgezero";
1221
1347
  const agentUser = options.user ?? "forgezero-agent";
1348
+ const egressDependency = options.enforceEgress ? `After=forgezero-agent-egress.service
1349
+ Requires=forgezero-agent-egress.service
1350
+ BindsTo=forgezero-agent-egress.service
1351
+ ` : "";
1352
+ const egressDirectives = options.enforceEgress ? systemdAgentEgressDirectives(options.runnerLoopbackPorts ?? []) : "";
1222
1353
  return `[Unit]
1223
1354
  Description=ForgeZero credential-free project command runner
1224
1355
  Documentation=https://www.forgezero.net/docs/agent
1356
+ ${egressDependency}
1225
1357
 
1226
1358
  [Service]
1227
1359
  Type=notify
@@ -1252,6 +1384,7 @@ ProtectControlGroups=true
1252
1384
  RestrictRealtime=true
1253
1385
  MemoryDenyWriteExecute=true
1254
1386
  LockPersonality=true
1387
+ ${egressDirectives}
1255
1388
  ReadWritePaths=${root}/releases ${root}/runner-home
1256
1389
 
1257
1390
  [Install]
@@ -1261,6 +1394,21 @@ WantedBy=multi-user.target
1261
1394
  function agentUnit(options) {
1262
1395
  if (!validNodeHostname(options.nodeHostname))
1263
1396
  throw new Error("node hostname is invalid");
1397
+ if (!options.telemetryEndpoint) {
1398
+ throw new Error("compute Agent provisioning requires OTEL_EXPORTER_OTLP_ENDPOINT as an explicit public HTTPS collector coordinate");
1399
+ }
1400
+ let telemetryEndpoint;
1401
+ {
1402
+ let endpoint;
1403
+ try {
1404
+ endpoint = new URL(options.telemetryEndpoint);
1405
+ } catch {
1406
+ throw new Error("compute telemetry endpoint must be an absolute public HTTPS URL");
1407
+ }
1408
+ if (endpoint.protocol !== "https:" || endpoint.username || endpoint.password || endpoint.search || endpoint.hash || isIP(endpoint.hostname) !== 0 || !endpoint.hostname.includes(".") || endpoint.hostname === "localhost" || endpoint.hostname.endsWith(".local"))
1409
+ throw new Error("compute telemetry endpoint must be an absolute public HTTPS URL without credentials, query or fragment");
1410
+ telemetryEndpoint = endpoint.toString().replace(/\/$/, "");
1411
+ }
1264
1412
  const bin = options.binPath ?? "fz-agent";
1265
1413
  const user = options.user ?? "forgezero";
1266
1414
  const seedCredentialPath = options.seedCredentialPath ?? "/etc/forgezero/creds/agent-seed.cred";
@@ -1309,6 +1457,7 @@ function agentUnit(options) {
1309
1457
  }
1310
1458
  }
1311
1459
  const environment = [
1460
+ "NODE_ENV=production",
1312
1461
  `FZ_SOCKET_PATH=${agentBackendSocketPath(options.socketPath)}`,
1313
1462
  `FZ_CONTROL_SOCKET=${controlSocketPath}`,
1314
1463
  `FZ_SEED_CREDENTIAL=agent-seed`,
@@ -1319,6 +1468,8 @@ function agentUnit(options) {
1319
1468
  options.enrolStatePath ? `FZ_ENROL_STATE_FILE=${options.enrolStatePath}` : null,
1320
1469
  options.nodeLabel ? `FZ_NODE_LABEL=${options.nodeLabel}` : null,
1321
1470
  options.nodeHostname ? `FZ_NODE_HOSTNAME=${options.nodeHostname}` : null,
1471
+ `OTEL_EXPORTER_OTLP_ENDPOINT=${telemetryEndpoint}`,
1472
+ "OTEL_SERVICE_NAME=forgezero-agent",
1322
1473
  options.gitPublicKeyPath ? `FZ_GIT_PUBLIC_KEY_FILE=${options.gitPublicKeyPath}` : null,
1323
1474
  options.repository ? `FZ_DEPLOY_REPO=${options.repository}` : null,
1324
1475
  options.branch ? `FZ_DEPLOY_BRANCH=${options.branch}` : null,
@@ -1354,6 +1505,7 @@ function agentUnit(options) {
1354
1505
  const after = [
1355
1506
  "network-online.target",
1356
1507
  "forgezero-agent-update-helper.service",
1508
+ options.enforceEgress ? "forgezero-agent-egress.service" : null,
1357
1509
  deploymentEnabled ? "forgezero-deploy-runner.service" : null,
1358
1510
  deploymentEnabled ? "forgezero-software-helper.service" : null,
1359
1511
  lifecycleEnabled ? "forgezero-lifecycle-helper.service" : null,
@@ -1362,6 +1514,7 @@ function agentUnit(options) {
1362
1514
  ].filter((value) => value !== null);
1363
1515
  const requires = [
1364
1516
  "forgezero-agent-update-helper.service",
1517
+ options.enforceEgress ? "forgezero-agent-egress.service" : null,
1365
1518
  deploymentEnabled ? "forgezero-deploy-runner.service" : null,
1366
1519
  deploymentEnabled ? "forgezero-software-helper.service" : null,
1367
1520
  lifecycleEnabled ? "forgezero-lifecycle-helper.service" : null,
@@ -1371,7 +1524,8 @@ function agentUnit(options) {
1371
1524
  const deploymentDependency = [
1372
1525
  `After=${after.join(" ")}`,
1373
1526
  "Wants=network-online.target",
1374
- requires.length > 0 ? `Requires=${requires.join(" ")}` : null
1527
+ requires.length > 0 ? `Requires=${requires.join(" ")}` : null,
1528
+ options.enforceEgress ? "BindsTo=forgezero-agent-egress.service" : null
1375
1529
  ].filter((value) => value !== null).join(`
1376
1530
  `);
1377
1531
  const snpDevice = options.mode === "attested" ? `DevicePolicy=closed
@@ -1379,6 +1533,7 @@ DeviceAllow=/dev/sev-guest rw` : "";
1379
1533
  const snpPrepare = options.mode === "attested" ? `ExecStartPre=+/bin/chgrp ${VAULT_GROUP} /dev/sev-guest
1380
1534
  ExecStartPre=+/bin/chmod 0640 /dev/sev-guest
1381
1535
  ` : "";
1536
+ const egressDirectives = options.enforceEgress ? systemdAgentEgressDirectives() : "";
1382
1537
  return `[Unit]
1383
1538
  Description=ForgeZero node agent (${options.mode})
1384
1539
  Documentation=https://www.forgezero.net/docs/agent
@@ -1423,6 +1578,7 @@ RestrictSUIDSGID=true
1423
1578
  RestrictRealtime=true
1424
1579
  MemoryDenyWriteExecute=true
1425
1580
  LockPersonality=true
1581
+ ${egressDirectives}
1426
1582
  ${snpDevice}
1427
1583
  ${deploymentWrites}
1428
1584
 
@@ -1438,6 +1594,8 @@ function planProvision(options) {
1438
1594
  const credentialDir = seedCredentialPath.replace(/\/[^/]+$/, "");
1439
1595
  const deployRoot = options.deployRoot ?? "/opt/forgezero";
1440
1596
  const deploymentEnabled = Boolean(options.repository || options.pullDeployments);
1597
+ const runnerLoopbackPorts = normalizeEgressTcpPorts(options.runnerLoopbackPorts ?? []);
1598
+ const runnerPublicTcpPorts = normalizeEgressTcpPorts(options.runnerPublicTcpPorts ?? DEFAULT_RUNNER_PUBLIC_TCP_PORTS);
1441
1599
  const lifecycleEnabled = Boolean(options.pullMigrations && options.lifecycleProfilePath);
1442
1600
  if (Boolean(options.pullMigrations) !== Boolean(options.lifecycleProfilePath)) {
1443
1601
  throw new Error("migration pull and lifecycle profile must be supplied together");
@@ -1478,6 +1636,9 @@ function planProvision(options) {
1478
1636
  { path: AGENT_SOCKET_UNIT_PATH, unit: agentSocketUnit(options) },
1479
1637
  { path: AGENT_SOCKET_PROXY_UNIT_PATH, unit: agentSocketProxyUnit(options) },
1480
1638
  { path: AGENT_UPDATE_HELPER_UNIT_PATH, unit: agentUpdateHelperUnit(options) },
1639
+ ...options.enforceEgress ? [
1640
+ { path: AGENT_EGRESS_UNIT_PATH, unit: agentEgressUnit(options) }
1641
+ ] : [],
1481
1642
  ...deploymentEnabled ? [
1482
1643
  { path: DEPLOYMENT_RUNNER_UNIT_PATH, unit: deploymentRunnerUnit(options) },
1483
1644
  { path: SOFTWARE_HELPER_UNIT_PATH, unit: softwareHelperUnit(options) }
@@ -1504,6 +1665,10 @@ function planProvision(options) {
1504
1665
  socketPath: options.socketPath,
1505
1666
  user,
1506
1667
  steps: [
1668
+ ...options.enforceEgress ? [{
1669
+ label: "Ubuntu Agent egress prerequisites",
1670
+ command: `. /etc/os-release; test "$ID" = ubuntu; ` + `DEBIAN_FRONTEND=noninteractive apt-get update -qq; ` + `DEBIAN_FRONTEND=noninteractive apt-get install -y nftables; ` + `systemctl enable --now systemd-resolved.service; ` + `test "$(readlink -f /etc/resolv.conf)" = /run/systemd/resolve/stub-resolv.conf; ` + `test -s /run/systemd/resolve/stub-resolv.conf`
1671
+ }] : [],
1507
1672
  {
1508
1673
  label: "vault socket access group",
1509
1674
  command: `groupadd --system ${VAULT_GROUP} || true`
@@ -1594,6 +1759,7 @@ function planProvision(options) {
1594
1759
  command: `systemctl enable ${[
1595
1760
  "forgezero-agent.socket",
1596
1761
  "forgezero-agent-update-helper.service",
1762
+ ...options.enforceEgress ? ["forgezero-agent-egress.service"] : [],
1597
1763
  ...deploymentEnabled ? ["forgezero-deploy-runner.service", "forgezero-software-helper.service"] : [],
1598
1764
  ...lifecycleEnabled ? ["forgezero-lifecycle-helper.service"] : [],
1599
1765
  ...warpEnabled ? ["forgezero-warp-config.service", "warp-svc.service"] : [],
@@ -1601,6 +1767,7 @@ function planProvision(options) {
1601
1767
  "forgezero-agent.service"
1602
1768
  ].join(" ")}; systemctl reset-failed forgezero-agent.service || true; systemctl restart ${[
1603
1769
  "forgezero-agent-update-helper.service",
1770
+ ...options.enforceEgress ? ["forgezero-agent-egress.service"] : [],
1604
1771
  ...deploymentEnabled ? ["forgezero-deploy-runner.service", "forgezero-software-helper.service"] : [],
1605
1772
  ...lifecycleEnabled ? ["forgezero-lifecycle-helper.service"] : [],
1606
1773
  ...warpEnabled ? ["forgezero-warp-config.service", "warp-svc.service"] : [],
@@ -1611,6 +1778,10 @@ function planProvision(options) {
1611
1778
  label: "prove the compute binding is durable",
1612
1779
  command: `test -s ${enrolStatePath}`
1613
1780
  }] : [],
1781
+ ...options.enforceEgress ? [{
1782
+ label: "prove the Agent egress policy is active",
1783
+ command: "systemctl is-active forgezero-agent-egress.service && " + "nft --numeric list table inet forgezero_agent_egress | grep -q forgezero-agent-egress-v1" + (deploymentEnabled ? ` && nft --numeric list table inet forgezero_agent_egress | grep -q 'public-tcp=${runnerPublicTcpPorts.join(",")}'` + (runnerLoopbackPorts.length > 0 ? ` && nft --numeric list table inet forgezero_agent_egress | grep -Eq 'loopback=[0-9]+:${runnerLoopbackPorts.join(",")}( |")'` : "") : "")
1784
+ }] : [],
1614
1785
  { label: "prove it is running", command: "systemctl is-active forgezero-agent.service" },
1615
1786
  { label: "prove the public Vault socket exists", command: awaitSocketCommand(options.socketPath) },
1616
1787
  { label: "prove the Agent Vault backend exists", command: awaitSocketCommand(agentBackendSocketPath(options.socketPath)) },
@@ -1652,6 +1823,7 @@ export {
1652
1823
  agentSocketUnit,
1653
1824
  agentSocketProxyUnit,
1654
1825
  agentEnrolmentUnit,
1826
+ agentEgressUnit,
1655
1827
  agentBackendSocketPath,
1656
1828
  WARP_SERVICE_DROP_IN_PATH,
1657
1829
  WARP_CONFIG_UNIT_PATH,
@@ -1665,7 +1837,9 @@ export {
1665
1837
  DEPLOYMENT_RUNNER_UNIT_PATH,
1666
1838
  DEPLOYMENT_RUNNER_SOCKET,
1667
1839
  DEPLOYMENT_GROUP,
1840
+ DEFAULT_RUNNER_PUBLIC_TCP_PORTS,
1668
1841
  CAPABILITY_CHECKS,
1669
1842
  AGENT_SOCKET_UNIT_PATH,
1670
- AGENT_SOCKET_PROXY_UNIT_PATH
1843
+ AGENT_SOCKET_PROXY_UNIT_PATH,
1844
+ AGENT_EGRESS_UNIT_PATH
1671
1845
  };
@@ -1,5 +1,6 @@
1
1
  import type { NodeKeyPair } from '@forgezero/runtime/identity';
2
2
  import { type SignedNodeHttpOptions } from './signed-node-http';
3
+ import type { AgentOperationTelemetry } from './telemetry-runtime';
3
4
  interface RemoteProvisionClaimBase {
4
5
  computeKey: string;
5
6
  claimToken: string;
@@ -73,6 +74,7 @@ export interface ProvisioningPullOptions extends SignedNodeHttpOptions {
73
74
  kvm: boolean;
74
75
  helper: boolean;
75
76
  };
77
+ telemetry?: AgentOperationTelemetry;
76
78
  }
77
79
  export type ProvisionPullResult = {
78
80
  status: 'idle';
@@ -70,6 +70,7 @@ async function postSignedNode(options, path, body) {
70
70
  // src/provisioning-pull.ts
71
71
  class ProvisionClaimLostError extends Error {
72
72
  }
73
+ var remoteOutcome = (cause) => cause instanceof SignedNodeHttpError && cause.status < 500 ? "refused" : "retryable";
73
74
  var post = (options, operation, body) => postSignedNode(options, `v1/metal/computes/${operation}`, body);
74
75
  async function runClaim(options, claim) {
75
76
  const setTimer = options.setTimer ?? ((callback, ms) => setTimeout(callback, ms));
@@ -88,10 +89,11 @@ async function runClaim(options, claim) {
88
89
  if (stopped || renewal)
89
90
  return;
90
91
  let retry;
91
- renewal = post(options, "renew", {
92
+ const renew = () => post(options, "renew", {
92
93
  computeKey: claim.computeKey,
93
94
  claimToken: claim.claimToken
94
- }).then((response) => {
95
+ });
96
+ renewal = (options.telemetry ? options.telemetry.observe("provisioning.renew", renew, () => "success", remoteOutcome) : renew()).then((response) => {
95
97
  expires = response.claimExpiresAtTs;
96
98
  options.onEvent?.("lease-renewed", { computeKey: claim.computeKey, claimExpiresAtTs: expires });
97
99
  }).catch((cause) => {
@@ -112,7 +114,8 @@ async function runClaim(options, claim) {
112
114
  schedule();
113
115
  let result;
114
116
  try {
115
- result = await options.run(claim);
117
+ const run = () => options.run(claim);
118
+ result = options.telemetry ? await options.telemetry.observe("provisioning.apply", run) : await run();
116
119
  } finally {
117
120
  stopped = true;
118
121
  clearTimer(timer);
@@ -143,7 +146,8 @@ async function complete(options, body) {
143
146
  async function pullProvisioningOnce(options) {
144
147
  if (options.metalPreflight) {
145
148
  const report = options.metalPreflight();
146
- const accepted = await postSignedNode(options, "v1/metal/preflight", report);
149
+ const preflight = () => postSignedNode(options, "v1/metal/preflight", report);
150
+ const accepted = options.telemetry ? await options.telemetry.observe("provisioning.preflight", preflight, (value) => value.ready ? "success" : "refused", remoteOutcome) : await preflight();
147
151
  if (options.metalHostname && accepted.hostname !== options.metalHostname) {
148
152
  throw new Error(`metal agent: configured inventory hostname ${options.metalHostname} is bound as ${accepted.hostname}; refusing work.`);
149
153
  }
@@ -156,7 +160,8 @@ async function pullProvisioningOnce(options) {
156
160
  if (!accepted.ready)
157
161
  return { status: "idle" };
158
162
  }
159
- const response = await post(options, "claim", {});
163
+ const claimWork = () => post(options, "claim", {});
164
+ const response = options.telemetry ? await options.telemetry.observe("provisioning.claim", claimWork, (value) => value.claim ? "success" : "idle", remoteOutcome) : await claimWork();
160
165
  if (!response.claim)
161
166
  return { status: "idle" };
162
167
  const claim = response.claim;
@@ -167,20 +172,28 @@ async function pullProvisioningOnce(options) {
167
172
  if (cause instanceof ProvisionClaimLostError)
168
173
  throw cause;
169
174
  const reason = (cause instanceof Error ? cause.message : String(cause)).slice(0, 2000);
170
- await complete(options, {
175
+ const acknowledge2 = () => complete(options, {
171
176
  computeKey: claim.computeKey,
172
177
  claimToken: claim.claimToken,
173
178
  ok: false,
174
179
  detail: reason
175
180
  });
181
+ if (options.telemetry) {
182
+ await options.telemetry.observe("provisioning.complete", acknowledge2, () => "success", remoteOutcome);
183
+ } else
184
+ await acknowledge2();
176
185
  return { status: "failed", claim, reason };
177
186
  }
178
- await complete(options, {
187
+ const acknowledge = () => complete(options, {
179
188
  computeKey: claim.computeKey,
180
189
  claimToken: claim.claimToken,
181
190
  ok: true,
182
191
  ...result.guestAddress ? { guestAddress: result.guestAddress } : {}
183
192
  });
193
+ if (options.telemetry) {
194
+ await options.telemetry.observe("provisioning.complete", acknowledge, () => "success", remoteOutcome);
195
+ } else
196
+ await acknowledge();
184
197
  return { status: claim.action === "delete" ? "terminated" : "running", claim, result };
185
198
  }
186
199
  function startProvisioningPull(options) {
@@ -0,0 +1,20 @@
1
+ import type { AgentTelemetry, AgentTelemetryEvent, AgentTelemetryOperation, AgentTelemetryOutcome } from './telemetry';
2
+ /**
3
+ * Process-wide operation/state accounting layered over the deliberately small
4
+ * OTLP emitter. Callers still cannot attach arbitrary attributes or payloads.
5
+ */
6
+ export declare class AgentTelemetryRuntime {
7
+ private readonly telemetry;
8
+ private readonly now;
9
+ private active;
10
+ private completed;
11
+ private failed;
12
+ private draining;
13
+ constructor(telemetry: AgentTelemetry, now?: () => number);
14
+ event(event: AgentTelemetryEvent): void;
15
+ setDraining(draining: boolean): void;
16
+ observe<T>(operation: AgentTelemetryOperation, work: () => Promise<T>, outcome?: (value: T) => AgentTelemetryOutcome, errorOutcome?: (cause: unknown) => AgentTelemetryOutcome): Promise<T>;
17
+ close(): Promise<void>;
18
+ private recordState;
19
+ }
20
+ export type AgentOperationTelemetry = Pick<AgentTelemetryRuntime, 'observe'>;
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Dependency-free OTLP/HTTP telemetry for the machine Agent.
3
+ *
4
+ * Callers can name only reviewed operations, outcomes and lifecycle events.
5
+ * There is deliberately no field for a URL, request/response body, header,
6
+ * credential, tenant coordinate, command, error detail or arbitrary label.
7
+ */
8
+ export declare const AGENT_TELEMETRY_OPERATIONS: readonly ["agent.heartbeat", "agent.update", "attestation.refresh", "deployment.claim", "deployment.run", "deployment.renew", "deployment.complete", "migration.claim", "migration.run", "migration.renew", "migration.complete", "provisioning.preflight", "provisioning.claim", "provisioning.apply", "provisioning.renew", "provisioning.complete", "vault.sync", "agent.other"];
9
+ export type AgentTelemetryOperation = (typeof AGENT_TELEMETRY_OPERATIONS)[number];
10
+ export declare const AGENT_TELEMETRY_OUTCOMES: readonly ["success", "idle", "refused", "retryable", "failed"];
11
+ export type AgentTelemetryOutcome = (typeof AGENT_TELEMETRY_OUTCOMES)[number];
12
+ export declare const AGENT_TELEMETRY_EVENTS: readonly ["agent.started", "agent.draining", "agent.stopped", "agent.update_prepared", "agent.update_recovered", "telemetry.queue_overflow"];
13
+ export type AgentTelemetryEvent = (typeof AGENT_TELEMETRY_EVENTS)[number];
14
+ export interface AgentTelemetryConfig {
15
+ endpoint: string;
16
+ serviceName: string;
17
+ instanceId: string;
18
+ environment: 'production' | 'development';
19
+ flushIntervalMs: number;
20
+ traceSampleRatio: number;
21
+ }
22
+ export interface AgentOperationObservation {
23
+ operation: AgentTelemetryOperation;
24
+ outcome: AgentTelemetryOutcome;
25
+ startedAtMs: number;
26
+ durationMs: number;
27
+ }
28
+ export interface AgentTelemetryState {
29
+ active: number;
30
+ completed: number;
31
+ failed: number;
32
+ draining: boolean;
33
+ }
34
+ export interface AgentTelemetry {
35
+ recordOperation(observation: AgentOperationObservation): void;
36
+ recordEvent(event: AgentTelemetryEvent): void;
37
+ recordState(state: AgentTelemetryState): void;
38
+ flush(): Promise<void>;
39
+ close(): Promise<void>;
40
+ }
41
+ type FetchLike = (input: string | URL | Request, init?: RequestInit) => Promise<Response>;
42
+ export declare const AGENT_OTLP_EXPORT_TIMEOUT_MS = 5000;
43
+ export declare const AGENT_OTLP_MAX_ATTEMPTS = 2;
44
+ export declare const AGENT_OTLP_MAX_RETRY_DELAY_MS = 1000;
45
+ export declare const AGENT_TELEMETRY_MAX_SPANS = 256;
46
+ export declare const AGENT_TELEMETRY_MAX_LOGS = 64;
47
+ /** Resolve once at process startup; production refuses to run without an exporter. */
48
+ export declare function resolveAgentTelemetryConfig(env?: Record<string, string | undefined>): AgentTelemetryConfig | null;
49
+ export declare function createAgentTelemetry(input: AgentTelemetryConfig | null, options?: {
50
+ fetch?: FetchLike;
51
+ warn?: (message: string) => void;
52
+ now?: () => number;
53
+ random?: () => number;
54
+ wait?: (milliseconds: number) => Promise<void>;
55
+ }): AgentTelemetry;
56
+ export {};
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.32";
2
+ export declare const VERSION = "0.1.33";
package/package.json CHANGED
@@ -1,7 +1,6 @@
1
1
  {
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
2
  "name": "@forgezero/agent",
4
- "version": "0.1.32",
3
+ "version": "0.1.33",
5
4
  "type": "module",
6
5
  "scripts": {
7
6
  "check": "tsc --noEmit",
@@ -18,19 +17,20 @@
18
17
  "@noble/post-quantum": "^0.6.1"
19
18
  },
20
19
  "dependencies": {
21
- "@forgezero/runtime": "^0.1.4",
22
- "@forgezero/vault": "^0.1.7",
23
- "@noble/curves": "^2.2.0",
24
- "@noble/hashes": "^2.2.0",
25
- "@noble/post-quantum": "^0.6.1",
26
- "@scure/bip39": "^2.2.0"
20
+ "@forgezero/runtime": "0.1.5",
21
+ "@forgezero/vault": "0.1.8",
22
+ "@noble/curves": "2.2.0",
23
+ "@noble/hashes": "2.2.0",
24
+ "@noble/post-quantum": "0.6.1",
25
+ "@scure/bip39": "2.2.0"
27
26
  },
28
27
  "bin": {
29
28
  "fz-agent": "dist/fz-agent.js",
30
29
  "fz": "dist/fz.js"
31
30
  },
32
31
  "publishConfig": {
33
- "access": "public"
32
+ "access": "public",
33
+ "provenance": true
34
34
  },
35
35
  "files": [
36
36
  "dist",
@@ -53,7 +53,7 @@
53
53
  "repository": {
54
54
  "type": "git",
55
55
  "url": "git+https://github.com/forgezero-net/packages.git",
56
- "directory": "packages/agent"
56
+ "directory": "agent"
57
57
  },
58
58
  "bugs": "https://github.com/forgezero-net/packages/issues",
59
59
  "exports": {