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:
@@ -1,9 +1,9 @@
|
|||||||
import { realpathSync } from 'node:fs';
|
|
||||||
import { dirname, join } from 'node:path';
|
import { dirname, join } from 'node:path';
|
||||||
import { DATA_PATH } from '../../data-path';
|
import { DATA_PATH } from '../../data-path';
|
||||||
import {
|
import {
|
||||||
appendTextAs,
|
appendTextAs,
|
||||||
findPathAs,
|
findPathAs,
|
||||||
|
realpathAs,
|
||||||
listTranscriptsAs,
|
listTranscriptsAs,
|
||||||
readHeadAs,
|
readHeadAs,
|
||||||
readTailAs,
|
readTailAs,
|
||||||
@@ -800,12 +800,12 @@ export function loadBackgroundTask(who: ChatIdentity, taskId: string): Backgroun
|
|||||||
const found = findTaskOutput(who, taskId);
|
const found = findTaskOutput(who, taskId);
|
||||||
if (!found) return null;
|
if (!found) return null;
|
||||||
|
|
||||||
let target = found;
|
// As the member, for the same reason the lookup is: resolving a link needs `x` on every directory above
|
||||||
try {
|
// it, and `/tmp/claude-<uid>` is `drwx------`. Resolving it as the platform threw EACCES on an INTACT
|
||||||
target = realpathSync(found);
|
// link, and the catch reported that as dangling — so a member's job was found and then declared to have
|
||||||
} catch {
|
// written nothing, which is exactly what it looked like on screen.
|
||||||
return null; // a dangling symlink — the task registered before its transcript existed
|
const target = realpathAs(who.osUser, found);
|
||||||
}
|
if (!target) return null; // dangling, or gone between the lookup and here
|
||||||
|
|
||||||
if (target.endsWith('.jsonl')) {
|
if (target.endsWith('.jsonl')) {
|
||||||
const detail = parseClaudeTranscript(who.osUser, target, taskId);
|
const detail = parseClaudeTranscript(who.osUser, target, taskId);
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { describe, expect, test } from 'bun:test';
|
import { describe, expect, test } from 'bun:test';
|
||||||
import { join } from 'node:path';
|
import { join } from 'node:path';
|
||||||
import { runAsArgv } from './os-user';
|
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
|
// 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
|
// 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', () => {
|
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();
|
expect(findPathAs(user, '/tmp', ['-mindepth', '5', '-maxdepth', '5', '-name', 'no-such-task.output'])).toBeNull();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
readdirSync,
|
readdirSync,
|
||||||
readFileSync,
|
readFileSync,
|
||||||
readSync,
|
readSync,
|
||||||
|
realpathSync,
|
||||||
rmSync,
|
rmSync,
|
||||||
statSync,
|
statSync,
|
||||||
} from 'node:fs';
|
} from 'node:fs';
|
||||||
@@ -197,6 +198,33 @@ export function findPathAs(osUser: AsUser, root: string, predicate: string[]): s
|
|||||||
return first ?? null;
|
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. */
|
/** 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 {
|
export function readTextAs(osUser: AsUser, path: string): string {
|
||||||
if (!osUser) return readFileSync(path, 'utf-8');
|
if (!osUser) return readFileSync(path, 'utf-8');
|
||||||
|
|||||||
Reference in New Issue
Block a user