@sweny-ai/core 0.1.190 → 0.1.192
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/budget.d.ts +15 -5
- package/dist/budget.js +24 -9
- package/dist/cli/run-answers.d.ts +5 -0
- package/dist/cli/run-answers.js +72 -17
- package/dist/cli/run-history.js +7 -1
- package/dist/cli/runs.js +12 -10
- package/dist/executor.js +6 -3
- package/dist/journal.d.ts +8 -0
- package/dist/journal.js +58 -0
- package/package.json +1 -1
package/dist/budget.d.ts
CHANGED
|
@@ -59,7 +59,7 @@ export declare function spendOf(u: NodeUsage | undefined): {
|
|
|
59
59
|
export declare function describeOverrun(o: BudgetOverrun, nodeId?: string): string;
|
|
60
60
|
/**
|
|
61
61
|
* Tracks spend for one run. `node()` opens a visit to a node; `attempt()` opens
|
|
62
|
-
* one agent run inside it. Spend is committed when an attempt
|
|
62
|
+
* one agent run inside it. Spend is committed once, when an attempt settles.
|
|
63
63
|
*/
|
|
64
64
|
export declare class BudgetGuard {
|
|
65
65
|
readonly runLimits: SpendLimits;
|
|
@@ -102,6 +102,7 @@ export declare class BudgetAttempt {
|
|
|
102
102
|
private live;
|
|
103
103
|
private liveUsage;
|
|
104
104
|
private stopped;
|
|
105
|
+
private settled;
|
|
105
106
|
private readonly onOuterAbort;
|
|
106
107
|
constructor(guard: BudgetGuard, node: NodeBudget, outer?: AbortSignal | undefined);
|
|
107
108
|
get signal(): AbortSignal;
|
|
@@ -113,11 +114,20 @@ export declare class BudgetAttempt {
|
|
|
113
114
|
readonly onUsage: (u: NodeUsage) => void;
|
|
114
115
|
private check;
|
|
115
116
|
/**
|
|
116
|
-
* The attempt is over
|
|
117
|
-
*
|
|
118
|
-
* stop, else a crossing visible only in the final usage
|
|
117
|
+
* The attempt is over: commit its spend exactly once (#549). The spend is the
|
|
118
|
+
* larger of `finalUsage` and what was seen live, per unit. The breach (the
|
|
119
|
+
* live stop, else a crossing visible only in the final usage) is judged
|
|
120
|
+
* against the totals before this commit. Later calls change nothing and
|
|
121
|
+
* return the first call's breach, so the executor settles as soon as the
|
|
122
|
+
* harness returns, before any fallible work, and every exit path may settle
|
|
123
|
+
* again without double counting.
|
|
119
124
|
*/
|
|
125
|
+
settle(finalUsage?: NodeUsage): BudgetOverrun | undefined;
|
|
126
|
+
/** Settle with the attempt's final usage and return the breach, if any. */
|
|
120
127
|
finish(finalUsage: NodeUsage | undefined): BudgetOverrun | undefined;
|
|
121
|
-
/**
|
|
128
|
+
/**
|
|
129
|
+
* Every exit path ends here: an attempt that threw still commits what it
|
|
130
|
+
* reported live. A no-op once settled.
|
|
131
|
+
*/
|
|
122
132
|
dispose(): void;
|
|
123
133
|
}
|
package/dist/budget.js
CHANGED
|
@@ -69,7 +69,7 @@ export function describeOverrun(o, nodeId) {
|
|
|
69
69
|
}
|
|
70
70
|
/**
|
|
71
71
|
* Tracks spend for one run. `node()` opens a visit to a node; `attempt()` opens
|
|
72
|
-
* one agent run inside it. Spend is committed when an attempt
|
|
72
|
+
* one agent run inside it. Spend is committed once, when an attempt settles.
|
|
73
73
|
*/
|
|
74
74
|
export class BudgetGuard {
|
|
75
75
|
runLimits;
|
|
@@ -139,6 +139,7 @@ export class BudgetAttempt {
|
|
|
139
139
|
live = { tokens: 0, costUsd: 0 };
|
|
140
140
|
liveUsage;
|
|
141
141
|
stopped;
|
|
142
|
+
settled;
|
|
142
143
|
onOuterAbort = () => this.controller.abort();
|
|
143
144
|
constructor(guard, node, outer) {
|
|
144
145
|
this.guard = guard;
|
|
@@ -184,23 +185,37 @@ export class BudgetAttempt {
|
|
|
184
185
|
breachOf("run", this.guard.runLimits, { tokens: r.tokens + attempt.tokens, costUsd: r.costUsd + attempt.costUsd }, true));
|
|
185
186
|
}
|
|
186
187
|
/**
|
|
187
|
-
* The attempt is over
|
|
188
|
-
*
|
|
189
|
-
* stop, else a crossing visible only in the final usage
|
|
188
|
+
* The attempt is over: commit its spend exactly once (#549). The spend is the
|
|
189
|
+
* larger of `finalUsage` and what was seen live, per unit. The breach (the
|
|
190
|
+
* live stop, else a crossing visible only in the final usage) is judged
|
|
191
|
+
* against the totals before this commit. Later calls change nothing and
|
|
192
|
+
* return the first call's breach, so the executor settles as soon as the
|
|
193
|
+
* harness returns, before any fallible work, and every exit path may settle
|
|
194
|
+
* again without double counting.
|
|
190
195
|
*/
|
|
191
|
-
|
|
196
|
+
settle(finalUsage) {
|
|
197
|
+
if (this.settled)
|
|
198
|
+
return this.settled.breach;
|
|
192
199
|
this.outer?.removeEventListener("abort", this.onOuterAbort);
|
|
193
200
|
const f = spendOf(finalUsage);
|
|
194
201
|
const spent = {
|
|
195
202
|
tokens: Math.max(this.live.tokens, f.tokens ?? 0),
|
|
196
203
|
costUsd: Math.max(this.live.costUsd, f.costUsd ?? 0),
|
|
197
204
|
};
|
|
198
|
-
const
|
|
205
|
+
const breach = this.stopped ?? this.check(spent);
|
|
206
|
+
this.settled = { breach };
|
|
199
207
|
this.node.commit(spent);
|
|
200
|
-
return
|
|
208
|
+
return breach;
|
|
209
|
+
}
|
|
210
|
+
/** Settle with the attempt's final usage and return the breach, if any. */
|
|
211
|
+
finish(finalUsage) {
|
|
212
|
+
return this.settle(finalUsage);
|
|
201
213
|
}
|
|
202
|
-
/**
|
|
214
|
+
/**
|
|
215
|
+
* Every exit path ends here: an attempt that threw still commits what it
|
|
216
|
+
* reported live. A no-op once settled.
|
|
217
|
+
*/
|
|
203
218
|
dispose() {
|
|
204
|
-
this.
|
|
219
|
+
this.settle();
|
|
205
220
|
}
|
|
206
221
|
}
|
|
@@ -41,6 +41,11 @@ export interface BuildRunAnswersInput {
|
|
|
41
41
|
results: Map<string, NodeResult>;
|
|
42
42
|
/** Secret values to redact (the same set the journal and output.md use). */
|
|
43
43
|
secrets?: string[];
|
|
44
|
+
/**
|
|
45
|
+
* The key for redacted key names, asked for only when a key needs one. Default: a random key that
|
|
46
|
+
* lives only in this process (names then differ from run to run, which a diff shows as a change).
|
|
47
|
+
*/
|
|
48
|
+
macKey?: () => Buffer;
|
|
44
49
|
}
|
|
45
50
|
/**
|
|
46
51
|
* Declared fields of each successful node, deep, redacted (values and keys). A failed or skipped
|
package/dist/cli/run-answers.js
CHANGED
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
import crypto from "node:crypto";
|
|
22
22
|
import fs from "node:fs";
|
|
23
23
|
import path from "node:path";
|
|
24
|
-
import { redact } from "../journal.js";
|
|
24
|
+
import { redact, workspaceSecretKey } from "../journal.js";
|
|
25
25
|
import { writeFileNoFollow } from "../safe-file.js";
|
|
26
26
|
import { RUN_HISTORY_DIR, RUN_ID_RE, listRuns } from "./run-history.js";
|
|
27
27
|
export const RUN_ANSWERS_SCHEMA_VERSION = 1;
|
|
@@ -32,13 +32,56 @@ const isObj = (v) => !!v && typeof v === "object" && !Array.isArray(v);
|
|
|
32
32
|
// ── Build, write, read ──────────────────────────────────────────
|
|
33
33
|
const has = (o, k) => Object.hasOwn(o, k);
|
|
34
34
|
const sha = (s) => crypto.createHash("sha256").update(s).digest("hex");
|
|
35
|
+
/** The JSON Schema types a schema declares: `type`, else implied by `properties` / `items`. */
|
|
36
|
+
function declaredTypes(sch) {
|
|
37
|
+
if (typeof sch.type === "string")
|
|
38
|
+
return [sch.type];
|
|
39
|
+
if (Array.isArray(sch.type))
|
|
40
|
+
return sch.type.filter((t) => typeof t === "string");
|
|
41
|
+
if (isObj(sch.properties))
|
|
42
|
+
return ["object"];
|
|
43
|
+
if (isObj(sch.items))
|
|
44
|
+
return ["array"];
|
|
45
|
+
return undefined;
|
|
46
|
+
}
|
|
47
|
+
function typeMatches(value, types) {
|
|
48
|
+
return types.some((t) => {
|
|
49
|
+
switch (t) {
|
|
50
|
+
case "string":
|
|
51
|
+
return typeof value === "string";
|
|
52
|
+
case "number":
|
|
53
|
+
return typeof value === "number" && Number.isFinite(value);
|
|
54
|
+
case "integer":
|
|
55
|
+
return typeof value === "number" && Number.isInteger(value);
|
|
56
|
+
case "boolean":
|
|
57
|
+
return typeof value === "boolean";
|
|
58
|
+
case "null":
|
|
59
|
+
return value === null;
|
|
60
|
+
case "object":
|
|
61
|
+
return isObj(value);
|
|
62
|
+
case "array":
|
|
63
|
+
return Array.isArray(value);
|
|
64
|
+
default:
|
|
65
|
+
return false;
|
|
66
|
+
}
|
|
67
|
+
});
|
|
68
|
+
}
|
|
35
69
|
/**
|
|
36
|
-
* Keep only what the schema declares, deep: `findings[].title`
|
|
37
|
-
*
|
|
38
|
-
*
|
|
70
|
+
* Keep only what the schema declares, deep, and only values whose shape matches it: `findings[].title`
|
|
71
|
+
* keeps `title` and nothing else, a string where an object is declared is dropped, as is a value of
|
|
72
|
+
* the wrong `type` or outside an `enum`. An object with no declared properties is dropped (nothing in
|
|
73
|
+
* it was declared). Undefined means "drop".
|
|
39
74
|
*/
|
|
40
75
|
function pick(value, schema) {
|
|
41
76
|
const sch = isObj(schema) ? schema : undefined;
|
|
77
|
+
if (sch) {
|
|
78
|
+
const types = declaredTypes(sch);
|
|
79
|
+
if (types && !typeMatches(value, types))
|
|
80
|
+
return undefined;
|
|
81
|
+
if (Array.isArray(sch.enum) && !isObj(value) && !Array.isArray(value) && !sch.enum.includes(value)) {
|
|
82
|
+
return undefined;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
42
85
|
if (Array.isArray(value)) {
|
|
43
86
|
const items = sch && isObj(sch.items) ? sch.items : undefined;
|
|
44
87
|
return value.map((v) => pick(v, items)).filter((v) => v !== undefined);
|
|
@@ -54,20 +97,24 @@ function pick(value, schema) {
|
|
|
54
97
|
}
|
|
55
98
|
return value;
|
|
56
99
|
}
|
|
57
|
-
/**
|
|
58
|
-
|
|
100
|
+
/**
|
|
101
|
+
* Secrets in keys too (`redact` scrubs values and keeps keys). A key that redaction changes, or that
|
|
102
|
+
* already looks like a redacted name, becomes `[redacted-<first 16 hex of HMAC-SHA256(key)>]`, keyed
|
|
103
|
+
* with `macKey()`: injective (no two keys share a name, and no plain key can equal a generated one),
|
|
104
|
+
* stable (the same key gets the same name in every run, whatever else is present, so a diff lines
|
|
105
|
+
* them up) and not a guessing oracle for a low-entropy secret (a plain hash would be).
|
|
106
|
+
*/
|
|
107
|
+
function redactKeys(value, secrets, macKey) {
|
|
59
108
|
if (Array.isArray(value))
|
|
60
|
-
return value.map((v) => redactKeys(v, secrets));
|
|
109
|
+
return value.map((v) => redactKeys(v, secrets, macKey));
|
|
61
110
|
if (!isObj(value))
|
|
62
111
|
return value;
|
|
63
|
-
const seen = new Map();
|
|
64
112
|
return Object.fromEntries(Object.entries(value).map(([k, v]) => {
|
|
65
|
-
|
|
66
|
-
const
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
return [name, redactKeys(v, secrets)];
|
|
113
|
+
const scrubbed = redact(k, secrets).value;
|
|
114
|
+
const name = scrubbed !== k || k.startsWith("[redacted")
|
|
115
|
+
? `[redacted-${crypto.createHmac("sha256", macKey()).update(k).digest("hex").slice(0, 16)}]`
|
|
116
|
+
: k;
|
|
117
|
+
return [name, redactKeys(v, secrets, macKey)];
|
|
71
118
|
}));
|
|
72
119
|
}
|
|
73
120
|
/** [display value, digest tree]: strings over the cap are cut, and their full-text sha256 goes in the tree. */
|
|
@@ -97,6 +144,8 @@ function clipTree(value) {
|
|
|
97
144
|
*/
|
|
98
145
|
export function buildRunAnswers(i) {
|
|
99
146
|
const secrets = i.secrets ?? [];
|
|
147
|
+
let ephemeral;
|
|
148
|
+
const macKey = i.macKey ?? (() => (ephemeral ??= crypto.randomBytes(32)));
|
|
100
149
|
const nodes = [];
|
|
101
150
|
const digests = [];
|
|
102
151
|
for (const [id, r] of i.results) {
|
|
@@ -109,12 +158,15 @@ export function buildRunAnswers(i) {
|
|
|
109
158
|
if (isObj(props) && Object.keys(props).length > 0) {
|
|
110
159
|
fields = pick(data, schema);
|
|
111
160
|
}
|
|
112
|
-
else if (typeof data.summary === "string" &&
|
|
161
|
+
else if (typeof data.summary === "string" &&
|
|
162
|
+
data.summary.trim() !== "" &&
|
|
163
|
+
!(isObj(schema) && schema.additionalProperties === false)) {
|
|
164
|
+
// The free-form summary, unless the node's schema closes the object to its declared fields.
|
|
113
165
|
fields = { summary: data.summary };
|
|
114
166
|
}
|
|
115
167
|
if (!isObj(fields) || Object.keys(fields).length === 0)
|
|
116
168
|
continue;
|
|
117
|
-
const clean = redactKeys(redact(fields, secrets).value, secrets);
|
|
169
|
+
const clean = redactKeys(redact(fields, secrets).value, secrets, macKey);
|
|
118
170
|
const [shown, digs] = clipTree(clean);
|
|
119
171
|
nodes.push([id, shown]);
|
|
120
172
|
if (digs !== undefined)
|
|
@@ -414,7 +466,10 @@ export function sinceLastRun(runId, workflowId, cwd = process.cwd()) {
|
|
|
414
466
|
/** Save this run's answers, then compare with the previous run. Never throws. */
|
|
415
467
|
export function recordRunAnswers(i, cwd = process.cwd()) {
|
|
416
468
|
try {
|
|
417
|
-
|
|
469
|
+
// The key is per user and workspace and lives in the state dir; none usable means a process-only key.
|
|
470
|
+
let key;
|
|
471
|
+
const macKey = i.macKey ?? (() => (key ??= workspaceSecretKey(cwd, "answers") ?? crypto.randomBytes(32)));
|
|
472
|
+
if (!writeRunAnswers(buildRunAnswers({ ...i, macKey }), cwd))
|
|
418
473
|
return null;
|
|
419
474
|
return sinceLastRun(i.runId, i.workflow.id, cwd);
|
|
420
475
|
}
|
package/dist/cli/run-history.js
CHANGED
|
@@ -19,6 +19,7 @@ import { openNoFollow } from "../safe-file.js";
|
|
|
19
19
|
export const RUN_HISTORY_SCHEMA_VERSION = 1;
|
|
20
20
|
export const RUN_HISTORY_DIR = path.join(".sweny", "runs");
|
|
21
21
|
export const RUN_HISTORY_KEEP = 200;
|
|
22
|
+
const RECORD_READ_MAX_BYTES = 1024 * 1024;
|
|
22
23
|
// ── Hash + ids ──────────────────────────────────────────────────
|
|
23
24
|
function canonicalize(value) {
|
|
24
25
|
if (Array.isArray(value))
|
|
@@ -215,7 +216,12 @@ export function listRuns(cwd = process.cwd()) {
|
|
|
215
216
|
const out = [];
|
|
216
217
|
for (const f of files) {
|
|
217
218
|
try {
|
|
218
|
-
|
|
219
|
+
// A link is not history: the target may be anywhere, and an agent can plant it.
|
|
220
|
+
const file = path.join(dir, f);
|
|
221
|
+
const st = fs.lstatSync(file);
|
|
222
|
+
if (!st.isFile() || st.size > RECORD_READ_MAX_BYTES)
|
|
223
|
+
continue;
|
|
224
|
+
const parsed = JSON.parse(fs.readFileSync(file, "utf-8"));
|
|
219
225
|
if (isRecord(parsed) && RUN_ID_RE.test(parsed.run_id) && f === `${parsed.run_id}.json`)
|
|
220
226
|
out.push(parsed);
|
|
221
227
|
}
|
package/dist/cli/runs.js
CHANGED
|
@@ -222,12 +222,14 @@ function formatAnswersSection(cmp, color, g) {
|
|
|
222
222
|
out.push(p.muted(` ${g.ellipsis} and ${list.length - MAX_ITEMS_SHOWN} more ${word}`));
|
|
223
223
|
}
|
|
224
224
|
};
|
|
225
|
+
// Node, field and nested field names are keys of agent-writable files: terminal text, not trusted.
|
|
226
|
+
const key = (s) => sanitizeTerminal(s, 64);
|
|
225
227
|
const fields = (list) => {
|
|
226
228
|
if (list.length === 0)
|
|
227
229
|
return;
|
|
228
|
-
const w = Math.max(...list.map((f) => f.field.length));
|
|
230
|
+
const w = Math.max(...list.map((f) => key(f.field).length));
|
|
229
231
|
for (const f of list.slice(0, MAX_FIELDS_SHOWN)) {
|
|
230
|
-
out.push(` ${pad(f.field, w)} ${valueChange(f.before, f.after, g)}`);
|
|
232
|
+
out.push(` ${pad(key(f.field), w)} ${valueChange(f.before, f.after, g)}`);
|
|
231
233
|
}
|
|
232
234
|
if (list.length > MAX_FIELDS_SHOWN)
|
|
233
235
|
out.push(p.muted(` ${g.ellipsis} and ${list.length - MAX_FIELDS_SHOWN} more fields`));
|
|
@@ -236,14 +238,14 @@ function formatAnswersSection(cmp, color, g) {
|
|
|
236
238
|
for (const node of nodes) {
|
|
237
239
|
const changed = cmp.entries.filter((e) => e.node === node && isChangedEntry(e));
|
|
238
240
|
if (changed.length === 0) {
|
|
239
|
-
out.push(` ${node} ${p.muted("unchanged")}`);
|
|
241
|
+
out.push(` ${key(node)} ${p.muted("unchanged")}`);
|
|
240
242
|
continue;
|
|
241
243
|
}
|
|
242
|
-
out.push(` ${node}`);
|
|
243
|
-
const w = Math.max(...changed.map((e) => e.field.length));
|
|
244
|
+
out.push(` ${key(node)}`);
|
|
245
|
+
const w = Math.max(...changed.map((e) => key(e.field).length));
|
|
244
246
|
for (const e of changed) {
|
|
245
247
|
if (e.kind === "collection") {
|
|
246
|
-
out.push(` ${pad(e.field, w)} ${p.muted(countsHead(e, g))}`);
|
|
248
|
+
out.push(` ${pad(key(e.field), w)} ${p.muted(countsHead(e, g))}`);
|
|
247
249
|
items("+", p.warning, e.added, "added");
|
|
248
250
|
items("-", p.success, e.resolved, "resolved");
|
|
249
251
|
for (const c of e.changed.slice(0, MAX_ITEMS_SHOWN)) {
|
|
@@ -255,18 +257,18 @@ function formatAnswersSection(cmp, color, g) {
|
|
|
255
257
|
}
|
|
256
258
|
}
|
|
257
259
|
else if (e.status === "added") {
|
|
258
|
-
out.push(` ${pad(e.field, w)} ${p.warning("added")} ${showValue(e.after, g)}`);
|
|
260
|
+
out.push(` ${pad(key(e.field), w)} ${p.warning("added")} ${showValue(e.after, g)}`);
|
|
259
261
|
}
|
|
260
262
|
else if (e.status === "resolved") {
|
|
261
|
-
out.push(` ${pad(e.field, w)} ${p.success("resolved")} ${p.muted(`(was ${showValue(e.before, g)})`)}`);
|
|
263
|
+
out.push(` ${pad(key(e.field), w)} ${p.success("resolved")} ${p.muted(`(was ${showValue(e.before, g)})`)}`);
|
|
262
264
|
}
|
|
263
265
|
else {
|
|
264
|
-
out.push(` ${pad(e.field, w)} ${valueChange(e.before, e.after, g)}`);
|
|
266
|
+
out.push(` ${pad(key(e.field), w)} ${valueChange(e.before, e.after, g)}`);
|
|
265
267
|
}
|
|
266
268
|
}
|
|
267
269
|
}
|
|
268
270
|
for (const n of cmp.not_compared) {
|
|
269
|
-
out.push(` ${n.node} ${p.muted(`not compared (answer in ${n.only_in} only)`)}`);
|
|
271
|
+
out.push(` ${key(n.node)} ${p.muted(`not compared (answer in ${n.only_in} only)`)}`);
|
|
270
272
|
}
|
|
271
273
|
if (nodes.length === 0 && cmp.not_compared.length === 0)
|
|
272
274
|
out.push(p.muted(" no declared outputs to compare"));
|
package/dist/executor.js
CHANGED
|
@@ -598,6 +598,8 @@ async function executeRun(workflow, input, options, results, trace, at) {
|
|
|
598
598
|
safeObserve(observer, { type: "node:progress", node: currentId, message }, logger);
|
|
599
599
|
},
|
|
600
600
|
});
|
|
601
|
+
// Commit the attempt's spend before anything here can throw (#549); `finish` below reads the breach.
|
|
602
|
+
attemptBudget?.settle(result.usage);
|
|
601
603
|
}
|
|
602
604
|
catch (err) {
|
|
603
605
|
if (journalFault)
|
|
@@ -608,8 +610,9 @@ async function executeRun(workflow, input, options, results, trace, at) {
|
|
|
608
610
|
throw err;
|
|
609
611
|
}
|
|
610
612
|
finally {
|
|
611
|
-
|
|
613
|
+
// A throw from the harness still commits the spend it reported live; a no-op once settled.
|
|
612
614
|
attemptBudget?.dispose();
|
|
615
|
+
parentSignal?.removeEventListener("abort", onParentAbort);
|
|
613
616
|
}
|
|
614
617
|
if (journalFault)
|
|
615
618
|
throw journalFault;
|
|
@@ -632,8 +635,8 @@ async function executeRun(workflow, input, options, results, trace, at) {
|
|
|
632
635
|
result = { ...result, degraded: [...new Set([...(result.degraded ?? []), ...budgetCheck.degraded])] };
|
|
633
636
|
}
|
|
634
637
|
if (attemptBudget) {
|
|
635
|
-
//
|
|
636
|
-
// in the final usage, fails the node whatever else it did.
|
|
638
|
+
// The spend was committed when the harness returned; a live stop, or a
|
|
639
|
+
// crossing visible only in the final usage, fails the node whatever else it did.
|
|
637
640
|
const breach = attemptBudget.finish(result.usage);
|
|
638
641
|
if (breach) {
|
|
639
642
|
const error = describeOverrun(breach, currentId);
|
package/dist/journal.d.ts
CHANGED
|
@@ -154,6 +154,14 @@ export declare class JournalLocationError extends Error {
|
|
|
154
154
|
* it points. Throws {@link JournalLocationError}.
|
|
155
155
|
*/
|
|
156
156
|
export declare function assertStateOutsideWorkspace(dir: string, cwd: string): void;
|
|
157
|
+
/**
|
|
158
|
+
* A 32-byte random key private to this user and workspace, for MACs that must stay the same from run to
|
|
159
|
+
* run (a run's journal key is per run, so it cannot serve). Stored hex, mode 0600, at
|
|
160
|
+
* `<state dir>/keys/<workspace-hash>.<purpose>.key`, read and created without following a link. Null
|
|
161
|
+
* when the state dir cannot be trusted (inside the workspace, where an agent can read it) or used;
|
|
162
|
+
* callers then fall back to a key that lives only in the process. Never written anywhere an agent reads.
|
|
163
|
+
*/
|
|
164
|
+
export declare function workspaceSecretKey(cwd: string, purpose: string, env?: Record<string, string | undefined>): Buffer | null;
|
|
157
165
|
/** Thrown when a journal's key is missing or unreadable: its records cannot be checked. */
|
|
158
166
|
export declare class JournalKeyError extends Error {
|
|
159
167
|
constructor(file: string, why: string);
|
package/dist/journal.js
CHANGED
|
@@ -313,6 +313,64 @@ export function assertStateOutsideWorkspace(dir, cwd) {
|
|
|
313
313
|
throw new JournalLocationError(real, ws);
|
|
314
314
|
}
|
|
315
315
|
}
|
|
316
|
+
/** True when `dir` (which may not exist yet) would resolve inside `cwd`: its nearest existing ancestor's real path decides. */
|
|
317
|
+
function resolvesInside(dir, cwd) {
|
|
318
|
+
const ws = realOr(cwd);
|
|
319
|
+
let existing = path.resolve(dir);
|
|
320
|
+
const rest = [];
|
|
321
|
+
while (!fs.existsSync(existing) && path.dirname(existing) !== existing) {
|
|
322
|
+
rest.unshift(path.basename(existing));
|
|
323
|
+
existing = path.dirname(existing);
|
|
324
|
+
}
|
|
325
|
+
const real = path.join(realOr(existing), ...rest);
|
|
326
|
+
return real === ws || real.startsWith(ws.endsWith(path.sep) ? ws : ws + path.sep);
|
|
327
|
+
}
|
|
328
|
+
/**
|
|
329
|
+
* A 32-byte random key private to this user and workspace, for MACs that must stay the same from run to
|
|
330
|
+
* run (a run's journal key is per run, so it cannot serve). Stored hex, mode 0600, at
|
|
331
|
+
* `<state dir>/keys/<workspace-hash>.<purpose>.key`, read and created without following a link. Null
|
|
332
|
+
* when the state dir cannot be trusted (inside the workspace, where an agent can read it) or used;
|
|
333
|
+
* callers then fall back to a key that lives only in the process. Never written anywhere an agent reads.
|
|
334
|
+
*/
|
|
335
|
+
export function workspaceSecretKey(cwd, purpose, env = process.env) {
|
|
336
|
+
try {
|
|
337
|
+
const dir = path.join(swenyStateDir(env), "keys");
|
|
338
|
+
if (resolvesInside(dir, cwd))
|
|
339
|
+
return null;
|
|
340
|
+
makePrivateDir(dir);
|
|
341
|
+
if (resolvesInside(dir, cwd))
|
|
342
|
+
return null;
|
|
343
|
+
const file = path.join(dir, `${workspaceScope(cwd)}.${purpose.replace(/[^A-Za-z0-9_-]/g, "_")}.key`);
|
|
344
|
+
const read = () => {
|
|
345
|
+
const text = readNoFollow(file).toString("utf8").trim();
|
|
346
|
+
if (!/^[0-9a-f]{64}$/.test(text))
|
|
347
|
+
return null;
|
|
348
|
+
if (process.platform !== "win32" && (fs.lstatSync(file).mode & 0o077) !== 0)
|
|
349
|
+
return null;
|
|
350
|
+
return Buffer.from(text, "hex");
|
|
351
|
+
};
|
|
352
|
+
try {
|
|
353
|
+
return read();
|
|
354
|
+
}
|
|
355
|
+
catch (err) {
|
|
356
|
+
if (err.code !== "ENOENT")
|
|
357
|
+
return null;
|
|
358
|
+
}
|
|
359
|
+
const key = crypto.randomBytes(32);
|
|
360
|
+
try {
|
|
361
|
+
writeNew(file, key.toString("hex") + "\n");
|
|
362
|
+
return key;
|
|
363
|
+
}
|
|
364
|
+
catch (err) {
|
|
365
|
+
if (err.code !== "EEXIST")
|
|
366
|
+
return null;
|
|
367
|
+
return read(); // another run created it first
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
catch {
|
|
371
|
+
return null;
|
|
372
|
+
}
|
|
373
|
+
}
|
|
316
374
|
/** Thrown when a journal's key is missing or unreadable: its records cannot be checked. */
|
|
317
375
|
export class JournalKeyError extends Error {
|
|
318
376
|
constructor(file, why) {
|