Skip to content

Commit 4e77e22

Browse files
broomvaclaude
andcommitted
fix(tools): a read-only workspace is a condition, not a defect (BRO-2227)
`chmod 555 <workspace>` then `propose` returned UNEXPECTED with a raw EACCES, and exit 1. The CLI's own contract says exit 1 means a defect and UNEXPECTED is "the backstop that should never fire" -- so an operator was told Parallax is broken when the accurate answer was that their directory is not writable. The two have completely different remedies, and the wrong one sends someone reading our source instead of their mount options. It is also not a rare case: a read-only bind mount is the confinement posture this design assumes, so a tenant hits it on the first call. Every write in src/tools/state.ts now goes through one guard that maps EACCES, EROFS, EPERM and ENOSPC to a distinct WorkspaceNotWritableError. A distinct class rather than a generic wrap, so every OTHER throw still reaches the backstop, which is where an actual defect belongs. Both adapters map it identically -- WORKSPACE_NOT_WRITABLE, and the CLI returns 2 (a typed refusal) rather than 1. A condition that is a typed refusal on one surface and a crash on the other is precisely the divergence the "agent is a user" claim rules out, so the two backstops carry the same mapping rather than one delegating politeness to the other. Verified on both surfaces with the control: read-only refuses with the code on CLI and tools alike, writable still returns ok on both. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 09e12d0 commit 4e77e22

4 files changed

Lines changed: 92 additions & 9 deletions

File tree

src/cli.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { DEFAULT_DOMAIN, DOMAIN_KEYS } from "./tools/domains";
22
import { type AnyErrorCode, fail, ok, type ParallaxError, type Result } from "./tools/errors";
33
import * as handlers from "./tools/handlers";
4+
import { WorkspaceNotWritableError } from "./tools/state";
45

56
/**
67
* Parallax at a terminal.
@@ -482,6 +483,23 @@ export async function runCli(argv: readonly string[], io: Io = stdio): Promise<n
482483
try {
483484
return await main(argv, io);
484485
} catch (e) {
486+
// A workspace that cannot be written to is an expectable condition, not a
487+
// defect in this program -- and it is the confinement posture the design
488+
// assumes, so it is the FIRST thing a tenant on a read-only mount hits.
489+
// Reporting it as UNEXPECTED told the operator Parallax is broken when the
490+
// accurate answer was that their directory is not writable; the two have
491+
// completely different remedies. Exit 2 (a typed refusal), not 1 (a defect).
492+
if (e instanceof WorkspaceNotWritableError) {
493+
io.err(
494+
`${JSON.stringify({
495+
code: "WORKSPACE_NOT_WRITABLE",
496+
reason:
497+
"Parallax needs to write its thread state to .parallax/ in this directory, and the directory is not writable",
498+
detail: { root: e.root, cause: e.cause },
499+
})}\n`,
500+
);
501+
return 2;
502+
}
485503
io.err(
486504
`${JSON.stringify({
487505
code: "UNEXPECTED",

src/tools/errors.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ export type ToolErrorCode =
2424
| "WORKSPACE_UNREADABLE"
2525
| "ROOT_NOT_ALLOWED"
2626
| "WORKSPACE_DENIED"
27+
| "WORKSPACE_NOT_WRITABLE"
2728
| "TABLES_REQUIRED"
2829
// pending-proposal addressing
2930
| "NO_PENDING_PROPOSAL"

src/tools/index.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
type ZodLike,
1111
type ZodTypeLike,
1212
} from "./schemas";
13+
import { WorkspaceNotWritableError } from "./state";
1314

1415
/**
1516
* Parallax as a set of tools an agent calls.
@@ -399,6 +400,22 @@ async function runTool(
399400
if (!checked.ok) return checked;
400401
return await handler(checked.value);
401402
} catch (e) {
403+
// Same mapping as the CLI backstop, for the same reason: a workspace that
404+
// cannot be written to is an expectable condition, not a defect in this
405+
// program. Both surfaces must name it identically -- a condition that is a
406+
// typed refusal on one surface and a crash on the other is exactly the
407+
// divergence the "agent is a user" claim rules out.
408+
if (e instanceof WorkspaceNotWritableError) {
409+
return {
410+
ok: false,
411+
error: {
412+
code: "WORKSPACE_NOT_WRITABLE",
413+
reason:
414+
"Parallax needs to write its thread state to .parallax/ in this directory, and the directory is not writable",
415+
detail: { root: e.root, cause: e.cause },
416+
},
417+
};
418+
}
402419
return {
403420
ok: false,
404421
error: {

src/tools/state.ts

Lines changed: 56 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -37,8 +37,49 @@ export function stateRoot(cwd: string = process.cwd()): string {
3737

3838
/** Reads never create directories. Only writes do -- so a failed lookup leaves no trace. */
3939
function ensureDirs(root: string): void {
40-
for (const d of ["pending", "accepted", "rejected", "runs"]) {
41-
mkdirSync(join(root, d), { recursive: true });
40+
guardWrite(root, () => {
41+
for (const d of ["pending", "accepted", "rejected", "runs"]) {
42+
mkdirSync(join(root, d), { recursive: true });
43+
}
44+
});
45+
}
46+
47+
/**
48+
* Raised when the workspace cannot be written to.
49+
*
50+
* A read-only workspace is a legitimate, expectable condition -- it is the
51+
* confinement posture this whole design assumes, and a tenant on a read-only
52+
* bind mount hits it on the first call. It was surfacing as UNEXPECTED, which
53+
* the CLI's own contract defines as "a defect, the backstop that should never
54+
* fire". So the operator was told Parallax is broken when the accurate answer
55+
* was that their directory is not writable, and the two have completely
56+
* different remedies.
57+
*
58+
* Carried as a distinct class rather than a raw fs error so the adapter layer
59+
* can map exactly this to a typed code and let every other throw keep going to
60+
* the backstop, where it belongs.
61+
*/
62+
export class WorkspaceNotWritableError extends Error {
63+
readonly root: string;
64+
readonly cause?: string;
65+
constructor(root: string, cause: string) {
66+
super(`the workspace is not writable: ${cause}`);
67+
this.name = "WorkspaceNotWritableError";
68+
this.root = root;
69+
this.cause = cause;
70+
}
71+
}
72+
73+
/** Every write in this module goes through here, so the mapping cannot be bypassed. */
74+
function guardWrite<T>(root: string, f: () => T): T {
75+
try {
76+
return f();
77+
} catch (e) {
78+
const code = (e as NodeJS.ErrnoException)?.code;
79+
if (code === "EACCES" || code === "EROFS" || code === "EPERM" || code === "ENOSPC") {
80+
throw new WorkspaceNotWritableError(root, e instanceof Error ? e.message : String(e));
81+
}
82+
throw e;
4283
}
4384
}
4485

@@ -47,9 +88,11 @@ function ensureDirs(root: string): void {
4788
* reader silently accepts as a smaller version of the truth.
4889
*/
4990
function writeJson(path: string, value: unknown): void {
50-
const tmp = `${path}.tmp-${process.pid}-${Date.now()}`;
51-
writeFileSync(tmp, `${JSON.stringify(value, null, 2)}\n`);
52-
renameSync(tmp, path);
91+
guardWrite(path, () => {
92+
const tmp = `${path}.tmp-${process.pid}-${Date.now()}`;
93+
writeFileSync(tmp, `${JSON.stringify(value, null, 2)}\n`);
94+
renameSync(tmp, path);
95+
});
5396
}
5497

5598
function readJson<T>(path: string): T | null {
@@ -130,8 +173,10 @@ export function writeHead(root: string, proposalId: string | null): void {
130173
return;
131174
}
132175
const tmp = `${path}.tmp-${process.pid}-${Date.now()}`;
133-
writeFileSync(tmp, `${proposalId}\n`);
134-
renameSync(tmp, path);
176+
guardWrite(path, () => {
177+
writeFileSync(tmp, `${proposalId}\n`);
178+
renameSync(tmp, path);
179+
});
135180
}
136181

137182
export interface RejectionRecord {
@@ -257,8 +302,10 @@ export function writeRun(root: string, rec: RunRecord, html: string): void {
257302
writeJson(join(root, "runs", `${rec.runId}.json`), rec);
258303
const path = join(root, "runs", `${rec.runId}.html`);
259304
const tmp = `${path}.tmp-${process.pid}-${Date.now()}`;
260-
writeFileSync(tmp, html);
261-
renameSync(tmp, path);
305+
guardWrite(path, () => {
306+
writeFileSync(tmp, html);
307+
renameSync(tmp, path);
308+
});
262309
}
263310

264311
export function readRun(root: string, runId: string): RunRecord | null {

0 commit comments

Comments
 (0)