@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 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 finishes.
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. Commit its spend (the larger of what the result
117
- * reports and what was seen live) and return the breach, if any: the live
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
- /** Release the outer-signal listener when the run threw before `finish`. */
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 finishes.
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. Commit its spend (the larger of what the result
188
- * reports and what was seen live) and return the breach, if any: the live
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
- finish(finalUsage) {
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 b = this.stopped ?? this.check(spent);
205
+ const breach = this.stopped ?? this.check(spent);
206
+ this.settled = { breach };
199
207
  this.node.commit(spent);
200
- return b;
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
- /** Release the outer-signal listener when the run threw before `finish`. */
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.outer?.removeEventListener("abort", this.onOuterAbort);
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
@@ -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` keeps `title` and nothing else. An
37
- * object with no declared properties is dropped (nothing in it was declared); scalars are kept.
38
- * Undefined means "drop".
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
- /** Secrets in keys too (`redact` scrubs values and keeps keys). */
58
- function redactKeys(value, secrets) {
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
- let name = redact(k, secrets).value;
66
- const n = (seen.get(name) ?? 0) + 1;
67
- seen.set(name, n);
68
- if (n > 1)
69
- name = `${name}#${n}`;
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" && data.summary.trim() !== "") {
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
- if (!writeRunAnswers(buildRunAnswers(i), cwd))
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
  }
@@ -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
- const parsed = JSON.parse(fs.readFileSync(path.join(dir, f), "utf-8"));
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
- parentSignal?.removeEventListener("abort", onParentAbort);
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
- // Commit this attempt's spend; a live stop, or a crossing visible only
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) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sweny-ai/core",
3
- "version": "0.1.190",
3
+ "version": "0.1.192",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "sweny": "./dist/cli/main.js"