@sema-agent/core 5.53.0 → 5.54.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.
@@ -35,6 +35,17 @@
35
35
  * compensation was the closest of the four and still wrong twice: it does not cover an unwritable
36
36
  * FILE (the reopen throws too), and its reopen error REPLACES the fault that caused the swap. The
37
37
  * answer is AppendLog's lazy reopen — {@link LedgerCore.compact}.
38
+ * · **(2026-08-22, cross-process defect probe) — one authority per directory needs one WRITER per
39
+ * directory, and that half was never enforced.** RB-55/59/134 closed the IN-PROCESS shape (two
40
+ * instances, two private replays, two winners); two OS PROCESSES over one directory reproduced it
41
+ * verbatim (a human approval and its refusal each reported as the winner) — and worse, the one that
42
+ * never loaded the other's rows ERASED them at its next {@link LedgerCore.compact} (the snapshot is
43
+ * rewritten from the in-memory map, and the live ledger is then truncated). The class docs all
44
+ * credited a fence they did not hold: `root/LOCK` belongs to `FileStorageBackend`, which never
45
+ * constructs the workflow-run store at all and which a directly-constructed store (all three are root
46
+ * exports) never goes through. So the fence now lives WITH the authority: {@link LedgerCore.bootstrap}
47
+ * takes `dir/LOCK` ({@link acquireStoreDirLock}) and the last {@link LedgerCore.release} drops it —
48
+ * exclusive across processes, refcount-joined in-process, exactly like the authority it guards.
38
49
  * · **RB-150 / RB-167 — compaction is HOUSEKEEPING.** It runs after the operation is durable AND
39
50
  * applied, so its failure must never be reported as the operation's failure: a committed
40
51
  * once-only CAS surfaced as "this did not happen" is the worst possible direction (the caller
@@ -57,6 +68,9 @@ export interface LedgerPaths {
57
68
  }
58
69
  /** The per-store half: how a SNAPSHOT row is keyed, and how one LEDGER event folds into the map. */
59
70
  export interface LedgerModel<Row, Ev> {
71
+ /** What this ledger IS, in an operator's words ("checkpoint ledger") — the noun the directory-fence
72
+ * refusal names, so the message points at the store that is actually in use rather than at a path. */
73
+ label: string;
60
74
  keyOf(row: Row): string;
61
75
  apply(rows: Map<string, Row>, ev: Ev): void;
62
76
  }
@@ -77,9 +91,21 @@ export declare class LedgerCore<Row, Ev> {
77
91
  private readonly locks;
78
92
  private events;
79
93
  private handle;
94
+ /** This directory's cross-process writer fence, held for as long as the authority exists. */
95
+ private fence;
96
+ /** Set when the last holder left: this object is no longer the directory's authority (see compact). */
97
+ private released;
80
98
  constructor(key: string, paths: LedgerPaths, model: LedgerModel<Row, Ev>, table: Map<string, LedgerCore<Row, Ev>>);
81
- /** Rebuild the authority: the snapshot (the compacted base) first, then the live ledger's events in
82
- * order (last-writer-wins), then open the append handle. Only the FIRST instance runs it. */
99
+ /**
100
+ * Rebuild the authority: take the directory's cross-process writer fence, then the snapshot (the
101
+ * compacted base), then the live ledger's events in order (last-writer-wins), then open the append
102
+ * handle. Only the FIRST instance over the directory runs it.
103
+ *
104
+ * The fence comes FIRST because everything after it assumes what the fence establishes: the replayed
105
+ * map is treated as THE authority for the directory (writes are decided against memory, and
106
+ * compaction rewrites the on-disk state FROM memory). Replaying first and locking after would be a
107
+ * window in which a second process's view is already stale.
108
+ */
83
109
  bootstrap(): void;
84
110
  /**
85
111
  * Serialize an op behind any in-flight op on the same key — the in-process per-key async mutex (the
@@ -93,11 +119,20 @@ export declare class LedgerCore<Row, Ev> {
93
119
  * memory and disk disagreeing. Then the best-effort housekeeping compaction (RB-150/RB-167).
94
120
  */
95
121
  commit(ev: Ev, fsync: boolean, compactEvery: number): void;
96
- /** Rewrite the snapshot from the authoritative map (atomic) and truncate the live ledger. After
97
- * this a replay reads the whole state from the snapshot alone the round-trip is identical. */
122
+ /**
123
+ * Rewrite the snapshot from the authoritative map (atomic) and truncate the live ledger. After
124
+ * this a replay reads the whole state from the snapshot alone — the round-trip is identical.
125
+ *
126
+ * REFUSED once this authority has been RELEASED. Compaction is the one operation that does not touch
127
+ * the append fd (it rewrites whole files), so a store object kept alive past its `close()` could still
128
+ * run it — rewriting the directory from a map that stopped being the authority, over rows a SUCCESSOR
129
+ * (here or in another process, holding the fence this core gave back) has since written. That is the
130
+ * same erasure the directory fence exists to prevent, arriving through a stale reference instead of a
131
+ * second process, so it is refused at the same door rather than trusted to caller discipline.
132
+ */
98
133
  compact(): void;
99
134
  /** Drop one instance's share. The LAST holder evicts the authority — identity-checked, so a stale
100
- * holder can never revoke a REBUILT entry — and closes the append fd. */
135
+ * holder can never revoke a REBUILT entry — retires it (see {@link compact}) and closes the append fd. */
101
136
  release(): void;
102
137
  /** Test/inspection: events appended since the last compaction. */
103
138
  get pendingEvents(): number;
@@ -1,4 +1,4 @@
1
- import { AppendLog, atomicWriteFile, canonicalStoreKey, ensureDir, readJsonlRecords } from "./fs-atomic.js";
1
+ import { acquireStoreDirLock, AppendLog, atomicWriteFile, canonicalStoreKey, ensureDir, readJsonlRecords, } from "./fs-atomic.js";
2
2
  export class LedgerCore {
3
3
  key;
4
4
  paths;
@@ -9,6 +9,8 @@ export class LedgerCore {
9
9
  locks = new Map();
10
10
  events = 0;
11
11
  handle;
12
+ fence;
13
+ released = false;
12
14
  constructor(key, paths, model, table) {
13
15
  this.key = key;
14
16
  this.paths = paths;
@@ -16,13 +18,21 @@ export class LedgerCore {
16
18
  this.table = table;
17
19
  }
18
20
  bootstrap() {
19
- for (const row of readJsonlRecords(this.paths.snapshotPath))
20
- this.rows.set(this.model.keyOf(row), row);
21
- const events = readJsonlRecords(this.paths.ledgerPath);
22
- this.events = events.length;
23
- for (const ev of events)
24
- this.model.apply(this.rows, ev);
25
- this.handle = new AppendLog(this.paths.ledgerPath);
21
+ this.fence = acquireStoreDirLock(this.paths.dir, { label: this.model.label });
22
+ try {
23
+ for (const row of readJsonlRecords(this.paths.snapshotPath))
24
+ this.rows.set(this.model.keyOf(row), row);
25
+ const events = readJsonlRecords(this.paths.ledgerPath);
26
+ this.events = events.length;
27
+ for (const ev of events)
28
+ this.model.apply(this.rows, ev);
29
+ this.handle = new AppendLog(this.paths.ledgerPath);
30
+ }
31
+ catch (e) {
32
+ this.fence.release();
33
+ this.fence = undefined;
34
+ throw e;
35
+ }
26
36
  }
27
37
  withLock(key, fn) {
28
38
  const prev = this.locks.get(key) ?? Promise.resolve();
@@ -48,6 +58,9 @@ export class LedgerCore {
48
58
  }
49
59
  }
50
60
  compact() {
61
+ if (this.released) {
62
+ throw new Error("file ledger: this authority was released (its store was closed) — refusing to compact a directory it no longer owns");
63
+ }
51
64
  const lines = [...this.rows.values()].map((r) => JSON.stringify(r)).join("\n");
52
65
  atomicWriteFile(this.paths.tmpDir, this.paths.snapshotPath, lines.length ? `${lines}\n` : "");
53
66
  this.log.closeForSwap();
@@ -60,7 +73,10 @@ export class LedgerCore {
60
73
  return;
61
74
  if (this.table.get(this.key) === this)
62
75
  this.table.delete(this.key);
76
+ this.released = true;
63
77
  this.handle?.close();
78
+ this.fence?.release();
79
+ this.fence = undefined;
64
80
  }
65
81
  get pendingEvents() {
66
82
  return this.events;
@@ -35,6 +35,13 @@ export declare class FileWorkflowRunStore implements WorkflowRunStore {
35
35
  compactNow(): void;
36
36
  /** Test/inspection helper: number of stored runs. */
37
37
  get size(): number;
38
- /** Release the append handle (best-effort). The boot LOCK is released by the backend factory. */
38
+ /**
39
+ * Release the append handle (best-effort). The LAST holder over the directory also drops its
40
+ * cross-process writer fence (`<dir>/LOCK`), so a successor process can open the same run ledger.
41
+ * There is no OTHER door: this store is never constructed by `FileStorageBackend`, so nothing else
42
+ * ever hands its fence back — the doc line that used to credit "the backend factory" here named a
43
+ * release that could not happen under any assembly, for a lock this store did not hold in the first
44
+ * place (see the class note above).
45
+ */
39
46
  close(): void;
40
47
  }
@@ -4,6 +4,7 @@ import { WorkflowRunStoreError, isTerminalWorkflowStatus, nextWorkflowRunOnUpdat
4
4
  import { SharedLedgerTable } from "./shared-ledger.js";
5
5
  import { assertAdoptionBootGate } from "./adoption/marker.js";
6
6
  const runLedgers = new SharedLedgerTable({
7
+ label: "workflow-run ledger",
7
8
  keyOf: (run) => run.id,
8
9
  apply: (runs, ev) => {
9
10
  if (ev.t === "delete") {
@@ -40,6 +40,29 @@ export interface LeadingCommandNameOptions {
40
40
  * operator character counts, exactly as without this option.
41
41
  */
42
42
  quotedOperatorsAreText?: boolean;
43
+ /**
44
+ * Read a PATH-PREFIXED `argv[0]` (`./gradlew`, `/usr/bin/git`, `bin/tool`) as an ordinary command
45
+ * name instead of refusing it.
46
+ *
47
+ * OPT-IN, and used by ONE family of callers: the persisted permission-RULE lane
48
+ * ({@link import("../../core/permission-rule-model.js").parseAllowRuleText} and its matcher), where
49
+ * both sides of the comparison are literal text a person read on an approval card. The refusal this
50
+ * option lifts exists for the argv[0]-NAME filters (the read-only allowlist, the coarse
51
+ * command-name policy, the skill specifier): those compare a bare token against a name set, and a
52
+ * path prefix is how a caller reaches a program the set never vetted. A rule lane compares the whole
53
+ * command line instead, so `./gradlew` there is not a way past a name set — it IS the name that was
54
+ * approved, and refusing it made the single most ordinary build command in a repository unable to
55
+ * carry a standing approval at all.
56
+ *
57
+ * What it does NOT lift: the argv[0] metacharacter refusal (quotes/braces/globs/`~` still make the
58
+ * parsed token differ from the program bash runs), the leading env-assignment refusal, and every
59
+ * shell operator. And it does not make a path-prefixed name equal its basename anywhere — the rule
60
+ * lane matches on the text as written, so `./gradlew` and `gradlew` stay two different commands. The
61
+ * one place a basename IS taken is the rule lane's own interpreter refusal, which must read
62
+ * `/usr/bin/node` as `node` (see
63
+ * {@link import("../../core/permission-rule-model.js").BARE_INTERPRETER_NAMES}).
64
+ */
65
+ pathPrefixedNameIsText?: boolean;
43
66
  }
44
67
  /**
45
68
  * The SINGLE fail-closed simple-command parser shared by `bash_readonly` ({@link coarseReadonlyCheck}), the
@@ -48,6 +71,8 @@ export interface LeadingCommandNameOptions {
48
71
  * `argv[0]` command NAME of a SINGLE simple command, rejecting anything that could chain past or escape an
49
72
  * argv[0]-name filter: shell operators (pipes / redirects / `;` / `&&` / `$(…)` / subshells / backticks /
50
73
  * newlines / backslash), a path-prefixed command (`/usr/bin/foo`), or a leading env-assignment (`FOO=bar cmd`).
74
+ * The path-prefix refusal is the one arm a caller may lift, opt-in and for the rule lane only — see
75
+ * {@link LeadingCommandNameOptions.pathPrefixedNameIsText} for why it is sound exactly there.
51
76
  *
52
77
  * Returns `{ name }` for a parseable single bare command, or `{ reject }` with a human reason otherwise. It
53
78
  * does NOT inspect ARGUMENTS for write flags or consult any allowlist — that is the caller's job (the
@@ -66,6 +91,52 @@ export declare function parseLeadingCommandName(command: string, options?: Leadi
66
91
  * design/154: this `effect:"read"` DECLARATION face deliberately stays strict-single-command; only the
67
92
  * classify face ({@link classifyCompoundReadonly} via {@link import("./fs-bash.js").bashReversibilityProbe}) segments compounds. */
68
93
  export declare function coarseReadonlyCheck(command: string, allow: ReadonlySet<string>, options?: LeadingCommandNameOptions): string | undefined;
94
+ /** What {@link splitShellCompoundSegments} produces: the connector-delimited segments, plus which of
95
+ * them a PIPE fed (the read-only classifier's stdin arm needs that distinction; a rule lane does not). */
96
+ export interface ShellCompoundSegments {
97
+ segments: string[];
98
+ /** Parallel to {@link segments}: was this segment preceded by `|` (rather than `;`/`&&`/`||`/nothing)? */
99
+ pipeFed: boolean[];
100
+ }
101
+ /**
102
+ * Split a compound command into the segments bash would run, or refuse the whole string.
103
+ *
104
+ * THE one segmentation in this repository. Extracted from {@link classifyCompoundReadonlyDetailed}
105
+ * (whose behaviour it reproduces exactly) when a SECOND face needed segments — the persisted
106
+ * permission-rule lane, which must judge every segment of a compound against the deny/ask rules rather
107
+ * than reading the whole string as one unmatched blob. The same argument that keeps ONE
108
+ * {@link parseLeadingCommandName} applies with more force here: a second splitter would drift, and
109
+ * drift in a segmentation is drift in what a deny rule is even looking at.
110
+ *
111
+ * Three steps, in this order and load-bearing:
112
+ * 1. WHOLE-STRING hard reject of {@link SHELL_SEGMENT_HARD_REJECT} — redirection, substitution,
113
+ * subshells, escapes, line breaks. This is what makes step 3's quote mask EXACT: with those
114
+ * characters gone, `'`/`"` pairing is the entirety of quoting, so the mask's regions are exactly
115
+ * bash's quoted regions and the unquoted connectors are exactly bash's command boundaries. Callers
116
+ * that tolerate a narrow redirection subset (the read-only classify face and its two data-free
117
+ * spellings) remove those words BEFORE calling; nothing here restores them.
118
+ * 2. one trailing `;` is stripped — a no-op terminator, not an empty command. OPTIONAL, because it is
119
+ * the one step whose answer depends on what the caller is asking. A face that asks "which programs
120
+ * does this run" wants it stripped (`ls;` runs `ls`, nothing else). A face that asks "is this ONE
121
+ * command" must NOT strip it: `"keep"` makes `ls;` two segments, the second empty, so the caller's
122
+ * own empty-command rule puts the whole string outside its lane — which is what keeps a statement
123
+ * like "this rule form never matches a command containing a connector" literally true instead of
124
+ * true-except-for-one-spelling.
125
+ * 3. the quote-aware connector scan over `;` `&&` `||` `|`. A lone `&` is REFUSED rather than split
126
+ * on: it backgrounds a process that outlives the command, which is not a combinator any face here
127
+ * is willing to reason about. Unbalanced quoting disables the mask, which degrades to the
128
+ * quote-blind scan — strictly MORE segments, each of which the caller must still vet, i.e. the
129
+ * fail-closed direction.
130
+ *
131
+ * Empty segments are NOT refused here (`;;`, a leading `;`, a trailing `&&`) — they come back as empty
132
+ * strings, and every caller's per-segment vetting rejects them through its own empty-command rule. The
133
+ * split does not judge segments; it says where they are.
134
+ */
135
+ export declare function splitShellCompoundSegments(source: string, options?: {
136
+ trailingTerminator?: "strip" | "keep";
137
+ }): ShellCompoundSegments | {
138
+ reject: string;
139
+ };
69
140
  /**
70
141
  * RB-412 — the READ-BOUNDARY face of the read-only classification (opt-in).
71
142
  *
@@ -45,8 +45,9 @@ export function parseLeadingCommandName(command, options) {
45
45
  };
46
46
  }
47
47
  const first = trimmed.split(/\s+/)[0];
48
- if (first.includes("/"))
48
+ if (first.includes("/") && options?.pathPrefixedNameIsText !== true) {
49
49
  return { reject: "the command must be a bare name resolved via PATH (no path prefix)" };
50
+ }
50
51
  if (first.includes("="))
51
52
  return { reject: "leading environment-variable assignments are not allowed" };
52
53
  if (/["'{}*?[\]~]/.test(first))
@@ -62,6 +63,55 @@ export function coarseReadonlyCheck(command, allow, options) {
62
63
  return undefined;
63
64
  }
64
65
  const SHELL_SEGMENT_HARD_REJECT = /[<>$()`\n\r\\]/;
66
+ export function splitShellCompoundSegments(source, options) {
67
+ if (SHELL_SEGMENT_HARD_REJECT.test(source)) {
68
+ return { reject: "redirection, command/variable substitution, subshells, escapes, and line breaks are not allowed" };
69
+ }
70
+ const scanned = options?.trailingTerminator !== "keep" ? source.replace(/;\s*$/, "") : source;
71
+ const segments = [];
72
+ const pipeFed = [false];
73
+ const mask = quoteMask(scanned);
74
+ const quoted = (i) => mask.balanced && mask.quoted[i] === true;
75
+ let cur = "";
76
+ for (let i = 0; i < scanned.length; i++) {
77
+ const c = scanned[i];
78
+ if (quoted(i)) {
79
+ cur += c;
80
+ }
81
+ else if (c === "&") {
82
+ if (scanned[i + 1] === "&") {
83
+ segments.push(cur);
84
+ cur = "";
85
+ pipeFed.push(false);
86
+ i++;
87
+ }
88
+ else
89
+ return { reject: "`&` backgrounding is not allowed — a backgrounded process outlives the command" };
90
+ }
91
+ else if (c === "|") {
92
+ if (scanned[i + 1] === "|") {
93
+ segments.push(cur);
94
+ cur = "";
95
+ pipeFed.push(false);
96
+ i++;
97
+ }
98
+ else {
99
+ segments.push(cur);
100
+ cur = "";
101
+ pipeFed.push(true);
102
+ }
103
+ }
104
+ else if (c === ";") {
105
+ segments.push(cur);
106
+ cur = "";
107
+ pipeFed.push(false);
108
+ }
109
+ else
110
+ cur += c;
111
+ }
112
+ segments.push(cur);
113
+ return { segments, pipeFed };
114
+ }
65
115
  const EXEMPT_REDIRECTION = /^(?:2>\/dev\/null|[12]?>&[12])$/;
66
116
  function stripExemptRedirections(command) {
67
117
  if (!command.includes(">"))
@@ -532,52 +582,13 @@ export function classifyCompoundReadonlyDetailed(command, allow, boundary) {
532
582
  if (!trimmed)
533
583
  return { reason: "empty command" };
534
584
  const redirectionStripped = stripExemptRedirections(trimmed);
535
- if (SHELL_SEGMENT_HARD_REJECT.test(redirectionStripped)) {
536
- return { reason: "redirection, command/variable substitution, subshells, escapes, and line breaks are not allowed" };
537
- }
538
- const source = redirectionStripped.endsWith(";") ? redirectionStripped.slice(0, -1) : redirectionStripped;
539
- const segments = [];
540
- const pipeFed = [false];
541
- const mask = quoteMask(source);
542
- const quoted = (i) => mask.balanced && mask.quoted[i] === true;
543
- let cur = "";
544
- for (let i = 0; i < source.length; i++) {
545
- const c = source[i];
546
- if (quoted(i)) {
547
- cur += c;
548
- }
549
- else if (c === "&") {
550
- if (source[i + 1] === "&") {
551
- segments.push(cur);
552
- cur = "";
553
- pipeFed.push(false);
554
- i++;
555
- }
556
- else
557
- return { reason: "`&` backgrounding is not allowed — a backgrounded process outlives the command" };
558
- }
559
- else if (c === "|") {
560
- if (source[i + 1] === "|") {
561
- segments.push(cur);
562
- cur = "";
563
- pipeFed.push(false);
564
- i++;
565
- }
566
- else {
567
- segments.push(cur);
568
- cur = "";
569
- pipeFed.push(true);
570
- }
571
- }
572
- else if (c === ";") {
573
- segments.push(cur);
574
- cur = "";
575
- pipeFed.push(false);
576
- }
577
- else
578
- cur += c;
579
- }
580
- segments.push(cur);
585
+ const terminatorStripped = redirectionStripped.endsWith(";")
586
+ ? redirectionStripped.slice(0, -1)
587
+ : redirectionStripped;
588
+ const split = splitShellCompoundSegments(terminatorStripped, { trailingTerminator: "keep" });
589
+ if ("reject" in split)
590
+ return { reason: split.reject };
591
+ const { segments, pipeFed } = split;
581
592
  for (const segment of segments) {
582
593
  const reason = coarseReadonlyCheck(segment, allow, { quotedOperatorsAreText: true });
583
594
  if (reason !== undefined)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sema-agent/core",
3
- "version": "5.53.0",
3
+ "version": "5.54.0",
4
4
  "description": "Stateless, task-oriented AI agent core",
5
5
  "type": "module",
6
6
  "license": "BUSL-1.1",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "_comment": "design/87 L3 — frozen public export surface of src/index.ts (name -> kind). DO NOT edit by hand to silence a red test. A removed/changed entry = a SemVer-BREAKING change; bump MAJOR and update this fixture in the SAME commit (design/87 §4.2 / §5.2). Regenerate via REGEN in test/export-surface.test.ts.",
3
- "count": 1649,
3
+ "count": 1651,
4
4
  "exports": {
5
5
  "A2ATaskState": "type",
6
6
  "A2ATaskStateReversal": "type",
@@ -319,6 +319,8 @@
319
319
  "FileStorageBackend": "class",
320
320
  "FileStorageBackendOptions": "interface",
321
321
  "FileStorageCorruptReadInfo": "interface",
322
+ "FileStoreLockError": "class",
323
+ "FileStoreLockErrorCode": "type",
322
324
  "FileStrategyStore": "class",
323
325
  "FileStrategyStoreOptions": "interface",
324
326
  "FileToolResultStore": "class",