Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,14 @@ removes that deployment's database volume and nothing else.
Startup failures keep enough of the log to name the cause, with every secret value redacted, and
carry a support link a whitelabel build can point elsewhere.

### A failed save of a Bot's browser control leaves no copy behind

The computer keeps who holds a Bot's browser, and its handoff requests, in one file per Bot under
the profiles volume, written to a temporary file first and renamed over it. When the write or the
rename failed, the temporary file stayed, a readable copy of that state beside the real one, and
every later failure added another. It is now removed whether or not the save succeeds, as the
learning setup and the model sign-in file already do.

### A Bot's image pull finds the Docker credential helper beside Docker

A Docker install whose credential helper sits next to the `docker` binary rather than on the desktop
Expand Down
16 changes: 11 additions & 5 deletions agent-computer/src/control-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
openSync,
readFileSync,
renameSync,
rmSync,
writeFileSync,
} from "node:fs";
import { join } from "node:path";
Expand Down Expand Up @@ -131,14 +132,19 @@ export function createControlStore(
validate(state);
mkdirSync(directory, { recursive: true, mode: 0o700 });
const temporary = `${path}.${crypto.randomUUID()}.tmp`;
const fd = openSync(temporary, "wx", 0o600);
try {
writeFileSync(fd, JSON.stringify(state));
fsyncSync(fd);
const fd = openSync(temporary, "wx", 0o600);
try {
writeFileSync(fd, JSON.stringify(state));
fsyncSync(fd);
} finally {
closeSync(fd);
}
renameSync(temporary, path);
} finally {
closeSync(fd);
// A write or rename that fails must not leave the copy behind; after a rename it is gone.
rmSync(temporary, { force: true });
}
renameSync(temporary, path);
},
};
}
21 changes: 20 additions & 1 deletion agent-computer/tests/control-store.test.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,17 @@
import { afterEach, expect, test } from "bun:test";
import {
mkdirSync,
mkdtempSync,
readFileSync,
readdirSync,
readFileSync,
rmSync,
writeFileSync,
} from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { createControl } from "../src/control";
import { createControlStore } from "../src/control-store";

const directories: string[] = [];
function fixture() {
const directory = mkdtempSync(join(tmpdir(), "control-store-"));
Expand Down Expand Up @@ -40,6 +42,23 @@ test("atomic persisted completion remains exact across reload and later request"
).not.toContain("private label");
expect(readdirSync(join(directory, ".control"))).toEqual(["bot-1.json"]);
});
test("a save that cannot replace the state leaves no temporary copy behind", () => {
const { directory, store } = fixture();
// A directory where the state file goes makes the final rename fail.
mkdirSync(join(directory, ".control", "bot-1.json"), { recursive: true });
expect(() =>
store.save({
version: 1,
holder: "bot",
since: new Date().toISOString(),
resumeSnapshotRequired: false,
recoveryRequired: false,
requests: [],
aliases: {},
}),
).toThrow();
expect(readdirSync(join(directory, ".control"))).toEqual(["bot-1.json"]);
});
test("reload interrupts active requests and retains idempotent identity", async () => {
for (const take of [false, true]) {
const { store, control } = fixture();
Expand Down
Loading