resolving a member's task symlink needs their identity too

One layer below yesterday's fix, and it looked identical on screen: the job was
found, then declared to have written nothing.

`loadBackgroundTask` resolved the located path with `realpathSync`, as the
platform. Resolving a link needs `x` on every directory above it, and
`/tmp/claude-<uid>` is `drwx------` — so it threw EACCES on an INTACT link, and
the catch below it reported that as a dangling symlink and returned null. The
tray polls, gets `{kind:'pending'}`, and shows "Nothing written yet" forever.

The lookup fix could not have exposed this: it made the file findable, which is
precisely what let execution reach the line that then threw it away.

`realpath -e` rather than `readlink -f`, because `-e` requires every component to
exist — that keeps a genuinely dangling link reporting as one instead of
resolving to a path that is not there. Verified against symlink, plain file,
dangling link and missing path: `realpath -e` and `realpathSync` agree on all
four, so the owner path is unchanged.

Audited the rest of claude-sessions.ts for the same shape. Every remaining
mention of `statSync`/`readdirSync`/`Bun.Glob` in that file is a comment
explaining why it is NOT used.

Live coverage extended: the member resolves their own link, the platform gets
null. Skips without OFFICER_TEST_MEMBER.

tsgo clean, frontend builds, 865 pass / 7 fail unchanged, 7 skips.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-16 03:30:57 +00:00
co-authored by Claude Opus 5
parent 6bee6b856e
commit bf44a4cfab
3 changed files with 50 additions and 8 deletions
+28
View File
@@ -6,6 +6,7 @@ import {
readdirSync,
readFileSync,
readSync,
realpathSync,
rmSync,
statSync,
} from 'node:fs';
@@ -197,6 +198,33 @@ export function findPathAs(osUser: AsUser, root: string, predicate: string[]): s
return first ?? null;
}
/**
* Resolve a symlink, as the account that can traverse to it. Null when it dangles or cannot be reached.
*
* `realpathSync` needs `x` on every directory in the path, which the service user does not have on a
* member's `/tmp/claude-<uid>` — so it throws EACCES on a link that is perfectly intact. The one caller
* treats a throw as "dangling symlink" and returns null, which for a member turned every background task
* into "nothing written yet", one layer below the lookup that had just been fixed to find it.
*
* `realpath -e` rather than `readlink -f`: `-e` requires every component to exist, which is what makes a
* genuinely dangling link report as one instead of resolving to a path that is not there.
*/
export function realpathAs(osUser: AsUser, path: string): string | null {
if (!osUser) {
try {
return realpathSync(path);
} catch {
return null;
}
}
const result = Bun.spawnSync(runAsArgv(osUser, ['realpath', '-e', '--', path]), {
stdout: 'pipe',
stderr: 'ignore',
});
if (result.exitCode !== 0) return null;
return new TextDecoder().decode(result.stdout).trim() || 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');