From b042485dc3ed2b83fd3f28d6d70828cb72419e2d Mon Sep 17 00:00:00 2001 From: Hamish-2569 Date: Wed, 14 Oct 2026 23:16:41 -0400 Subject: [PATCH 001/187] fix(gate): keep a gate.json that won't load, and reload gate files by their text addPolicy and removePolicy (wake_add_rule and wake_remove_rule, and the framework's addGatePolicy and removeGatePolicy) read gate.json again before a change, and when it was there but wouldn't load, they fell back to the rules in force and wrote those over it. Whatever the file held, such as an operator's unfinished edit, was lost, though addPolicy's doc says a concurrent operator edit isn't clobbered. Now the bytes the change read are first copied, with an exclusive create, to gate.json.unreadable- beside it, such as gate.json.unreadable-20261015T025905Z (no colons, which Windows refuses), with -2 and on when the name is taken. Then the change is written into gate.json in place, as before. A copy rather than a rename, so a symlinked gate.json keeps its link, the file keeps its mode and owner, and a single-file bind mount works; what's kept is the bytes that were judged, whatever their encoding (a Latin-1 edit, or UTF-16 as Windows PowerShell 5's > writes it, would not survive decoding and encoding again), and a copy never replaces an earlier one. The change is told where the copy is through a new onUnreadableKept option; the wake-rule tools put that in their result as keptUnreadable, and a [gate] log line and a gate:config-kept trace (now in the TraceEvent union) say the same. When the copy can't be made, the change throws before anything is written or any rule's state is cleared, and a write that fails after the copy names it. A gate.json the process can't read at all refuses the change: there's nothing to keep, and a file the process can't read is often one its operator closed off on purpose. A change that writes nothing (a placement it can't make, or removing a rule that isn't there) keeps nothing. gate_status lists each copy still beside gate.json, in a new keptCopies field, with what to do with it, until it's removed: the moment a copy is made reaches only the log, the trace and the change's caller (channel-mode in connectome-host passes no callback), and the copy exists to be found later. Reloading: at most once a second, gate.json is read again and applied when its text differs from the text whose rules are in force (configText, set only by a load or a write that applies it), and the privileged-users file is loaded when its text differs from the text last read (a failed read forgets that one). A gate.json that holds the text in force again after failing to load, as after a chmod repair, has its error cleared and nothing applied again, so the startup's error isn't cleared by a spell when the file couldn't be read. Before, gate.json's reload compared only its mtime, taken after the read, so two writes in one clock tick, a write between the read and the stat, or a read that only looked (listPolicyNames, a change that wrote nothing) left a new gate.json unapplied until it changed again, and a chmod that made it readable wasn't noticed. Recording the text only once it's in force also means a reload that throws before applying is tried again. isPrivilegedUser, which tune-out's wake classification calls for each diverted message, read and parsed the privileged-users file on every call, and while it couldn't be read each call pushed another copy of its error onto gate_status's errors; it now takes the throttled check, and so does the sleep path, through isPrivilegedUser, so an empty author id is never privileged there either. A clock stepped backwards makes the next check run rather than hold off both reloads until it catches up. A missing file is ENOENT from the read itself, not existsSync, which also said "absent" for a file behind a directory the process can't search. The privileged-users file loads entry by entry: a non-empty string or a safe integer is a user id, and any other entry is skipped and named in the file's error, including a number too large for JSON to read exactly, whose rounded value could be another user's id. A file that isn't JSON, or isn't a list or {"userIds": [...]}, is that file's error, and the list that last loaded in this process stays (none, at startup); it used to load as no one privileged, without a word. removePolicy clears the removed rule's state (delivering its pending debounce batch, dropping its rate-limit and sampling counters) only after the write succeeds, so a failed keep or write leaves the rule in force with its state. It used to clear first. The write stays in place, not a temporary file and a rename: a rename fails on a gate.json that's a single-file bind mount, needs a writable directory, replaces a symlink with a file, and resets the file's mode and owner. gate_status: handleToolCall runs both throttled reloads before it reads, so what it shows is at most a second old. Its errors, a list that every gate.json load replaced and every privileged-users failure appended to, becomes one current error for gate.json, one for the startup's seeding or reconciling of gate.json (kept apart, so the constructor's own load no longer clears it at once; it goes when gate.json loads new text or is written), and one for the privileged-users file. A missing gate.json clears its error. gate:config-error is traced when gate.json's error changes, rather than at every read that fails the same way. test/event-gate-unreadable-files.test.ts covers keeping (by add and remove, for a JSON error and for a policy the validator refuses, the -2 name, the trace, the log line and gate_status's list), a gate.json that isn't UTF-8 kept byte for byte, a symlinked gate.json keeping its link and mode, nothing kept when nothing is written or the file is missing, a gate.json that can't be read refusing, a copy that can't be made writing nothing and keeping the rule's pending batch, a write that fails after the copy naming it, a remove whose write fails keeping its rule and batch, a text only looked at being applied by the next reload, the reload's skip of unchanged text, its throttle and a clock stepped back, both files reloading by text (same size and mtime, and a chmod repair), a removed gate.json taking its error, files behind a closed directory as errors, the startup's error beside a gate.json that loads, the trace once, the privileged-users file's shapes and entries, its throttle, the sleep path, gate_status reading first, the errors kept apart, and what wake_add_rule and wake_remove_rule tell the model. Co-Authored-By: Claude Opus 5.5 --- changelog.d/unreadable-gate-files.fixed.md | 5 + src/framework.ts | 46 +- src/gate/event-gate.ts | 398 +++++++++--- src/gate/index.ts | 1 + src/gate/types.ts | 18 + src/index.ts | 2 +- src/types/trace.ts | 8 + test/event-gate-unreadable-files.test.ts | 667 +++++++++++++++++++++ 8 files changed, 1056 insertions(+), 89 deletions(-) create mode 100644 changelog.d/unreadable-gate-files.fixed.md create mode 100644 test/event-gate-unreadable-files.test.ts diff --git a/changelog.d/unreadable-gate-files.fixed.md b/changelog.d/unreadable-gate-files.fixed.md new file mode 100644 index 000000000..47c084dc1 --- /dev/null +++ b/changelog.d/unreadable-gate-files.fixed.md @@ -0,0 +1,5 @@ +- A wake-rule change no longer destroys a gate.json that won't load. `wake_add_rule` and `wake_remove_rule` (and `addGatePolicy` and `removeGatePolicy`) start from the rules in force when gate.json is there but isn't JSON or holds a policy the gate refuses, and they wrote those rules straight over the file, so whatever it held, such as an operator's unfinished edit, was lost. Now a copy of the file, byte for byte, is kept first, at `gate.json.unreadable-` beside it (such as `gate.json.unreadable-20261015T025905Z`, with `-2` and so on if that name is taken), and then the change is written into gate.json in place, as before, so a symlinked gate.json keeps its link, the file keeps its mode and owner, and a single-file bind mount still works. The tool's result says where the copy is, in `keptUnreadable`, and so do a `[gate]` log line and a `gate:config-kept` trace. When the copy can't be made, nothing is written and the tool says why, and a write that fails after the copy is made names the copy. A gate.json the gate can't read at all, such as one it lacks permission for, refuses the change, since there's nothing to keep. A change that writes nothing, such as removing a rule that isn't there, leaves the file as it was. `addGatePolicy` and `removeGatePolicy` take an optional `onUnreadableKept` callback that's told the copy's path and the reason. `gate_status` lists each copy still beside gate.json in `keptCopies`, saying that nothing in it is in force and how to use it, until it's removed. +- The gate's files are reloaded when their text changes. At most once a second, gate.json is read again and applied when its text differs from the text whose rules are in force, and the privileged-users file is read again and loaded when its text differs from the text last read. A gate.json that holds the text in force again after failing to load, as after a `chmod` that makes it readable, has its error cleared, and nothing is applied again. gate.json's reload compared only its mtime, so two writes in one clock tick, a write between its read and its stat, or a read that only looked (`getGatePolicyNames`, or a change that wrote nothing) left a new gate.json unapplied until it next changed, and a `chmod` that made it readable again wasn't noticed. `isPrivilegedUser`, which tune-out's wake classification calls for each diverted message, read and parsed the privileged-users file on every call, and while it couldn't be read each call added another copy of its error to `gate_status`'s `errors`. A clock stepped backwards now makes the next check run, rather than holding off both reloads until the clock catches up. A missing file is told apart from one that's there but can't be reached, such as behind a directory the process can't search: only a missing gate.json keeps the rules in force without an error, and only a missing privileged-users file means no one is privileged. +- A privileged-users file loads entry by entry. An entry of its list that's a non-empty string, or a safe integer (a whole number within 2^53 - 1 of zero), is a user id. Any other entry is skipped and named in the file's error, including a number too large for JSON to read exactly, whose rounded value could be another user's id. A file that isn't JSON, or isn't a list or `{"userIds": [...]}`, is that file's error, and the list that last loaded stays in effect, as for a file that can't be read (at startup, that's no one). Such a file used to load as no one privileged, without a word, and an entry that wasn't an id was loaded as text. An empty author id is never privileged, while the agent sleeps as for tune-out. +- `gate_status` reads both files again, at most once a second, before it answers, and its `errors` holds each problem once: one for gate.json, one for the startup's seeding or reconciling of gate.json, and one for the privileged-users file. A gate.json load no longer clears the startup's error (it stays until gate.json loads new text or is written) or the privileged-users file's, and a privileged-users file that loads again, or is removed, clears its own. The `gate:config-error` trace is emitted when gate.json's error changes, not at every read that fails the same way. +- `wake_remove_rule` and `removeGatePolicy` clear the removed rule's state (delivering its pending debounce batch, and dropping its rate-limit and sampling counters) only once gate.json is written. A write that failed used to leave the rule in force with its state already cleared. diff --git a/src/framework.ts b/src/framework.ts index 013277a59..618e064b0 100644 --- a/src/framework.ts +++ b/src/framework.ts @@ -392,7 +392,7 @@ function validateUtilityArgs( } import { CheckpointManager } from './mcpl/checkpoint-manager.js'; import { isToolAllowed } from './mcpl/tool-policy.js'; -import { EventGate, formatShadowWarning } from './gate/event-gate.js'; +import { EventGate, formatShadowWarning, formatUnreadableKept } from './gate/event-gate.js'; import { UsageTracker, type PersistedUsageState } from './usage/usage-tracker.js'; import type { SessionUsageSnapshot, UsageUpdatedEvent } from './usage/types.js'; import type { McplServerConnection } from './mcpl/server-connection.js'; @@ -5298,15 +5298,22 @@ export class AgentFramework { /** * Add or replace (upsert) a gate policy at runtime. Validated and persisted - * to gate.json; hot-applied in memory. Throws if no gate is configured or the - * policy is invalid. Placement: `'prepend'` puts a rule ahead of EVERYTHING - * (including addressed-message wake rules — see EventGate.lintShadows); - * `{before: name}` / `{after: name}` anchor it against a specific rule, - * which is usually what a sampler or debounce actually wants. + * to gate.json; hot-applied in memory. Placement: `'prepend'` puts a rule + * ahead of EVERYTHING (including addressed-message wake rules — see + * EventGate.lintShadows); `{before: name}` / `{after: name}` anchor it + * against a specific rule, which is usually what a sampler or debounce + * actually wants. When gate.json is there but won't load, a copy of it is + * kept beside it before the change is written, and `onUnreadableKept` says + * where. Throws if no gate is configured, the policy is invalid, the + * placement can't be made, or gate.json can't be read, kept or written + * (EventGate.addPolicy). */ addGatePolicy( rawPolicy: unknown, - options?: { position?: 'append' | 'prepend' | { before: string } | { after: string } }, + options?: { + position?: 'append' | 'prepend' | { before: string } | { after: string }; + onUnreadableKept?: (kept: import('./gate/types.js').UnreadableConfigKept) => void; + }, ): import('./gate/types.js').GatePolicy { if (!this.eventGate) { throw new Error('No EventGate configured (FrameworkConfig.gate is unset).'); @@ -5316,13 +5323,18 @@ export class AgentFramework { /** * Remove a gate policy by name at runtime. Returns false if it didn't exist. - * Throws if no gate is configured. + * A copy of a gate.json that won't load is kept first, as for addGatePolicy. + * Throws if no gate is configured, or gate.json can't be read, kept or + * written (EventGate.removePolicy); the rule then stays in force. */ - removeGatePolicy(name: string): boolean { + removeGatePolicy( + name: string, + options?: { onUnreadableKept?: (kept: import('./gate/types.js').UnreadableConfigKept) => void }, + ): boolean { if (!this.eventGate) { throw new Error('No EventGate configured (FrameworkConfig.gate is unset).'); } - return this.eventGate.removePolicy(name); + return this.eventGate.removePolicy(name, options); } /** Current gate policy names (freshest on-disk view). Empty when no gate. */ @@ -18142,10 +18154,13 @@ export class AgentFramework { finish({ success: false, error: 'wake_remove_rule: `name` (string) is required', isError: true }); return; } - const removed = this.removeGatePolicy(input.name); + const report: { keptUnreadable?: string } = {}; + const removed = this.removeGatePolicy(input.name, { + onUnreadableKept: (k) => { report.keptUnreadable = formatUnreadableKept(k); }, + }); finish({ success: true, - data: { removed, name: input.name, policies: this.getGatePolicyNames() }, + data: { removed, name: input.name, ...report, policies: this.getGatePolicyNames() }, }); return; } @@ -18226,7 +18241,11 @@ export class AgentFramework { const before = new Map( this.eventGate!.probeTable().map((r) => [r.probe, r]), ); - const policy = this.addGatePolicy(rawPolicy, position ? { position } : undefined); + const report: { keptUnreadable?: string } = {}; + const policy = this.addGatePolicy(rawPolicy, { + ...(position ? { position } : {}), + onUnreadableKept: (k) => { report.keptUnreadable = formatUnreadableKept(k); }, + }); const after = this.eventGate!.probeTable(); const probes = after.map((r) => { const prev = before.get(r.probe); @@ -18245,6 +18264,7 @@ export class AgentFramework { success: true, data: { added: policy.name, + ...report, policies: this.getGatePolicyNames(), ...(shadowWarnings.length > 0 ? { shadowWarnings } : {}), probes, diff --git a/src/gate/event-gate.ts b/src/gate/event-gate.ts index bb8bd3d1a..3a8d93e4b 100644 --- a/src/gate/event-gate.ts +++ b/src/gate/event-gate.ts @@ -11,8 +11,8 @@ * inference tokens, not whether the agent should know about the event. */ -import { existsSync, readFileSync, statSync, writeFileSync, mkdirSync } from 'node:fs'; -import { dirname, join } from 'node:path'; +import { existsSync, readdirSync, readFileSync, unlinkSync, writeFileSync, mkdirSync } from 'node:fs'; +import { basename, dirname, join } from 'node:path'; import { GateScript } from './gate-script.js'; import type { ToolDefinition } from '../types/events.js'; @@ -27,6 +27,7 @@ import type { GateStatus, ShadowWarning, GateProbeRow, + UnreadableConfigKept, } from './types.js'; // Re-export types consumers need @@ -49,6 +50,21 @@ const MIN_DEBOUNCE_MS = 100; /** Maximum debounce value (ms). */ const MAX_DEBOUNCE_MS = 300_000; +/** Whether a check made at `last` is still fresh at `now`, less than the + * reload throttle ago. A clock stepped backwards since makes it stale, so + * the next check runs rather than wait for the clock to catch up. */ +function checkedRecently(now: number, last: number): boolean { + const elapsed = now - last; + return elapsed >= 0 && elapsed < RELOAD_THROTTLE_MS; +} + +/** A read that failed because nothing is at the path. Any other failure + * (EACCES on a directory, ELOOP, ENOTDIR) is a file that's there but can't + * be read. */ +function isMissing(err: unknown): boolean { + return (err as NodeJS.ErrnoException | null)?.code === 'ENOENT'; +} + // --------------------------------------------------------------------------- // Default config // --------------------------------------------------------------------------- @@ -533,6 +549,30 @@ export function formatShadowWarning(w: ShadowWarning): string { + `(first match wins — every event "${w.later}" would match is consumed by "${w.earlier}" first)`; } +/** What a wake-rule tool tells the agent when its change kept a copy of an unreadable gate.json. */ +export function formatUnreadableKept(kept: UnreadableConfigKept): string { + return `gate.json couldn't be read (${kept.reason}), so a copy of it was kept at ${kept.keptPath}, and gate.json ` + + 'now holds the rules that were in force, with this change. What the copy holds is not in force; whoever ' + + 'runs this host can restore rules from it.'; +} + +/** The privileged-users file's error when some entries of its list aren't user ids. */ +function skippedPrivilegedIds(skipped: unknown[], loaded: number): string { + const one = skipped.length === 1; + const shown = skipped.slice(0, 5).map((x) => { + if (typeof x === 'number' && Number.isInteger(x)) { + return `${x} (a number this large can't be read exactly: write the id as a string, in quotes)`; + } + const json = JSON.stringify(x) ?? String(x); + return json.length > 40 ? `${json.slice(0, 39)}…` : json; + }); + const more = skipped.length > shown.length ? `, and ${skipped.length - shown.length} more` : ''; + const rest = loaded === 0 ? 'no id loaded, so no one is privileged' : `${loaded} ${loaded === 1 ? 'id' : 'ids'} did load`; + return `${skipped.length} ${one ? 'entry' : 'entries'} in the privileged-users file ${one ? "isn't a user id" : "aren't user ids"}, ` + + `so ${one ? 'it was' : 'they were'} skipped: ${shown.join(', ')}${more}. A user id is a non-empty string, or a safe integer ` + + `(a whole number within 2^53 - 1 of zero); ${rest}.`; +} + // --------------------------------------------------------------------------- // EventGate // --------------------------------------------------------------------------- @@ -543,11 +583,22 @@ export class EventGate { /** Optional agent-authored programmable gate (gate.js). */ private gateScript: GateScript; private compiledPolicies: CompiledPolicy[] = []; - private configMtime: number = 0; + /** The text of gate.json whose rules are in force, from a load or a write. + * A reload parses the file again only when its text differs; a file that + * holds it again after failing to load (a chmod that makes it readable, + * an edit undone) needs nothing applied, only its error cleared. */ + private configText: string | undefined; private lastReloadCheck: number = 0; private configSource: 'file' | 'initial' | 'default' = 'default'; private lastReloadTimestamp: number | null = null; - private configErrors: string[] = []; + /** The current problem with each file the gate reads, one each, replaced + * whenever that file is read again (gate_status `errors`). */ + private configError: string | null = null; + private privilegedError: string | null = null; + /** Why the startup couldn't seed or reconcile gate.json, kept apart from + * gate.json's own error so the load that follows can't clear it, until + * gate.json loads new text or is written. */ + private startupError: string | null = null; // Debounce state private debounceTimers = new Map(); @@ -604,7 +655,9 @@ export class EventGate { // privilegedUsersPath on change. private privilegedUsersPath: string | undefined; private privilegedUserIds = new Set(); - private privilegedMtime = 0; + /** The privileged-users file's text as last read, whether or not it loaded; + * undefined when it was missing or its last read failed. */ + private privilegedText: string | undefined; private lastPrivilegedCheck = 0; // Dependency-injected callbacks @@ -692,12 +745,20 @@ export class EventGate { } } catch (err) { const msg = err instanceof Error ? err.message : String(err); - this.configErrors.push(`Failed to seed/reconcile gate.json: ${msg}`); + // Its own slot: the load below would clear gate.json's error. + this.startupError = `Failed to seed/reconcile gate.json: ${msg}`; } } // Load config - this.config = this.loadConfig() ?? opts.initialConfig ?? { ...DEFAULT_CONFIG }; + const read = this.readConfigFile(); + if (read.state === 'loaded') { + this.config = read.config; + this.configText = read.text; + this.configSource = 'file'; + } else { + this.config = opts.initialConfig ?? { ...DEFAULT_CONFIG }; + } this.compiledPolicies = this.compilePolicies(this.config); this.refreshShadowWarnings('startup'); @@ -715,27 +776,157 @@ export class EventGate { // ========================================================================= private loadConfig(): GateConfig | null { - if (!existsSync(this.configPath)) { - return null; + const read = this.readConfigFile(); + return read.state === 'loaded' ? read.config : null; // Caller keeps previous config + } + + /** + * gate.json as it is on disk: absent, loaded, or there but unreadable (it + * can't be read, isn't JSON, or holds something validateConfig refuses), + * with its bytes when it could be read, since text decoded from a file that + * isn't UTF-8 isn't what it held. Reading applies nothing: whoever + * applies a loaded config records its text as `configText`. With + * `unlessUnchanged`, a file whose text is `configText` comes back + * `unchanged`, unparsed, since the rules in force came from it. + */ + private readConfigFile(opts?: { unlessUnchanged?: boolean }): + | { state: 'absent' } + | { state: 'unchanged' } + | { state: 'loaded'; config: GateConfig; text: string } + | { state: 'unreadable'; error: string; bytes?: Buffer } { + let bytes: Buffer; + try { + bytes = readFileSync(this.configPath); + } catch (err) { + if (!isMissing(err)) return this.configUnreadable(err); + // No file is no problem with it: the rules in force stay. + this.configError = null; + return { state: 'absent' }; + } + const raw = bytes.toString('utf-8'); + if (opts?.unlessUnchanged && raw === this.configText) { + // The file holds the text in force, which loaded, so it has no problem. + this.configError = null; + return { state: 'unchanged' }; } try { - const raw = readFileSync(this.configPath, 'utf-8'); - const stat = statSync(this.configPath); - this.configMtime = stat.mtimeMs; const config = validateConfig(JSON.parse(raw)); - this.configSource = 'file'; - this.configErrors = []; - return config; + this.configError = null; + return { state: 'loaded', config, text: raw }; } catch (err) { - const msg = err instanceof Error ? err.message : String(err); - this.configErrors = [msg]; + return { ...this.configUnreadable(err), bytes }; + } + } + + /** gate.json's current error, replacing the last one, and traced when it's new. */ + private configUnreadable(err: unknown): { state: 'unreadable'; error: string } { + const msg = err instanceof Error ? err.message : String(err); + if (msg !== this.configError) { this.emitTrace({ type: 'gate:config-error', error: msg, configPath: this.configPath, timestamp: Date.now(), }); - return null; // Caller keeps previous config + } + this.configError = msg; + return { state: 'unreadable', error: msg }; + } + + /** + * The rules a policy change starts from: gate.json as it is on disk, or the + * rules in force when it's absent or unreadable. An unreadable file comes + * back with its error, and with its bytes when it could be read, for + * keepUnreadableConfig before anything is written over it. + */ + private configForChange(): { config: GateConfig; unreadable?: { error: string; bytes?: Buffer } } { + const read = this.readConfigFile(); + if (read.state === 'loaded') return { config: read.config }; + if (read.state === 'unreadable') return { config: this.config, unreadable: { error: read.error, bytes: read.bytes } }; + return { config: this.config }; + } + + /** + * Keep a copy of an unreadable gate.json, byte for byte as the change read it, at + * `gate.json.unreadable-` beside it, before the rules in force are + * written over it in place, so what it held (often an operator's + * unfinished edit) isn't lost. A copy, not a rename, so gate.json itself + * stays where it is: a symlink keeps its target, the file its mode and + * owner, and a single-file bind mount works. Returns where the copy is. + * Throws, so the caller writes nothing, when the file couldn't be read at + * all (there's nothing to copy, and a file the process can't read is often + * one its operator closed off on purpose) or when the copy can't be made. + */ + private keepUnreadableConfig(unreadable: { error: string; bytes?: Buffer }): string { + const reason = unreadable.error; + if (unreadable.bytes === undefined) { + throw new Error(`gate.json couldn't be read (${reason}), so it can't be kept, and nothing was written over it.`); + } + // No colons: Windows doesn't allow them in a file name. + const stamp = new Date(this.now()).toISOString().replace(/\.\d+Z$/, 'Z').replace(/[-:]/g, ''); + const base = `${this.configPath}.unreadable-${stamp}`; + for (let n = 1; ; n++) { + const kept = n === 1 ? base : `${base}-${n}`; + try { + // An exclusive create, so a copy never replaces an earlier one. + writeFileSync(kept, unreadable.bytes, { flag: 'wx' }); + } catch (err) { + const e = err as NodeJS.ErrnoException; + if (e.code === 'EEXIST') continue; + // A copy cut short is this call's own file: remove it, so nothing is left. + if (e.syscall !== 'open') { + try { unlinkSync(kept); } catch { /* it's named below */ } + } + throw new Error( + `gate.json couldn't be read (${reason}), and keeping a copy of it at ${kept} failed (${e.message ?? String(err)}), ` + + 'so nothing was written.', + ); + } + console.error(`[gate] ${this.configPath} couldn't be read (${reason}); kept a copy at ${kept} before writing the rules in force`); + this.emitTrace({ + type: 'gate:config-kept', + configPath: this.configPath, + keptPath: kept, + error: reason, + timestamp: Date.now(), + }); + return kept; + } + } + + /** + * The copies keepUnreadableConfig made that are still beside gate.json, + * oldest first, each with what to do with it, for gate_status: the moment a + * copy is made reaches only the log, the trace and the change's caller, and + * the copy exists to be found later. + */ + private keptCopies(): string[] { + const dir = dirname(this.configPath); + const prefix = `${basename(this.configPath)}.unreadable-`; + let names: string[]; + try { + names = readdirSync(dir).filter((name) => name.startsWith(prefix)); + } catch { + return []; + } + return names + .sort((a, b) => a.localeCompare(b, 'en', { numeric: true })) + .map((name) => `${join(dir, name)}: a copy of gate.json, kept when it couldn't be read. Nothing in it is in force. ` + + "To use it, fix it and copy it over gate.json. Once it isn't needed, remove it."); + } + + /** + * writeConfig, after keepUnreadableConfig may have kept a copy of gate.json: + * then a write that fails names the copy, since what the file held may now + * be only there. + */ + private writeConfigAfterKeep(config: GateConfig, kept: UnreadableConfigKept | undefined): void { + try { + this.writeConfig(config); + } catch (err) { + if (!kept) throw err; + const msg = err instanceof Error ? err.message : String(err); + throw new Error(`${msg} A copy of what gate.json held when it couldn't be read is kept at ${kept.keptPath}.`, { cause: err }); } } @@ -766,16 +957,14 @@ export class EventGate { private reloadIfChanged(): void { const now = Date.now(); - if (now - this.lastReloadCheck < RELOAD_THROTTLE_MS) return; + if (checkedRecently(now, this.lastReloadCheck)) return; this.lastReloadCheck = now; try { - if (!existsSync(this.configPath)) return; - const stat = statSync(this.configPath); - if (stat.mtimeMs === this.configMtime) return; - - const newConfig = this.loadConfig(); - if (!newConfig) return; // Parse error — keep previous config + // Missing, unchanged or unreadable: the rules in force stay. + const read = this.readConfigFile({ unlessUnchanged: true }); + if (read.state !== 'loaded') return; + const newConfig = read.config; // Capture old policies' behavior fingerprints before any state // mutation — needed to detect kept policies whose parameters changed. @@ -812,6 +1001,12 @@ export class EventGate { this.config = newConfig; this.compiledPolicies = this.compilePolicies(newConfig); + // Recorded only now that its rules are in force, so a reload that + // throws before here is tried again at the next check. + this.configText = read.text; + this.configSource = 'file'; + // New text in gate.json now decides the rules, not the startup's. + this.startupError = null; this.lastReloadTimestamp = now; this.refreshShadowWarnings('reload'); @@ -825,7 +1020,7 @@ export class EventGate { timestamp: now, }); } catch { - // Ignore stat errors + // The rules in force stay, and the next check tries again. } } @@ -841,10 +1036,13 @@ export class EventGate { * surfaces it as a tool error. On success the policy is persisted to gate.json * (so it survives restart and is visible to `workspace--edit _config/gate.json`) * and the in-memory config is swapped in immediately, so the rule takes effect - * without waiting for the throttled mtime reload. + * without waiting for the throttled reload. * * The freshest on-disk config is re-read first, so a concurrent operator edit - * isn't clobbered. A policy with the same `name` is replaced IN PLACE when no + * isn't clobbered. When gate.json is there but won't load, the change starts + * from the rules in force, after a copy of the file is kept beside it + * (keepUnreadableConfig) and reported to `onUnreadableKept` once the change + * is written. A policy with the same `name` is replaced IN PLACE when no * `position` is given (order preserved); WITH a `position` it is moved there * — so re-issuing a rule is also how you reposition it. New policies append * by default; `'prepend'` puts one at the very front (ahead of EVERYTHING, @@ -852,13 +1050,20 @@ export class EventGate { * anchored forms `{before: name}` / `{after: name}` insert relative to an * existing rule, which is almost always the safer way to order a sampler * or debounce against a specific catch-all. + * + * Throws, writing nothing, for an invalid policy, a placement it can't make, + * or a gate.json that can't be read or kept; and throws when the write + * fails, naming any copy kept. */ addPolicy( rawPolicy: unknown, - options?: { position?: 'append' | 'prepend' | { before: string } | { after: string } }, + options?: { + position?: 'append' | 'prepend' | { before: string } | { after: string }; + onUnreadableKept?: (kept: UnreadableConfigKept) => void; + }, ): GatePolicy { const policy = validatePolicy(rawPolicy); - const current = this.loadConfig() ?? this.config; + const { config: current, unreadable } = this.configForChange(); const policies = [...current.policies]; const idx = policies.findIndex((p) => p.name === policy.name); const position = options?.position; @@ -884,7 +1089,11 @@ export class EventGate { } } - this.writeConfig({ ...current, policies }); + const kept = unreadable !== undefined + ? { keptPath: this.keepUnreadableConfig(unreadable), reason: unreadable.error } + : undefined; + this.writeConfigAfterKeep({ ...current, policies }, kept); + if (kept) options?.onUnreadableKept?.(kept); this.emitTrace({ type: 'gate:policy-added', configPath: this.configPath, @@ -899,19 +1108,28 @@ export class EventGate { * Remove a policy by name from gate.json and clear its live state. Any pending * debounce batch is delivered first (matching reloadIfChanged's removal path, * so buffered events aren't silently dropped). Returns false if no policy of - * that name exists (nothing written). Freshest on-disk config is re-read first - * (see addPolicy). + * that name exists (nothing written). Freshest on-disk config is re-read first, + * and a copy of a gate.json that won't load is kept before it's written over + * (see addPolicy). Throws when gate.json can't be read or kept, or when the + * write fails (naming any copy kept); the rule then stays in force, with its + * state. */ - removePolicy(name: string): boolean { - const current = this.loadConfig() ?? this.config; + removePolicy(name: string, options?: { onUnreadableKept?: (kept: UnreadableConfigKept) => void }): boolean { + const { config: current, unreadable } = this.configForChange(); if (!current.policies.some((p) => p.name === name)) { return false; } - // Deliver any pending debounce batch, then drop timers/buckets/counters. + const kept = unreadable !== undefined + ? { keptPath: this.keepUnreadableConfig(unreadable), reason: unreadable.error } + : undefined; + const policies = current.policies.filter((p) => p.name !== name); + this.writeConfigAfterKeep({ ...current, policies }, kept); + // Only once the write has succeeded, so a failed keep or write leaves the + // rule in force with its state: deliver any pending debounce batch, then + // drop timers, buckets and counters. this.clearPolicyState(name, { deliverPendingDebounce: true }); this.stats.delete(name); - const policies = current.policies.filter((p) => p.name !== name); - this.writeConfig({ ...current, policies }); + if (kept) options?.onUnreadableKept?.(kept); this.emitTrace({ type: 'gate:policy-removed', configPath: this.configPath, @@ -932,24 +1150,22 @@ export class EventGate { /** * Persist a config to gate.json and apply it in memory together: write the - * file, swap in the compiled policies, and sync `configMtime` to the new file - * so `reloadIfChanged` doesn't redundantly re-parse identical content. Throws + * file, swap in the compiled policies, and keep the text written as + * `configText`, so `reloadIfChanged` doesn't parse it again. Throws * if the write fails (disk/permission) — callers surface it as a tool error. */ private writeConfig(config: GateConfig): void { mkdirSync(dirname(this.configPath), { recursive: true }); - writeFileSync(this.configPath, JSON.stringify(config, null, 2) + '\n'); + const text = JSON.stringify(config, null, 2) + '\n'; + writeFileSync(this.configPath, text); + this.configText = text; this.config = config; this.compiledPolicies = this.compilePolicies(config); this.refreshShadowWarnings('policy-mutation'); this.configSource = 'file'; - this.configErrors = []; + this.configError = null; + this.startupError = null; this.lastReloadTimestamp = Date.now(); - try { - this.configMtime = statSync(this.configPath).mtimeMs; - } catch { - // A failed stat only risks one redundant reload; keep the old mtime. - } } // ========================================================================= @@ -1040,11 +1256,10 @@ export class EventGate { // and a privileged user's ping. The event always enters context regardless // (the gate only governs inference triggering). if (this.sleepUntil > this.now()) { - this.reloadPrivilegedIfChanged(); const isHeartbeat = info.metadata?.source === 'heartbeat' || info.serverId === 'heartbeat'; const authorId = this.extractAuthorId(info.metadata); - const privileged = authorId !== null && this.privilegedUserIds.has(authorId); + const privileged = this.isPrivilegedUser(authorId); if (!isHeartbeat && !privileged) { this.sleepSuppressed++; this.totalEvaluations++; @@ -1835,7 +2050,6 @@ export class EventGate { return null; } - /** Load the privileged-users file (bare array or { userIds: [...] }). */ /** * Whether an author id is on the privileged-users list (hot-reloaded). * Shared by sleep bypass and tune-out wake classification (#77: wakes are @@ -1843,45 +2057,74 @@ export class EventGate { */ isPrivilegedUser(authorId: string | null | undefined): boolean { if (!authorId) return false; - this.loadPrivileged(); + this.reloadPrivilegedIfChanged(); return this.privilegedUserIds.has(authorId); } - private loadPrivileged(): void { + /** + * Read the privileged-users file: a list of user ids, or `{"userIds": [...]}`, + * each a non-empty string or a safe integer (a whole number within 2^53 - 1 of zero). An absent file + * means no one is privileged. A file that can't be read, isn't JSON, or + * holds no list leaves the list that last loaded in this process in effect + * (none, at startup), with its error in gate_status until the file is read + * again. In a list, an entry that isn't a user id is skipped, and named in + * the file's error, and the rest load. With `unlessUnchanged`, a file whose + * text is the text last read isn't parsed again. + */ + private loadPrivileged(opts?: { unlessUnchanged?: boolean }): void { if (!this.privilegedUsersPath) return; + const failed = (why: string): string => `Failed to load privileged-users file: ${why}`; + let raw: string; try { - if (!existsSync(this.privilegedUsersPath)) { + raw = readFileSync(this.privilegedUsersPath, 'utf8'); + } catch (err) { + // Read again next time: a file can become readable with its text + // unchanged, as a chmod makes it. + this.privilegedText = undefined; + if (isMissing(err)) { this.privilegedUserIds = new Set(); - this.privilegedMtime = 0; - return; + this.privilegedError = null; + } else { + this.privilegedError = failed(err instanceof Error ? err.message : String(err)); } - this.privilegedMtime = statSync(this.privilegedUsersPath).mtimeMs; - const raw = JSON.parse(readFileSync(this.privilegedUsersPath, 'utf8')); - const ids = Array.isArray(raw) ? raw : Array.isArray(raw?.userIds) ? raw.userIds : []; - this.privilegedUserIds = new Set(ids.map((x: unknown) => String(x))); + return; + } + if (opts?.unlessUnchanged && raw === this.privilegedText) return; + this.privilegedText = raw; + let ids: unknown; + try { + const parsed: unknown = JSON.parse(raw); + ids = Array.isArray(parsed) ? parsed : (parsed as { userIds?: unknown } | null)?.userIds; } catch (err) { - this.configErrors.push( - `Failed to load privileged-users file: ${err instanceof Error ? err.message : String(err)}`, - ); + this.privilegedError = failed(err instanceof Error ? err.message : String(err)); + return; + } + // A file that parses but holds no list hasn't loaded either: it used to + // mean no one is privileged, without a word. + if (!Array.isArray(ids)) { + this.privilegedError = failed('expected a list of user ids, or {"userIds": [...]}'); + return; + } + const loaded = new Set(); + const skipped: unknown[] = []; + for (const id of ids) { + if (typeof id === 'string' && id !== '') loaded.add(id); + // JSON.parse has already rounded an integer past 2^53, and the rounded + // value can be another user's id, so it's skipped rather than loaded. + else if (typeof id === 'number' && Number.isSafeInteger(id)) loaded.add(String(id)); + else skipped.push(id); } + this.privilegedUserIds = loaded; + this.privilegedError = skipped.length > 0 ? skippedPrivilegedIds(skipped, loaded.size) : null; } - /** Throttled mtime-based reload of the privileged-users file. */ + /** At most once a second, the privileged-users file read again, and loaded when its text has changed. */ private reloadPrivilegedIfChanged(): void { if (!this.privilegedUsersPath) return; const now = this.now(); - if (now - this.lastPrivilegedCheck < RELOAD_THROTTLE_MS) return; + if (checkedRecently(now, this.lastPrivilegedCheck)) return; this.lastPrivilegedCheck = now; - try { - if (!existsSync(this.privilegedUsersPath)) { - if (this.privilegedUserIds.size > 0) this.privilegedUserIds = new Set(); - return; - } - const mtime = statSync(this.privilegedUsersPath).mtimeMs; - if (mtime !== this.privilegedMtime) this.loadPrivileged(); - } catch { - /* leave the last-known set in place on a transient stat/read error */ - } + this.loadPrivileged({ unlessUnchanged: true }); } getToolDefinition(): ToolDefinition { @@ -1895,6 +2138,10 @@ export class EventGate { } async handleToolCall(): Promise<{ success: boolean; data?: GateStatus; error?: string }> { + // What's in force now: each file read again if a check is due, as the + // next event would. + this.reloadIfChanged(); + this.reloadPrivilegedIfChanged(); const status = this.getStatus(); return { success: true, data: status }; } @@ -1953,8 +2200,9 @@ export class EventGate { default: this.config.default ?? 'always', script: this.gateScript.status(), policies, - errors: [...this.configErrors], + errors: [this.configError, this.startupError, this.privilegedError].filter((e): e is string => e !== null), shadowWarnings: this.shadowWarningsCache.map(formatShadowWarning), + keptCopies: this.keptCopies(), totalEvaluations: this.totalEvaluations, defaultDecisions: { triggered: this.defaultTriggered, diff --git a/src/gate/index.ts b/src/gate/index.ts index 2722ce340..7c9b0124c 100644 --- a/src/gate/index.ts +++ b/src/gate/index.ts @@ -9,4 +9,5 @@ export type { GateEventInfo, GateStatus, GatePolicyStats, + UnreadableConfigKept, } from './types.js'; diff --git a/src/gate/types.ts b/src/gate/types.ts index 29c67244a..5ede6b37b 100644 --- a/src/gate/types.ts +++ b/src/gate/types.ts @@ -253,6 +253,18 @@ export interface GatePolicyStats { }; } +/** + * What a policy change (addPolicy, removePolicy) reports when gate.json was + * there but wouldn't load: a copy of it was kept before the change was + * written over it, from the rules in force. + */ +export interface UnreadableConfigKept { + /** Where the copy is: `gate.json.unreadable-`, beside gate.json. */ + keptPath: string; + /** Why gate.json couldn't be loaded. */ + reason: string; +} + /** Full gate status returned by gate:status tool. */ export interface GateStatus { configPath: string; @@ -269,6 +281,12 @@ export interface GateStatus { * same hazards wake_add_rule warns about at install time. */ shadowWarnings: string[]; + /** + * The copies wake-rule changes kept of a gate.json that couldn't be read + * (gate.json.unreadable-, beside it), oldest first, each with + * what to do with it, until it's removed. Nothing in a copy is in force. + */ + keptCopies: string[]; /** Total events the gate has evaluated since startup. */ totalEvaluations: number; /** diff --git a/src/index.ts b/src/index.ts index 20a1e2c8c..da19d67e3 100644 --- a/src/index.ts +++ b/src/index.ts @@ -79,7 +79,7 @@ export type { SessionUsage, AgentUsage, SessionUsageSnapshot, UsageUpdatedEvent // EventGate export { EventGate, type WakeProvenance } from './gate/index.js'; -export type { GateConfig, GateOptions, GatePolicy, GatePolicyMatch, GateBehavior } from './gate/index.js'; +export type { GateConfig, GateOptions, GatePolicy, GatePolicyMatch, GateBehavior, UnreadableConfigKept } from './gate/index.js'; // MCPL channel registry (exposed for modules that need channel-level operations) export { ChannelRegistry } from './mcpl/index.js'; diff --git a/src/types/trace.ts b/src/types/trace.ts index b1526bbcb..b1c1b8f24 100644 --- a/src/types/trace.ts +++ b/src/types/trace.ts @@ -318,6 +318,14 @@ export type TraceEvent = error: string; configPath: string; }) + | (TraceEventBase & { + /** gate.json couldn't be loaded, and a copy of it was kept at `keptPath` + * before a wake-rule change wrote the rules in force over it. */ + type: 'gate:config-kept'; + error: string; + configPath: string; + keptPath: string; + }) | (TraceEventBase & { type: 'gate:debounce-delivered'; policyName: string; diff --git a/test/event-gate-unreadable-files.test.ts b/test/event-gate-unreadable-files.test.ts new file mode 100644 index 000000000..f1b20e23e --- /dev/null +++ b/test/event-gate-unreadable-files.test.ts @@ -0,0 +1,667 @@ +/** + * The gate's files when they can't be read. A wake-rule change starts from the + * rules in force when gate.json is there but won't load, and it used to write + * them straight over the file, so whatever the file held, such as an + * operator's unfinished edit, was lost. Now a copy of the file is kept first, + * at gate.json.unreadable-, gate.json is written in place, and the + * change says where the copy is. Both files are reloaded by their text, and + * each file's current problem shows once in gate_status. + */ +import { test, type TestContext } from 'node:test'; +import assert from 'node:assert/strict'; +import { + chmodSync, lstatSync, mkdtempSync, readdirSync, readFileSync, rmSync, statSync, symlinkSync, utimesSync, writeFileSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { AgentFramework } from '../src/index.js'; +import { EventGate } from '../src/gate/event-gate.js'; +import type { GateConfig, GateEventInfo, UnreadableConfigKept } from '../src/gate/types.js'; +import { MockMembrane, createMockResponse } from './helpers/mock-membrane.js'; + +/** 2026-10-15T02:59:05.250Z, so a kept copy is named gate.json.unreadable-20261015T025905Z. */ +const AT = Date.UTC(2026, 9, 15, 2, 59, 5, 250); +const BROKEN = '{"default": "defer", "policies": [{"name": "half-made",'; +const RULES: GateConfig = { + default: 'defer', + policies: [{ name: 'mentions', match: { tagsAny: ['chat:mention'] }, behavior: 'always' }], +}; +const QUIET: GateConfig = { default: 'always', policies: [{ name: 'quiet', match: {}, behavior: { debounce: 60000 } }] }; +const INCOMING: GateEventInfo = { content: 'x', eventType: 'mcpl:channel-incoming', serverId: 'discord', channelId: 'c1', tags: [] }; +const NO_CHMOD = process.platform === 'win32' || process.getuid?.() === 0; + +function tempDir(t: TestContext): string { + const dir = mkdtempSync(join(tmpdir(), 'gate-unreadable-')); + t.after(() => { + chmodSync(dir, 0o755); + rmSync(dir, { recursive: true, force: true }); + }); + return dir; +} + +function makeGate( + dir: string, + opts: { config?: GateConfig; privileged?: string; now?: () => number; traces?: Array<{ type: string; [k: string]: unknown }> } = {}, +) { + const configPath = join(dir, 'gate.json'); + if (opts.config) writeFileSync(configPath, JSON.stringify(opts.config)); + const privilegedUsersPath = join(dir, 'privileged.json'); + if (opts.privileged !== undefined) writeFileSync(privilegedUsersPath, opts.privileged); + const gate = new EventGate({ + configPath, + privilegedUsersPath, + emitTrace: (e) => { opts.traces?.push(e); }, + addMessage: () => '', + requestInference: () => {}, + getAgentNames: () => ['agent'], + now: opts.now ?? (() => AT), + }); + return { gate, configPath, privilegedUsersPath }; +} + +const onDisk = (path: string): GateConfig => JSON.parse(readFileSync(path, 'utf-8')) as GateConfig; +const keptFiles = (dir: string): string[] => readdirSync(dir).filter((f) => f.startsWith('gate.json.unreadable-')).sort(); +const escaped = (s: string): string => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +const renamedRules = (name: string): string => JSON.stringify({ ...RULES, policies: [{ ...RULES.policies[0]!, name }] }); +/** What a call threw, so a test can restore a mode before it asserts. */ +function thrownBy(fn: () => unknown): string { + try { + fn(); + } catch (err) { + return err instanceof Error ? err.message : String(err); + } + return '(nothing was thrown)'; +} + +test('a change over a gate.json that will not load keeps a copy first, then writes the rules in force with the change', (t) => { + const dir = tempDir(t); + const traces: Array<{ type: string; [k: string]: unknown }> = []; + const { gate, configPath } = makeGate(dir, { config: RULES, traces }); + const logged = t.mock.method(console, 'error', () => {}); + writeFileSync(configPath, BROKEN); + assert.deepEqual(gate.getStatus().keptCopies, []); + + const kept: UnreadableConfigKept[] = []; + gate.addPolicy({ name: 'ambient', match: { tagsAny: ['chat:ambient'] }, behavior: 'defer' }, { onUnreadableKept: (k) => kept.push(k) }); + + const keptPath = join(dir, 'gate.json.unreadable-20261015T025905Z'); + assert.equal(readFileSync(keptPath, 'utf-8'), BROKEN, 'the unreadable file is kept, byte for byte'); + assert.deepEqual(onDisk(configPath).policies.map((p) => p.name), ['mentions', 'ambient']); + assert.equal(kept.length, 1); + assert.equal(kept[0]!.keptPath, keptPath); + assert.match(kept[0]!.reason, /JSON/, 'the reason is why it could not be loaded'); + assert.deepEqual(gate.getStatus().errors, [], 'gate.json loads again'); + const keptTraces = traces.filter((e) => e.type === 'gate:config-kept'); + assert.equal(keptTraces.length, 1); + assert.equal(keptTraces[0]!.keptPath, keptPath); + assert.equal(keptTraces[0]!.error, kept[0]!.reason); + assert.deepEqual( + logged.mock.calls.map((c) => c.arguments[0]), + [`[gate] ${configPath} couldn't be read (${kept[0]!.reason}); kept a copy at ${keptPath} before writing the rules in force`], + ); + + // A second unreadable file in the same second takes the next free name, by removePolicy. + writeFileSync(configPath, `${BROKEN} "second"`); + assert.equal(gate.removePolicy('ambient', { onUnreadableKept: (k) => kept.push(k) }), true); + assert.deepEqual(keptFiles(dir), ['gate.json.unreadable-20261015T025905Z', 'gate.json.unreadable-20261015T025905Z-2']); + assert.equal(readFileSync(keptPath, 'utf-8'), BROKEN, 'the first copy is not replaced'); + assert.equal(readFileSync(`${keptPath}-2`, 'utf-8'), `${BROKEN} "second"`); + assert.equal(kept[1]!.keptPath, `${keptPath}-2`); + assert.deepEqual(onDisk(configPath).policies.map((p) => p.name), ['mentions']); + + // gate_status lists each copy, with what to do with it, until it's removed. + const told = ": a copy of gate.json, kept when it couldn't be read. Nothing in it is in force. " + + "To use it, fix it and copy it over gate.json. Once it isn't needed, remove it."; + assert.deepEqual(gate.getStatus().keptCopies, [`${keptPath}${told}`, `${keptPath}-2${told}`]); + rmSync(keptPath); + assert.deepEqual(gate.getStatus().keptCopies, [`${keptPath}-2${told}`]); +}); + +test('a policy the validator refuses also keeps gate.json from loading, and it is kept the same way', (t) => { + const dir = tempDir(t); + const { gate, configPath } = makeGate(dir, { config: RULES }); + const refused = JSON.stringify({ default: 'defer', policies: [{ name: 'quick', behavior: { debounce: 5 } }] }); + writeFileSync(configPath, refused); + + gate.addPolicy({ name: 'ambient', match: { tagsAny: ['chat:ambient'] }, behavior: 'defer' }); + + assert.equal(readFileSync(join(dir, 'gate.json.unreadable-20261015T025905Z'), 'utf-8'), refused); + assert.deepEqual(onDisk(configPath).policies.map((p) => p.name), ['mentions', 'ambient']); +}); + +test('a gate.json that is not UTF-8 is kept byte for byte', (t) => { + const dir = tempDir(t); + const { gate, configPath } = makeGate(dir, { config: RULES }); + // The keep's log line quotes JSON.parse's error, which quotes the decoded text: NULs, for UTF-16. + t.mock.method(console, 'error', () => {}); + // A half-made edit saved as Latin-1, then a file as Windows PowerShell 5's + // `>` writes one: UTF-16LE, with a byte order mark. + const latin1 = Buffer.from('{"default": "defer", "policies": [{"name": "caf\u00e9",', 'latin1'); + const utf16 = Buffer.concat([Buffer.from([0xff, 0xfe]), Buffer.from(JSON.stringify(RULES), 'utf16le')]); + writeFileSync(configPath, latin1); + gate.addPolicy({ name: 'ambient', behavior: 'defer' }); + writeFileSync(configPath, utf16); + assert.equal(gate.removePolicy('ambient'), true); + + assert.deepEqual(keptFiles(dir), ['gate.json.unreadable-20261015T025905Z', 'gate.json.unreadable-20261015T025905Z-2']); + assert.deepEqual(readFileSync(join(dir, 'gate.json.unreadable-20261015T025905Z')), latin1); + assert.deepEqual(readFileSync(join(dir, 'gate.json.unreadable-20261015T025905Z-2')), utf16); + assert.deepEqual(onDisk(configPath).policies.map((p) => p.name), ['mentions']); +}); + +test('keeping a copy leaves a symlinked gate.json pointing at its file, which keeps its mode and takes the change', { + skip: process.platform === 'win32' ? 'needs a symlink' : false, +}, (t) => { + const dir = tempDir(t); + const target = join(dir, 'real-gate.json'); + writeFileSync(target, JSON.stringify(RULES)); + chmodSync(target, 0o640); + const configPath = join(dir, 'gate.json'); + symlinkSync(target, configPath); + const { gate } = makeGate(dir); + writeFileSync(configPath, BROKEN); + + gate.addPolicy({ name: 'ambient', behavior: 'defer' }); + + assert.ok(lstatSync(configPath).isSymbolicLink(), 'gate.json is still the link'); + assert.equal(statSync(target).mode & 0o777, 0o640, 'its file keeps its mode'); + assert.deepEqual(onDisk(target).policies.map((p) => p.name), ['mentions', 'ambient'], 'and holds the change'); + assert.equal(readFileSync(join(dir, 'gate.json.unreadable-20261015T025905Z'), 'utf-8'), BROKEN); +}); + +test('nothing is kept, and an unreadable gate.json stays as it was, when nothing is written over it', (t) => { + const dir = tempDir(t); + const { gate, configPath } = makeGate(dir, { config: RULES }); + writeFileSync(configPath, BROKEN); + + // A placement the change can't make refuses before anything is written. + assert.throws(() => gate.addPolicy({ name: 'ambient', behavior: 'defer' }, { position: { before: 'missing' } }), /no policy named "missing"/); + // Removing a rule that isn't there writes nothing. + assert.equal(gate.removePolicy('missing'), false); + + assert.deepEqual(keptFiles(dir), []); + assert.equal(readFileSync(configPath, 'utf-8'), BROKEN); +}); + +test('a missing gate.json is written with nothing kept', (t) => { + const dir = tempDir(t); + const { gate, configPath } = makeGate(dir); + gate.addPolicy({ name: 'ambient', behavior: 'defer' }); + assert.deepEqual(keptFiles(dir), []); + assert.deepEqual(onDisk(configPath).policies.map((p) => p.name), ['ambient']); +}); + +test('a gate.json the gate cannot read at all refuses a change, since there is nothing to keep', { + skip: NO_CHMOD ? 'needs a file the test user cannot read' : false, +}, (t) => { + const dir = tempDir(t); + const { gate, configPath } = makeGate(dir, { config: RULES }); + writeFileSync(configPath, BROKEN); + chmodSync(configPath, 0o000); + const thrown = thrownBy(() => gate.addPolicy({ name: 'ambient', behavior: 'defer' })); + chmodSync(configPath, 0o644); + + assert.match(thrown, /^gate\.json couldn't be read \(EACCES: .*\), so it can't be kept, and nothing was written over it\.$/); + assert.equal(readFileSync(configPath, 'utf-8'), BROKEN); + assert.deepEqual(keptFiles(dir), []); + assert.deepEqual(gate.getStatus().policies.map((p) => p.name), ['mentions'], 'the rules in force stay'); +}); + +test('when a copy of the unreadable gate.json cannot be kept, nothing is written, and the rule stays with its pending batch', { + skip: NO_CHMOD ? 'needs a directory the test user cannot write to' : false, +}, (t) => { + const dir = tempDir(t); + const configPath = join(dir, 'gate.json'); + writeFileSync(configPath, JSON.stringify(QUIET)); + const wakes: string[] = []; + const gate = new EventGate({ + configPath, + emitTrace: () => {}, + addMessage: () => '', + requestInference: (agent) => { wakes.push(agent); }, + getAgentNames: () => ['agent'], + now: () => AT, + }); + t.after(() => gate.dispose()); + assert.equal(gate.evaluate(INCOMING).trigger, false, 'the event waits in the debounce batch'); + writeFileSync(configPath, BROKEN); + chmodSync(dir, 0o555); + const thrown = thrownBy(() => gate.removePolicy('quiet')); + chmodSync(dir, 0o755); + + assert.match( + thrown, + /^gate\.json couldn't be read \(.*\), and keeping a copy of it at .*gate\.json\.unreadable-20261015T025905Z failed \(EACCES: .*\), so nothing was written\.$/, + ); + assert.equal(readFileSync(configPath, 'utf-8'), BROKEN); + assert.deepEqual(keptFiles(dir), []); + const [quiet] = gate.getStatus().policies; + assert.equal(quiet?.name, 'quiet', 'the rule stays in force'); + assert.equal(quiet?.debounceState?.pendingCount, 1, 'its batch is still pending'); + assert.deepEqual(wakes, [], 'and was not delivered'); +}); + +test('a write that fails after the copy is kept names the copy', { + skip: NO_CHMOD ? 'needs a file the test user cannot write' : false, +}, (t) => { + const dir = tempDir(t); + const { gate, configPath } = makeGate(dir, { config: RULES }); + writeFileSync(configPath, BROKEN); + chmodSync(configPath, 0o444); + const thrown = thrownBy(() => gate.addPolicy({ name: 'ambient', behavior: 'defer' })); + chmodSync(configPath, 0o644); + + const keptPath = join(dir, 'gate.json.unreadable-20261015T025905Z'); + assert.match(thrown, new RegExp(`EACCES.* A copy of what gate\\.json held when it couldn't be read is kept at ${escaped(keptPath)}\\.$`)); + assert.equal(readFileSync(keptPath, 'utf-8'), BROKEN); + assert.equal(readFileSync(configPath, 'utf-8'), BROKEN, 'the write failed opening gate.json, so it is as it was'); + assert.deepEqual(gate.getStatus().policies.map((p) => p.name), ['mentions'], 'the rules in force stay'); +}); + +test('a remove whose write fails changes nothing: the rule stays, with its pending debounce batch', { + skip: NO_CHMOD ? 'needs a file the test user cannot write' : false, +}, (t) => { + const dir = tempDir(t); + const configPath = join(dir, 'gate.json'); + writeFileSync(configPath, JSON.stringify(QUIET)); + const wakes: string[] = []; + const gate = new EventGate({ + configPath, + emitTrace: () => {}, + addMessage: () => '', + requestInference: (agent) => { wakes.push(agent); }, + getAgentNames: () => ['agent'], + now: () => AT, + }); + t.after(() => gate.dispose()); + assert.equal(gate.evaluate(INCOMING).trigger, false); + chmodSync(configPath, 0o444); + + const thrown = thrownBy(() => gate.removePolicy('quiet')); + chmodSync(configPath, 0o644); + assert.match(thrown, /EACCES/); + const [quiet] = gate.getStatus().policies; + assert.equal(quiet?.name, 'quiet', 'the rule stays in force'); + assert.equal(quiet?.debounceState?.pendingCount, 1, 'its batch is still pending'); + assert.deepEqual(wakes, [], 'and was not delivered'); +}); + +test('a new gate.json that a read only looked at, as listPolicyNames does, is still applied by the next reload', (t) => { + const dir = tempDir(t); + const { gate, configPath } = makeGate(dir, { config: RULES }); + writeFileSync(configPath, renamedRules('mentionz')); + + assert.deepEqual(gate.listPolicyNames(), ['mentionz'], 'the listing shows the file'); + assert.equal(gate.removePolicy('missing'), false, 'a remove that finds nothing writes nothing'); + assert.deepEqual(gate.getStatus().policies.map((p) => p.name), ['mentions'], 'neither applied it'); + gate.evaluate(INCOMING); + assert.deepEqual(gate.getStatus().policies.map((p) => p.name), ['mentionz'], 'the next reload does'); +}); + +test("gate.json's reload: unchanged text is not applied again, a check within a second is skipped, and a clock stepped back checks at once", (t) => { + const dir = tempDir(t); + const traces: Array<{ type: string }> = []; + const { gate, configPath } = makeGate(dir, { config: RULES, traces }); + const lastCheckAt = (at: number) => { (gate as unknown as { lastReloadCheck: number }).lastReloadCheck = at; }; + const names = () => gate.getStatus().policies.map((p) => p.name); + const reloads = () => traces.filter((e) => e.type === 'gate:config-reloaded').length; + + writeFileSync(configPath, renamedRules('one')); + gate.evaluate(INCOMING); + assert.deepEqual(names(), ['one']); + assert.equal(reloads(), 1); + + lastCheckAt(0); + gate.evaluate(INCOMING); + assert.equal(reloads(), 1, 'the text in force is not applied again'); + + lastCheckAt(Date.now()); + writeFileSync(configPath, renamedRules('two')); + gate.evaluate(INCOMING); + assert.deepEqual(names(), ['one'], 'within a second of the last check, the file is not read'); + + lastCheckAt(Date.now() + 600_000); + gate.evaluate(INCOMING); + assert.deepEqual(names(), ['two'], 'a clock stepped back since the last check checks at once'); +}); + +test('gate.json is reloaded when its text changes, whatever its mtime and size', (t) => { + const dir = tempDir(t); + const configPath = join(dir, 'gate.json'); + const second = Math.floor(AT / 1000); + writeFileSync(configPath, JSON.stringify(RULES)); + utimesSync(configPath, second, second); + const { gate } = makeGate(dir); + + // New text of the same length, mtime set back: as two writes in one clock tick leave it. + writeFileSync(configPath, renamedRules('mentionz')); + utimesSync(configPath, second, second); + gate.evaluate(INCOMING); + assert.deepEqual(gate.getStatus().policies.map((p) => p.name), ['mentionz']); +}); + +test('a gate.json that could not be read is read again once it can be, though its text and stat are unchanged', { + skip: NO_CHMOD ? 'needs a file the test user cannot read' : false, +}, (t) => { + const dir = tempDir(t); + const traces: Array<{ type: string }> = []; + const { gate, configPath } = makeGate(dir, { config: RULES, traces }); + chmodSync(configPath, 0o000); + const names = gate.listPolicyNames(); + const errors = gate.getStatus().errors; + chmodSync(configPath, 0o644); + assert.deepEqual(names, ['mentions'], 'the rules in force stay'); + assert.equal(errors.length, 1, JSON.stringify(errors)); + + gate.evaluate(INCOMING); + assert.deepEqual(gate.getStatus().errors, [], 'the reload read it again'); + assert.equal(traces.filter((e) => e.type === 'gate:config-reloaded').length, 0, 'it holds the text in force, so nothing was applied'); +}); + +test('a gate.json that is removed takes its error with it, and the rules in force stay', (t) => { + const dir = tempDir(t); + const { gate, configPath } = makeGate(dir, { config: RULES }); + writeFileSync(configPath, BROKEN); + assert.deepEqual(gate.listPolicyNames(), ['mentions']); + assert.equal(gate.getStatus().errors.length, 1); + + rmSync(configPath); + assert.deepEqual(gate.listPolicyNames(), ['mentions'], 'the rules in force stay'); + assert.deepEqual(gate.getStatus().errors, [], 'a missing gate.json is no problem with it'); +}); + +test('a file the gate cannot reach, its directory closed off, is an error rather than a missing file, and what is in force stays', { + skip: NO_CHMOD ? 'needs a directory the test user cannot search' : false, +}, (t) => { + const dir = tempDir(t); + let now = AT; + const { gate } = makeGate(dir, { config: RULES, privileged: '["u1"]', now: () => now }); + chmodSync(dir, 0o000); + now += 2_000; + const privileged = gate.isPrivilegedUser('u1'); + gate.evaluate(INCOMING); + const { policies, errors } = gate.getStatus(); + chmodSync(dir, 0o755); + + assert.equal(privileged, true, 'the privileged list that loaded stays'); + assert.deepEqual(policies.map((p) => p.name), ['mentions'], 'the rules in force stay'); + assert.equal(errors.length, 2, JSON.stringify(errors)); + assert.match(errors[0]!, /EACCES/); + assert.match(errors[1]!, /^Failed to load privileged-users file: EACCES/); +}); + +test("the startup's failure to reconcile gate.json shows beside a gate.json that loads, until gate.json loads new text", { + skip: NO_CHMOD ? 'needs a file the test user cannot write' : false, +}, (t) => { + const dir = tempDir(t); + const configPath = join(dir, 'gate.json'); + writeFileSync(configPath, JSON.stringify(RULES)); + chmodSync(configPath, 0o444); + const initialConfig: GateConfig = { + ...RULES, + policies: [...RULES.policies, { name: 'ambient', match: { tagsAny: ['chat:ambient'] }, behavior: 'defer' }], + }; + const gate = new EventGate({ + configPath, + initialConfig, + emitTrace: () => {}, + addMessage: () => '', + requestInference: () => {}, + getAgentNames: () => ['agent'], + now: () => AT, + }); + chmodSync(configPath, 0o644); + + const errors = gate.getStatus().errors; + assert.equal(errors.length, 1, JSON.stringify(errors)); + assert.match(errors[0]!, /^Failed to seed\/reconcile gate\.json: EACCES/); + assert.deepEqual(gate.getStatus().policies.map((p) => p.name), ['mentions'], 'gate.json loaded, without the starting rule it could not add'); + + // A spell when gate.json can't be read, then the same text again: nothing new decides the rules, so the error stays. + chmodSync(configPath, 0o000); + gate.evaluate(INCOMING); + const whileUnreadable = gate.getStatus().errors.length; + chmodSync(configPath, 0o644); + (gate as unknown as { lastReloadCheck: number }).lastReloadCheck = 0; + gate.evaluate(INCOMING); + assert.equal(whileUnreadable, 2, "gate.json's own error joins it"); + assert.deepEqual(gate.getStatus().errors, errors, 'the same text read again leaves only the startup\'s error'); + + writeFileSync(configPath, JSON.stringify({ ...RULES, default: 'skip' })); + (gate as unknown as { lastReloadCheck: number }).lastReloadCheck = 0; + gate.evaluate(INCOMING); + assert.deepEqual(gate.getStatus().errors, [], 'new text in gate.json decides the rules now'); +}); + +test('an unreadable gate.json read again unchanged is traced once', (t) => { + const dir = tempDir(t); + const traces: Array<{ type: string }> = []; + const { gate, configPath } = makeGate(dir, { config: RULES, traces }); + writeFileSync(configPath, BROKEN); + for (let i = 0; i < 3; i++) gate.listPolicyNames(); + assert.equal(traces.filter((e) => e.type === 'gate:config-error').length, 1); +}); + +test('a privileged-users file that parses but holds no list is its error, and the last list that loaded stays', (t) => { + const dir = tempDir(t); + let now = AT; + const { gate, privilegedUsersPath } = makeGate(dir, { config: RULES, privileged: '["u1"]', now: () => now }); + const check = (id: string) => { + now += 2_000; // past the reload throttle + return gate.isPrivilegedUser(id); + }; + for (const shape of ['{"users": ["u2"]}', '"u2"', '{"userIds": "u2"}', 'null']) { + writeFileSync(privilegedUsersPath, shape); + assert.equal(check('u1'), true, `the last list that loaded stays, with ${shape}`); + assert.equal(gate.isPrivilegedUser('u2'), false); + assert.deepEqual( + gate.getStatus().errors, + ['Failed to load privileged-users file: expected a list of user ids, or {"userIds": [...]}'], + shape, + ); + } + writeFileSync(privilegedUsersPath, '{"userIds": ["u2", 3]}'); + assert.equal(check('u2'), true); + assert.equal(gate.isPrivilegedUser('3'), true); + assert.deepEqual(gate.getStatus().errors, []); +}); + +test('a privileged-users list loads entry by entry: what is not a user id is skipped and named, and a number too large to read exactly is never loaded', (t) => { + const dir = tempDir(t); + let now = AT; + const { gate, privilegedUsersPath } = makeGate(dir, { config: RULES, privileged: '["u1"]', now: () => now }); + writeFileSync(privilegedUsersPath, '["u2", "", 12345678901234567890, {"id": "u3"}, 7]'); + now += 2_000; + + assert.equal(gate.isPrivilegedUser('u2'), true); + assert.equal(gate.isPrivilegedUser('7'), true); + assert.equal(gate.isPrivilegedUser('u1'), false, "the list is the file's"); + const rounded = String(JSON.parse('12345678901234567890')); + assert.equal(gate.isPrivilegedUser(rounded), false, 'the rounded number, which could be someone else, is not loaded'); + assert.deepEqual(gate.getStatus().errors, [ + `3 entries in the privileged-users file aren't user ids, so they were skipped: "", ${rounded} (a number this large can't ` + + 'be read exactly: write the id as a string, in quotes), {"id":"u3"}. A user id is a non-empty string, or a safe integer ' + + '(a whole number within 2^53 - 1 of zero); 2 ids did load.', + ]); +}); + +test("a privileged-users file that isn't JSON keeps the last list that loaded, with its error once, until it changes", (t) => { + const dir = tempDir(t); + let now = AT; + const { gate, privilegedUsersPath } = makeGate(dir, { config: RULES, privileged: '["u1"]', now: () => now }); + const failures = () => gate.getStatus().errors.filter((e) => e.startsWith('Failed to load privileged-users file')); + const check = (id: string) => { + now += 2_000; // past the reload throttle + return gate.isPrivilegedUser(id); + }; + + writeFileSync(privilegedUsersPath, '["u1", '); + for (let i = 0; i < 5; i++) assert.equal(check('u1'), true, 'the last list that loaded stays in effect'); + assert.equal(failures().length, 1, `one error, not one per call: ${JSON.stringify(gate.getStatus().errors)}`); + + writeFileSync(privilegedUsersPath, '["u1", "u2", '); + assert.equal(check('u1'), true); + assert.equal(failures().length, 1, 'a second failed load replaces the first error'); + + writeFileSync(privilegedUsersPath, '["u2"]'); + assert.equal(check('u2'), true, 'a changed file is read again'); + assert.equal(gate.isPrivilegedUser('u1'), false); + assert.deepEqual(failures(), [], 'its error goes once it loads'); + + writeFileSync(privilegedUsersPath, '["u2", '); + assert.equal(check('u2'), true); + rmSync(privilegedUsersPath); + assert.equal(check('u2'), false, 'with no file, no one is privileged'); + assert.deepEqual(failures(), [], 'and there is no error'); +}); + +test('the privileged-users file is read at most once a second, and at once after the clock steps back', (t) => { + const dir = tempDir(t); + let now = AT; + const { gate, privilegedUsersPath } = makeGate(dir, { config: RULES, privileged: '["u1"]', now: () => now }); + now += 2_000; + assert.equal(gate.isPrivilegedUser('u1'), true); + + writeFileSync(privilegedUsersPath, '["u2"]'); + now += 500; + assert.equal(gate.isPrivilegedUser('u2'), false, 'within a second of the last check, the change is not seen yet'); + now += 500; + assert.equal(gate.isPrivilegedUser('u2'), true, 'a second after it, it is'); + + writeFileSync(privilegedUsersPath, '["u3"]'); + now -= 600_000; + assert.equal(gate.isPrivilegedUser('u3'), true, 'a clock stepped back since the last check checks at once'); +}); + +test('a privileged-users file whose text changes is read again whatever its mtime and size, and a chmod that repairs it is noticed', { + skip: NO_CHMOD ? 'needs a file the test user cannot read' : false, +}, (t) => { + const dir = tempDir(t); + const privilegedUsersPath = join(dir, 'privileged.json'); + const second = Math.floor(AT / 1000); // a whole second, so the mtime set back is exactly the one first read + writeFileSync(privilegedUsersPath, '["u1"]'); + utimesSync(privilegedUsersPath, second, second); + let now = AT; + const { gate } = makeGate(dir, { config: RULES, now: () => now }); + const check = (id: string) => { + now += 2_000; // past the reload throttle + return gate.isPrivilegedUser(id); + }; + + // New text, same size, mtime set back: as two writes in one clock tick leave it. + writeFileSync(privilegedUsersPath, '["u2"]'); + utimesSync(privilegedUsersPath, second, second); + assert.equal(statSync(privilegedUsersPath).mtimeMs, second * 1000); + assert.equal(check('u2'), true); + + chmodSync(privilegedUsersPath, 0o000); + const whileUnreadable = check('u2'); + const errorsWhileUnreadable = gate.getStatus().errors; + chmodSync(privilegedUsersPath, 0o644); + assert.equal(whileUnreadable, true, 'the last list that loaded stays'); + assert.equal(errorsWhileUnreadable.length, 1, JSON.stringify(errorsWhileUnreadable)); + + // Readable again, with the same text and stat as before: noticed at the next check. + assert.equal(check('u2'), true); + assert.deepEqual(gate.getStatus().errors, []); +}); + +test('while the agent sleeps, a user added to the privileged-users file wakes it once the file is read again, and no one else does', (t) => { + const dir = tempDir(t); + let now = AT; + const { gate, privilegedUsersPath } = makeGate(dir, { config: RULES, privileged: '["u1", ""]', now: () => now }); + t.after(() => { + gate.clearSleep(); + gate.dispose(); + }); + gate.setSleep(600); + const from = (id: string) => gate.evaluate({ ...INCOMING, tags: ['chat:mention'], metadata: { author: { id } } }).policyName; + + assert.equal(from('u2'), 'asleep'); + assert.equal(from(''), 'asleep', 'an empty id is no one'); + assert.equal(from('u1'), 'mentions', 'a privileged user is let through to the rules'); + + writeFileSync(privilegedUsersPath, '["u2"]'); + now += 2_000; + assert.equal(from('u2'), 'mentions'); + assert.equal(from('u1'), 'asleep'); +}); + +test('gate_status reads both files again before it answers, so a repair shows without any event', async (t) => { + const dir = tempDir(t); + let now = AT; + const { gate, configPath, privilegedUsersPath } = makeGate(dir, { config: RULES, privileged: '["u1", ', now: () => now }); + writeFileSync(configPath, BROKEN); + const status = async () => (await gate.handleToolCall()).data!; + + assert.equal((await status()).errors.length, 2, 'both files are read, and neither loads'); + + writeFileSync(configPath, renamedRules('mentionz')); + writeFileSync(privilegedUsersPath, '["u1"]'); + (gate as unknown as { lastReloadCheck: number }).lastReloadCheck = 0; + now += 2_000; + const repaired = await status(); + assert.deepEqual(repaired.errors, []); + assert.deepEqual(repaired.policies.map((p) => p.name), ['mentionz']); +}); + +test("a gate.json that won't load and a privileged-users file that won't load each show their own error", (t) => { + const dir = tempDir(t); + const { gate, configPath } = makeGate(dir, { config: RULES, privileged: '{"userIds": [' }); + writeFileSync(configPath, BROKEN); + assert.deepEqual(gate.listPolicyNames(), ['mentions'], 'the rules in force stay'); + + const errors = gate.getStatus().errors; + assert.equal(errors.length, 2, JSON.stringify(errors)); + assert.ok(errors[1]!.startsWith('Failed to load privileged-users file'), 'the gate.json load kept the privileged-users error'); + + writeFileSync(configPath, JSON.stringify(RULES)); + assert.deepEqual(gate.listPolicyNames(), ['mentions'], 'gate.json loads again'); + assert.deepEqual(gate.getStatus().errors, [errors[1]], 'and that clears only its own error'); +}); + +test('wake_add_rule and wake_remove_rule tell the agent where the copy of an unreadable gate.json is', async (t) => { + const dir = mkdtempSync(join(tmpdir(), 'gate-unreadable-')); + const configPath = join(dir, 'gate.json'); + const membrane = new MockMembrane(); + const framework = await AgentFramework.create({ + storePath: join(dir, 'store'), + membrane: membrane.asMembrane(), + agents: [{ name: 'scout', model: 'test-model', systemPrompt: 'You are scout.', proseRouting: 'disabled' }], + modules: [], + gate: { configPath, config: RULES }, + }); + t.after(async () => { + await framework.stop(); + rmSync(dir, { recursive: true, force: true }); + }); + + /** One turn making `call`, over a gate.json that won't load; the result the model is sent. */ + const callOverUnreadable = async (call: { id: string; name: string; input: Record }, broken: string) => { + writeFileSync(configPath, broken); + membrane.pushResponse(createMockResponse([{ type: 'tool_use', ...call }], 'tool_use')); + membrane.pushResponse(createMockResponse([])); + assert.equal(framework.nudgeAgent('scout').ok, true); + await framework.runUntilIdle(); + const [round] = membrane.lastStream!.receivedToolResults as Array>; + return round![0]!; + }; + const added = await callOverUnreadable({ id: 'add', name: 'wake_add_rule', input: { name: 'ambient', behavior: 'defer' } }, BROKEN); + const removed = await callOverUnreadable({ id: 'remove', name: 'wake_remove_rule', input: { name: 'ambient' } }, `${BROKEN} "again"`); + + const [first, second] = keptFiles(dir); + assert.ok(first && second, `two kept copies: ${JSON.stringify(keptFiles(dir))}`); + assert.equal(readFileSync(join(dir, first), 'utf-8'), BROKEN); + assert.equal(readFileSync(join(dir, second), 'utf-8'), `${BROKEN} "again"`); + const note = (kept: string) => new RegExp( + `^gate\\.json couldn't be read \\(.*\\), so a copy of it was kept at ${escaped(join(dir, kept))}, and gate\\.json now holds ` + + 'the rules that were in force, with this change\\. What the copy holds is not in force; whoever runs this host can ' + + 'restore rules from it\\.$', + ); + assert.equal(added.isError, false); + assert.match(JSON.parse(added.content).keptUnreadable, note(first)); + assert.equal(removed.isError, false); + assert.match(JSON.parse(removed.content).keptUnreadable, note(second)); + assert.deepEqual(onDisk(configPath).policies.map((p) => p.name), ['mentions']); +}); From ae67efcdc04783dd248366ddc3adce6f213ce056 Mon Sep 17 00:00:00 2001 From: Hamish-2569 Date: Thu, 15 Oct 2026 04:07:35 -0400 Subject: [PATCH 002/187] fix(gate): keep a copy no more readable than gate.json keepUnreadableConfig created gate.json.unreadable-