Skip to content

Commit 7008438

Browse files
fix(agents): trust session fingerprints before publishing
1 parent daa1be2 commit 7008438

2 files changed

Lines changed: 76 additions & 2 deletions

File tree

src/agents/pi-embedded-runner/run/attempt.session-lock.test.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -248,6 +248,42 @@ describe("embedded attempt session lock lifecycle", () => {
248248
expect(releases).toEqual(["release", "release", "release"]);
249249
});
250250

251+
it("rejects external session edits even when another controller appends under lock afterward", async () => {
252+
const sessionFile = await createTempSessionFile();
253+
const releases: string[] = [];
254+
const acquireSessionWriteLock = vi.fn(async () => ({
255+
release: vi.fn(async () => {
256+
releases.push("release");
257+
}),
258+
}));
259+
const firstController = await createEmbeddedAttemptSessionLockController({
260+
acquireSessionWriteLock,
261+
lockOptions: { ...lockOptions, sessionFile },
262+
});
263+
264+
await firstController.releaseForPrompt();
265+
await fs.appendFile(sessionFile, '{"type":"message","id":"external"}\n', "utf8");
266+
267+
const secondController = await createEmbeddedAttemptSessionLockController({
268+
acquireSessionWriteLock,
269+
lockOptions: { ...lockOptions, sessionFile },
270+
});
271+
await secondController.withSessionWriteLock(async () => {
272+
await fs.appendFile(sessionFile, '{"type":"message","id":"same-process"}\n', "utf8");
273+
});
274+
await secondController.releaseForPrompt();
275+
276+
await expect(
277+
firstController.withSessionWriteLock(async () => {
278+
await fs.appendFile(sessionFile, '{"type":"message","id":"late"}\n', "utf8");
279+
}),
280+
).rejects.toBeInstanceOf(EmbeddedAttemptSessionTakeoverError);
281+
282+
expect(firstController.hasSessionTakeover()).toBe(true);
283+
expect(acquireSessionWriteLock).toHaveBeenCalledTimes(3);
284+
expect(releases).toEqual(["release", "release", "release"]);
285+
});
286+
251287
it("returns a no-op cleanup lock after prompt lock reacquisition times out", async () => {
252288
const releases: string[] = [];
253289
const acquireSessionWriteLock = vi

src/agents/pi-embedded-runner/run/attempt.session-lock.ts

Lines changed: 40 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -134,12 +134,18 @@ type OwnedSessionFileWrite = {
134134
fingerprint: SessionFileFingerprint;
135135
};
136136

137+
type TrustedSessionFileState = {
138+
generation: number;
139+
fingerprint: SessionFileFingerprint;
140+
};
141+
137142
// Controllers in the same OpenClaw process can legitimately take turns writing
138143
// the same session file while another attempt is released for model I/O. Track
139144
// only fingerprints that changed while OpenClaw held the write lock so the
140145
// takeover fence can distinguish those locked in-process writes from unowned
141146
// external file changes.
142147
const ownedSessionFileWrites = new Map<string, OwnedSessionFileWrite>();
148+
const trustedSessionFileStates = new Map<string, TrustedSessionFileState>();
143149
let ownedSessionFileWriteGeneration = 0;
144150

145151
function resolveSessionFileFenceKey(sessionFile: string): string {
@@ -151,13 +157,41 @@ function recordOwnedSessionFileWrite(
151157
fingerprint: SessionFileFingerprint,
152158
): number {
153159
ownedSessionFileWriteGeneration += 1;
154-
ownedSessionFileWrites.set(sessionFileKey, {
160+
const state = {
161+
generation: ownedSessionFileWriteGeneration,
162+
fingerprint,
163+
};
164+
ownedSessionFileWrites.set(sessionFileKey, state);
165+
trustedSessionFileStates.set(sessionFileKey, state);
166+
return ownedSessionFileWriteGeneration;
167+
}
168+
169+
function trustSessionFileState(
170+
sessionFileKey: string,
171+
fingerprint: SessionFileFingerprint,
172+
): number | undefined {
173+
const trusted = trustedSessionFileStates.get(sessionFileKey);
174+
if (trusted) {
175+
return sameSessionFileFingerprint(trusted.fingerprint, fingerprint)
176+
? trusted.generation
177+
: undefined;
178+
}
179+
ownedSessionFileWriteGeneration += 1;
180+
trustedSessionFileStates.set(sessionFileKey, {
155181
generation: ownedSessionFileWriteGeneration,
156182
fingerprint,
157183
});
158184
return ownedSessionFileWriteGeneration;
159185
}
160186

187+
function isTrustedSessionFileState(
188+
sessionFileKey: string,
189+
fingerprint: SessionFileFingerprint,
190+
): boolean {
191+
const trusted = trustedSessionFileStates.get(sessionFileKey);
192+
return !!trusted && sameSessionFileFingerprint(trusted.fingerprint, fingerprint);
193+
}
194+
161195
async function readSessionFileFingerprint(sessionFile: string): Promise<SessionFileFingerprint> {
162196
try {
163197
const stat = await fs.stat(sessionFile, { bigint: true });
@@ -347,6 +381,9 @@ export async function createEmbeddedAttemptSessionLockController(params: {
347381
if (sameSessionFileFingerprint(beforeWrite, fingerprint)) {
348382
return null;
349383
}
384+
if (!isTrustedSessionFileState(sessionFileFenceKey, beforeWrite)) {
385+
return null;
386+
}
350387
const generation = recordOwnedSessionFileWrite(sessionFileFenceKey, fingerprint);
351388
return { fingerprint, generation };
352389
}
@@ -390,11 +427,12 @@ export async function createEmbeddedAttemptSessionLockController(params: {
390427
heldLock = undefined;
391428
const fingerprint = await readSessionFileFingerprint(params.lockOptions.sessionFile);
392429
const ownedWrite = ownedSessionFileWrites.get(sessionFileFenceKey);
430+
const trustedGeneration = trustSessionFileState(sessionFileFenceKey, fingerprint);
393431
fenceFingerprint = fingerprint;
394432
fenceGeneration =
395433
ownedWrite && sameSessionFileFingerprint(ownedWrite.fingerprint, fingerprint)
396434
? ownedWrite.generation
397-
: fenceGeneration;
435+
: (trustedGeneration ?? fenceGeneration);
398436
fenceActive = true;
399437
await lock.release();
400438
},

0 commit comments

Comments
 (0)