@inixiative/agent-session 0.9.0 → 0.11.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/README.md +24 -21
- package/package.json +2 -2
- package/src/index.ts +18 -0
- package/src/secret-scratch.ts +634 -0
package/README.md
CHANGED
|
@@ -1,17 +1,19 @@
|
|
|
1
1
|
# @inixiative/agent-session
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
3
|
+
> Part of [inixiative](https://www.inixiative.com), technology for cooperation. See [all the projects](https://github.com/inixiative).
|
|
4
|
+
|
|
5
|
+
Drive Claude Code, Codex and Muse as persistent streaming sessions behind one
|
|
6
|
+
interface. Claude Code runs over its CLI or the Agent SDK, Codex over its
|
|
7
|
+
app-server and Muse over `muse serve`; Gemini and Grok are planned. The package
|
|
8
|
+
also holds warm **primed decision sessions**, **subscription pools** with
|
|
7
9
|
deterministic routing, **limit polling** and **session continuity**.
|
|
8
10
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
in over stdin, and classifies
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
11
|
+
A one-shot call (`claude -p "…"`) ends the process after one answer.
|
|
12
|
+
`agent-session` keeps one long-lived agent process per session, sends each turn
|
|
13
|
+
in over stdin, and classifies each event that comes back (text, thinking, tool
|
|
14
|
+
calls, tool results, usage), so you can drive multi-turn work and record what
|
|
15
|
+
the agent did. Foundry, Oracle and Archive run their agent sessions on it, as do
|
|
16
|
+
our eval harnesses and agent comparisons.
|
|
15
17
|
|
|
16
18
|
```ts
|
|
17
19
|
import { ClaudeCodeSession } from "@inixiative/agent-session";
|
|
@@ -33,23 +35,23 @@ for (const e of result.events) { // full classified event stream
|
|
|
33
35
|
session.kill();
|
|
34
36
|
```
|
|
35
37
|
|
|
36
|
-
##
|
|
38
|
+
## What you get
|
|
37
39
|
|
|
38
40
|
- **Persistent, multi-turn.** One process per session; `send()` per turn,
|
|
39
|
-
resolves on the runtime's `result` event.
|
|
41
|
+
resolves on the runtime's `result` event.
|
|
40
42
|
- **Full event capture.** A normalized `SessionEvent` taxonomy
|
|
41
43
|
(`text` / `thinking` / `tool_use` / `tool_result` / `result` / `error` /
|
|
42
|
-
`session_compact`)
|
|
44
|
+
`session_compact`), the same shape for every runtime.
|
|
43
45
|
- **Subscription auth, no API billing.** The Claude Code adapter strips
|
|
44
46
|
`ANTHROPIC_API_KEY` and authenticates via the CLI's subscription / an
|
|
45
|
-
`CLAUDE_CODE_OAUTH_TOKEN` from `claude setup-token
|
|
47
|
+
`CLAUDE_CODE_OAUTH_TOKEN` from `claude setup-token`; it works headless and in
|
|
46
48
|
containers.
|
|
47
49
|
- **Images with a turn.** `send(text, { images: [{ mediaType, data }] })` (png, jpeg, gif or
|
|
48
50
|
webp, base64) where the transport declares `capabilities.images`: Claude gets base64 `image`
|
|
49
51
|
blocks in the same user message, Codex `image` inputs with a `data:` URL. Anything else is
|
|
50
52
|
refused before a byte is written.
|
|
51
53
|
- **fork / resume / interrupt** and pre-send hooks (rewrite the outgoing
|
|
52
|
-
message
|
|
54
|
+
message, for example to add context for that turn).
|
|
53
55
|
- **A resume whose native conversation is not found is typed.** Claude finding no transcript
|
|
54
56
|
("No conversation found with session ID": cleaned up, deleted, or under another project
|
|
55
57
|
directory or config home) and Codex finding no rollout ("no rollout found for thread id") both
|
|
@@ -57,9 +59,10 @@ session.kill();
|
|
|
57
59
|
with the `cwd` the lookup ran in). No turn ran, so the attempt settles as a no-send instead of
|
|
58
60
|
uncertain native work, and the caller can start fresh from its own record.
|
|
59
61
|
- **Transports with declared capabilities.** How a model is reached is a
|
|
60
|
-
pluggable transport; switching it is a routing change
|
|
61
|
-
- **
|
|
62
|
-
transport
|
|
62
|
+
pluggable transport; switching it is a routing change.
|
|
63
|
+
- **Two dependencies.** `@inixiative/json-rules` and Meta's `@muse-code/sdk`
|
|
64
|
+
(for the Muse transport), plus Bun and the agent CLI you're driving. The Agent
|
|
65
|
+
SDK transport takes the SDK's `query` from the caller.
|
|
63
66
|
|
|
64
67
|
## Interface
|
|
65
68
|
|
|
@@ -272,15 +275,15 @@ snapshots in the artifact but do not fabricate a completed-turn total.
|
|
|
272
275
|
| Gemini CLI | planned |
|
|
273
276
|
| Grok CLI | planned (when its headless/stream mode matures) |
|
|
274
277
|
|
|
275
|
-
|
|
278
|
+
A runtime that reports less produces fewer event types; it never fails a turn for that.
|
|
276
279
|
|
|
277
280
|
[Subscription pooling goals](tickets/README.md) (AS-001…005) are implemented as described in [docs/transports-pools-routing.md](docs/transports-pools-routing.md); each ticket lists what remains.
|
|
278
281
|
|
|
279
282
|
## Provenance
|
|
280
283
|
|
|
281
284
|
Extracted from [`inixiative/foundry`](https://github.com/inixiative/foundry),
|
|
282
|
-
where
|
|
283
|
-
|
|
285
|
+
where it runs Foundry's agent sessions. Foundry, Oracle, Archive and the
|
|
286
|
+
inixiative bench use it.
|
|
284
287
|
|
|
285
288
|
## License
|
|
286
289
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@inixiative/agent-session",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.11.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.
|
|
44
|
+
"@inixiative/config": "^0.5.185",
|
|
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,634 @@
|
|
|
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
|
+
/**
|
|
212
|
+
* Lift every `$$word` out of `text` into `thread`'s pad, under the dagger for `message`. With
|
|
213
|
+
* `patterns` (global regexes, e.g. a caller's known token shapes), every match is lifted too, the
|
|
214
|
+
* person having forgotten the marker; a match may span lines (a private key). Positions follow the
|
|
215
|
+
* text's order; a match inside another lifted span is left to it. `recognised` counts the matches.
|
|
216
|
+
*/
|
|
217
|
+
lift(
|
|
218
|
+
text: string,
|
|
219
|
+
at: { thread: string; message: string; now?: Date },
|
|
220
|
+
options: { patterns?: readonly RegExp[] } = {},
|
|
221
|
+
): { text: string; refs: string[]; recognised: number } {
|
|
222
|
+
const spans: Array<{
|
|
223
|
+
start: number;
|
|
224
|
+
end: number;
|
|
225
|
+
value: string;
|
|
226
|
+
marked: boolean;
|
|
227
|
+
}> = [];
|
|
228
|
+
for (const match of text.matchAll(MARKED)) {
|
|
229
|
+
const lead = match[1] ?? '';
|
|
230
|
+
spans.push({
|
|
231
|
+
start: (match.index ?? 0) + lead.length,
|
|
232
|
+
end: (match.index ?? 0) + match[0].length,
|
|
233
|
+
value: match[2] ?? '',
|
|
234
|
+
marked: true,
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
for (const pattern of options.patterns ?? []) {
|
|
238
|
+
if (!pattern.global) throw new SecretScratchError('A lift pattern must be global (/g).');
|
|
239
|
+
for (const match of text.matchAll(pattern)) {
|
|
240
|
+
if (!match[0]) continue;
|
|
241
|
+
const start = match.index ?? 0;
|
|
242
|
+
spans.push({
|
|
243
|
+
start,
|
|
244
|
+
end: start + match[0].length,
|
|
245
|
+
value: match[0],
|
|
246
|
+
marked: false,
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
if (!spans.length) return { text, refs: [], recognised: 0 };
|
|
251
|
+
// Earliest first, the longer of two at one place; a span overlapping a kept one is dropped.
|
|
252
|
+
spans.sort((a, b) => a.start - b.start || b.end - a.end);
|
|
253
|
+
const kept: typeof spans = [];
|
|
254
|
+
for (const span of spans)
|
|
255
|
+
if (!kept.length || span.start >= kept[kept.length - 1]!.end) kept.push(span);
|
|
256
|
+
checkId(at.thread, 'thread');
|
|
257
|
+
checkId(at.message, 'message');
|
|
258
|
+
const daggers = this._read(at.thread);
|
|
259
|
+
let dagger = daggers.find((d) => d.message === at.message);
|
|
260
|
+
const first = dagger ? Math.max(0, ...dagger.blocks.keys()) + 1 : 1;
|
|
261
|
+
const refs = kept.map((_, i) =>
|
|
262
|
+
formatScratchRef({ thread: at.thread, message: at.message, position: first + i }, at.thread),
|
|
263
|
+
);
|
|
264
|
+
let lifted = '';
|
|
265
|
+
let cursor = 0;
|
|
266
|
+
kept.forEach((span, i) => {
|
|
267
|
+
lifted += text.slice(cursor, span.start) + refs[i];
|
|
268
|
+
cursor = span.end;
|
|
269
|
+
});
|
|
270
|
+
lifted += text.slice(cursor);
|
|
271
|
+
if (!dagger) {
|
|
272
|
+
dagger = {
|
|
273
|
+
message: at.message,
|
|
274
|
+
at: (at.now ?? new Date()).toISOString(),
|
|
275
|
+
excerpt: lifted.replace(/\s+/g, ' ').trim().slice(0, 80),
|
|
276
|
+
blocks: new Map(),
|
|
277
|
+
};
|
|
278
|
+
daggers.push(dagger);
|
|
279
|
+
}
|
|
280
|
+
for (const [i, span] of kept.entries()) dagger.blocks.set(first + i, span.value);
|
|
281
|
+
this._write(at.thread, daggers);
|
|
282
|
+
return { text: lifted, refs, recognised: kept.filter((span) => !span.marked).length };
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/** Every pad here (or one thread's) with what it holds pending, never the values. */
|
|
286
|
+
list(thread?: string): ScratchPadSummary[] {
|
|
287
|
+
const threads = thread ? [checkId(thread, 'thread')] : this.threads();
|
|
288
|
+
return threads
|
|
289
|
+
.map((id) => ({ thread: id, daggers: this._read(id).map(summary) }))
|
|
290
|
+
.filter((pad) => pad.daggers.length > 0);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/** The threads that have a pad here. */
|
|
294
|
+
threads(): string[] {
|
|
295
|
+
let names: string[];
|
|
296
|
+
try {
|
|
297
|
+
names = readdirSync(this._dir);
|
|
298
|
+
} catch {
|
|
299
|
+
return [];
|
|
300
|
+
}
|
|
301
|
+
return names
|
|
302
|
+
.filter((name) => name.endsWith(PAD_SUFFIX))
|
|
303
|
+
.map((name) => name.slice(0, -PAD_SUFFIX.length))
|
|
304
|
+
.filter((id) => ID.test(id))
|
|
305
|
+
.sort();
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/** Files the tool has written secrets into, absolute: the gate keeps them from being read. */
|
|
309
|
+
heldFiles(): string[] {
|
|
310
|
+
const text = readText(this._heldPath());
|
|
311
|
+
if (!text) return [];
|
|
312
|
+
try {
|
|
313
|
+
const files = JSON.parse(text) as unknown;
|
|
314
|
+
return Array.isArray(files) ? files.filter((f): f is string => typeof f === 'string') : [];
|
|
315
|
+
} catch {
|
|
316
|
+
throw new SecretScratchError('The held-files list is unreadable.');
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/** Write the value into a file: `replace` writes it exactly, `append` adds it on its own line. New files are 0600. */
|
|
321
|
+
write(
|
|
322
|
+
ref: string,
|
|
323
|
+
from: string,
|
|
324
|
+
path: string,
|
|
325
|
+
options: ScratchUse & { mode?: 'replace' | 'append'; cwd?: string } = {},
|
|
326
|
+
): void {
|
|
327
|
+
const target = parseScratchRef(ref, from);
|
|
328
|
+
const value = this._held(target);
|
|
329
|
+
const file = this._target(options.cwd, path);
|
|
330
|
+
const before = options.mode === 'append' ? readText(file) : '';
|
|
331
|
+
const lead = before && !before.endsWith('\n') ? '\n' : '';
|
|
332
|
+
writeTarget(file, options.mode === 'append' ? `${before}${lead}${value}\n` : value);
|
|
333
|
+
this._hold(file);
|
|
334
|
+
if (options.consume) this._drop(target);
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/** Set `KEY=value` in an env file: the line for KEY is replaced, or added at the end. */
|
|
338
|
+
env(
|
|
339
|
+
ref: string,
|
|
340
|
+
from: string,
|
|
341
|
+
path: string,
|
|
342
|
+
key: string,
|
|
343
|
+
options: ScratchUse & { cwd?: string } = {},
|
|
344
|
+
): void {
|
|
345
|
+
if (!ENV_KEY.test(key)) throw new SecretScratchError(`Not an env key: ${clip(key)}`);
|
|
346
|
+
const target = parseScratchRef(ref, from);
|
|
347
|
+
const value = this._held(target);
|
|
348
|
+
const file = this._target(options.cwd, path);
|
|
349
|
+
const text = readText(file);
|
|
350
|
+
const newline = text.includes('\r\n') ? '\r\n' : '\n';
|
|
351
|
+
const lines = text.split(/\r?\n/);
|
|
352
|
+
if (lines[lines.length - 1] === '') lines.pop();
|
|
353
|
+
const line = `${key}=${envValue(value)}`;
|
|
354
|
+
// Loaders take the last line for a key, so that is the one replaced.
|
|
355
|
+
const at = lines.findLastIndex((l) => envLineKey(l) === key);
|
|
356
|
+
if (at >= 0) lines[at] = line;
|
|
357
|
+
else lines.push(line);
|
|
358
|
+
writeTarget(file, `${lines.join(newline)}${newline}`);
|
|
359
|
+
this._hold(file);
|
|
360
|
+
if (options.consume) this._drop(target);
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/**
|
|
364
|
+
* Run `command` in a shell with the value on its stdin. Its output is thrown away; only the exit
|
|
365
|
+
* code comes back. The value comes from a held block or a key already in an env file. A failed
|
|
366
|
+
* command keeps its block, so it can be tried again.
|
|
367
|
+
*/
|
|
368
|
+
async pipe(
|
|
369
|
+
source: ScratchSource,
|
|
370
|
+
from: string,
|
|
371
|
+
command: string,
|
|
372
|
+
options: ScratchUse & { cwd?: string; timeoutMs?: number } = {},
|
|
373
|
+
): Promise<{ exitCode: number }> {
|
|
374
|
+
if ('ref' in source) {
|
|
375
|
+
const target = parseScratchRef(source.ref, from);
|
|
376
|
+
const result = await runWithStdin(command, this._held(target), options);
|
|
377
|
+
if (options.consume && result.exitCode === 0) this._drop(target);
|
|
378
|
+
return result;
|
|
379
|
+
}
|
|
380
|
+
if (!ENV_KEY.test(source.key))
|
|
381
|
+
throw new SecretScratchError(`Not an env key: ${clip(source.key)}`);
|
|
382
|
+
const file = this._target(options.cwd, source.envFile);
|
|
383
|
+
if (!existsSync(file)) throw new SecretScratchError(`No env file at ${clip(source.envFile)}`);
|
|
384
|
+
const line = readText(file)
|
|
385
|
+
.split(/\r?\n/)
|
|
386
|
+
.findLast((l) => envLineKey(l) === source.key);
|
|
387
|
+
if (line === undefined)
|
|
388
|
+
throw new SecretScratchError(`${source.key} is not set in ${clip(source.envFile)}`);
|
|
389
|
+
return runWithStdin(command, envLineValue(line), options);
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/** Remove a block; a dagger with no blocks left goes too, and an empty pad is deleted. */
|
|
393
|
+
consume(ref: string, from: string): void {
|
|
394
|
+
const target = parseScratchRef(ref, from);
|
|
395
|
+
this._held(target);
|
|
396
|
+
this._drop(target);
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
/** Delete a thread's pad. */
|
|
400
|
+
clear(thread: string): void {
|
|
401
|
+
rmSync(this._path(checkId(thread, 'thread')), { force: true });
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/** Replace every held value of `thread` found in `text` with its reference. */
|
|
405
|
+
scrub(thread: string, text: string): string {
|
|
406
|
+
const pairs = this._read(checkId(thread, 'thread'))
|
|
407
|
+
.flatMap((d) =>
|
|
408
|
+
[...d.blocks].map(([position, value]) => ({
|
|
409
|
+
value,
|
|
410
|
+
ref: formatScratchRef({ thread, message: d.message, position }, thread),
|
|
411
|
+
})),
|
|
412
|
+
)
|
|
413
|
+
.filter((pair) => pair.value.length >= SCRUB_MIN)
|
|
414
|
+
.sort((a, b) => b.value.length - a.value.length);
|
|
415
|
+
if (!pairs.length) return text;
|
|
416
|
+
const refs = new Map(pairs.map((pair) => [pair.value, pair.ref]));
|
|
417
|
+
const pattern = new RegExp(pairs.map((pair) => RegExp.escape(pair.value)).join('|'), 'g');
|
|
418
|
+
return text.replace(pattern, (value) => refs.get(value) ?? value);
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
private _held(ref: ScratchRef): string {
|
|
422
|
+
const value = this._read(ref.thread)
|
|
423
|
+
.find((d) => d.message === ref.message)
|
|
424
|
+
?.blocks.get(ref.position);
|
|
425
|
+
if (value === undefined)
|
|
426
|
+
throw new SecretScratchError(
|
|
427
|
+
`Nothing is held at ${formatScratchRef(ref)}: it was consumed, or the reference is wrong. Call list to see what is pending.`,
|
|
428
|
+
);
|
|
429
|
+
return value;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
/** Re-read and drop the block, so a lift made while a pipe ran is kept. */
|
|
433
|
+
private _drop(ref: ScratchRef): void {
|
|
434
|
+
const daggers = this._read(ref.thread);
|
|
435
|
+
daggers.find((d) => d.message === ref.message)?.blocks.delete(ref.position);
|
|
436
|
+
this._write(ref.thread, daggers);
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
/** A file the agent names, never inside the pad directory. */
|
|
440
|
+
private _target(cwd: string | undefined, path: string): string {
|
|
441
|
+
const file = resolve(cwd ?? '.', path);
|
|
442
|
+
if (file === this._dir || file.startsWith(this._dir + sep))
|
|
443
|
+
throw new SecretScratchError('The scratch pad directory is not a target.');
|
|
444
|
+
return file;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
private _heldPath(): string {
|
|
448
|
+
return join(this._dir, 'held-files.json');
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
private _hold(file: string): void {
|
|
452
|
+
const files = this.heldFiles();
|
|
453
|
+
if (files.includes(file)) return;
|
|
454
|
+
mkdirSync(this._dir, { recursive: true, mode: 0o700 });
|
|
455
|
+
writePrivate(this._heldPath(), JSON.stringify([...files, file].sort(), null, 2));
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
private _path(thread: string): string {
|
|
459
|
+
return join(this._dir, `${thread}${PAD_SUFFIX}`);
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
private _read(thread: string): Dagger[] {
|
|
463
|
+
try {
|
|
464
|
+
return parsePad(readText(this._path(thread)));
|
|
465
|
+
} catch (error) {
|
|
466
|
+
if (error instanceof SecretScratchError) throw error;
|
|
467
|
+
throw new SecretScratchError(`The pad for ${thread} is unreadable.`);
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
private _write(thread: string, daggers: Dagger[]): void {
|
|
472
|
+
const live = daggers.filter((d) => d.blocks.size > 0);
|
|
473
|
+
if (!live.length) {
|
|
474
|
+
rmSync(this._path(thread), { force: true });
|
|
475
|
+
return;
|
|
476
|
+
}
|
|
477
|
+
mkdirSync(this._dir, { recursive: true, mode: 0o700 });
|
|
478
|
+
chmodSync(this._dir, 0o700);
|
|
479
|
+
writePrivate(this._path(thread), formatPad(thread, live));
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
function checkId(id: string, what: string): string {
|
|
484
|
+
if (!ID.test(id)) throw new SecretScratchError(`Not a ${what} id: ${clip(id)}`);
|
|
485
|
+
return id;
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
function clip(text: string): string {
|
|
489
|
+
return text.length > 120 ? `${text.slice(0, 117)}...` : text;
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
function summary(d: Dagger): ScratchDagger {
|
|
493
|
+
return {
|
|
494
|
+
message: d.message,
|
|
495
|
+
at: d.at,
|
|
496
|
+
excerpt: d.excerpt,
|
|
497
|
+
positions: [...d.blocks.keys()].sort((a, b) => a - b),
|
|
498
|
+
};
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
/** The pad as written: a line naming the thread, then per dagger its line, its blocks and a blank line. */
|
|
502
|
+
function formatPad(thread: string, daggers: Dagger[]): string {
|
|
503
|
+
const body = daggers.map((d) =>
|
|
504
|
+
[
|
|
505
|
+
`†${d.message} (${d.at}, ${JSON.stringify(d.excerpt)})`,
|
|
506
|
+
...[...d.blocks]
|
|
507
|
+
.sort(([a], [b]) => a - b)
|
|
508
|
+
.map(([position, value]) => `[${position}] ${JSON.stringify(value)}`),
|
|
509
|
+
].join('\n'),
|
|
510
|
+
);
|
|
511
|
+
return `# secret scratch pad: thread ${thread}\n\n${body.join('\n\n')}\n`;
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
function parsePad(text: string): Dagger[] {
|
|
515
|
+
const daggers: Dagger[] = [];
|
|
516
|
+
let current: Dagger | undefined;
|
|
517
|
+
for (const line of text.split('\n')) {
|
|
518
|
+
const dagger = /^†(\S+) \((\S+), (".*")\)$/.exec(line);
|
|
519
|
+
if (dagger) {
|
|
520
|
+
current = {
|
|
521
|
+
message: dagger[1]!,
|
|
522
|
+
at: dagger[2]!,
|
|
523
|
+
excerpt: JSON.parse(dagger[3]!) as string,
|
|
524
|
+
blocks: new Map(),
|
|
525
|
+
};
|
|
526
|
+
daggers.push(current);
|
|
527
|
+
continue;
|
|
528
|
+
}
|
|
529
|
+
const block = /^\[(\d+)\] (".*")$/.exec(line);
|
|
530
|
+
if (block && current) {
|
|
531
|
+
current.blocks.set(Number(block[1]), JSON.parse(block[2]!) as string);
|
|
532
|
+
continue;
|
|
533
|
+
}
|
|
534
|
+
// Anything else but the heading and blank lines is damage: stop rather than rewrite without it.
|
|
535
|
+
if (line !== '' && !line.startsWith('# '))
|
|
536
|
+
throw new SecretScratchError('The pad is unreadable.');
|
|
537
|
+
}
|
|
538
|
+
return daggers;
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
function writePrivate(path: string, text: string): void {
|
|
542
|
+
const temp = `${path}.${process.pid}.${Date.now()}.tmp`;
|
|
543
|
+
try {
|
|
544
|
+
writeFileSync(temp, text, { mode: 0o600, flag: 'wx' });
|
|
545
|
+
renameSync(temp, path);
|
|
546
|
+
chmodSync(path, 0o600);
|
|
547
|
+
} catch (error) {
|
|
548
|
+
rmSync(temp, { force: true });
|
|
549
|
+
throw new SecretScratchError(`Could not write ${clip(path)}: ${errorCode(error)}`);
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
/** A file the agent named: written in place (a symlinked env file stays a link); a new one is 0600. */
|
|
554
|
+
function writeTarget(path: string, text: string): void {
|
|
555
|
+
try {
|
|
556
|
+
writeFileSync(path, text, { mode: 0o600 });
|
|
557
|
+
} catch (error) {
|
|
558
|
+
throw new SecretScratchError(`Could not write ${clip(path)}: ${errorCode(error)}`);
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
/** A file's text; a missing file is empty, any other failure stops the operation. */
|
|
563
|
+
function readText(path: string): string {
|
|
564
|
+
try {
|
|
565
|
+
return readFileSync(path, 'utf8');
|
|
566
|
+
} catch (error) {
|
|
567
|
+
if (errorCode(error) === 'ENOENT') return '';
|
|
568
|
+
throw new SecretScratchError(`Could not read ${clip(path)}: ${errorCode(error)}`);
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
function errorCode(error: unknown): string {
|
|
573
|
+
return (error as NodeJS.ErrnoException | undefined)?.code ?? 'error';
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
/** A dotenv value: bare when plain, otherwise double-quoted with escapes. */
|
|
577
|
+
function envValue(value: string): string {
|
|
578
|
+
if (/^[A-Za-z0-9_./:@+,=-]*$/.test(value)) return value;
|
|
579
|
+
// Single quotes are literal in dotenv loaders and shells: no `$` expansion.
|
|
580
|
+
if (!/['\n\r]/.test(value)) return `'${value}'`;
|
|
581
|
+
return `"${value.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, '\\n').replace(/\r/g, '\\r')}"`;
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
function envLineKey(line: string): string | undefined {
|
|
585
|
+
return /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=/.exec(line)?.[1];
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
function envLineValue(line: string): string {
|
|
589
|
+
const raw = line.slice(line.indexOf('=') + 1).trim();
|
|
590
|
+
const double = /^"((?:[^"\\]|\\.)*)"\s*(?:#.*)?$/.exec(raw);
|
|
591
|
+
if (double)
|
|
592
|
+
return (double[1] ?? '').replace(/\\(.)/g, (_m, c: string) =>
|
|
593
|
+
c === 'n' ? '\n' : c === 'r' ? '\r' : c,
|
|
594
|
+
);
|
|
595
|
+
const single = /^'([^']*)'\s*(?:#.*)?$/.exec(raw);
|
|
596
|
+
if (single) return single[1] ?? '';
|
|
597
|
+
if (raw.startsWith('"') || raw.startsWith("'"))
|
|
598
|
+
throw new SecretScratchError(
|
|
599
|
+
'The env value spans lines or is unterminated; it cannot be piped.',
|
|
600
|
+
);
|
|
601
|
+
return raw.replace(/\s+#.*$/, '');
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
function runWithStdin(
|
|
605
|
+
command: string,
|
|
606
|
+
value: string,
|
|
607
|
+
options: { cwd?: string; timeoutMs?: number },
|
|
608
|
+
): Promise<{ exitCode: number }> {
|
|
609
|
+
return new Promise((done, fail) => {
|
|
610
|
+
const child = spawn('/bin/sh', ['-c', command], {
|
|
611
|
+
cwd: options.cwd,
|
|
612
|
+
stdio: ['pipe', 'ignore', 'ignore'],
|
|
613
|
+
detached: true,
|
|
614
|
+
});
|
|
615
|
+
// The whole group dies on a timeout, so nothing the command started keeps the value.
|
|
616
|
+
const timer = setTimeout(() => {
|
|
617
|
+
try {
|
|
618
|
+
if (child.pid) process.kill(-child.pid, 'SIGKILL');
|
|
619
|
+
} catch {
|
|
620
|
+
child.kill('SIGKILL');
|
|
621
|
+
}
|
|
622
|
+
}, options.timeoutMs ?? 120_000);
|
|
623
|
+
child.on('error', () => {
|
|
624
|
+
clearTimeout(timer);
|
|
625
|
+
fail(new SecretScratchError('The command could not start.'));
|
|
626
|
+
});
|
|
627
|
+
child.on('close', (code, signal) => {
|
|
628
|
+
clearTimeout(timer);
|
|
629
|
+
done({ exitCode: code ?? (signal ? 128 : 1) });
|
|
630
|
+
});
|
|
631
|
+
child.stdin.on('error', () => undefined);
|
|
632
|
+
child.stdin.end(value);
|
|
633
|
+
});
|
|
634
|
+
}
|