@forgezero/agent 0.1.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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 ForgeZero
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,90 @@
1
+ # @forgezero/agent
2
+
3
+ **`fz-agent` — so the application beside it holds no credential at all.**
4
+
5
+ The agent keeps a project-scoped copy of your vault in RAM and answers over a
6
+ unix socket. `@forgezero/vault` prefers that socket over an API key whenever it
7
+ exists, so an application on a machine running the agent reads its secrets
8
+ because of *where it is*, not because it holds something that could be stolen
9
+ from it.
10
+
11
+ ```bash
12
+ bun add -g @forgezero/agent # or let `fz agent install` do it
13
+ ```
14
+
15
+ Most people never install this directly:
16
+
17
+ ```bash
18
+ fz agent install --apply # writes a hardened systemd unit
19
+ ```
20
+
21
+ ## What changes for an application
22
+
23
+ Nothing.
24
+
25
+ ```ts
26
+ import { ForgeZero } from '@forgezero/vault';
27
+ const fz = new ForgeZero({ project: 'altpilot', environment: 'production' });
28
+ await fz.get('STRIPE_KEY');
29
+ ```
30
+
31
+ With no agent, that needs `FORGEZERO_API_KEY` in the environment. With the
32
+ agent, it does not — and the key that would have been in the environment does
33
+ not exist on the box.
34
+
35
+ ## A local copy, not a cache
36
+
37
+ The agent holds the **whole assigned project + environment**, not just what has
38
+ already been read. That is the difference between surviving an outage and not: a
39
+ cache only has what you fetched, so a secret you have never read is exactly the
40
+ one you cannot get when the platform is unreachable.
41
+
42
+ The cost, stated rather than buried: a compromised guest exposes everything in
43
+ scope rather than only what was read. It is bounded by the scope the compute is
44
+ assigned to, and by SEV-SNP keeping the host out of guest memory.
45
+
46
+ **Never on disk.** A cache file would hand an attacker with filesystem access
47
+ every secret this guest has ever held — the exact artefact the design exists to
48
+ remove. A restart re-fetches.
49
+
50
+ **Invalidated by cursor, not by a timer.** A TTL alone means a rotated secret
51
+ keeps working for the length of the TTL, which is the window rotation exists to
52
+ close. The agent polls a change cursor and drops what moved.
53
+
54
+ **A stale read is refused, not served.** If sync has not succeeded within the
55
+ staleness bound, `get` fails rather than returning a value it can no longer
56
+ vouch for. An application that receives a revoked credential and succeeds with
57
+ it is worse off than one that receives an error — the error is visible.
58
+
59
+ ## Two postures, decided by the hardware
60
+
61
+ | | |
62
+ |---|---|
63
+ | `attested` | SEV-SNP guest. The credential is a hardware report, and the platform can refuse a node whose measurement is wrong. |
64
+ | `enrolled` | No SNP. The credential is the enrolment token plus a hybrid Ed25519 + ML-DSA-65 signature. |
65
+
66
+ Both are real. `enrolled` is still strictly better than an API key in the
67
+ application: the key never leaves the agent, rotation reaches every process, and
68
+ the socket is filesystem-scoped.
69
+
70
+ ## The socket interface
71
+
72
+ ```
73
+ identity who this node is
74
+ sign sign a request with the node key
75
+ attest a hardware report, or a refusal — never a fake
76
+ get one secret
77
+ sync what changed since a cursor
78
+ held what this guest is holding, by name only
79
+ ```
80
+
81
+ `attest` **refuses** when no source is configured rather than returning
82
+ something attestation-shaped. An operator who believes they have an attestation
83
+ when nothing produced one is worse off than one told plainly it is unavailable.
84
+
85
+ Full documentation: **https://forgezero.net/docs/agent**
86
+
87
+ ## Licence
88
+
89
+ MIT. Part of [ForgeZero](https://forgezero.net) — secrets, attested compute and
90
+ deploys.
@@ -0,0 +1,124 @@
1
+ /**
2
+ * The agent's secret cache, and why it never touches disk.
3
+ *
4
+ * An application on managed compute asks the agent for a value rather than the
5
+ * platform, so a rotation reaches every process on the box without any of them
6
+ * holding a credential to fetch it with. That only helps if the cache is
7
+ * cheaper than the call it replaces AND does not create the very artefact the
8
+ * design exists to remove.
9
+ *
10
+ * ## Memory only, deliberately
11
+ *
12
+ * Writing the cache to disk would give an attacker with filesystem access every
13
+ * secret this guest has ever read — which is precisely the file that does not
14
+ * exist today, and the entire reason a node holds a signing key instead of a
15
+ * `.env`. A restart re-fetches. That costs one round trip and removes a class of
16
+ * compromise; there is no version of "just persist it, it is encrypted" that
17
+ * survives the key also being on the box.
18
+ *
19
+ * ## Invalidation is driven by the cursor, not by a timer
20
+ *
21
+ * A TTL alone means a rotated secret keeps working for the length of the TTL —
22
+ * which is exactly the window rotation exists to close. `sync()` polls the
23
+ * platform's `/changes` cursor and drops what moved, so a rotation propagates at
24
+ * the poll interval regardless of TTL. The TTL is the backstop for the case
25
+ * where sync itself has stopped, and it is short for that reason.
26
+ *
27
+ * ## A stale read is refused, not served
28
+ *
29
+ * If sync has not succeeded within the staleness bound, `get` refuses rather
30
+ * than serving a value it can no longer vouch for. An application that receives
31
+ * a revoked credential and succeeds with it is worse off than one that receives
32
+ * an error: the error is visible, the success is not.
33
+ */
34
+ export declare class CacheError extends Error {
35
+ readonly code: 'STALE' | 'NOT_CACHED' | 'FETCH_FAILED';
36
+ constructor(code: 'STALE' | 'NOT_CACHED' | 'FETCH_FAILED', message: string);
37
+ }
38
+ export interface CacheOptions {
39
+ /**
40
+ * Fetches one value from the platform. Injected so the cache holds no
41
+ * transport and a test needs no network.
42
+ */
43
+ fetch(name: string): Promise<{
44
+ value: string;
45
+ version: number;
46
+ }>;
47
+ /**
48
+ * Names changed since a cursor, and the new cursor.
49
+ *
50
+ * `resync` means the caller's cursor is unusable — a restored backup or a
51
+ * moved clock — and everything is dropped rather than a gap being left
52
+ * exactly where a rotation was.
53
+ */
54
+ changes(since: number): Promise<{
55
+ version: number;
56
+ changed: string[];
57
+ resync?: boolean;
58
+ }>;
59
+ /**
60
+ * Every name in the assigned scope. Present means REPLICA mode.
61
+ *
62
+ * Absent means the old behaviour — fetch on demand, hold what was asked for.
63
+ * Present means the agent pulls the whole project+environment at startup and
64
+ * on every resync, so an application reads with no round trip and keeps
65
+ * working while the platform is unreachable.
66
+ *
67
+ * That is a deliberate widening of what a compromised guest exposes: not
68
+ * "whatever this app read" but "everything in scope". It is bounded by the
69
+ * scope the compute is assigned and by SEV-SNP keeping the host out of guest
70
+ * memory, and it is the point of the agent — a local copy that survives the
71
+ * API being down is not a cache, and a cache that only has what you already
72
+ * fetched does not survive anything.
73
+ */
74
+ list?(): Promise<readonly string[]>;
75
+ /** Backstop for a sync that has stopped. Short on purpose. */
76
+ ttlMs?: number;
77
+ /** Refuse to serve anything if sync has not succeeded within this. */
78
+ maxStaleMs?: number;
79
+ now?: () => number;
80
+ }
81
+ export declare function createSecretCache(options: CacheOptions): {
82
+ /** For an operator asking what this guest is holding. Names, never values. */
83
+ names: () => string[];
84
+ /** True once the whole scope is resident. False in on-demand mode. */
85
+ readonly replica: boolean;
86
+ /**
87
+ * Load the whole assigned scope into memory.
88
+ *
89
+ * Called at startup and again after a `resync`. A resync means the cursor
90
+ * is unusable, so the alternative is an empty cache that refills lazily —
91
+ * which is exactly the availability the replica exists to provide, lost at
92
+ * the moment something already went wrong.
93
+ */
94
+ load: () => Promise<{
95
+ loaded: number;
96
+ failed: string[];
97
+ }>;
98
+ readonly cursor: number;
99
+ /**
100
+ * One value, from cache when it is fresh enough to vouch for.
101
+ *
102
+ * The staleness check runs BEFORE the cache lookup. Serving a cached value
103
+ * while sync is broken is the failure this guards, so checking after the
104
+ * lookup would mean the hit path — the common one — skipped the guard.
105
+ */
106
+ get(name: string): Promise<string>;
107
+ /**
108
+ * Poll for changes and drop what moved.
109
+ *
110
+ * Returns what it invalidated so a caller can log a rotation actually
111
+ * arriving — "sync ran" and "sync did something" are different facts, and
112
+ * only the second one tells you rotation works.
113
+ */
114
+ sync(): Promise<{
115
+ invalidated: string[];
116
+ cursor: number;
117
+ resync: boolean;
118
+ }>;
119
+ /** Drop everything. Used on revocation, and by tests. */
120
+ clear(): void;
121
+ /** For the journal: how long since sync last succeeded. */
122
+ staleForMs: () => number;
123
+ };
124
+ export type SecretCache = ReturnType<typeof createSecretCache>;
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,392 @@
1
+ #!/usr/bin/env bun
2
+ // @bun
3
+
4
+ // src/index.ts
5
+ import { randomBytes } from "crypto";
6
+ import { readFileSync, writeFileSync, existsSync as existsSync2, mkdirSync, chmodSync as chmodSync2 } from "fs";
7
+ import { dirname } from "path";
8
+ import { deriveKeysFromSeed } from "@forgezero/runtime/identity";
9
+ import { DEFAULT_SOCKET } from "@forgezero/vault";
10
+
11
+ // src/socket.ts
12
+ import { createServer } from "net";
13
+ import { chmodSync, existsSync, unlinkSync } from "fs";
14
+ import { signRequest } from "@forgezero/runtime/identity";
15
+
16
+ // src/pipeline.ts
17
+ class PipelineError extends Error {
18
+ code;
19
+ constructor(code, message) {
20
+ super(message);
21
+ this.code = code;
22
+ this.name = "PipelineError";
23
+ }
24
+ }
25
+ function redact(text, values) {
26
+ let out = text;
27
+ for (const value of [...values].sort((a, b) => b.length - a.length)) {
28
+ if (value.length < 8)
29
+ continue;
30
+ out = out.split(value).join("\u2022\u2022\u2022\u2022redacted\u2022\u2022\u2022\u2022");
31
+ }
32
+ return out;
33
+ }
34
+ async function runPipeline(options) {
35
+ const now = options.now ?? (() => Date.now());
36
+ const { pipeline } = options;
37
+ let assurance = "enrolled";
38
+ if (options.attest) {
39
+ try {
40
+ await options.attest();
41
+ assurance = "attested";
42
+ } catch (cause) {
43
+ if (pipeline.requireAttestation) {
44
+ throw new PipelineError("ATTESTATION_FAILED", `${pipeline.name} requires attestation and this machine could not produce one: ${cause.message}`);
45
+ }
46
+ }
47
+ } else if (pipeline.requireAttestation) {
48
+ throw new PipelineError("ATTESTATION_REQUIRED", `${pipeline.name} requires attestation. This agent has no attestation source, so it cannot run it.`);
49
+ }
50
+ const steps = [];
51
+ let failed = false;
52
+ for (const step of pipeline.steps) {
53
+ if (failed && !step.always) {
54
+ steps.push({ name: step.name, outcome: "skipped", exitCode: null, log: "", durationMs: 0 });
55
+ continue;
56
+ }
57
+ const env = {};
58
+ const values = [];
59
+ for (const name of step.secrets ?? []) {
60
+ try {
61
+ const value = await options.secret(name);
62
+ env[name] = value;
63
+ values.push(value);
64
+ } catch {
65
+ throw new PipelineError("SECRET_MISSING", `step "${step.name}" needs ${name}, which is not in this compute's scope.`);
66
+ }
67
+ }
68
+ const started = now();
69
+ let exitCode;
70
+ let output;
71
+ try {
72
+ const result = await options.exec({ command: step.run, env, timeoutMs: step.timeoutMs });
73
+ exitCode = result.exitCode;
74
+ output = result.output;
75
+ } catch (cause) {
76
+ exitCode = -1;
77
+ output = cause.message;
78
+ }
79
+ const outcome = exitCode === 0 ? "ok" : "failed";
80
+ if (outcome === "failed")
81
+ failed = true;
82
+ steps.push({
83
+ name: step.name,
84
+ outcome,
85
+ exitCode,
86
+ log: redact(output, values),
87
+ durationMs: now() - started
88
+ });
89
+ }
90
+ return { pipeline: pipeline.name, ok: !failed, assurance, steps };
91
+ }
92
+
93
+ // src/socket.ts
94
+ var MAX_LINE_BYTES = 64 * 1024;
95
+ function handleRequest(options, request) {
96
+ switch (request?.op) {
97
+ case "identity":
98
+ return Promise.resolve({
99
+ ok: true,
100
+ op: "identity",
101
+ nodeKey: options.nodeKey,
102
+ publicKeys: {
103
+ publicKey: options.keys.ed25519.publicKey,
104
+ secretKey: "",
105
+ mlDsa: options.keys.mlDsa.publicKey
106
+ }
107
+ });
108
+ case "sign": {
109
+ if (typeof request.method !== "string" || typeof request.path !== "string") {
110
+ return Promise.resolve(refuse("BAD_REQUEST", "A signature needs a method and a path."));
111
+ }
112
+ return Promise.resolve({
113
+ ok: true,
114
+ op: "sign",
115
+ envelope: signRequest(options.keys, options.nodeKey, {
116
+ method: request.method,
117
+ path: request.path,
118
+ query: request.query ?? "",
119
+ body: request.body ?? ""
120
+ })
121
+ });
122
+ }
123
+ case "get": {
124
+ if (!options.cache) {
125
+ return Promise.resolve(refuse("NO_CACHE", "This agent holds no secrets. It signs; it does not serve values."));
126
+ }
127
+ if (typeof request.name !== "string" || !request.name) {
128
+ return Promise.resolve(refuse("BAD_REQUEST", "A read needs a name."));
129
+ }
130
+ return options.cache.get(request.name).then((value) => ({ ok: true, op: "get", value })).catch((cause) => refuse(cause.code ?? "READ_FAILED", cause instanceof Error ? cause.message : "Could not read that value."));
131
+ }
132
+ case "sync": {
133
+ if (!options.cache) {
134
+ return Promise.resolve(refuse("NO_CACHE", "This agent holds no secrets to synchronise."));
135
+ }
136
+ return options.cache.sync().then((result) => ({ ok: true, op: "sync", ...result })).catch((cause) => refuse("SYNC_FAILED", cause instanceof Error ? cause.message : "Synchronisation failed."));
137
+ }
138
+ case "held": {
139
+ if (!options.cache) {
140
+ return Promise.resolve({
141
+ ok: true,
142
+ op: "held",
143
+ names: [],
144
+ staleForMs: 0
145
+ });
146
+ }
147
+ return Promise.resolve({
148
+ ok: true,
149
+ op: "held",
150
+ names: options.cache.names(),
151
+ staleForMs: options.cache.staleForMs()
152
+ });
153
+ }
154
+ case "run": {
155
+ if (!options.cache) {
156
+ return Promise.resolve(refuse("NO_SCOPE", "This agent holds no project scope, so it cannot run a pipeline."));
157
+ }
158
+ if (!options.exec) {
159
+ return Promise.resolve(refuse("PIPELINE_DISABLED", "This agent was not started with an executor, so it will not run pipelines."));
160
+ }
161
+ const cache = options.cache;
162
+ const attestation = options.attestation;
163
+ return runPipeline({
164
+ pipeline: request.pipeline,
165
+ secret: (name) => cache.get(name),
166
+ exec: options.exec,
167
+ attest: attestation ? async () => ({ report: await attestation.report(""), source: attestation.name }) : undefined
168
+ }).then((result) => ({ ok: true, op: "run", result })).catch((cause) => refuse(cause.code ?? "PIPELINE_FAILED", cause.message));
169
+ }
170
+ case "attest": {
171
+ if (!options.attestation) {
172
+ return Promise.resolve(refuse("ATTESTATION_UNAVAILABLE", "No attestation source is configured on this guest. Attestation is refused rather " + "than faked, because a caller that believes it verified one when nothing did is " + "worse off than one told plainly it is unavailable."));
173
+ }
174
+ const nonce = request.nonce ?? "";
175
+ if (!nonce) {
176
+ return Promise.resolve(refuse("NONCE_REQUIRED", "An attestation report needs a nonce."));
177
+ }
178
+ return options.attestation.report(nonce).then((report) => ({
179
+ ok: true,
180
+ op: "attest",
181
+ report,
182
+ source: options.attestation.name
183
+ })).catch((cause) => refuse("ATTESTATION_FAILED", cause instanceof Error ? cause.message : "Attestation failed."));
184
+ }
185
+ default:
186
+ return Promise.resolve(refuse("UNKNOWN_OP", `No such operation. The socket signs; it does not hand over keys.`));
187
+ }
188
+ }
189
+ var refuse = (code, message) => ({
190
+ ok: false,
191
+ error: { code, message }
192
+ });
193
+ function startAgent(options) {
194
+ if (existsSync(options.socketPath))
195
+ unlinkSync(options.socketPath);
196
+ const server = createServer((socket) => {
197
+ let buffer = "";
198
+ socket.on("data", (chunk) => {
199
+ buffer += chunk.toString("utf8");
200
+ if (buffer.length > MAX_LINE_BYTES) {
201
+ socket.end(`${JSON.stringify(refuse("TOO_LARGE", "Request too large."))}
202
+ `);
203
+ buffer = "";
204
+ return;
205
+ }
206
+ let newline = buffer.indexOf(`
207
+ `);
208
+ while (newline !== -1) {
209
+ const line = buffer.slice(0, newline);
210
+ buffer = buffer.slice(newline + 1);
211
+ respond(options, socket, line);
212
+ newline = buffer.indexOf(`
213
+ `);
214
+ }
215
+ });
216
+ socket.on("error", () => socket.destroy());
217
+ });
218
+ server.listen(options.socketPath, () => {
219
+ chmodSync(options.socketPath, 384);
220
+ });
221
+ return server;
222
+ }
223
+ async function respond(options, socket, line) {
224
+ if (!line.trim())
225
+ return;
226
+ let response;
227
+ try {
228
+ response = await handleRequest(options, JSON.parse(line));
229
+ } catch (cause) {
230
+ response = refuse("MALFORMED", cause instanceof Error ? cause.message : "Malformed request.");
231
+ }
232
+ options.record?.({
233
+ op: JSON.parse(safeOp(line)).op ?? "unknown",
234
+ outcome: response.ok ? "ok" : "refused",
235
+ detail: response.ok ? undefined : response.error.code
236
+ });
237
+ socket.write(`${JSON.stringify(response)}
238
+ `);
239
+ }
240
+ function safeOp(line) {
241
+ try {
242
+ const parsed = JSON.parse(line);
243
+ return JSON.stringify({ op: typeof parsed.op === "string" ? parsed.op : "unknown" });
244
+ } catch {
245
+ return JSON.stringify({ op: "unparseable" });
246
+ }
247
+ }
248
+ // src/cache.ts
249
+ class CacheError extends Error {
250
+ code;
251
+ constructor(code, message) {
252
+ super(message);
253
+ this.code = code;
254
+ this.name = "CacheError";
255
+ }
256
+ }
257
+ var DEFAULT_TTL_MS = 60000;
258
+ var DEFAULT_MAX_STALE_MS = 300000;
259
+ function createSecretCache(options) {
260
+ const entries = new Map;
261
+ const now = options.now ?? (() => Date.now());
262
+ const ttlMs = options.ttlMs ?? DEFAULT_TTL_MS;
263
+ const maxStaleMs = options.maxStaleMs ?? DEFAULT_MAX_STALE_MS;
264
+ let cursor = 0;
265
+ let replicated = false;
266
+ let lastSyncOkMs = now();
267
+ const loadScope = async () => {
268
+ if (!options.list)
269
+ return { loaded: 0, failed: [] };
270
+ const names = await options.list();
271
+ const failed = [];
272
+ let loaded = 0;
273
+ for (const name of names) {
274
+ try {
275
+ const result = await options.fetch(name);
276
+ entries.set(name, { value: result.value, version: result.version, fetchedAtMs: now() });
277
+ loaded += 1;
278
+ } catch {
279
+ failed.push(name);
280
+ }
281
+ }
282
+ replicated = true;
283
+ return { loaded, failed };
284
+ };
285
+ return {
286
+ names: () => [...entries.keys()],
287
+ get replica() {
288
+ return replicated;
289
+ },
290
+ load: loadScope,
291
+ get cursor() {
292
+ return cursor;
293
+ },
294
+ async get(name) {
295
+ const staleFor = now() - lastSyncOkMs;
296
+ if (staleFor > maxStaleMs) {
297
+ 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.");
298
+ }
299
+ const cached = entries.get(name);
300
+ if (cached && now() - cached.fetchedAtMs < ttlMs)
301
+ return cached.value;
302
+ let fetched;
303
+ try {
304
+ fetched = await options.fetch(name);
305
+ } catch (cause) {
306
+ if (cached)
307
+ return cached.value;
308
+ throw new CacheError("FETCH_FAILED", cause instanceof Error ? cause.message : `Could not fetch ${name}.`);
309
+ }
310
+ entries.set(name, { ...fetched, fetchedAtMs: now() });
311
+ return fetched.value;
312
+ },
313
+ async sync() {
314
+ const result = await options.changes(cursor);
315
+ if (result.resync) {
316
+ const dropped = [...entries.keys()];
317
+ entries.clear();
318
+ cursor = 0;
319
+ lastSyncOkMs = now();
320
+ if (options.list)
321
+ await loadScope();
322
+ return { invalidated: dropped, cursor: 0, resync: true };
323
+ }
324
+ const invalidated = [];
325
+ for (const name of result.changed) {
326
+ if (entries.delete(name))
327
+ invalidated.push(name);
328
+ }
329
+ cursor = result.version;
330
+ lastSyncOkMs = now();
331
+ return { invalidated, cursor, resync: false };
332
+ },
333
+ clear() {
334
+ entries.clear();
335
+ cursor = 0;
336
+ },
337
+ staleForMs: () => now() - lastSyncOkMs
338
+ };
339
+ }
340
+
341
+ // src/index.ts
342
+ var VERSION = "0.1.0";
343
+ function loadOrCreateSeed(path) {
344
+ if (existsSync2(path)) {
345
+ const seed2 = new Uint8Array(Buffer.from(readFileSync(path, "utf8").trim(), "base64url"));
346
+ if (seed2.length < 32) {
347
+ throw new Error(`agent: the seed at ${path} is too short to derive a key from.`);
348
+ }
349
+ return seed2;
350
+ }
351
+ mkdirSync(dirname(path), { recursive: true });
352
+ const seed = new Uint8Array(randomBytes(32));
353
+ writeFileSync(path, Buffer.from(seed).toString("base64url"), { mode: 384 });
354
+ chmodSync2(path, 384);
355
+ return seed;
356
+ }
357
+ var DEFAULT_SOCKET_PATH = DEFAULT_SOCKET;
358
+ var DEFAULT_SEED_PATH = "/var/lib/forgezero/node.seed";
359
+ function runAgent(config = {}) {
360
+ const seedPath = config.seedPath ?? DEFAULT_SEED_PATH;
361
+ const keys = deriveKeysFromSeed(loadOrCreateSeed(seedPath));
362
+ const nodeKey = config.nodeKey ?? keys.ed25519.publicKey;
363
+ const server = startAgent({
364
+ socketPath: config.socketPath ?? DEFAULT_SOCKET_PATH,
365
+ keys,
366
+ nodeKey,
367
+ attestation: config.attestation,
368
+ cache: config.cache,
369
+ record: config.record
370
+ });
371
+ return { server, keys, nodeKey };
372
+ }
373
+ if (import.meta.main) {
374
+ const { nodeKey } = runAgent({
375
+ socketPath: process.env.FZ_SOCKET_PATH ?? DEFAULT_SOCKET_PATH,
376
+ seedPath: process.env.FZ_SEED_PATH ?? DEFAULT_SEED_PATH,
377
+ nodeKey: process.env.FZ_NODE_KEY,
378
+ record: (entry) => console.log(`[agent] ${entry.op} ${entry.outcome}${entry.detail ? ` ${entry.detail}` : ""}`)
379
+ });
380
+ console.log(`[agent] ${VERSION} signing as ${nodeKey}`);
381
+ }
382
+ export {
383
+ startAgent,
384
+ runAgent,
385
+ loadOrCreateSeed,
386
+ handleRequest,
387
+ createSecretCache,
388
+ VERSION,
389
+ DEFAULT_SOCKET_PATH,
390
+ DEFAULT_SEED_PATH,
391
+ CacheError
392
+ };