Skip to content

Commit 9eff04d

Browse files
committed
Keep approval persistence failures from crashing the session
1 parent 2503cc6 commit 9eff04d

3 files changed

Lines changed: 129 additions & 4 deletions

File tree

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,12 @@ parallel copies under `docs/` or `scripts/notes/`. At cut time: rename
1313

1414
## [Unreleased]
1515

16+
### Fixed
17+
18+
- A failed write of a project, global, or provider-model approval no longer
19+
crashes the session. The grant still applies in memory, the approved tool
20+
call still completes, and a concise diagnostic is logged.
21+
1622
## [0.3.10] - 2026-08-30
1723

1824
### Fixed

src/session/runtime-assembly.test.ts

Lines changed: 103 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,14 @@
1-
import { afterEach, describe, expect, mock, spyOn, test } from "bun:test";
1+
import { afterEach, beforeEach, describe, expect, mock, spyOn, test } from "bun:test";
22
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
33
import { tmpdir } from "node:os";
44
import { join } from "node:path";
5+
import { getLogger } from "@intx/log";
6+
import type { ToolCall } from "@intx/types/runtime";
57

8+
import { LOG_NAMESPACE_ROOT } from "../branding.js";
69
import * as permissionStore from "../permission/store.js";
10+
import { createPermissionGate } from "../permission/gate.js";
11+
import type { GrantScope } from "../permission/types.js";
712
import {
813
buildSubAgentProvider,
914
createApprovalPersist,
@@ -160,6 +165,12 @@ describe("loadSeededApprovals merge order", () => {
160165
});
161166

162167
describe("createApprovalPersist", () => {
168+
const persistLogger = getLogger([LOG_NAMESPACE_ROOT, "session", "approvals"]);
169+
170+
beforeEach(() => {
171+
spyOn(persistLogger, "warn");
172+
});
173+
163174
afterEach(() => {
164175
mock.restore();
165176
});
@@ -187,6 +198,97 @@ describe("createApprovalPersist", () => {
187198
expect(global).toHaveBeenCalledTimes(1);
188199
expect(providerModel).toHaveBeenCalledTimes(1);
189200
});
201+
202+
const persistedScopes: {
203+
scope: Exclude<GrantScope, "session">;
204+
reject: (message: string) => void;
205+
}[] = [
206+
{
207+
scope: "project",
208+
reject: (message) => {
209+
spyOn(permissionStore, "saveProjectApproval").mockRejectedValue(new Error(message));
210+
},
211+
},
212+
{
213+
scope: "global",
214+
reject: (message) => {
215+
spyOn(permissionStore, "saveGlobalApproval").mockRejectedValue(new Error(message));
216+
},
217+
},
218+
{
219+
scope: "provider-model",
220+
reject: (message) => {
221+
spyOn(permissionStore, "saveProviderModelApproval").mockRejectedValue(new Error(message));
222+
},
223+
},
224+
];
225+
226+
const shellCall = (command: string): ToolCall => ({
227+
id: "c",
228+
name: "run_shell",
229+
arguments: { command },
230+
});
231+
232+
async function flushUnhandledRejections(): Promise<unknown> {
233+
let unhandled: unknown = null;
234+
const onUnhandled = (reason: unknown): void => {
235+
unhandled = reason;
236+
};
237+
process.on("unhandledRejection", onUnhandled);
238+
try {
239+
await new Promise((resolve) => setTimeout(resolve, 0));
240+
} finally {
241+
process.off("unhandledRejection", onUnhandled);
242+
}
243+
return unhandled;
244+
}
245+
246+
for (const { scope, reject } of persistedScopes) {
247+
test(`a rejected ${scope} write is contained, logged, and never becomes an unhandled rejection`, async () => {
248+
const message = `${scope} disk full`;
249+
reject(message);
250+
251+
const persist = createApprovalPersist("/tmp/proj", "openai:gpt-5");
252+
persist({ tool: "run_shell", pattern: "npm *" }, scope);
253+
254+
expect(await flushUnhandledRejections()).toBeNull();
255+
expect(persistLogger.warn).toHaveBeenCalledTimes(1);
256+
expect(persistLogger.warn).toHaveBeenCalledWith(
257+
"Failed to persist {scope} approval: {error}",
258+
{
259+
scope,
260+
error: message,
261+
},
262+
);
263+
});
264+
265+
test(`an approved call still completes and the in-memory ${scope} grant still applies when persist rejects`, async () => {
266+
reject(`${scope} EACCES`);
267+
const persist = createApprovalPersist("/tmp/proj", "openai:gpt-5");
268+
let asked = 0;
269+
const gate = createPermissionGate({
270+
approvals: [],
271+
requestApproval: async () => {
272+
asked++;
273+
return {
274+
allow: true,
275+
persist: { id: scope, label: "", pattern: "npm *", grant: scope },
276+
};
277+
},
278+
persist,
279+
interactive: true,
280+
skipPermissions: false,
281+
providerName: "openai",
282+
model: "gpt-5",
283+
});
284+
285+
expect((await gate.evaluate(shellCall("npm test"))).allowed).toBe(true);
286+
expect(asked).toBe(1);
287+
expect(await flushUnhandledRejections()).toBeNull();
288+
expect((await gate.evaluate(shellCall("npm run build"))).allowed).toBe(true);
289+
expect(asked).toBe(1);
290+
});
291+
}
190292
});
191293

192294
describe("skillDirsFromEnabledPlugins", () => {

src/session/runtime-assembly.ts

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
// Only near-verbatim blocks live here. Gate / toolset / director construction
44
// bind runner-specific state and stay in each runner.
55

6+
import { getLogger } from "@intx/log";
67
import type { ConversationTurn, InferenceSource } from "@intx/types/runtime";
78
import type { Compactor } from "@intx/types/runtime";
89

@@ -13,6 +14,7 @@ import {
1314
loadAgentContextExtensions,
1415
loadSystemPromptOverrides,
1516
} from "../agent/context-extensions.js";
17+
import { LOG_NAMESPACE_ROOT } from "../branding.js";
1618
import type { ProviderCatalogEntry } from "../config/index.js";
1719
import { buildMainSessionSources } from "../config/inference-sources.js";
1820
import type { SessionMode } from "../config/session-mode.js";
@@ -119,19 +121,34 @@ export async function loadSeededApprovals(
119121
return [...sessionApprovals, ...projectApprovals, ...globalApprovals, ...providerModelApprovals];
120122
}
121123

124+
const persistLogger = getLogger([LOG_NAMESPACE_ROOT, "session", "approvals"]);
125+
126+
// The persist callback is fire-and-forget from the gate. A rejected write must
127+
// not become an unhandledRejection (that path is fatal at process level); the
128+
// in-memory grant already applies, so the approved call still completes.
129+
function persistBestEffort(scope: GrantScope, write: Promise<void>): void {
130+
void write.catch((err: unknown) => {
131+
persistLogger.warn("Failed to persist {scope} approval: {error}", {
132+
scope,
133+
error: err instanceof Error ? err.message : String(err),
134+
});
135+
});
136+
}
137+
122138
/**
123139
* Route a gate-persisted grant to the store its scope selects.
124140
* Session grants never reach here — the gate keeps those in memory only.
141+
* Disk failures are logged and swallowed so they cannot crash the session.
125142
*/
126143
export function createApprovalPersist(
127144
cwd: string,
128145
activeProviderModel: string,
129146
): (approval: Approval, scope: GrantScope) => void {
130147
return (approval: Approval, scope: GrantScope) => {
131-
if (scope === "project") void saveProjectApproval(cwd, approval);
132-
else if (scope === "global") void saveGlobalApproval(approval);
148+
if (scope === "project") persistBestEffort(scope, saveProjectApproval(cwd, approval));
149+
else if (scope === "global") persistBestEffort(scope, saveGlobalApproval(approval));
133150
else if (scope === "provider-model") {
134-
void saveProviderModelApproval(activeProviderModel, approval);
151+
persistBestEffort(scope, saveProviderModelApproval(activeProviderModel, approval));
135152
}
136153
};
137154
}

0 commit comments

Comments
 (0)