@zenera/engine 0.0.0-stage → 1.1.39

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/engine.js ADDED
@@ -0,0 +1,315 @@
1
+ import { AgentRunner, FilePayloadStore, SANDBOX_MOUNT, SKILLS_MOUNT, Workspace, assertState, buildRunReport, exaTools, fileTools, lastText, loadProject, memoryDir, readProjectConfig, renderReportHtml, sandboxTools, turns, } from '@zenera/neo';
2
+ import { existsSync } from 'node:fs';
3
+ import { writeFile } from 'node:fs/promises';
4
+ import { resolve } from 'node:path';
5
+ import { pathToFileURL } from 'node:url';
6
+ import { auditModels, describeIssue } from "./audit.js";
7
+ import { loadProjectEnv } from "./env.js";
8
+ import { EXIT, EngineError, invalidError, stderrLogger } from "./errors.js";
9
+ import { readJson, writeJson } from "./home.js";
10
+ import { KeyStore, assertUsable } from "./keys.js";
11
+ import { projectMounts } from "./projects.js";
12
+ import { buildSandbox, prepareHome, teardown, usesSandbox, } from "./sandbox.js";
13
+ import { acquire, createRun, readSessionMeta, writeRunMeta, writeSessionMeta, } from "./session.js";
14
+ /**
15
+ * Wires the library to a session directory. Every store is file-backed and
16
+ * rooted inside `.data/`, so a session is self-contained: copy the directory
17
+ * and the whole conversation, its memory and its blobs travel with it.
18
+ */
19
+ export async function openEngine(opts) {
20
+ const logger = opts.logger ?? stderrLogger;
21
+ const onIssue = opts.onIssue ?? ((issue) => logger.warn(describeIssue(issue)));
22
+ // Before the keyring, so the project's own file wins over the machine's
23
+ // keys, and before the load, so `${VAR}` in agents.yaml can name anything
24
+ // in it. The real environment still beats both.
25
+ const env = loadProjectEnv(opts.project.dir);
26
+ const keys = await KeyStore.open();
27
+ keys.materialize();
28
+ assertUsable(keys);
29
+ // Said before the load, because the load stops at the first model it cannot
30
+ // build: one SDK's words about one model, when the useful answer is which
31
+ // of the project's models are reachable and which are not.
32
+ for (const issue of auditModels(opts.project.dir, keys)) {
33
+ if (!opts.quiet) {
34
+ onIssue(issue);
35
+ }
36
+ }
37
+ const meta = await readSessionMeta(opts.session);
38
+ const workspace = resolve(meta.workspace);
39
+ const payloads = new FilePayloadStore({ dir: opts.session.blobs, id: 'file' });
40
+ // The config is read before the project is loaded because the sandbox is
41
+ // configured by it and the tools have to exist before selectors can be
42
+ // resolved against them. They are always registered, whether or not anyone
43
+ // selects them: a project that names `sandbox:*` against an empty tool list
44
+ // fails to load with "unknown tool", which would be a lie.
45
+ let sandbox;
46
+ let project;
47
+ let files;
48
+ let memory;
49
+ try {
50
+ const { root, config } = readProjectConfig(opts.project.dir);
51
+ // Naming a directory declares the graph, but it cannot bind anyone to
52
+ // it — so say when the run is pointed at a memory no agent will touch.
53
+ if (opts.memoryDir && !opts.quiet && !config.agents.some((a) => a.memory)) {
54
+ logger.warn('--memory names a directory no agent uses — none has `memory: true`');
55
+ }
56
+ memory = memoryDir(root, config, opts.memoryDir);
57
+ // Assets and the skill catalog are mounted for both, under one name.
58
+ const mounts = projectMounts(root, config, opts.memoryDir);
59
+ sandbox = buildSandbox({
60
+ config,
61
+ root,
62
+ session: opts.session,
63
+ workspace,
64
+ readOnly: opts.readOnly,
65
+ image: opts.image,
66
+ keys: opts.keys,
67
+ env: env?.names,
68
+ mounts,
69
+ ...(opts.disposable ? { persist: false } : {}),
70
+ });
71
+ const workspaceOptions = {
72
+ root: workspace,
73
+ readOnly: opts.readOnly,
74
+ mount: sandbox.spec.workdir ?? SANDBOX_MOUNT,
75
+ mounts,
76
+ };
77
+ // The same view the file tools have, so a path the agent just wrote
78
+ // resolves the same way when it asks memory to keep a copy.
79
+ files = new Workspace(workspaceOptions);
80
+ project = await loadProject(opts.project.dir, {
81
+ tools: [
82
+ // Both groups are pointed at one directory, so both are told the
83
+ // one name for it: whatever `run_command` prints a path as,
84
+ // `read_file` accepts.
85
+ ...fileTools(workspaceOptions),
86
+ ...sandboxTools(sandbox.pool),
87
+ // Registered whether or not a key exists: the credential is
88
+ // read when a tool is called, so a project that names `exa:*`
89
+ // loads on a machine that cannot yet search, and says so on the
90
+ // turn that tried.
91
+ ...exaTools(),
92
+ ],
93
+ skillsAt: SKILLS_MOUNT,
94
+ memory: opts.memory,
95
+ memoryDir: opts.memoryDir,
96
+ memoryReadOnly: opts.memoryReadOnly,
97
+ payloads,
98
+ resolveFile: (path) => files.within(path),
99
+ });
100
+ }
101
+ catch (err) {
102
+ throw invalidError(err instanceof Error ? err.message : String(err), `while loading ${opts.project.dir}`);
103
+ }
104
+ // Asked here rather than at the first `run_command`, so a host with no
105
+ // container engine costs an error instead of a round trip. Nothing is
106
+ // started: the container itself waits until something actually runs.
107
+ if (usesSandbox(project)) {
108
+ prepareHome(sandbox);
109
+ await opts.preflight?.(sandbox, opts.yes);
110
+ }
111
+ // `recordRequests` is what makes the report show the exact bytes sent
112
+ // rather than a reconstruction. It costs state size, and a CLI run is
113
+ // written to disk once and inspected by a human — exactly the case the
114
+ // library leaves it off for by default.
115
+ //
116
+ // `--model` becomes the runner's fallback rather than an alias, so it
117
+ // overrides the config's `model:` without touching agents that pinned one.
118
+ const runner = project.runner({
119
+ recordRequests: true,
120
+ model: opts.model ? project.models.model(opts.model) : undefined,
121
+ });
122
+ const state = await loadState(opts.session);
123
+ const lock = acquire(opts.session);
124
+ return {
125
+ project,
126
+ runner,
127
+ name: opts.project.name,
128
+ workspace,
129
+ workspaceMount: sandbox.spec.workdir ?? SANDBOX_MOUNT,
130
+ files,
131
+ readOnly: Boolean(opts.readOnly),
132
+ session: opts.session,
133
+ memory,
134
+ state,
135
+ sandbox,
136
+ lock,
137
+ graph: opts.graph,
138
+ close: async () => {
139
+ await teardown(sandbox.pool, logger);
140
+ // A host-supplied graph is the host's to release.
141
+ if (!opts.memory) {
142
+ project.close();
143
+ }
144
+ lock.release();
145
+ },
146
+ };
147
+ }
148
+ async function loadState(session) {
149
+ if (!existsSync(session.state)) {
150
+ return undefined;
151
+ }
152
+ const json = await readJson(session.state, undefined);
153
+ try {
154
+ return assertState(json);
155
+ }
156
+ catch {
157
+ throw invalidError(`${session.state} is not a usable run state`, 'start a fresh session with --new');
158
+ }
159
+ }
160
+ /** Every tree the agent can see, the workspace first. */
161
+ export function mounts(engine) {
162
+ return [
163
+ { host: engine.workspace, at: engine.workspaceMount, readOnly: engine.readOnly },
164
+ ...engine.sandbox.mounts.map((m) => ({
165
+ host: m.host,
166
+ at: m.at,
167
+ readOnly: Boolean(m.readOnly),
168
+ })),
169
+ ];
170
+ }
171
+ /**
172
+ * What one finished run looks like to a program: `zen run --json` prints it,
173
+ * and a batch writes one beside every item. The same function on purpose — a
174
+ * batch whose items were shaped differently would make every consumer of a
175
+ * single run a special case.
176
+ */
177
+ export function envelope(engine, outcome) {
178
+ return {
179
+ session: { id: engine.session.id, dir: engine.session.dir },
180
+ run: {
181
+ id: outcome.run.id,
182
+ dir: outcome.run.dir,
183
+ input: outcome.run.input,
184
+ output: outcome.run.output,
185
+ state: outcome.run.state,
186
+ meta: outcome.run.meta,
187
+ ...(outcome.report ? { report: outcome.report } : {}),
188
+ ...(outcome.graph ? { graph: outcome.graph } : {}),
189
+ },
190
+ mounts: mounts(engine),
191
+ agent: outcome.result.agent,
192
+ stopReason: outcome.result.stopReason,
193
+ durationMs: outcome.durationMs,
194
+ usage: outcome.result.usage,
195
+ output: outcome.text,
196
+ };
197
+ }
198
+ /**
199
+ * One turn. The session's state decides whether this starts a run or continues
200
+ * one — which is the whole of what `resume` used to be, and why it is not a
201
+ * command.
202
+ */
203
+ export async function runEngine(engine, input, onEvent, signal) {
204
+ const startedAt = new Date();
205
+ const stream = engine.state
206
+ ? engine.runner.send(engine.state, input, { signal })
207
+ : engine.runner.run(engine.project.entry, input, { signal });
208
+ if (onEvent) {
209
+ for await (const event of stream) {
210
+ onEvent(event);
211
+ }
212
+ }
213
+ const result = await stream.final();
214
+ const durationMs = Date.now() - startedAt.getTime();
215
+ engine.state = result.state;
216
+ const outcome = await record(engine, input, result, startedAt, durationMs);
217
+ if (result.stopReason === 'failed') {
218
+ throw new EngineError(result.state.error ?? 'the run failed', EXIT.failed, `report: ${outcome.run.report}`);
219
+ }
220
+ return outcome;
221
+ }
222
+ /**
223
+ * Everything a finished turn leaves behind. The session state is written first:
224
+ * if the report fails to render, the conversation is still resumable, which is
225
+ * the ordering that loses least.
226
+ */
227
+ async function record(engine, input, result, startedAt, durationMs) {
228
+ const { session } = engine;
229
+ writeJson(session.state, result.state, 0o644);
230
+ const meta = await readSessionMeta(session);
231
+ meta.lastRunAt = new Date().toISOString();
232
+ meta.title ??= title(input);
233
+ writeSessionMeta(session, meta);
234
+ const run = createRun(session);
235
+ const text = await lastText(result.state, engine.runner.services.payloads);
236
+ await writeFile(run.input, `${asText(input)}\n`, 'utf8');
237
+ await writeFile(run.output, `${text}\n`, 'utf8');
238
+ writeJson(run.state, result.state, 0o644);
239
+ let report;
240
+ try {
241
+ const built = await buildRunReport(result.state, engine.runner.services.payloads, {
242
+ title: `${engine.name} · ${run.id}`,
243
+ architecture: await engine.runner.describe(),
244
+ memory: engine.runner.services.memory?.store,
245
+ mounts: [
246
+ { at: engine.workspaceMount, href: pathToFileURL(engine.files.root).href },
247
+ ...engine.files.mounts.map((m) => ({ at: m.at, href: pathToFileURL(m.host).href })),
248
+ ],
249
+ });
250
+ await writeFile(run.report, renderReportHtml(built), 'utf8');
251
+ report = run.report;
252
+ }
253
+ catch {
254
+ // A report is a convenience. Losing it must not lose the run.
255
+ }
256
+ // The same trajectory for the other reader. `report.html` is for a person;
257
+ // this is what `zen inspect graph` prints, written down while the state is
258
+ // already in hand — a batch of a hundred items is otherwise a hundred more
259
+ // commands to run before anything can be read.
260
+ let graph;
261
+ try {
262
+ if (engine.graph) {
263
+ await writeFile(run.graph, engine.graph(result.state, {
264
+ runId: run.id,
265
+ dir: run.dir,
266
+ workspace: engine.workspace,
267
+ memory: engine.memory,
268
+ }), 'utf8');
269
+ graph = run.graph;
270
+ }
271
+ }
272
+ catch {
273
+ // Same rule as the report: a view of the run is not the run.
274
+ }
275
+ writeRunMeta(run, {
276
+ version: 1,
277
+ id: run.id,
278
+ session: session.id,
279
+ startedAt: startedAt.toISOString(),
280
+ finishedAt: new Date().toISOString(),
281
+ durationMs,
282
+ agent: result.agent,
283
+ stopReason: result.stopReason,
284
+ turns: turns(result.state),
285
+ usage: result.usage,
286
+ workspace: engine.workspace,
287
+ memory: engine.memory,
288
+ error: result.state.error,
289
+ });
290
+ return { run, result, text, durationMs, report, graph };
291
+ }
292
+ function asText(input) {
293
+ if (typeof input === 'string') {
294
+ return input;
295
+ }
296
+ return input
297
+ .map((part) => {
298
+ if (typeof part === 'string') {
299
+ return part;
300
+ }
301
+ if ('text' in part && typeof part.text === 'string') {
302
+ return part.text;
303
+ }
304
+ // A media shorthand is a single key; naming it is more useful in a
305
+ // transcript than printing the url or the base64 behind it.
306
+ return `[${Object.keys(part)[0] ?? 'part'}]`;
307
+ })
308
+ .join('\n');
309
+ }
310
+ /** A session's label in a picker: the first thing that was ever asked of it. */
311
+ function title(input) {
312
+ const text = asText(input).replace(/\s+/g, ' ').trim();
313
+ return text.length > 60 ? `${text.slice(0, 57)}…` : text;
314
+ }
315
+ //# sourceMappingURL=engine.js.map
package/dist/env.d.ts ADDED
@@ -0,0 +1,20 @@
1
+ /** The file, relative to the project directory. */
2
+ export declare const ENV_FILE = ".env";
3
+ export interface ProjectEnv {
4
+ /** the file it was read from */
5
+ path: string;
6
+ /** every name the file declares, whether or not its value won */
7
+ names: string[];
8
+ }
9
+ /**
10
+ * Reads `<dir>/.env` into `process.env`, without overwriting anything already
11
+ * set, and returns the names it found. A project with no such file is not a
12
+ * problem to report — most have none — so the answer is simply `undefined`.
13
+ *
14
+ * The names are returned in full, including the ones the environment already
15
+ * answered: the variable is in play either way, and the sandbox forwards by
16
+ * name, so what it needs to know is which names matter and not which of the
17
+ * two sources supplied them.
18
+ */
19
+ export declare function loadProjectEnv(dir: string): ProjectEnv | undefined;
20
+ //# sourceMappingURL=env.d.ts.map
package/dist/env.js ADDED
@@ -0,0 +1,65 @@
1
+ import { existsSync, readFileSync } from 'node:fs';
2
+ import { isAbsolute, join, resolve } from 'node:path';
3
+ import { parseEnv } from 'node:util';
4
+ import { FILE_ENVS } from "./keys.js";
5
+ // ---------------------------------------------------------------------------
6
+ // The project's `.env`
7
+ //
8
+ // A project is a directory, and some of what it needs to run is particular to
9
+ // it: the token for the service it talks to, the base url of the environment
10
+ // it talks to it in. The keyring answers "what can this machine pay for"; this
11
+ // answers "what does this project need", and the two are different questions
12
+ // often enough to be worth two places.
13
+ //
14
+ // Precedence is the same one everything else here uses — the real environment
15
+ // always wins. What is already set in the shell is left alone, this file fills
16
+ // the gaps, and the keyring fills whatever is still missing after that. So a
17
+ // variable exported for one command still overrides the file, which is the
18
+ // behaviour anybody typing `FOO=bar zen run` is expecting.
19
+ //
20
+ // The names, not the values, are what leaves this module: they are what the
21
+ // sandbox forwards, and forwarding by name is what keeps a secret out of an
22
+ // argv. See `forwarded()` in sandbox.ts for the rest of that story.
23
+ // ---------------------------------------------------------------------------
24
+ /** The file, relative to the project directory. */
25
+ export const ENV_FILE = '.env';
26
+ /**
27
+ * Reads `<dir>/.env` into `process.env`, without overwriting anything already
28
+ * set, and returns the names it found. A project with no such file is not a
29
+ * problem to report — most have none — so the answer is simply `undefined`.
30
+ *
31
+ * The names are returned in full, including the ones the environment already
32
+ * answered: the variable is in play either way, and the sandbox forwards by
33
+ * name, so what it needs to know is which names matter and not which of the
34
+ * two sources supplied them.
35
+ */
36
+ export function loadProjectEnv(dir) {
37
+ const path = join(dir, ENV_FILE);
38
+ if (!existsSync(path)) {
39
+ return undefined;
40
+ }
41
+ const names = [];
42
+ for (const [name, value] of Object.entries(parseEnv(readFileSync(path, 'utf8')))) {
43
+ if (value === undefined) {
44
+ continue;
45
+ }
46
+ names.push(name);
47
+ // An empty string is not an answer: tools test for absence, and a
48
+ // commented-out line someone half-filled in should not shadow a key
49
+ // the keyring has.
50
+ if (!process.env[name]) {
51
+ process.env[name] = located(dir, name, value);
52
+ }
53
+ }
54
+ return { path, names };
55
+ }
56
+ /**
57
+ * A service-account file is normally kept beside the `.env` that names it, so
58
+ * a relative path there means relative to the *project* — not to wherever the
59
+ * command happened to be run, which is what both the Google SDK and podman's
60
+ * bind mount would otherwise make of it.
61
+ */
62
+ function located(dir, name, value) {
63
+ return FILE_ENVS.includes(name) && !isAbsolute(value) ? resolve(dir, value) : value;
64
+ }
65
+ //# sourceMappingURL=env.js.map
@@ -0,0 +1,28 @@
1
+ export declare const EXIT: {
2
+ readonly ok: 0;
3
+ readonly failed: 1;
4
+ readonly usage: 2;
5
+ readonly invalid: 3;
6
+ readonly credentials: 4;
7
+ readonly sandbox: 5;
8
+ };
9
+ export type ExitCode = (typeof EXIT)[keyof typeof EXIT];
10
+ /**
11
+ * An error that already knows what it means. Anything else reaching the top is
12
+ * a bug, which is the distinction a caller needs: a wrong request and a wrong
13
+ * answer are not the same failure.
14
+ */
15
+ export declare class EngineError extends Error {
16
+ readonly code: ExitCode;
17
+ readonly hint?: string;
18
+ constructor(message: string, code?: ExitCode, hint?: string);
19
+ }
20
+ export declare const usageError: (m: string, hint?: string) => EngineError;
21
+ export declare const invalidError: (m: string, hint?: string) => EngineError;
22
+ export declare const credentialError: (m: string, hint?: string) => EngineError;
23
+ export interface Logger {
24
+ warn(message: string): void;
25
+ }
26
+ /** Plain lines on stderr, for a host that did not say where warnings go. */
27
+ export declare const stderrLogger: Logger;
28
+ //# sourceMappingURL=errors.d.ts.map
package/dist/errors.js ADDED
@@ -0,0 +1,41 @@
1
+ // ---------------------------------------------------------------------------
2
+ // Errors and warnings
3
+ //
4
+ // The engine runs under a terminal and under a server, so it never prints and
5
+ // never exits. An error carries a code a CLI turns into an exit status and a
6
+ // server turns into an HTTP status; a warning goes to whatever `Logger` the
7
+ // host handed in.
8
+ // ---------------------------------------------------------------------------
9
+ export const EXIT = {
10
+ ok: 0,
11
+ failed: 1,
12
+ usage: 2,
13
+ invalid: 3,
14
+ credentials: 4,
15
+ sandbox: 5,
16
+ };
17
+ /**
18
+ * An error that already knows what it means. Anything else reaching the top is
19
+ * a bug, which is the distinction a caller needs: a wrong request and a wrong
20
+ * answer are not the same failure.
21
+ */
22
+ export class EngineError extends Error {
23
+ code;
24
+ hint;
25
+ constructor(message, code = EXIT.failed, hint) {
26
+ super(message);
27
+ this.name = 'EngineError';
28
+ this.code = code;
29
+ this.hint = hint;
30
+ }
31
+ }
32
+ export const usageError = (m, hint) => new EngineError(m, EXIT.usage, hint);
33
+ export const invalidError = (m, hint) => new EngineError(m, EXIT.invalid, hint);
34
+ export const credentialError = (m, hint) => new EngineError(m, EXIT.credentials, hint);
35
+ /** Plain lines on stderr, for a host that did not say where warnings go. */
36
+ export const stderrLogger = {
37
+ warn: (message) => {
38
+ process.stderr.write(`warning ${message}\n`);
39
+ },
40
+ };
41
+ //# sourceMappingURL=errors.js.map
package/dist/home.d.ts ADDED
@@ -0,0 +1,35 @@
1
+ export declare function home(): string;
2
+ export declare const paths: {
3
+ home: typeof home;
4
+ projects: () => string;
5
+ keys: () => string;
6
+ keyDir: () => string;
7
+ gym: () => string;
8
+ /** what the meta agent runs on, which is a choice about a tool, not a project */
9
+ meta: () => string;
10
+ /** work already done and worth keeping: vectors, parses, model listings */
11
+ cache: () => string;
12
+ };
13
+ /** Creates a directory owner-only, and leaves an existing one's mode alone. */
14
+ export declare function ensureDir(dir: string, mode?: number): string;
15
+ export declare function ensureHome(): string;
16
+ /**
17
+ * Refuses to read a credential file that anyone else can read, the way `ssh`
18
+ * does. A world-readable key is not a warning-level event: warnings are
19
+ * ignored, and the whole point of the store is that the secret is not lying
20
+ * around in the open.
21
+ *
22
+ * Windows reports a mode that means nothing here, so the check is POSIX-only.
23
+ */
24
+ export declare function assertPrivate(path: string): void;
25
+ export declare function readJson<T>(path: string, fallback: T): Promise<T>;
26
+ /**
27
+ * Write to a sibling and rename. A crash then leaves either the old file or the
28
+ * new one, never a half-written registry — which matters more here than it
29
+ * looks, because the alternative is a corrupt `keys.json` locking someone out
30
+ * of every provider at once.
31
+ *
32
+ * Synchronous on purpose: this is also called from exit paths.
33
+ */
34
+ export declare function writeJson(path: string, value: unknown, mode?: number): void;
35
+ //# sourceMappingURL=home.d.ts.map
package/dist/home.js ADDED
@@ -0,0 +1,112 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { chmodSync, mkdirSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs';
3
+ import { readFile } from 'node:fs/promises';
4
+ import { homedir } from 'node:os';
5
+ import { dirname, join } from 'node:path';
6
+ import { EngineError, EXIT } from "./errors.js";
7
+ // ---------------------------------------------------------------------------
8
+ // The home directory
9
+ //
10
+ // ~/.zenera/neo holds exactly two kinds of thing: credentials, which belong to
11
+ // the machine and never to a project, and an index of where projects are, which
12
+ // is a convenience and is allowed to be wrong.
13
+ //
14
+ // `ZENERA_HOME` moves the whole tree. That is what makes any of this testable
15
+ // and what lets CI start from an empty one.
16
+ // ---------------------------------------------------------------------------
17
+ const DIR_MODE = 0o700;
18
+ const FILE_MODE = 0o600;
19
+ export function home() {
20
+ const override = process.env.ZENERA_HOME?.trim();
21
+ return override ? override : join(homedir(), '.zenera', 'neo');
22
+ }
23
+ export const paths = {
24
+ home,
25
+ projects: () => join(home(), 'projects.json'),
26
+ keys: () => join(home(), 'keys.json'),
27
+ keyDir: () => join(home(), 'keys'),
28
+ gym: () => join(home(), 'gym'),
29
+ /** what the meta agent runs on, which is a choice about a tool, not a project */
30
+ meta: () => join(home(), 'meta.json'),
31
+ /** work already done and worth keeping: vectors, parses, model listings */
32
+ cache: () => join(home(), 'cache'),
33
+ };
34
+ /** Creates a directory owner-only, and leaves an existing one's mode alone. */
35
+ export function ensureDir(dir, mode = DIR_MODE) {
36
+ mkdirSync(dir, { recursive: true, mode });
37
+ return dir;
38
+ }
39
+ export function ensureHome() {
40
+ return ensureDir(home());
41
+ }
42
+ // ---------------------------------------------------------------------------
43
+ // Permissions
44
+ // ---------------------------------------------------------------------------
45
+ /**
46
+ * Refuses to read a credential file that anyone else can read, the way `ssh`
47
+ * does. A world-readable key is not a warning-level event: warnings are
48
+ * ignored, and the whole point of the store is that the secret is not lying
49
+ * around in the open.
50
+ *
51
+ * Windows reports a mode that means nothing here, so the check is POSIX-only.
52
+ */
53
+ export function assertPrivate(path) {
54
+ if (process.platform === 'win32') {
55
+ return;
56
+ }
57
+ let mode;
58
+ try {
59
+ mode = statSync(path).mode;
60
+ }
61
+ catch {
62
+ return; // absent is fine; it will be created with the right mode
63
+ }
64
+ const open = mode & 0o077;
65
+ if (open !== 0) {
66
+ const octal = (mode & 0o777).toString(8).padStart(3, '0');
67
+ throw new EngineError(`permissions ${octal} on ${path} are too open`, EXIT.credentials, `run: chmod 600 ${path}`);
68
+ }
69
+ }
70
+ // ---------------------------------------------------------------------------
71
+ // JSON files
72
+ // ---------------------------------------------------------------------------
73
+ export async function readJson(path, fallback) {
74
+ let text;
75
+ try {
76
+ text = await readFile(path, 'utf8');
77
+ }
78
+ catch (err) {
79
+ if (err.code === 'ENOENT') {
80
+ return fallback;
81
+ }
82
+ throw err;
83
+ }
84
+ try {
85
+ return JSON.parse(text);
86
+ }
87
+ catch {
88
+ throw new EngineError(`${path} is not valid JSON`, EXIT.invalid, 'fix or delete the file');
89
+ }
90
+ }
91
+ /**
92
+ * Write to a sibling and rename. A crash then leaves either the old file or the
93
+ * new one, never a half-written registry — which matters more here than it
94
+ * looks, because the alternative is a corrupt `keys.json` locking someone out
95
+ * of every provider at once.
96
+ *
97
+ * Synchronous on purpose: this is also called from exit paths.
98
+ */
99
+ export function writeJson(path, value, mode = FILE_MODE) {
100
+ ensureDir(dirname(path));
101
+ const tmp = `${path}.${process.pid}.${randomUUID()}.tmp`;
102
+ try {
103
+ writeFileSync(tmp, `${JSON.stringify(value, null, 2)}\n`, { mode });
104
+ chmodSync(tmp, mode);
105
+ renameSync(tmp, path);
106
+ }
107
+ catch (err) {
108
+ rmSync(tmp, { force: true });
109
+ throw err;
110
+ }
111
+ }
112
+ //# sourceMappingURL=home.js.map
package/dist/ids.d.ts ADDED
@@ -0,0 +1,12 @@
1
+ export declare function stamp(now?: Date): string;
2
+ /**
3
+ * Ids arrive from `--session` and `--run` and become path segments, so they are
4
+ * checked rather than trusted. The shape has no `.` and no separator, which
5
+ * makes traversal unrepresentable rather than merely rejected.
6
+ */
7
+ export declare function isStamp(value: string): boolean;
8
+ /** Human-readable form of a stamp: `2026-08-25 14:30:12`. */
9
+ export declare function stampDate(id: string): string;
10
+ /** ISO instant a stamp names, for `ago()`. Local time in, local time out. */
11
+ export declare function stampInstant(id: string): string | undefined;
12
+ //# sourceMappingURL=ids.d.ts.map
package/dist/ids.js ADDED
@@ -0,0 +1,44 @@
1
+ import { randomBytes } from 'node:crypto';
2
+ // ---------------------------------------------------------------------------
3
+ // Identifiers
4
+ //
5
+ // `YYYYMMDD-HHMMSS-xxxx`, local time, four hex characters of entropy.
6
+ //
7
+ // Sortable as a plain string, readable without a decoder, and collision-free
8
+ // when two runs start inside the same second. An epoch integer gives up the
9
+ // first two properties and a uuid gives up all three.
10
+ // ---------------------------------------------------------------------------
11
+ const STAMP = /^\d{8}-\d{6}-[0-9a-f]{4}$/;
12
+ export function stamp(now = new Date()) {
13
+ const p = (n, w = 2) => String(n).padStart(w, '0');
14
+ const date = `${now.getFullYear()}${p(now.getMonth() + 1)}${p(now.getDate())}`;
15
+ const time = `${p(now.getHours())}${p(now.getMinutes())}${p(now.getSeconds())}`;
16
+ return `${date}-${time}-${randomBytes(2).toString('hex')}`;
17
+ }
18
+ /**
19
+ * Ids arrive from `--session` and `--run` and become path segments, so they are
20
+ * checked rather than trusted. The shape has no `.` and no separator, which
21
+ * makes traversal unrepresentable rather than merely rejected.
22
+ */
23
+ export function isStamp(value) {
24
+ return STAMP.test(value);
25
+ }
26
+ /** Human-readable form of a stamp: `2026-08-25 14:30:12`. */
27
+ export function stampDate(id) {
28
+ if (!isStamp(id)) {
29
+ return id;
30
+ }
31
+ const [d, t] = id.split('-');
32
+ return (`${d.slice(0, 4)}-${d.slice(4, 6)}-${d.slice(6, 8)} ` +
33
+ `${t.slice(0, 2)}:${t.slice(2, 4)}:${t.slice(4, 6)}`);
34
+ }
35
+ /** ISO instant a stamp names, for `ago()`. Local time in, local time out. */
36
+ export function stampInstant(id) {
37
+ if (!isStamp(id)) {
38
+ return undefined;
39
+ }
40
+ const [d, t] = id.split('-');
41
+ const at = new Date(Number(d.slice(0, 4)), Number(d.slice(4, 6)) - 1, Number(d.slice(6, 8)), Number(t.slice(0, 2)), Number(t.slice(2, 4)), Number(t.slice(4, 6)));
42
+ return Number.isNaN(at.getTime()) ? undefined : at.toISOString();
43
+ }
44
+ //# sourceMappingURL=ids.js.map