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:
2026-08-16 03:18:47 +00:00
co-authored by Claude Opus 5
parent 23bfacf412
commit 6bee6b856e
3 changed files with 151 additions and 12 deletions
+39 -12
View File
@@ -3,6 +3,7 @@ import { dirname, join } from 'node:path';
import { DATA_PATH } from '../../data-path';
import {
appendTextAs,
findPathAs,
listTranscriptsAs,
readHeadAs,
readTailAs,
@@ -10,6 +11,7 @@ import {
removeAs,
type AsUser,
} from '../../read-as-user';
import { runAsArgv } from '../../os-user';
// ── Claude session store (source of truth) ──
// The `claude` CLI persists every session as a JSONL transcript at
@@ -694,20 +696,32 @@ export type BackgroundTaskDetail =
| { kind: 'agent'; messages: ClaudeChatMessage[] }
| { kind: 'log'; text: string; truncated: boolean };
/**
* Where a task's file is, looked for AS the account that owns it.
*
* This used to be a `Bun.Glob` scan running as the platform's own uid, and it worked for exactly one
* account: the owner. `/tmp/claude-<uid>` is `drwx------`, so for a member the scan matched nothing — and
* a glob that cannot traverse does not throw, it returns an empty iterator. Both callers read that as "no
* such task", which is why a member saw a job that ran fine, streamed no logs at all, and answered
* `{"stopped":false,"reason":"no such task"}` when they pressed Stop. One unreachable directory, two
* symptoms that look unrelated.
*
* The depths are the old glob's shape, kept as explicit bounds so neither `find` wanders: a task output is
* `<tmp>/claude-<uid>/<project-slug>/<session>/tasks/<id>.output` (5), a subagent transcript is
* `<projects>/<slug>/<session>/subagents/agent-<id>.jsonl` (4). `taskId` is `TASK_ID_RE`-checked above, so
* it carries no glob or shell metacharacter into either.
*/
function findTaskOutput(who: ChatIdentity, taskId: string): string | null {
// The member's `claude` runs under `--reset-env`, so its TMPDIR is the passwd default, not ours. Both
// resolve to /tmp on Linux; a server started with a TMPDIR of its own would look in the wrong place.
const tmpRoot = process.env.TMPDIR ?? '/tmp';
const candidates: [string, string][] = [
[tmpRoot, `claude-*/*/*/tasks/${taskId}.output`],
[claudeProjectsDir(who.home), `*/*/subagents/agent-${taskId}.jsonl`],
const candidates: [string, string[]][] = [
[tmpRoot, ['-mindepth', '5', '-maxdepth', '5', '-name', `${taskId}.output`]],
[claudeProjectsDir(who.home), ['-mindepth', '4', '-maxdepth', '4', '-name', `agent-${taskId}.jsonl`]],
];
for (const [root, pattern] of candidates) {
try {
for (const rel of new Bun.Glob(pattern).scanSync({ cwd: root, followSymlinks: false, onlyFiles: false })) {
return join(root, rel);
}
} catch {
/* root doesn't exist — try the next one */
}
for (const [root, predicate] of candidates) {
const hit = findPathAs(who.osUser, root, predicate);
if (hit) return hit;
}
return null;
}
@@ -743,7 +757,12 @@ export async function stopBackgroundTask(
if (!found) return { stopped: false, reason: 'no such task' };
if (found.endsWith('.jsonl')) return { stopped: false, reason: 'subagents cannot be stopped this way' };
const proc = Bun.spawn(['fuser', found], { stdout: 'pipe', stderr: 'ignore' });
// As the member, for both halves. `fuser` reads `/proc/<pid>/fd`, which only the process's own user and
// root may open, so run as the platform it would find no pids for a member's job and report it already
// stopped. And the kill would then be cross-user: EPERM, silently swallowed by the catch below.
const asUser = (command: string[]) => (who.osUser ? runAsArgv(who.osUser, command) : command);
const proc = Bun.spawn(asUser(['fuser', found]), { stdout: 'pipe', stderr: 'ignore' });
const out = await new Response(proc.stdout).text();
await proc.exited;
@@ -754,6 +773,14 @@ export async function stopBackgroundTask(
if (pids.length === 0) return { stopped: false, reason: 'already stopped' };
if (who.osUser) {
// One fork for the whole set rather than one per pid, and the kernel checks the member's own identity
// against processes that are already theirs.
const kill = Bun.spawn(asUser(['kill', '-TERM', ...pids.map(String)]), { stdout: 'ignore', stderr: 'ignore' });
await kill.exited;
return { stopped: true };
}
for (const pid of pids) {
try {
process.kill(pid, 'SIGTERM');