@inixiative/agent-session 0.9.0 → 0.10.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@inixiative/agent-session",
3
- "version": "0.9.0",
3
+ "version": "0.10.0",
4
4
  "description": "Drive coding-agent CLIs as persistent, streaming, event-captured sessions over interchangeable transports, with primed decision sessions, subscription pools, routing, limit polling and continuity.",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -41,7 +41,7 @@
41
41
  },
42
42
  "devDependencies": {
43
43
  "@biomejs/biome": "2.5.2",
44
- "@inixiative/config": "^0.5.176",
44
+ "@inixiative/config": "^0.5.183",
45
45
  "@types/bun": "1.4.4",
46
46
  "lefthook": "2.1.9",
47
47
  "typescript": "6.0.3"
package/src/index.ts CHANGED
@@ -141,6 +141,24 @@ export {
141
141
  type SubscriptionCandidate,
142
142
  type UnknownUtilizationPolicy,
143
143
  } from './routing';
144
+ // The secret scratch pad (AS-009): `$$word` secrets used by reference, never read.
145
+ export {
146
+ formatScratchRef,
147
+ isPrivatePath,
148
+ type PrivatePathOptions,
149
+ parseScratchRef,
150
+ privatePathDenial,
151
+ privatePathGate,
152
+ type ScratchDagger,
153
+ type ScratchPadSummary,
154
+ type ScratchRef,
155
+ type ScratchSource,
156
+ type ScratchUse,
157
+ SECRET_MARKER,
158
+ SECRET_SCRATCH_GUIDANCE,
159
+ SecretScratchError,
160
+ SecretScratchPad,
161
+ } from './secret-scratch';
144
162
  // Transports: how a session reaches its model, with declared capabilities.
145
163
  export {
146
164
  TRANSPORTS,
@@ -0,0 +1,595 @@
1
+ import { spawn } from 'node:child_process';
2
+ import {
3
+ chmodSync,
4
+ existsSync,
5
+ mkdirSync,
6
+ readdirSync,
7
+ readFileSync,
8
+ renameSync,
9
+ rmSync,
10
+ writeFileSync,
11
+ } from 'node:fs';
12
+ import { basename, isAbsolute, join, resolve, sep } from 'node:path';
13
+ import type { ToolGate, ToolRequest } from './harness-session';
14
+
15
+ /**
16
+ * The secret scratch pad (AS-009): secrets a person hands an agent as `$$word` are lifted out of the
17
+ * message into a private file per thread, and the agent uses them by reference without reading them.
18
+ *
19
+ * One pad per thread accumulates: a dagger per message that carried secrets, a numbered block per
20
+ * secret, a blank line, the next message. A reference is thread, message and position; inside its
21
+ * own thread it is written `†<message>.<n>`, anywhere else `†<thread>/<message>.<n>`.
22
+ */
23
+
24
+ /** The marker: a word starting with it is a secret, up to the next whitespace. */
25
+ export const SECRET_MARKER = '$$';
26
+
27
+ /** What the agent is told, wherever the tool is offered: use the values, never read them. */
28
+ export const SECRET_SCRATCH_GUIDANCE =
29
+ "A reference like †<message>.<n> in a message is a secret the person handed you, held in the thread's scratch pad. You never see the value and must not try to read it: use it. The normal path is env (set KEY in an env file) and then pipe from that env file (a command gets the value on its stdin; its output is thrown away and only the exit code comes back). write puts a value in a file. Pass consume: true on the last use to remove it from the pad. list shows what is pending, here or, given a thread, in another thread whose references you have. A file the pad has written to is then off limits to read, search or print, like files named .private, *.private or *.private.*: use it through pipe. A secret is one word after $$; a multi-line secret cannot be lifted.";
30
+
31
+ const REF = /^†?(?:([A-Za-z0-9_.:-]+)\/)?([A-Za-z0-9_.:-]+)\.(\d+)$/;
32
+ const ID = /^[A-Za-z0-9_:-][A-Za-z0-9_.:-]*$/;
33
+ const ENV_KEY = /^[A-Za-z_][A-Za-z0-9_]*$/;
34
+ const MARKED = /(^|\s)\$\$(\S+)/g;
35
+ const PAD_SUFFIX = '.pad';
36
+ /** A held value shorter than this is not scrubbed from text: it would match ordinary words. */
37
+ const SCRUB_MIN = 6;
38
+
39
+ export interface ScratchRef {
40
+ readonly thread: string;
41
+ readonly message: string;
42
+ readonly position: number;
43
+ }
44
+
45
+ /** A dagger as the agent may see it: where it came from and what is pending, never a value. */
46
+ export interface ScratchDagger {
47
+ readonly message: string;
48
+ readonly at: string;
49
+ /** The start of the message, with its secrets already replaced by references. */
50
+ readonly excerpt: string;
51
+ readonly positions: readonly number[];
52
+ }
53
+
54
+ export interface ScratchPadSummary {
55
+ readonly thread: string;
56
+ readonly daggers: readonly ScratchDagger[];
57
+ }
58
+
59
+ /** Where `pipe` reads its value: a held block, or a key already set in an env file. */
60
+ export type ScratchSource =
61
+ | { readonly ref: string }
62
+ | { readonly envFile: string; readonly key: string };
63
+
64
+ export interface ScratchUse {
65
+ /** Remove the block from the pad once this use succeeds. */
66
+ readonly consume?: boolean;
67
+ }
68
+
69
+ /** A failure that names references and paths only, never a value. */
70
+ export class SecretScratchError extends Error {
71
+ override readonly name = 'SecretScratchError';
72
+ }
73
+
74
+ interface Dagger {
75
+ message: string;
76
+ at: string;
77
+ excerpt: string;
78
+ blocks: Map<number, string>;
79
+ }
80
+
81
+ /** `†<message>.<n>` inside `thread`, `†<thread>/<message>.<n>` from anywhere. */
82
+ export function formatScratchRef(ref: ScratchRef, from?: string): string {
83
+ return from === ref.thread
84
+ ? `†${ref.message}.${ref.position}`
85
+ : `†${ref.thread}/${ref.message}.${ref.position}`;
86
+ }
87
+
88
+ /** A reference as written by the agent; a short one belongs to `thread`. */
89
+ export function parseScratchRef(text: string, thread: string): ScratchRef {
90
+ const match = REF.exec(text.trim());
91
+ if (!match) throw new SecretScratchError(`Not a scratch pad reference: ${clip(text)}`);
92
+ const position = Number(match[3]);
93
+ const ref = { thread: match[1] ?? thread, message: match[2]!, position };
94
+ if (!ID.test(ref.thread) || !ID.test(ref.message) || position < 1)
95
+ throw new SecretScratchError(`Not a scratch pad reference: ${clip(text)}`);
96
+ return ref;
97
+ }
98
+
99
+ /** A path no agent reads: a `.private` segment, a name ending in `.private` or holding `.private.`, or an extra glob. */
100
+ export function isPrivatePath(path: string, extra: readonly string[] = []): boolean {
101
+ const segments = path.split(/[\\/]+/).filter(Boolean);
102
+ if (
103
+ segments.some(
104
+ (segment) =>
105
+ segment === '.private' || segment.endsWith('.private') || segment.includes('.private.'),
106
+ )
107
+ )
108
+ return true;
109
+ return extra.some((pattern) => {
110
+ const glob = new Bun.Glob(pattern);
111
+ return glob.match(path) || glob.match(basename(path));
112
+ });
113
+ }
114
+
115
+ const PATH_KEYS = [
116
+ 'file_path',
117
+ 'path',
118
+ 'paths',
119
+ 'notebook_path',
120
+ 'pattern',
121
+ 'glob',
122
+ 'include',
123
+ 'cwd',
124
+ ] as const;
125
+
126
+ /** Every path and command word a tool call names. */
127
+ function namedTargets(request: Pick<ToolRequest, 'toolInput' | 'blockedPath'>): string[] {
128
+ const input = request.toolInput;
129
+ const changes = Array.isArray(input.changes) ? input.changes : [];
130
+ const command = Array.isArray(input.command)
131
+ ? input.command.filter((part): part is string => typeof part === 'string').join(' ')
132
+ : input.command;
133
+ return [
134
+ request.blockedPath,
135
+ ...PATH_KEYS.flatMap((key) => (Array.isArray(input[key]) ? input[key] : [input[key]])),
136
+ ...changes.flatMap((change) => {
137
+ const c = change as { path?: unknown; movePath?: unknown } | null;
138
+ return [c?.path, c?.movePath];
139
+ }),
140
+ ...(typeof command === 'string' ? command.replace(/\\/g, '').split(/[\s'"`;|&<>()=]+/) : []),
141
+ ].filter((value): value is string => typeof value === 'string' && value.length > 0);
142
+ }
143
+
144
+ export interface PrivatePathOptions {
145
+ /** The scratch pad directory, private whatever its name. */
146
+ readonly scratchDir?: string;
147
+ /** Extra globs that are private too (`*.pem`). */
148
+ readonly privatePaths?: readonly string[];
149
+ /** Files the tool has written secrets into (`SecretScratchPad.heldFiles()`): used through pipe, never read. */
150
+ readonly heldFiles?: readonly string[];
151
+ /** Whether a call is the scratch tool's own (it names paths it writes, never reads). Default: none is. */
152
+ readonly isOwnTool?: (
153
+ request: Pick<ToolRequest, 'toolName' | 'toolServer' | 'toolMethod'>,
154
+ ) => boolean;
155
+ }
156
+
157
+ /** A command word or path that names a held file: the same absolute path, or a relative path ending one. */
158
+ function namesHeldFile(target: string, held: readonly string[]): boolean {
159
+ if (isAbsolute(target)) return held.includes(resolve(target));
160
+ const tail = target.replace(/^(\.\/)+/, '');
161
+ if (!tail || tail === '.' || tail.startsWith('..')) return false;
162
+ return held.some((file) => file.endsWith(`${sep}${tail}`));
163
+ }
164
+
165
+ /** Why a tool call is refused for naming a private path or the scratch pad; undefined when it names none. */
166
+ export function privatePathDenial(
167
+ request: Pick<
168
+ ToolRequest,
169
+ 'toolInput' | 'blockedPath' | 'toolMethod' | 'toolName' | 'toolServer'
170
+ >,
171
+ options: PrivatePathOptions = {},
172
+ ): string | undefined {
173
+ if (options.isOwnTool?.(request)) return undefined;
174
+ const scratchDir = options.scratchDir ? resolve(options.scratchDir) : undefined;
175
+ const hit = namedTargets(request).find(
176
+ (target) =>
177
+ isPrivatePath(target, options.privatePaths) ||
178
+ namesHeldFile(target, options.heldFiles ?? []) ||
179
+ (scratchDir !== undefined &&
180
+ isAbsolute(target) &&
181
+ (resolve(target) === scratchDir || resolve(target).startsWith(scratchDir + sep))),
182
+ );
183
+ if (!hit) return undefined;
184
+ return `${clip(hit)} is private: it is never read, searched or printed. Use the scratch pad's tools instead: pipe from an env file, or env, pipe or write a held reference. They never show a value.`;
185
+ }
186
+
187
+ /** A tool gate that denies every call naming a private path or the scratch pad. */
188
+ export function privatePathGate(options: PrivatePathOptions = {}): ToolGate {
189
+ return (request) => {
190
+ const message = privatePathDenial(request, options);
191
+ return message ? { behavior: 'deny', message } : { behavior: 'allow' };
192
+ };
193
+ }
194
+
195
+ /**
196
+ * The pads of one directory: one file per thread, 0600 in a 0700 directory. Every pad change is a
197
+ * synchronous read, edit and rewrite (a temporary file and a rename), so in one process no two
198
+ * changes interleave and a turn path that cannot wait can lift.
199
+ */
200
+ export class SecretScratchPad {
201
+ private readonly _dir: string;
202
+
203
+ constructor(dir: string) {
204
+ this._dir = resolve(dir);
205
+ }
206
+
207
+ get dir(): string {
208
+ return this._dir;
209
+ }
210
+
211
+ /** Lift every `$$word` out of `text` into `thread`'s pad, under the dagger for `message`. */
212
+ lift(
213
+ text: string,
214
+ at: { thread: string; message: string; now?: Date },
215
+ ): { text: string; refs: string[] } {
216
+ const values = [...text.matchAll(MARKED)].map((match) => match[2] ?? '');
217
+ if (!values.length) return { text, refs: [] };
218
+ checkId(at.thread, 'thread');
219
+ checkId(at.message, 'message');
220
+ const daggers = this._read(at.thread);
221
+ let dagger = daggers.find((d) => d.message === at.message);
222
+ const first = dagger ? Math.max(0, ...dagger.blocks.keys()) + 1 : 1;
223
+ const refs: string[] = [];
224
+ const lifted = text.replace(MARKED, (_whole, lead: string) => {
225
+ const ref = formatScratchRef(
226
+ { thread: at.thread, message: at.message, position: first + refs.length },
227
+ at.thread,
228
+ );
229
+ refs.push(ref);
230
+ return `${lead}${ref}`;
231
+ });
232
+ if (!dagger) {
233
+ dagger = {
234
+ message: at.message,
235
+ at: (at.now ?? new Date()).toISOString(),
236
+ excerpt: lifted.replace(/\s+/g, ' ').trim().slice(0, 80),
237
+ blocks: new Map(),
238
+ };
239
+ daggers.push(dagger);
240
+ }
241
+ for (const [i, value] of values.entries()) dagger.blocks.set(first + i, value);
242
+ this._write(at.thread, daggers);
243
+ return { text: lifted, refs };
244
+ }
245
+
246
+ /** Every pad here (or one thread's) with what it holds pending, never the values. */
247
+ list(thread?: string): ScratchPadSummary[] {
248
+ const threads = thread ? [checkId(thread, 'thread')] : this.threads();
249
+ return threads
250
+ .map((id) => ({ thread: id, daggers: this._read(id).map(summary) }))
251
+ .filter((pad) => pad.daggers.length > 0);
252
+ }
253
+
254
+ /** The threads that have a pad here. */
255
+ threads(): string[] {
256
+ let names: string[];
257
+ try {
258
+ names = readdirSync(this._dir);
259
+ } catch {
260
+ return [];
261
+ }
262
+ return names
263
+ .filter((name) => name.endsWith(PAD_SUFFIX))
264
+ .map((name) => name.slice(0, -PAD_SUFFIX.length))
265
+ .filter((id) => ID.test(id))
266
+ .sort();
267
+ }
268
+
269
+ /** Files the tool has written secrets into, absolute: the gate keeps them from being read. */
270
+ heldFiles(): string[] {
271
+ const text = readText(this._heldPath());
272
+ if (!text) return [];
273
+ try {
274
+ const files = JSON.parse(text) as unknown;
275
+ return Array.isArray(files) ? files.filter((f): f is string => typeof f === 'string') : [];
276
+ } catch {
277
+ throw new SecretScratchError('The held-files list is unreadable.');
278
+ }
279
+ }
280
+
281
+ /** Write the value into a file: `replace` writes it exactly, `append` adds it on its own line. New files are 0600. */
282
+ write(
283
+ ref: string,
284
+ from: string,
285
+ path: string,
286
+ options: ScratchUse & { mode?: 'replace' | 'append'; cwd?: string } = {},
287
+ ): void {
288
+ const target = parseScratchRef(ref, from);
289
+ const value = this._held(target);
290
+ const file = this._target(options.cwd, path);
291
+ const before = options.mode === 'append' ? readText(file) : '';
292
+ const lead = before && !before.endsWith('\n') ? '\n' : '';
293
+ writeTarget(file, options.mode === 'append' ? `${before}${lead}${value}\n` : value);
294
+ this._hold(file);
295
+ if (options.consume) this._drop(target);
296
+ }
297
+
298
+ /** Set `KEY=value` in an env file: the line for KEY is replaced, or added at the end. */
299
+ env(
300
+ ref: string,
301
+ from: string,
302
+ path: string,
303
+ key: string,
304
+ options: ScratchUse & { cwd?: string } = {},
305
+ ): void {
306
+ if (!ENV_KEY.test(key)) throw new SecretScratchError(`Not an env key: ${clip(key)}`);
307
+ const target = parseScratchRef(ref, from);
308
+ const value = this._held(target);
309
+ const file = this._target(options.cwd, path);
310
+ const text = readText(file);
311
+ const newline = text.includes('\r\n') ? '\r\n' : '\n';
312
+ const lines = text.split(/\r?\n/);
313
+ if (lines[lines.length - 1] === '') lines.pop();
314
+ const line = `${key}=${envValue(value)}`;
315
+ // Loaders take the last line for a key, so that is the one replaced.
316
+ const at = lines.findLastIndex((l) => envLineKey(l) === key);
317
+ if (at >= 0) lines[at] = line;
318
+ else lines.push(line);
319
+ writeTarget(file, `${lines.join(newline)}${newline}`);
320
+ this._hold(file);
321
+ if (options.consume) this._drop(target);
322
+ }
323
+
324
+ /**
325
+ * Run `command` in a shell with the value on its stdin. Its output is thrown away; only the exit
326
+ * code comes back. The value comes from a held block or a key already in an env file. A failed
327
+ * command keeps its block, so it can be tried again.
328
+ */
329
+ async pipe(
330
+ source: ScratchSource,
331
+ from: string,
332
+ command: string,
333
+ options: ScratchUse & { cwd?: string; timeoutMs?: number } = {},
334
+ ): Promise<{ exitCode: number }> {
335
+ if ('ref' in source) {
336
+ const target = parseScratchRef(source.ref, from);
337
+ const result = await runWithStdin(command, this._held(target), options);
338
+ if (options.consume && result.exitCode === 0) this._drop(target);
339
+ return result;
340
+ }
341
+ if (!ENV_KEY.test(source.key))
342
+ throw new SecretScratchError(`Not an env key: ${clip(source.key)}`);
343
+ const file = this._target(options.cwd, source.envFile);
344
+ if (!existsSync(file)) throw new SecretScratchError(`No env file at ${clip(source.envFile)}`);
345
+ const line = readText(file)
346
+ .split(/\r?\n/)
347
+ .findLast((l) => envLineKey(l) === source.key);
348
+ if (line === undefined)
349
+ throw new SecretScratchError(`${source.key} is not set in ${clip(source.envFile)}`);
350
+ return runWithStdin(command, envLineValue(line), options);
351
+ }
352
+
353
+ /** Remove a block; a dagger with no blocks left goes too, and an empty pad is deleted. */
354
+ consume(ref: string, from: string): void {
355
+ const target = parseScratchRef(ref, from);
356
+ this._held(target);
357
+ this._drop(target);
358
+ }
359
+
360
+ /** Delete a thread's pad. */
361
+ clear(thread: string): void {
362
+ rmSync(this._path(checkId(thread, 'thread')), { force: true });
363
+ }
364
+
365
+ /** Replace every held value of `thread` found in `text` with its reference. */
366
+ scrub(thread: string, text: string): string {
367
+ const pairs = this._read(checkId(thread, 'thread'))
368
+ .flatMap((d) =>
369
+ [...d.blocks].map(([position, value]) => ({
370
+ value,
371
+ ref: formatScratchRef({ thread, message: d.message, position }, thread),
372
+ })),
373
+ )
374
+ .filter((pair) => pair.value.length >= SCRUB_MIN)
375
+ .sort((a, b) => b.value.length - a.value.length);
376
+ if (!pairs.length) return text;
377
+ const refs = new Map(pairs.map((pair) => [pair.value, pair.ref]));
378
+ const pattern = new RegExp(pairs.map((pair) => RegExp.escape(pair.value)).join('|'), 'g');
379
+ return text.replace(pattern, (value) => refs.get(value) ?? value);
380
+ }
381
+
382
+ private _held(ref: ScratchRef): string {
383
+ const value = this._read(ref.thread)
384
+ .find((d) => d.message === ref.message)
385
+ ?.blocks.get(ref.position);
386
+ if (value === undefined)
387
+ throw new SecretScratchError(
388
+ `Nothing is held at ${formatScratchRef(ref)}: it was consumed, or the reference is wrong. Call list to see what is pending.`,
389
+ );
390
+ return value;
391
+ }
392
+
393
+ /** Re-read and drop the block, so a lift made while a pipe ran is kept. */
394
+ private _drop(ref: ScratchRef): void {
395
+ const daggers = this._read(ref.thread);
396
+ daggers.find((d) => d.message === ref.message)?.blocks.delete(ref.position);
397
+ this._write(ref.thread, daggers);
398
+ }
399
+
400
+ /** A file the agent names, never inside the pad directory. */
401
+ private _target(cwd: string | undefined, path: string): string {
402
+ const file = resolve(cwd ?? '.', path);
403
+ if (file === this._dir || file.startsWith(this._dir + sep))
404
+ throw new SecretScratchError('The scratch pad directory is not a target.');
405
+ return file;
406
+ }
407
+
408
+ private _heldPath(): string {
409
+ return join(this._dir, 'held-files.json');
410
+ }
411
+
412
+ private _hold(file: string): void {
413
+ const files = this.heldFiles();
414
+ if (files.includes(file)) return;
415
+ mkdirSync(this._dir, { recursive: true, mode: 0o700 });
416
+ writePrivate(this._heldPath(), JSON.stringify([...files, file].sort(), null, 2));
417
+ }
418
+
419
+ private _path(thread: string): string {
420
+ return join(this._dir, `${thread}${PAD_SUFFIX}`);
421
+ }
422
+
423
+ private _read(thread: string): Dagger[] {
424
+ try {
425
+ return parsePad(readText(this._path(thread)));
426
+ } catch (error) {
427
+ if (error instanceof SecretScratchError) throw error;
428
+ throw new SecretScratchError(`The pad for ${thread} is unreadable.`);
429
+ }
430
+ }
431
+
432
+ private _write(thread: string, daggers: Dagger[]): void {
433
+ const live = daggers.filter((d) => d.blocks.size > 0);
434
+ if (!live.length) {
435
+ rmSync(this._path(thread), { force: true });
436
+ return;
437
+ }
438
+ mkdirSync(this._dir, { recursive: true, mode: 0o700 });
439
+ chmodSync(this._dir, 0o700);
440
+ writePrivate(this._path(thread), formatPad(thread, live));
441
+ }
442
+ }
443
+
444
+ function checkId(id: string, what: string): string {
445
+ if (!ID.test(id)) throw new SecretScratchError(`Not a ${what} id: ${clip(id)}`);
446
+ return id;
447
+ }
448
+
449
+ function clip(text: string): string {
450
+ return text.length > 120 ? `${text.slice(0, 117)}...` : text;
451
+ }
452
+
453
+ function summary(d: Dagger): ScratchDagger {
454
+ return {
455
+ message: d.message,
456
+ at: d.at,
457
+ excerpt: d.excerpt,
458
+ positions: [...d.blocks.keys()].sort((a, b) => a - b),
459
+ };
460
+ }
461
+
462
+ /** The pad as written: a line naming the thread, then per dagger its line, its blocks and a blank line. */
463
+ function formatPad(thread: string, daggers: Dagger[]): string {
464
+ const body = daggers.map((d) =>
465
+ [
466
+ `†${d.message} (${d.at}, ${JSON.stringify(d.excerpt)})`,
467
+ ...[...d.blocks]
468
+ .sort(([a], [b]) => a - b)
469
+ .map(([position, value]) => `[${position}] ${JSON.stringify(value)}`),
470
+ ].join('\n'),
471
+ );
472
+ return `# secret scratch pad: thread ${thread}\n\n${body.join('\n\n')}\n`;
473
+ }
474
+
475
+ function parsePad(text: string): Dagger[] {
476
+ const daggers: Dagger[] = [];
477
+ let current: Dagger | undefined;
478
+ for (const line of text.split('\n')) {
479
+ const dagger = /^†(\S+) \((\S+), (".*")\)$/.exec(line);
480
+ if (dagger) {
481
+ current = {
482
+ message: dagger[1]!,
483
+ at: dagger[2]!,
484
+ excerpt: JSON.parse(dagger[3]!) as string,
485
+ blocks: new Map(),
486
+ };
487
+ daggers.push(current);
488
+ continue;
489
+ }
490
+ const block = /^\[(\d+)\] (".*")$/.exec(line);
491
+ if (block && current) {
492
+ current.blocks.set(Number(block[1]), JSON.parse(block[2]!) as string);
493
+ continue;
494
+ }
495
+ // Anything else but the heading and blank lines is damage: stop rather than rewrite without it.
496
+ if (line !== '' && !line.startsWith('# '))
497
+ throw new SecretScratchError('The pad is unreadable.');
498
+ }
499
+ return daggers;
500
+ }
501
+
502
+ function writePrivate(path: string, text: string): void {
503
+ const temp = `${path}.${process.pid}.${Date.now()}.tmp`;
504
+ try {
505
+ writeFileSync(temp, text, { mode: 0o600, flag: 'wx' });
506
+ renameSync(temp, path);
507
+ chmodSync(path, 0o600);
508
+ } catch (error) {
509
+ rmSync(temp, { force: true });
510
+ throw new SecretScratchError(`Could not write ${clip(path)}: ${errorCode(error)}`);
511
+ }
512
+ }
513
+
514
+ /** A file the agent named: written in place (a symlinked env file stays a link); a new one is 0600. */
515
+ function writeTarget(path: string, text: string): void {
516
+ try {
517
+ writeFileSync(path, text, { mode: 0o600 });
518
+ } catch (error) {
519
+ throw new SecretScratchError(`Could not write ${clip(path)}: ${errorCode(error)}`);
520
+ }
521
+ }
522
+
523
+ /** A file's text; a missing file is empty, any other failure stops the operation. */
524
+ function readText(path: string): string {
525
+ try {
526
+ return readFileSync(path, 'utf8');
527
+ } catch (error) {
528
+ if (errorCode(error) === 'ENOENT') return '';
529
+ throw new SecretScratchError(`Could not read ${clip(path)}: ${errorCode(error)}`);
530
+ }
531
+ }
532
+
533
+ function errorCode(error: unknown): string {
534
+ return (error as NodeJS.ErrnoException | undefined)?.code ?? 'error';
535
+ }
536
+
537
+ /** A dotenv value: bare when plain, otherwise double-quoted with escapes. */
538
+ function envValue(value: string): string {
539
+ if (/^[A-Za-z0-9_./:@+,=-]*$/.test(value)) return value;
540
+ // Single quotes are literal in dotenv loaders and shells: no `$` expansion.
541
+ if (!/['\n\r]/.test(value)) return `'${value}'`;
542
+ return `"${value.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, '\\n').replace(/\r/g, '\\r')}"`;
543
+ }
544
+
545
+ function envLineKey(line: string): string | undefined {
546
+ return /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=/.exec(line)?.[1];
547
+ }
548
+
549
+ function envLineValue(line: string): string {
550
+ const raw = line.slice(line.indexOf('=') + 1).trim();
551
+ const double = /^"((?:[^"\\]|\\.)*)"\s*(?:#.*)?$/.exec(raw);
552
+ if (double)
553
+ return (double[1] ?? '').replace(/\\(.)/g, (_m, c: string) =>
554
+ c === 'n' ? '\n' : c === 'r' ? '\r' : c,
555
+ );
556
+ const single = /^'([^']*)'\s*(?:#.*)?$/.exec(raw);
557
+ if (single) return single[1] ?? '';
558
+ if (raw.startsWith('"') || raw.startsWith("'"))
559
+ throw new SecretScratchError(
560
+ 'The env value spans lines or is unterminated; it cannot be piped.',
561
+ );
562
+ return raw.replace(/\s+#.*$/, '');
563
+ }
564
+
565
+ function runWithStdin(
566
+ command: string,
567
+ value: string,
568
+ options: { cwd?: string; timeoutMs?: number },
569
+ ): Promise<{ exitCode: number }> {
570
+ return new Promise((done, fail) => {
571
+ const child = spawn('/bin/sh', ['-c', command], {
572
+ cwd: options.cwd,
573
+ stdio: ['pipe', 'ignore', 'ignore'],
574
+ detached: true,
575
+ });
576
+ // The whole group dies on a timeout, so nothing the command started keeps the value.
577
+ const timer = setTimeout(() => {
578
+ try {
579
+ if (child.pid) process.kill(-child.pid, 'SIGKILL');
580
+ } catch {
581
+ child.kill('SIGKILL');
582
+ }
583
+ }, options.timeoutMs ?? 120_000);
584
+ child.on('error', () => {
585
+ clearTimeout(timer);
586
+ fail(new SecretScratchError('The command could not start.'));
587
+ });
588
+ child.on('close', (code, signal) => {
589
+ clearTimeout(timer);
590
+ done({ exitCode: code ?? (signal ? 128 : 1) });
591
+ });
592
+ child.stdin.on('error', () => undefined);
593
+ child.stdin.end(value);
594
+ });
595
+ }