a member's background job can be read and stopped
A member started `bun dev`, watched it run, and got no logs at all — then
`{"stopped":false,"reason":"no such task"}` from the Stop button. Two symptoms,
one cause, and it is the same one `read-as-user.ts` was written for.
`findTaskOutput` globbed `/tmp/claude-*/…` as the platform's own uid.
`/tmp/claude-<uid>` is `drwx------` (checked, not assumed), so for a member that
directory cannot even be traversed. A glob that cannot traverse does not throw —
it returns an empty iterator. Both callers read that as "no such task": the log
view showed "Nothing written yet" forever, and Stop refused an id that was
perfectly real.
Every other filesystem access in that module already goes through the as-user
helpers. This one path never did, and it was invisible for as long as only the
owner used chat.
So the lookup moves into `findPathAs`, and Stop runs as the member too — both
halves of it. `fuser` reads `/proc/<pid>/fd`, which only the process's own user
and root may open, so as the platform it would have found no pids and reported
the job already stopped; the kill after it would have been cross-user EPERM,
swallowed by a catch. Neither would have said anything.
── The exit code is ignored, and that is the point ──
The first version of this fix located the file and threw it away. `find` exits 1
when it cannot descend into something, and searching `/tmp` from ANY identity
hits another account's `drwx------` — it prints the match on stdout first, then
returns 1. Treating that as failure reproduced the original bug through a new
mechanism. Caught by testing the owner path against a real task file rather than
assuming the refactor was behaviour-preserving; a missing root still exits 1 with
empty stdout, so absence still reads as absence.
Owner and member now take the same `find`, deliberately: one set of matching
semantics, rather than a glob for us and a find for them, which is how a bug ends
up reproducing on one account and not the other.
Verified on the owner path here against a live task file. The member path is
covered by `read-as-user.live.test.ts` — same opt-in shape as
`spawn-as-member.live.test.ts`, asserting the two identities DISAGREE, which a
world-readable tree would not satisfy. It cannot run on a machine with one
account, so it skips here and needs OFFICER_TEST_MEMBER on the host.
tsgo clean, frontend builds, 865 pass / 7 fail unchanged, 3 new skips.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -165,6 +165,38 @@ function runSync(osUser: string, command: string[]): string {
|
||||
return new TextDecoder().decode(result.stdout);
|
||||
}
|
||||
|
||||
/**
|
||||
* The first path under `root` matching `predicate`, as the owner of the tree.
|
||||
*
|
||||
* Locating a file needs `x` on every directory above it, which the service user does not have on a
|
||||
* member's tree — the header explains why the ACL cannot give it. `/tmp/claude-<uid>` is `drwx------`,
|
||||
* so a glob run as the platform's own uid does not fail: it silently matches NOTHING, which reads
|
||||
* downstream as "no such task".
|
||||
*
|
||||
* `predicate` is `find` arguments, so callers state their own depth bounds — an unbounded walk of `/tmp`
|
||||
* or a member's home is not something to leave to chance.
|
||||
*
|
||||
* Owner and member take the same `find`, deliberately. One set of matching semantics beats a glob for us
|
||||
* and a `find` for them, which is exactly the kind of split that makes a bug reproduce on one account and
|
||||
* not the other.
|
||||
*/
|
||||
export function findPathAs(osUser: AsUser, root: string, predicate: string[]): string | null {
|
||||
const command = ['find', root, ...predicate];
|
||||
const result = Bun.spawnSync(osUser ? runAsArgv(osUser, command) : command, { stdout: 'pipe', stderr: 'ignore' });
|
||||
|
||||
// The EXIT CODE IS IGNORED, and that is the whole trick. `find` exits 1 when it could not descend into
|
||||
// something — and searching `/tmp` from any identity is guaranteed to hit another account's
|
||||
// `drwx------`. It still prints what it DID find, on stdout, before returning 1. Treating that as
|
||||
// failure threw away the match: verified here, where the file was located and then discarded.
|
||||
//
|
||||
// A genuinely missing root also exits 1, with nothing on stdout, so absence still reads as absence.
|
||||
const first = new TextDecoder()
|
||||
.decode(result.stdout)
|
||||
.split('\n')
|
||||
.find((line) => line.length > 0);
|
||||
return first ?? null;
|
||||
}
|
||||
|
||||
/** The whole file, as text. Throws on any failure, so existing `try`/`catch` around reads keeps working. */
|
||||
export function readTextAs(osUser: AsUser, path: string): string {
|
||||
if (!osUser) return readFileSync(path, 'utf-8');
|
||||
|
||||
Reference in New Issue
Block a user