diff --git a/src/servers/api/chat/claude-sessions.ts b/src/servers/api/chat/claude-sessions.ts index e393b3be..9ea85634 100644 --- a/src/servers/api/chat/claude-sessions.ts +++ b/src/servers/api/chat/claude-sessions.ts @@ -1,9 +1,9 @@ -import { realpathSync } from 'node:fs'; import { dirname, join } from 'node:path'; import { DATA_PATH } from '../../data-path'; import { appendTextAs, findPathAs, + realpathAs, listTranscriptsAs, readHeadAs, readTailAs, @@ -800,12 +800,12 @@ export function loadBackgroundTask(who: ChatIdentity, taskId: string): Backgroun const found = findTaskOutput(who, taskId); if (!found) return null; - let target = found; - try { - target = realpathSync(found); - } catch { - return null; // a dangling symlink — the task registered before its transcript existed - } + // As the member, for the same reason the lookup is: resolving a link needs `x` on every directory above + // it, and `/tmp/claude-` is `drwx------`. Resolving it as the platform threw EACCES on an INTACT + // link, and the catch reported that as dangling — so a member's job was found and then declared to have + // written nothing, which is exactly what it looked like on screen. + const target = realpathAs(who.osUser, found); + if (!target) return null; // dangling, or gone between the lookup and here if (target.endsWith('.jsonl')) { const detail = parseClaudeTranscript(who.osUser, target, taskId); diff --git a/src/servers/read-as-user.live.test.ts b/src/servers/read-as-user.live.test.ts index 2f4ea7a6..4782361e 100644 --- a/src/servers/read-as-user.live.test.ts +++ b/src/servers/read-as-user.live.test.ts @@ -1,7 +1,7 @@ import { describe, expect, test } from 'bun:test'; import { join } from 'node:path'; import { runAsArgv } from './os-user'; -import { findPathAs } from './read-as-user'; +import { findPathAs, realpathAs } from './read-as-user'; // Can the platform LOCATE a member's file? Same shape as `spawn-as-member.live.test.ts`, and for the same // reason: the failure only exists where a second Linux account does, which is the production host and no @@ -61,6 +61,20 @@ describe.skipIf(!live)(`findPathAs (live, as ${osUser ?? 'nobody'})`, () => { } }); + test('a symlink under the member tree resolves as them and not as us', () => { + // An AGENT task's `.output` is a symlink into the member's own `~/.claude/.../subagents/*.jsonl`. + // Resolving it as the platform threw EACCES on an intact link, which the caller reported as dangling — + // the layer below the lookup, and identical on screen: "Nothing written yet", forever. + runAs(user, ['mkdir', '-p', dir]); + runAs(user, ['sh', '-c', 'printf "live\\n" > "$1"; ln -sf "$1" "$2"', '_', file, `${file}.link`]); + try { + expect(realpathAs(user, `${file}.link`)).toBe(file); + expect(realpathAs(null, `${file}.link`)).toBeNull(); + } finally { + runAs(user, ['rm', '-rf', join('/tmp', `claude-${uid}`, '-live-test-slug')]); + } + }); + test('a task id that does not exist is null, not a throw', () => { expect(findPathAs(user, '/tmp', ['-mindepth', '5', '-maxdepth', '5', '-name', 'no-such-task.output'])).toBeNull(); }); diff --git a/src/servers/read-as-user.ts b/src/servers/read-as-user.ts index 27752fe6..78529e45 100644 --- a/src/servers/read-as-user.ts +++ b/src/servers/read-as-user.ts @@ -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-` — 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');