@forgezero/agent 0.1.0 → 0.1.9

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 (46) hide show
  1. package/README.md +45 -2
  2. package/dist/attestation-client.d.ts +22 -0
  3. package/dist/attestation-client.test.d.ts +1 -0
  4. package/dist/compute.d.ts +122 -0
  5. package/dist/compute.js +150 -0
  6. package/dist/compute.test.d.ts +1 -0
  7. package/dist/control.d.ts +57 -0
  8. package/dist/control.test.d.ts +1 -0
  9. package/dist/definition.d.ts +34 -0
  10. package/dist/definition.js +159 -0
  11. package/dist/definition.test.d.ts +1 -0
  12. package/dist/deployment-pull.d.ts +60 -0
  13. package/dist/deployment-pull.test.d.ts +1 -0
  14. package/dist/deployment-runner.d.ts +23 -0
  15. package/dist/deployment-runner.js +199 -0
  16. package/dist/deployment-runner.test.d.ts +1 -0
  17. package/dist/deployment-watch.d.ts +36 -0
  18. package/dist/deployment-watch.test.d.ts +1 -0
  19. package/dist/deployment.d.ts +86 -0
  20. package/dist/deployment.test.d.ts +1 -0
  21. package/dist/fz-agent.js +2901 -155
  22. package/dist/guest-enrolment.d.ts +29 -0
  23. package/dist/guest-enrolment.js +88 -0
  24. package/dist/guest-enrolment.test.d.ts +1 -0
  25. package/dist/index.d.ts +50 -4
  26. package/dist/metal-helper-socket.d.ts +15 -0
  27. package/dist/metal-helper-socket.js +1123 -0
  28. package/dist/metal-helper-socket.test.d.ts +1 -0
  29. package/dist/metal-isolation.d.ts +14 -0
  30. package/dist/metal-isolation.test.d.ts +1 -0
  31. package/dist/metal-provision.d.ts +85 -0
  32. package/dist/metal-provision.js +1014 -0
  33. package/dist/metal-provision.test.d.ts +1 -0
  34. package/dist/node-vault.d.ts +24 -0
  35. package/dist/node-vault.js +211 -0
  36. package/dist/node-vault.test.d.ts +1 -0
  37. package/dist/provision.d.ts +50 -2
  38. package/dist/provision.js +286 -12
  39. package/dist/provisioning-pull.d.ts +75 -0
  40. package/dist/provisioning-pull.js +188 -0
  41. package/dist/provisioning-pull.test.d.ts +1 -0
  42. package/dist/signed-node-http.d.ts +14 -0
  43. package/dist/snp-attestation.d.ts +18 -0
  44. package/dist/snp-attestation.test.d.ts +1 -0
  45. package/dist/socket.d.ts +4 -23
  46. package/package.json +91 -71
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,24 @@
1
+ import type { NodeKeyPair } from '@forgezero/runtime/identity';
2
+ import { type SecretCache } from './cache';
3
+ import { type SignedNodeHttpOptions } from './signed-node-http';
4
+ export interface NodeVaultOptions extends Omit<SignedNodeHttpOptions, 'apiUrl'> {
5
+ /** Tenant node base, for example https://api.example/api/t/acme. */
6
+ apiUrl: string;
7
+ keys: NodeKeyPair;
8
+ ttlMs?: number;
9
+ maxStaleMs?: number;
10
+ }
11
+ /** Turn the public API origin into the realm-selected node endpoint. */
12
+ export declare function tenantNodeApiUrl(apiUrl: string, tenantSlug: string): string;
13
+ /** Project/environment selection is server-owned through the compute binding. */
14
+ export declare function createNodeVaultCache(options: NodeVaultOptions): SecretCache;
15
+ export interface NodeVaultSyncOptions {
16
+ intervalMs?: number;
17
+ setTimer?: (callback: () => void, ms: number) => unknown;
18
+ clearTimer?: (handle: unknown) => void;
19
+ onEvent?: (event: string, detail?: unknown) => void;
20
+ }
21
+ /** Poll rotation cursors without overlapping calls; stop waits for the active sync. */
22
+ export declare function startNodeVaultSync(cache: SecretCache, options?: NodeVaultSyncOptions): {
23
+ stop(): Promise<void>;
24
+ };
@@ -0,0 +1,211 @@
1
+ // src/cache.ts
2
+ class CacheError extends Error {
3
+ code;
4
+ constructor(code, message) {
5
+ super(message);
6
+ this.code = code;
7
+ this.name = "CacheError";
8
+ }
9
+ }
10
+ var DEFAULT_TTL_MS = 60000;
11
+ var DEFAULT_MAX_STALE_MS = 300000;
12
+ function createSecretCache(options) {
13
+ const entries = new Map;
14
+ const now = options.now ?? (() => Date.now());
15
+ const ttlMs = options.ttlMs ?? DEFAULT_TTL_MS;
16
+ const maxStaleMs = options.maxStaleMs ?? DEFAULT_MAX_STALE_MS;
17
+ let cursor = 0;
18
+ let replicated = false;
19
+ let lastSyncOkMs = now();
20
+ const loadScope = async () => {
21
+ if (!options.list)
22
+ return { loaded: 0, failed: [] };
23
+ const names = await options.list();
24
+ const failed = [];
25
+ let loaded = 0;
26
+ for (const name of names) {
27
+ try {
28
+ const result = await options.fetch(name);
29
+ entries.set(name, { value: result.value, version: result.version, fetchedAtMs: now() });
30
+ loaded += 1;
31
+ } catch {
32
+ failed.push(name);
33
+ }
34
+ }
35
+ replicated = true;
36
+ return { loaded, failed };
37
+ };
38
+ return {
39
+ names: () => [...entries.keys()],
40
+ get replica() {
41
+ return replicated;
42
+ },
43
+ load: loadScope,
44
+ get cursor() {
45
+ return cursor;
46
+ },
47
+ async get(name) {
48
+ const staleFor = now() - lastSyncOkMs;
49
+ if (staleFor > maxStaleMs) {
50
+ throw new CacheError("STALE", `Synchronisation has not succeeded for ${Math.round(staleFor / 1000)}s, so this ` + "cache can no longer vouch for what it holds. Refusing rather than serving a value " + "that may already be revoked.");
51
+ }
52
+ const cached = entries.get(name);
53
+ if (cached && now() - cached.fetchedAtMs < ttlMs)
54
+ return cached.value;
55
+ let fetched;
56
+ try {
57
+ fetched = await options.fetch(name);
58
+ } catch (cause) {
59
+ if (cached)
60
+ return cached.value;
61
+ throw new CacheError("FETCH_FAILED", cause instanceof Error ? cause.message : `Could not fetch ${name}.`);
62
+ }
63
+ entries.set(name, { ...fetched, fetchedAtMs: now() });
64
+ return fetched.value;
65
+ },
66
+ async sync() {
67
+ const result = await options.changes(cursor);
68
+ if (result.resync) {
69
+ const dropped = [...entries.keys()];
70
+ entries.clear();
71
+ cursor = 0;
72
+ lastSyncOkMs = now();
73
+ if (options.list)
74
+ await loadScope();
75
+ return { invalidated: dropped, cursor: 0, resync: true };
76
+ }
77
+ const invalidated = [];
78
+ for (const name of result.changed) {
79
+ if (entries.delete(name))
80
+ invalidated.push(name);
81
+ }
82
+ cursor = result.version;
83
+ lastSyncOkMs = now();
84
+ return { invalidated, cursor, resync: false };
85
+ },
86
+ clear() {
87
+ entries.clear();
88
+ cursor = 0;
89
+ },
90
+ staleForMs: () => now() - lastSyncOkMs
91
+ };
92
+ }
93
+
94
+ // src/signed-node-http.ts
95
+ import { signRequest } from "@forgezero/runtime/identity";
96
+
97
+ class SignedNodeHttpError extends Error {
98
+ status;
99
+ constructor(status, message) {
100
+ super(message);
101
+ this.status = status;
102
+ this.name = "SignedNodeHttpError";
103
+ }
104
+ }
105
+ var signatureHeader = (envelope) => Buffer.from(JSON.stringify({
106
+ timestamp: envelope.timestamp,
107
+ nonce: envelope.nonce,
108
+ edSignature: envelope.edSignature,
109
+ mlDsaSignature: envelope.mlDsaSignature
110
+ })).toString("base64url");
111
+ async function postSignedNode(options, path, body) {
112
+ const url = new URL(options.apiUrl);
113
+ url.pathname = `${url.pathname.replace(/\/$/, "")}/${path.replace(/^\//, "")}`.replace(/\/+/g, "/");
114
+ url.search = "";
115
+ url.hash = "";
116
+ const raw = JSON.stringify(body);
117
+ const envelope = signRequest(options.keys, options.nodeKey, {
118
+ method: "POST",
119
+ path: url.pathname,
120
+ query: "",
121
+ body: raw
122
+ });
123
+ const response = await (options.fetch ?? globalThis.fetch)(url, {
124
+ method: "POST",
125
+ headers: {
126
+ "content-type": "application/json",
127
+ "x-fz-node": options.nodeKey,
128
+ "x-fz-signature": signatureHeader(envelope)
129
+ },
130
+ body: raw,
131
+ signal: AbortSignal.timeout(options.requestTimeoutMs ?? 15000)
132
+ });
133
+ const payload = await response.json().catch(() => null);
134
+ if (!response.ok) {
135
+ const failure = payload;
136
+ const reason = failure ? failure.error?.message ?? failure.message : undefined;
137
+ throw new SignedNodeHttpError(response.status, reason || `signed node request returned HTTP ${response.status}`);
138
+ }
139
+ return payload;
140
+ }
141
+
142
+ // src/node-vault.ts
143
+ function tenantNodeApiUrl(apiUrl, tenantSlug) {
144
+ if (!/^[a-z0-9][a-z0-9-]{0,62}$/.test(tenantSlug))
145
+ throw new Error("tenant slug is malformed");
146
+ const url = new URL(apiUrl);
147
+ url.pathname = `/api/t/${encodeURIComponent(tenantSlug)}`;
148
+ url.search = "";
149
+ url.hash = "";
150
+ return url.toString().replace(/\/$/, "");
151
+ }
152
+ function createNodeVaultCache(options) {
153
+ const post = (operation, body) => postSignedNode(options, `v1/node/vault/${operation}`, body);
154
+ return createSecretCache({
155
+ ttlMs: options.ttlMs,
156
+ maxStaleMs: options.maxStaleMs,
157
+ list: async () => {
158
+ const payload = await post("list", {});
159
+ if (!Array.isArray(payload.names) || payload.names.some((name) => typeof name !== "string")) {
160
+ throw new Error("node vault list response is malformed");
161
+ }
162
+ return payload.names;
163
+ },
164
+ fetch: async (name) => {
165
+ const payload = await post("read", { name });
166
+ if (typeof payload.value !== "string" || !Number.isSafeInteger(payload.version)) {
167
+ throw new Error("node vault read response is malformed");
168
+ }
169
+ return { value: payload.value, version: payload.version };
170
+ },
171
+ changes: async (since) => {
172
+ const payload = await post("changes", { since });
173
+ if (!Number.isSafeInteger(payload.version) || !Array.isArray(payload.changed) || payload.changed.some((name) => typeof name !== "string"))
174
+ throw new Error("node vault changes response is malformed");
175
+ return { version: payload.version, changed: payload.changed, resync: payload.resync };
176
+ }
177
+ });
178
+ }
179
+ function startNodeVaultSync(cache, options = {}) {
180
+ const interval = Math.max(1000, options.intervalMs ?? 30000);
181
+ const setTimer = options.setTimer ?? ((callback, ms) => setTimeout(callback, ms));
182
+ const clearTimer = options.clearTimer ?? ((handle) => clearTimeout(handle));
183
+ let stopped = false;
184
+ let timer;
185
+ let active = null;
186
+ const schedule = () => {
187
+ if (!stopped)
188
+ timer = setTimer(tick, interval);
189
+ };
190
+ const tick = () => {
191
+ if (stopped || active)
192
+ return;
193
+ active = cache.sync().then((result) => options.onEvent?.("synced", result)).catch((cause) => options.onEvent?.("sync-failed", cause)).finally(() => {
194
+ active = null;
195
+ schedule();
196
+ });
197
+ };
198
+ schedule();
199
+ return {
200
+ async stop() {
201
+ stopped = true;
202
+ clearTimer(timer);
203
+ await active;
204
+ }
205
+ };
206
+ }
207
+ export {
208
+ tenantNodeApiUrl,
209
+ startNodeVaultSync,
210
+ createNodeVaultCache
211
+ };
@@ -0,0 +1 @@
1
+ export {};
@@ -55,8 +55,9 @@ export declare const CAPABILITY_CHECKS: Record<string, Check> & {
55
55
  snpGuest: Check;
56
56
  systemd: Check;
57
57
  bun: Check;
58
+ python: Check;
58
59
  };
59
- export type CapabilityId = 'snpGuest' | 'systemd' | 'bun';
60
+ export type CapabilityId = 'snpGuest' | 'systemd' | 'bun' | 'python';
60
61
  /** The answers, however they were gathered. */
61
62
  export type Capabilities = Record<CapabilityId, boolean>;
62
63
  /**
@@ -70,14 +71,57 @@ export declare const reasonFor: (mode: AgentMode) => string;
70
71
  export interface UnitOptions {
71
72
  mode: AgentMode;
72
73
  socketPath: string;
73
- seedPath: string;
74
+ /** Legacy/dev fallback only. Production uses `seedCredentialPath`. */
75
+ seedPath?: string;
76
+ /** Host-bound encrypted credential loaded by systemd for this unit only. */
77
+ seedCredentialPath?: string;
78
+ gitCredentialPath?: string;
79
+ /** Non-secret OpenSSH public half reported during compute enrolment. */
80
+ gitPublicKeyPath?: string;
81
+ /** Generate a unique Ed25519 deploy identity on this machine when absent. */
82
+ generateGitIdentity?: boolean;
83
+ controlSocketPath?: string;
84
+ repository?: string;
85
+ branch?: string;
86
+ role?: string;
87
+ deployRoot?: string;
88
+ publicApiUrl?: string;
89
+ /** Non-secret phase values, named explicitly instead of inheriting the unit environment. */
90
+ deploymentEnvironment?: Record<string, string>;
91
+ /** Pipeline secret name -> encrypted systemd credential source. */
92
+ deploymentCredentials?: Record<string, string>;
93
+ /** Enable authenticated outbound deployment claims after enrolment. */
94
+ pullDeployments?: boolean;
74
95
  /** Where `fz-agent` ended up. `bun add -g` puts it on PATH. */
75
96
  binPath?: string;
97
+ /** Package-owned binary copied to binPath before hardened units start. */
98
+ sourceBinPath?: string;
76
99
  user?: string;
77
100
  apiUrl?: string;
78
101
  project?: string;
79
102
  environment?: string;
103
+ /** One-time direct-compute enrolment, consumed before the long-running agent. */
104
+ enrolTokenSourcePath?: string;
105
+ enrolTokenCredentialPath?: string;
106
+ enrolStatePath?: string;
107
+ nodeLabel?: string;
80
108
  }
109
+ export declare const DEPLOYMENT_RUNNER_USER = "forgezero-runner";
110
+ export declare const DEPLOYMENT_GROUP = "forgezero-deploy";
111
+ export declare const DEPLOYMENT_RUNNER_UNIT_PATH = "/etc/systemd/system/forgezero-deploy-runner.service";
112
+ export declare const DEPLOYMENT_RUNNER_SOCKET_UNIT_PATH = "/etc/systemd/system/forgezero-deploy-runner.socket";
113
+ export declare const DEPLOYMENT_RUNNER_SOCKET = "/run/forgezero-deploy/runner.sock";
114
+ export declare const ENROLMENT_UNIT_PATH = "/etc/systemd/system/forgezero-agent-enrol.service";
115
+ /**
116
+ * Exchange a tenant-issued one-time capability before the durable agent starts.
117
+ *
118
+ * The main unit never names the capability, so deleting the encrypted blob
119
+ * after success cannot break a later reboot. A durable non-secret binding makes
120
+ * the oneshot skip; a failed exchange keeps the encrypted token for a retry.
121
+ */
122
+ export declare function agentEnrolmentUnit(options: UnitOptions): string;
123
+ export declare function deploymentRunnerSocketUnit(agentUser: string): string;
124
+ export declare function deploymentRunnerUnit(options: Pick<UnitOptions, 'binPath' | 'deployRoot'>): string;
81
125
  /**
82
126
  * A systemd unit for the agent.
83
127
  *
@@ -100,6 +144,10 @@ export interface ProvisionPlan {
100
144
  reason: string;
101
145
  unitPath: string;
102
146
  unit: string;
147
+ auxiliaryUnits: readonly {
148
+ path: string;
149
+ unit: string;
150
+ }[];
103
151
  socketPath: string;
104
152
  user: string;
105
153
  steps: readonly Step[];