mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-14 02:29:33 +01:00
The remote file reads ran under the user's login shell, quoted for a POSIX one. Under fish the probe was a parse error, so previews failed, and csh and tcsh failed every multi-line read. Wrapping the probe in sh -c as the review proposed would have opened a hole: fish reads \' inside single quotes as a quote, so nested shell quoting flips, and a path holding ';touch x;' ran touch x (measured, also on the existing folder listing). csh and tcsh run a multi-line command line by line, so a newline in a path did the same. buildRemoteFileCommand now sends every script as one word of printf text with those bytes in octal, and only sh reads it. Probe, folder listing, read, range read and transcript lookup gave the same result under bash, zsh, dash, fish, tcsh and csh in a throwaway container ($SHELL -c, no real sshd), and 24 hostile paths ran nothing. Plain csh caps a word near 8 KB, noted in the docs. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1101 lines
49 KiB
TypeScript
1101 lines
49 KiB
TypeScript
/**
|
|
* @fileoverview Remote (SSH) file access for remote-SSH cases.
|
|
*
|
|
* A remote case's `workingDir` is an absolute path on ANOTHER host
|
|
* (`Session.workingDir = RemoteCase.remotePath`, see docs/remote-sessions.md). Every
|
|
* file route used to read it with local `fs`, which cannot work: the local
|
|
* `realpathSync` in `validateSessionFilePath` fails first, so the request died as a
|
|
* 404 "File not found" before a byte was read (#415). This module is the ONE place
|
|
* that reads remote bytes, mirroring how `remote-hosts.ts` is the one place that
|
|
* builds an ssh command line.
|
|
*
|
|
* Connection options come from `buildSshConnectionArgs()` — never a hand-built ssh
|
|
* line (the COD-107 discipline in docs/remote-sessions.md) — so a proxied,
|
|
* custom-port or jump-hosted case reaches its files with exactly the credentials the
|
|
* launch used, and `BatchMode=yes` means a host that needs a passphrase fails fast
|
|
* instead of hanging on a prompt nothing can answer.
|
|
*
|
|
* ⚠️ The path is the injection surface: it arrives from the browser (`?path=`). It is
|
|
* always interpolated as a single `shellescape`d token, and the whole remote command
|
|
* is {@link remoteLoginShellLine} shellescaped into the ssh line, so the local shell
|
|
* and the remote login shell each see one opaque argument, whichever shell that is.
|
|
* Never build a command here by concatenating a raw path into the string.
|
|
*
|
|
* Read-only by design: previews, text reads and streaming. Writing to a remote file
|
|
* is deliberately NOT implemented (docs/file-viewer-edit-plan.md §6), nor are the
|
|
* office-conversion/thumbnail paths that would need the bytes on the server's disk.
|
|
*/
|
|
|
|
import { exec, spawn } from 'node:child_process';
|
|
import { promisify } from 'node:util';
|
|
import { PassThrough, type Readable } from 'node:stream';
|
|
import type { SessionRemote } from './types/session.js';
|
|
import { buildSshConnectionArgs, remoteSshTarget, shellescape, withRemoteTmuxPath } from './remote-hosts.js';
|
|
import { runWithRemoteSshLimit } from './remote-ssh-limiter.js';
|
|
import type { EditSourceReadOptions, EditSourceRequest, EditSources } from './edit-history-local-source.js';
|
|
|
|
const execAsync = promisify(exec);
|
|
|
|
/**
|
|
* Bound on the probe (realpath + stat) round trip. The connect itself is already
|
|
* bounded by `buildSshConnectionArgs`'s default `-o ConnectTimeout=10`; this covers
|
|
* a host that accepts the TCP connection and then never answers.
|
|
*/
|
|
const REMOTE_PROBE_TIMEOUT_MS = 20_000;
|
|
|
|
/** Bound on a buffered remote read (`cat`), on top of the caller's own size cap. */
|
|
const REMOTE_READ_TIMEOUT_MS = 30_000;
|
|
|
|
/** Slack over the caller's byte cap so a file exactly at the limit still fits. */
|
|
const READ_BUFFER_SLACK_BYTES = 64 * 1024;
|
|
|
|
/** Marker a probe prints when the path does not exist on the remote host. */
|
|
const NOT_FOUND_MARKER = 'n';
|
|
|
|
/**
|
|
* Marker a probe prints when the path exists but could NOT be canonicalized (no
|
|
* `readlink -f`, and the portable fallback hit its hop cap or a `readlink` failure).
|
|
* Parsed as `null`, i.e. 404: a path whose real target is unknown must never be
|
|
* served, because every containment and blocklist check runs on the resolved path.
|
|
*/
|
|
const UNRESOLVABLE_MARKER = 'x';
|
|
|
|
/**
|
|
* Paths per ssh round trip. The whole remote script is ONE shellescaped argument,
|
|
* and Linux caps a single argv string at 128 KiB, so a 100-entry attachment history
|
|
* of long paths is split rather than risking `E2BIG` on the local `sh`.
|
|
*/
|
|
const REMOTE_PROBE_CHUNK_SIZE = 40;
|
|
|
|
/** Symlink hops the portable resolver follows before giving up (Linux uses 40). */
|
|
const REMOTE_SYMLINK_MAX_HOPS = 40;
|
|
|
|
/**
|
|
* Under vitest no real ssh connection may ever be opened (mirrors
|
|
* `checkRemoteTmuxAvailable` and friends in remote-hosts.ts). The route tests mock
|
|
* this module, so nothing reaches here today; this is what keeps the NEXT
|
|
* remote-session test that touches a file route from opening a connection from CI.
|
|
* A clear 502-shaped error, never a fake success: there are no fake bytes to return.
|
|
*/
|
|
function assertNotUnderTest(): void {
|
|
if (process.env.VITEST) {
|
|
throw new RemoteFileAccessError('remote file access is disabled under test');
|
|
}
|
|
}
|
|
|
|
/** What a remote path turned out to be. `other` = symlink/socket/fifo/device. */
|
|
export type RemotePathKind = 'file' | 'directory' | 'other';
|
|
|
|
export interface RemoteProbe {
|
|
/** The path with symlinks resolved on the REMOTE host. */
|
|
realPath: string;
|
|
kind: RemotePathKind;
|
|
/** Size in bytes (0 for anything that is not a regular file). */
|
|
size: number;
|
|
/** mtime in ms since epoch (0 when the remote `stat` reported none). */
|
|
mtimeMs: number;
|
|
}
|
|
|
|
/**
|
|
* A remote file access failed for a reason that is NOT "the file is missing" —
|
|
* unreachable host, timeout, ssh error, unexpected probe output. Callers map this to
|
|
* a 5xx with the remote reason in the message; a missing file is reported separately
|
|
* as `null`/404 so the two cannot be confused.
|
|
*/
|
|
export class RemoteFileAccessError extends Error {
|
|
constructor(message: string) {
|
|
super(message);
|
|
this.name = 'RemoteFileAccessError';
|
|
}
|
|
}
|
|
|
|
/**
|
|
* A POSIX script as one line that every remote login shell reads the same way, and hands
|
|
* to `sh`.
|
|
*
|
|
* ⚠️ sshd gives the command to the user's LOGIN shell, and {@link shellescape} quotes for a
|
|
* POSIX one. Inside single quotes fish takes `\'` as a literal quote, so `'\''` stops
|
|
* closing and reopening the string, and csh and tcsh end a command at a newline even there
|
|
* and run the script line by line. Both let a path act as a command (measured: a path
|
|
* holding `';touch x;'` ran `touch x` under fish, and one holding a newline ran the next
|
|
* line under tcsh). So the script travels as `printf` text with the bytes those shells
|
|
* treat specially written as octal: no quote, backslash pair, newline or `!` for the login
|
|
* shell to read, and `%` so `printf` keeps its hands off too. Only `sh` sees the script.
|
|
*/
|
|
export function remoteLoginShellLine(script: string): string {
|
|
const text = Array.from(Buffer.from(script), (byte) => {
|
|
const char = String.fromCharCode(byte);
|
|
return byte < 0x20 || byte > 0x7e || "'\\%!".includes(char) ? `\\${byte.toString(8).padStart(3, '0')}` : char;
|
|
}).join('');
|
|
return `sh -c 'eval "$(printf "$1")"' sh '${text}'`;
|
|
}
|
|
|
|
/**
|
|
* Wrap a remote shell command in the shared, shellescaped ssh line.
|
|
*
|
|
* The single entry point for "run this on the remote host": connection args (port,
|
|
* identity, jump host, SOCKS ProxyCommand, extra `-o`) all come from
|
|
* `buildSshConnectionArgs`, and the command is ONE shellescaped token holding
|
|
* {@link remoteLoginShellLine}, so a path with spaces, quotes or `$(…)` cannot escape
|
|
* into the ssh command line or the remote login shell.
|
|
*/
|
|
export function buildRemoteFileCommand(remote: SessionRemote, shellCommand: string): string {
|
|
return [
|
|
...buildSshConnectionArgs(remote),
|
|
remoteSshTarget(remote),
|
|
shellescape(remoteLoginShellLine(shellCommand)),
|
|
].join(' ');
|
|
}
|
|
|
|
/**
|
|
* `realpath + stat + existence` for one or more paths, in a SINGLE ssh round trip.
|
|
*
|
|
* One call instead of three matters: without a shared connection (no ControlMaster)
|
|
* every extra `ssh` is a fresh handshake, and the file routes need the path AND the
|
|
* workspace root canonicalized to compare them.
|
|
*
|
|
* Output format: the script first prints a lone NUL, then one NUL-terminated record
|
|
* per path, `<index>|n` (missing), `<index>|x` (exists but cannot be canonicalized) or
|
|
* `<index>|kind|size|mtime|realPath`. Records are keyed by INDEX and separated by NUL
|
|
* rather than newline so that a remote filename containing a newline cannot shift the
|
|
* alignment, and the leading NUL is what separates a login banner or an eager rc-file
|
|
* `echo` (which land before the script runs) from the records without any "last N
|
|
* lines" guesswork. `realPath` is the last field, so a `|` in a path still parses.
|
|
*
|
|
* A path starting with `~/` is taken relative to the remote user's `$HOME`: this
|
|
* server does not know that home, and a clicked `~/…` link means the host the
|
|
* session runs on. The record's `realPath` is absolute, as for any other path.
|
|
*
|
|
* Symlink resolution is portable AND fails closed. `readlink -f` where available
|
|
* (Linux, macOS >= 12.3); otherwise the fallback canonicalizes the directory chain
|
|
* with `cd -P`/`pwd -P` and then follows the LAST component with plain `readlink`
|
|
* (which the systems lacking `-f` do have) for a bounded number of hops. A path the
|
|
* fallback cannot resolve prints `x`, never the unresolved string: every containment
|
|
* and blocklist check downstream runs on `realPath`, and an earlier version of this
|
|
* fallback returned the directory-resolved path with the final symlink still in it,
|
|
* so `ws/notes.txt -> ~/.ssh/id_rsa` passed containment while `cat` served the key.
|
|
*/
|
|
export function buildRemoteProbeCommand(paths: readonly string[]): string {
|
|
const probes = paths.map((path, index) => `probe ${index} ${shellescape(path)}`).join('\n');
|
|
return [
|
|
'resolve_last() {',
|
|
' q=$1',
|
|
' hops=0',
|
|
' while :; do',
|
|
' d=$(cd -P "$(dirname "$q")" 2>/dev/null && pwd -P) || return 1',
|
|
' q=$d/$(basename "$q")',
|
|
' [ -L "$q" ] || break',
|
|
' hops=$((hops + 1))',
|
|
` [ "$hops" -le ${REMOTE_SYMLINK_MAX_HOPS} ] || return 1`,
|
|
' l=$(readlink "$q" 2>/dev/null) || return 1',
|
|
' [ -n "$l" ] || return 1',
|
|
' case $l in /*) q=$l ;; *) q=$d/$l ;; esac',
|
|
' done',
|
|
' if [ -d "$q" ]; then q=$(cd -P "$q" 2>/dev/null && pwd -P) || return 1; fi',
|
|
' printf %s "$q"',
|
|
'}',
|
|
'probe() {',
|
|
' i=$1',
|
|
' p=$2',
|
|
// `~/…` from a clicked link: THIS host's home (assignment, so no word splitting).
|
|
" case $p in '~/'*) p=$HOME/${p#??} ;; esac",
|
|
` if [ ! -e "$p" ]; then printf '%s|${NOT_FOUND_MARKER}\\0' "$i"; return; fi`,
|
|
` r=$(readlink -f "$p" 2>/dev/null) || r=$(resolve_last "$p") || { printf '%s|${UNRESOLVABLE_MARKER}\\0' "$i"; return; }`,
|
|
` [ -n "$r" ] || { printf '%s|${UNRESOLVABLE_MARKER}\\0' "$i"; return; }`,
|
|
' if [ -d "$r" ]; then t=d; elif [ -f "$r" ]; then t=f; else t=o; fi',
|
|
' s=0',
|
|
' if [ "$t" = f ]; then s=$(stat -c %s "$r" 2>/dev/null || stat -f %z "$r" 2>/dev/null); [ -n "$s" ] || s=0; fi',
|
|
' m=$(stat -c %Y "$r" 2>/dev/null || stat -f %m "$r" 2>/dev/null || printf 0)',
|
|
` printf '%s|%s|%s|%s|%s\\0' "$i" "$t" "$s" "$m" "$r"`,
|
|
'}',
|
|
"printf '\\0'",
|
|
probes,
|
|
].join('\n');
|
|
}
|
|
|
|
/**
|
|
* Parse one probe record (index prefix already stripped). `null` for the not-found
|
|
* and unresolvable markers or anything malformed.
|
|
*/
|
|
export function parseRemoteProbeRecord(record: string): RemoteProbe | null {
|
|
if (!record || record === NOT_FOUND_MARKER || record === UNRESOLVABLE_MARKER) return null;
|
|
|
|
const parts = record.split('|');
|
|
if (parts.length < 4) return null;
|
|
|
|
const [kindRaw, sizeRaw, mtimeRaw] = parts;
|
|
const kind: RemotePathKind | null =
|
|
kindRaw === 'f' ? 'file' : kindRaw === 'd' ? 'directory' : kindRaw === 'o' ? 'other' : null;
|
|
if (!kind) return null;
|
|
|
|
const realPath = parts.slice(3).join('|');
|
|
if (!realPath) return null;
|
|
|
|
const size = Number.parseInt(sizeRaw, 10);
|
|
const mtimeSeconds = Number.parseInt(mtimeRaw, 10);
|
|
return {
|
|
realPath,
|
|
kind,
|
|
size: Number.isFinite(size) && size > 0 ? size : 0,
|
|
mtimeMs: Number.isFinite(mtimeSeconds) && mtimeSeconds > 0 ? mtimeSeconds * 1000 : 0,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Parse the output of {@link buildRemoteProbeCommand} into one entry per requested
|
|
* path, in order. Throws when a path's record is missing: that means the transport
|
|
* or the remote shell did something unexpected, and silently treating it as "not
|
|
* found" would turn an infrastructure failure into a wrong 404.
|
|
*
|
|
* Everything before the first NUL is the remote shell's own chatter (banner, rc-file
|
|
* output) and is discarded; records are matched by their index prefix, so neither
|
|
* extra output nor a newline inside a filename can shift the mapping.
|
|
*/
|
|
export function parseRemoteProbeOutput(stdout: string, paths: readonly string[]): Array<RemoteProbe | null> {
|
|
const records = stdout.split('\0').slice(1);
|
|
const byIndex = new Map<number, string>();
|
|
for (const record of records) {
|
|
const match = /^(\d+)\|([\s\S]*)$/.exec(record);
|
|
if (!match) continue;
|
|
const index = Number.parseInt(match[1], 10);
|
|
if (!byIndex.has(index)) byIndex.set(index, match[2]);
|
|
}
|
|
return paths.map((_, index) => {
|
|
const record = byIndex.get(index);
|
|
if (record === undefined) {
|
|
throw new RemoteFileAccessError('remote host returned no usable file information');
|
|
}
|
|
return parseRemoteProbeRecord(record);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Probe one or more remote paths. Entry is `null` for a path that does not exist (or
|
|
* could not be canonicalized, which is refused the same way).
|
|
*
|
|
* Large batches are split into round trips of {@link REMOTE_PROBE_CHUNK_SIZE}, each
|
|
* counted against the global ssh limiter, so an attachment history of 100 entries
|
|
* costs three connections in sequence rather than 100 at once.
|
|
*/
|
|
export async function remoteProbePaths(
|
|
remote: SessionRemote,
|
|
paths: readonly string[]
|
|
): Promise<Array<RemoteProbe | null>> {
|
|
assertNotUnderTest();
|
|
const results: Array<RemoteProbe | null> = [];
|
|
for (let offset = 0; offset < paths.length; offset += REMOTE_PROBE_CHUNK_SIZE) {
|
|
const chunk = paths.slice(offset, offset + REMOTE_PROBE_CHUNK_SIZE);
|
|
const command = buildRemoteFileCommand(remote, buildRemoteProbeCommand(chunk));
|
|
let stdout: string;
|
|
try {
|
|
const result = await runWithRemoteSshLimit(() =>
|
|
execAsync(command, { timeout: REMOTE_PROBE_TIMEOUT_MS, maxBuffer: 256 * 1024 })
|
|
);
|
|
stdout = result.stdout;
|
|
} catch (err) {
|
|
throw new RemoteFileAccessError(
|
|
`remote host ${remote.label || remote.host} unreachable: ${describeExecError(err)}`
|
|
);
|
|
}
|
|
results.push(...parseRemoteProbeOutput(stdout, chunk));
|
|
}
|
|
return results;
|
|
}
|
|
|
|
/** One entry of a remote folder listing: a file (with size/mtime) or a subfolder. */
|
|
export interface RemoteFolderEntry {
|
|
kind: 'file' | 'directory';
|
|
name: string;
|
|
size: number;
|
|
mtimeMs: number;
|
|
}
|
|
|
|
/** A remote folder listing; `dir` is the folder with symlinks resolved on the remote host. */
|
|
export interface RemoteFolderListing {
|
|
dir: string;
|
|
entries: RemoteFolderEntry[];
|
|
truncated: boolean;
|
|
}
|
|
|
|
/**
|
|
* List one remote folder in a SINGLE ssh round trip: its canonical path, then every
|
|
* non-hidden subfolder and every regular file (symlinks followed) whose name ends in
|
|
* one of `extensions` (any case), up to `maxEntries`.
|
|
*
|
|
* Same output discipline as {@link buildRemoteProbeCommand}: a lone NUL first (so rc
|
|
* chatter before it is discarded), then NUL-terminated records — `E|n` (no such
|
|
* folder), `E|x` (cannot be canonicalized), `D|<dir>`, `d|0|0|<name>`,
|
|
* `f|<size>|<mtime>|<name>`, and `T|` when the cap cut the listing. The name is the
|
|
* last field, so a `|` in it still parses, and NUL is the one byte a filename cannot
|
|
* hold. A leading `~/` is the remote user's `$HOME`, as in the probe.
|
|
*/
|
|
export function buildRemoteFolderListCommand(dir: string, extensions: readonly string[], maxEntries: number): string {
|
|
// `[pP][nN][gG]`: any case, as the local listing compares lowercased (`shot.Png`).
|
|
const patterns = [...new Set(extensions.map((ext) => ext.toLowerCase()))]
|
|
.map((ext) => `*.${ext.replace(/[a-z]/g, (c) => `[${c}${c.toUpperCase()}]`)}`)
|
|
.join('|');
|
|
const script = [
|
|
`d=${shellescape(dir)}`,
|
|
"case $d in '~/'*) d=$HOME/${d#??} ;; esac",
|
|
"printf '\\0'",
|
|
'[ -d "$d" ] || { [ -e "$d" ] && printf \'E|f\\0\' || printf \'E|n\\0\'; exit 0; }',
|
|
'r=$(cd -P "$d" 2>/dev/null && pwd -P) || { printf \'E|x\\0\'; exit 0; }',
|
|
'printf \'D|%s\\0\' "$r"',
|
|
'n=0',
|
|
'for f in "$r"/*; do',
|
|
' [ -e "$f" ] || continue',
|
|
` [ "$n" -lt ${Math.max(1, Math.floor(maxEntries))} ] || { printf 'T|\\0'; break; }`,
|
|
' b=${f##*/}',
|
|
' if [ -d "$f" ]; then n=$((n + 1)); printf \'d|0|0|%s\\0\' "$b"; continue; fi',
|
|
' [ -f "$f" ] || continue',
|
|
` case $b in ${patterns || '*.__none__'}) ;; *) continue ;; esac`,
|
|
" sm=$(stat -L -c '%s %Y' \"$f\" 2>/dev/null || stat -L -f '%z %m' \"$f\" 2>/dev/null) || sm='0 0'",
|
|
' n=$((n + 1))',
|
|
' printf \'f|%s|%s|%s\\0\' "${sm% *}" "${sm#* }" "$b"',
|
|
'done',
|
|
].join('\n');
|
|
// ⚠️ sshd runs the command in the remote user's LOGIN shell, rc aliases included.
|
|
// On Hufflepuff `cd` is an alias that echoes the new directory (`$(cd -P "$d" &&
|
|
// pwd -P)` came back as two lines, then zsh aborted on the glob: "no matches found:
|
|
// /x\n/x/*"), so no folder listing worked over ssh. An explicit `sh` reads no rc
|
|
// file — the same wrapper the transcript read uses.
|
|
return `sh -c ${shellescape(script)}`;
|
|
}
|
|
|
|
/**
|
|
* Parse {@link buildRemoteFolderListCommand} output: null when nothing exists there
|
|
* (or it cannot be resolved), `'not-a-folder'` when it is a file.
|
|
*/
|
|
export function parseRemoteFolderListOutput(stdout: string): RemoteFolderListing | 'not-a-folder' | null {
|
|
const records = stdout.split('\0').slice(1);
|
|
let dir: string | null = null;
|
|
let truncated = false;
|
|
const entries: RemoteFolderEntry[] = [];
|
|
for (const record of records) {
|
|
if (record === 'E|f') return 'not-a-folder';
|
|
if (record.startsWith('E|')) return null;
|
|
if (record.startsWith('D|')) {
|
|
dir = record.slice(2);
|
|
continue;
|
|
}
|
|
if (record === 'T|') {
|
|
truncated = true;
|
|
continue;
|
|
}
|
|
const match = /^([fd])\|(\d*)\|(\d*)\|([\s\S]+)$/.exec(record);
|
|
if (!match) continue;
|
|
const size = Number.parseInt(match[2], 10);
|
|
const mtime = Number.parseInt(match[3], 10);
|
|
entries.push({
|
|
kind: match[1] === 'd' ? 'directory' : 'file',
|
|
name: match[4],
|
|
size: Number.isFinite(size) && size > 0 ? size : 0,
|
|
mtimeMs: Number.isFinite(mtime) && mtime > 0 ? mtime * 1000 : 0,
|
|
});
|
|
}
|
|
if (!dir) {
|
|
throw new RemoteFileAccessError('remote host returned no usable folder listing');
|
|
}
|
|
return { dir, entries, truncated };
|
|
}
|
|
|
|
/** List a remote folder (see {@link buildRemoteFolderListCommand}). */
|
|
export async function remoteListFolder(
|
|
remote: SessionRemote,
|
|
dir: string,
|
|
extensions: readonly string[],
|
|
maxEntries: number
|
|
): Promise<RemoteFolderListing | 'not-a-folder' | null> {
|
|
assertNotUnderTest();
|
|
const command = buildRemoteFileCommand(remote, buildRemoteFolderListCommand(dir, extensions, maxEntries));
|
|
let stdout: string;
|
|
try {
|
|
const result = await runWithRemoteSshLimit(() =>
|
|
execAsync(command, { timeout: REMOTE_PROBE_TIMEOUT_MS, maxBuffer: 4 * 1024 * 1024 })
|
|
);
|
|
stdout = result.stdout;
|
|
} catch (err) {
|
|
throw new RemoteFileAccessError(
|
|
`remote host ${remote.label || remote.host} unreachable: ${describeExecError(err)}`
|
|
);
|
|
}
|
|
return parseRemoteFolderListOutput(stdout);
|
|
}
|
|
|
|
/** Read a whole remote file into memory, capped by `maxBytes`. */
|
|
export async function remoteReadFile(remote: SessionRemote, remotePath: string, maxBytes: number): Promise<Buffer> {
|
|
assertNotUnderTest();
|
|
const command = buildRemoteFileCommand(remote, `cat ${shellescape(remotePath)}`);
|
|
try {
|
|
const result = await runWithRemoteSshLimit(() =>
|
|
execAsync(command, {
|
|
timeout: REMOTE_READ_TIMEOUT_MS,
|
|
maxBuffer: maxBytes + READ_BUFFER_SLACK_BYTES,
|
|
encoding: 'buffer',
|
|
})
|
|
);
|
|
return Buffer.isBuffer(result.stdout) ? result.stdout : Buffer.from(result.stdout);
|
|
} catch (err) {
|
|
throw new RemoteFileAccessError(`failed to read remote file: ${describeExecError(err)}`);
|
|
}
|
|
}
|
|
|
|
/** Conversation ids are uuids; anything else never reaches the remote shell. */
|
|
const CLAUDE_TRANSCRIPT_ID_PATTERN = /^[A-Za-z0-9._-]+$/;
|
|
|
|
/** Exit status the edit-history scripts use for "no such transcript on that host". */
|
|
const TRANSCRIPT_NOT_FOUND_EXIT = 3;
|
|
|
|
/** Transcript files an edit-source request may name; nothing else reaches the shell. */
|
|
const EDIT_SOURCE_REL_PATH_PATTERN = /^(main|subagents\/agent-[A-Za-z0-9_-]+\.jsonl)$/;
|
|
|
|
/** The meta record's relPath; group 1 is the agentId. */
|
|
const EDIT_SOURCE_META_REL_PATH_PATTERN = /^subagents\/agent-([A-Za-z0-9_-]+)\.meta\.json$/;
|
|
|
|
/** Cap on one subagent `.meta.json` record (outside the transcript budget), as in the local reader. */
|
|
const EDIT_SOURCE_META_MAX_BYTES = 8192;
|
|
|
|
/**
|
|
* Cap on all meta records of one read (the local reader has none). A meta comes only with a
|
|
* subagent's first read, so one past the cap is never sent: that subagent's transcript is read
|
|
* as usual, and the panel names it by its id instead of its description.
|
|
*/
|
|
const EDIT_SOURCE_META_BUDGET_BYTES = 4 * 1024 * 1024;
|
|
|
|
/** Room for the record headers (one per transcript file) on top of budget and metas. */
|
|
const EDIT_SOURCE_HEADER_SLACK_BYTES = 1024 * 1024;
|
|
|
|
/**
|
|
* Subagent requests per ssh round trip of an edit-history read. Like
|
|
* {@link REMOTE_PROBE_CHUNK_SIZE}: the script is ONE argument of the ssh line, Linux caps a
|
|
* single argv string at 128 KiB, and each request adds a `case` line of 139 bytes once
|
|
* encoded for the login shell (376 with the longest name a filesystem allows; the test pins
|
|
* that worst case). See {@link planRemoteEditSourceRounds}.
|
|
*/
|
|
const EDIT_SOURCE_REQUESTS_PER_ROUND_TRIP = 300;
|
|
|
|
/**
|
|
* The slowest link an edit-history read is given time for. Such a read can carry megabytes
|
|
* (a forward pass its budget plus the metas, "Load older" up to three budgets), which a flat
|
|
* {@link REMOTE_READ_TIMEOUT_MS} would cut off below ~1 MiB/s. 256 KiB/s ≈ 2 Mbit/s.
|
|
*/
|
|
const EDIT_SOURCE_MIN_BYTES_PER_SEC = 256 * 1024;
|
|
|
|
/**
|
|
* Timeout of an edit-history read that may return up to `maxBytes`: the flat read timeout plus
|
|
* the transfer time at {@link EDIT_SOURCE_MIN_BYTES_PER_SEC}. It ends a hung read (a host that
|
|
* fell asleep mid-transfer; no keepalives, see the wake invariants), it does not cut a slow one.
|
|
*/
|
|
export function remoteEditSourcesTimeoutMs(maxBytes: number): number {
|
|
return REMOTE_READ_TIMEOUT_MS + Math.ceil((Math.max(0, maxBytes) / EDIT_SOURCE_MIN_BYTES_PER_SEC) * 1000);
|
|
}
|
|
|
|
/**
|
|
* The ssh line of an edit-history lookup or read: {@link buildRemoteFileCommand} with `exec`, so a
|
|
* timeout reaches ssh itself. Node signals the local `sh -c`, and dash stays the parent of a
|
|
* plain command, which would leave ssh running and the remote read holding its connection.
|
|
*/
|
|
export function buildRemoteEditHistoryCommand(remote: SessionRemote, shellCommand: string): string {
|
|
return `exec ${buildRemoteFileCommand(remote, shellCommand)}`;
|
|
}
|
|
|
|
const TMUX_NAME_PATTERN = /^[A-Za-z0-9._-]+$/;
|
|
|
|
/** Where a remote session's agent runs: the remote tmux socket and session (`remoteTmuxLocation`). */
|
|
export interface RemoteClaudePane {
|
|
socket: string;
|
|
sessionName: string;
|
|
}
|
|
|
|
function isByteCount(value: unknown): value is number {
|
|
return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0;
|
|
}
|
|
|
|
/** De-duplicated ids that match {@link CLAUDE_TRANSCRIPT_ID_PATTERN}, in order. */
|
|
function safeClaudeTranscriptIds(ids: readonly string[]): string[] {
|
|
return [...new Set(ids)].filter((id) => CLAUDE_TRANSCRIPT_ID_PATTERN.test(id));
|
|
}
|
|
|
|
/** A main transcript path the read script takes: absolute, `<id>.jsonl`, no NUL or newline. */
|
|
export function isRemoteClaudeTranscriptPath(path: string): boolean {
|
|
if (!path.startsWith('/') || !path.endsWith('.jsonl') || /[\0\n]/.test(path)) return false;
|
|
return CLAUDE_TRANSCRIPT_ID_PATTERN.test(path.slice(path.lastIndexOf('/') + 1, -'.jsonl'.length));
|
|
}
|
|
|
|
/**
|
|
* Shell lines that set `cfg` to claude's config dir on the remote host: an absolute
|
|
* `CLAUDE_CONFIG_DIR` from the ssh command's environment, else `$HOME/.claude` — claude's own
|
|
* rule. ⚠️ The remote claude runs under `$SHELL -i -l -c` (`remoteLoginShellCommand`), this
|
|
* script under a plain non-interactive `sh` from sshd's `$SHELL -c`: a `CLAUDE_CONFIG_DIR` set
|
|
* only in a login or interactive profile (`.profile`, `.bash_profile`, `.zshrc`) is not seen
|
|
* here; one from `.zshenv`, sshd `SetEnv` or the system environment is.
|
|
*/
|
|
function claudeConfigDirLines(): string[] {
|
|
return ['cfg=$HOME/.claude', 'case ${CLAUDE_CONFIG_DIR:-} in /*) cfg=$CLAUDE_CONFIG_DIR ;; esac'];
|
|
}
|
|
|
|
/**
|
|
* Shell lines that set `live` to the conversation the claude in `pane` is writing NOW,
|
|
* or leave it empty; they need `cfg` ({@link claudeConfigDirLines}). Codeman launches a
|
|
* remote claude with `--session-id <id>`, but a `/clear` (or a resume picked inside claude)
|
|
* starts a new conversation, and with no hooks from a remote host Codeman never hears of it.
|
|
* claude itself keeps `<config dir>/sessions/<pid>.json` with the current `sessionId`, so: the
|
|
* pane's pid, its children and grandchildren (a wrapper shell may sit in between), the first
|
|
* of those with such a file. Only `[A-Za-z0-9._-]` survives the `sed`, the id pattern, so
|
|
* `$live` is safe unquoted. `ps -A -o pid= -o ppid=` is POSIX (no GNU `--ppid`).
|
|
*
|
|
* ⚠️ tmux runs on the PATH of every other remote tmux call ({@link withRemoteTmuxPath}): sshd's
|
|
* non-interactive shell may not list the directory it lives in, and a silent miss here leaves
|
|
* the panel on the launch conversation after a `/clear`. The `export` stays inside the `$(…)`,
|
|
* and inside this script, which runs under `sh` whatever the remote user's login shell is.
|
|
*
|
|
* ⚠️ `=name:` is an EXACT session match. Without the colon tmux reads a window target, so a
|
|
* session that is gone resolves to a window of that name or a session the name is a prefix of,
|
|
* and that session's conversation would land in this one's edit history.
|
|
*/
|
|
function liveClaudeIdLines(pane: RemoteClaudePane | undefined): string[] {
|
|
if (!pane || !TMUX_NAME_PATTERN.test(pane.socket) || !TMUX_NAME_PATTERN.test(pane.sessionName)) return [];
|
|
return [
|
|
'live=',
|
|
`pp=$(${withRemoteTmuxPath(`tmux -L ${shellescape(pane.socket)} list-panes -t ${shellescape(`=${pane.sessionName}:`)} -F '#{pane_pid}' 2>/dev/null | head -n 1`)})`,
|
|
"case $pp in ''|*[!0-9]*) pp= ;; esac",
|
|
'if [ -n "$pp" ]; then',
|
|
' tree=$(ps -A -o pid= -o ppid= 2>/dev/null)',
|
|
' kids=$(printf \'%s\\n\' "$tree" | awk -v p="$pp" \'$2 == p { print $1 }\')',
|
|
' grand=$(for k in $kids; do printf \'%s\\n\' "$tree" | awk -v p="$k" \'$2 == p { print $1 }\'; done)',
|
|
' for c in $pp $kids $grand; do',
|
|
' f="$cfg/sessions/$c.json"',
|
|
' [ -f "$f" ] || continue',
|
|
' live=$(sed -n \'s/.*"sessionId":"\\([A-Za-z0-9._-]*\\)".*/\\1/p\' "$f" | head -n 1)',
|
|
' [ -n "$live" ] && break',
|
|
' done',
|
|
'fi',
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Script that finds a remote claude session's main transcript and prints `\0<path>\0`;
|
|
* exit {@link TRANSCRIPT_NOT_FOUND_EXIT} when there is none. First the conversation the
|
|
* claude in `pane` writes now ({@link liveClaudeIdLines}); without one, the most recently
|
|
* written transcript of ALL `ids`, not the first id that has one: the conversation claude
|
|
* wrote last is the current one whether a `/clear` moved on from the launch id or a respawn
|
|
* resumed it again, and no order of the ids gets both right. Each id is looked up as
|
|
* `<config dir>/projects/*\/<id>.jsonl` ({@link claudeConfigDirLines}). `$HOME` is expanded
|
|
* by the REMOTE shell: the transcript lives in the remote user's home, which the server
|
|
* cannot know.
|
|
*
|
|
* ⚠️ Runs under an explicit `sh` with `LC_ALL=C`: sshd runs a command in the remote user's
|
|
* LOGIN shell, and on a zsh host `ls` can be an alias and an unmatched glob aborts the whole
|
|
* script ("no matches found"). So: no `ls` at all, the newest file wins via `-nt`, and an
|
|
* `[ -f ]` guard on every glob. `C` makes bracket ranges and glob order plain bytes.
|
|
*/
|
|
export function buildRemoteClaudeTranscriptLocateCommand(ids: readonly string[], pane?: RemoteClaudePane): string {
|
|
const safeIds = safeClaudeTranscriptIds(ids);
|
|
if (safeIds.length === 0) return `exit ${TRANSCRIPT_NOT_FOUND_EXIT}`;
|
|
const liveLines = liveClaudeIdLines(pane);
|
|
const script = [
|
|
'LC_ALL=C',
|
|
'export LC_ALL',
|
|
'best=',
|
|
...claudeConfigDirLines(),
|
|
// newest <id>: `best` becomes that id's transcript if it is newer than the current best.
|
|
// BusyBox's (and bash 3.2's) `-nt` compares whole seconds: a tie keeps the earlier id, the
|
|
// stored conversation, until the next write; only without a live id.
|
|
'newest() {',
|
|
' for f in "$cfg"/projects/*/"$1".jsonl; do',
|
|
' [ -f "$f" ] || continue',
|
|
' if [ -z "$best" ] || [ "$f" -nt "$best" ]; then best=$f; fi',
|
|
' done',
|
|
'}',
|
|
...liveLines,
|
|
...(liveLines.length ? ['[ -z "$live" ] || newest "$live"'] : []),
|
|
'if [ -z "$best" ]; then',
|
|
` for id in ${safeIds.map((id) => shellescape(id)).join(' ')}; do newest "$id"; done`,
|
|
'fi',
|
|
`[ -n "$best" ] || exit ${TRANSCRIPT_NOT_FOUND_EXIT}`,
|
|
'printf \'\\0%s\\0\' "$best"',
|
|
].join('\n');
|
|
return `sh -c ${shellescape(script)}`;
|
|
}
|
|
|
|
/** The path from {@link buildRemoteClaudeTranscriptLocateCommand} output; null when there is none. */
|
|
export function parseRemoteClaudeTranscriptLocate(stdout: Buffer): string | null {
|
|
const start = stdout.indexOf(0);
|
|
const end = start === -1 ? -1 : stdout.indexOf(0, start + 1);
|
|
if (end === -1) return null;
|
|
const path = stdout.toString('utf8', start + 1, end);
|
|
return isRemoteClaudeTranscriptPath(path) ? path : null;
|
|
}
|
|
|
|
/**
|
|
* Find a remote claude session's main transcript in ONE ssh round trip (see
|
|
* {@link buildRemoteClaudeTranscriptLocateCommand}). `null` when it has none; an unreachable
|
|
* host, an ssh failure or broken output is a `RemoteFileAccessError`. Never wakes a host: a
|
|
* sleeping one is a connect timeout, like every other read in this module.
|
|
*/
|
|
export async function remoteLocateClaudeTranscript(
|
|
remote: SessionRemote,
|
|
ids: readonly string[],
|
|
pane?: RemoteClaudePane
|
|
): Promise<string | null> {
|
|
assertNotUnderTest();
|
|
const command = buildRemoteEditHistoryCommand(remote, buildRemoteClaudeTranscriptLocateCommand(ids, pane));
|
|
let stdout: Buffer;
|
|
try {
|
|
const result = await runWithRemoteSshLimit(() =>
|
|
execAsync(command, { timeout: REMOTE_READ_TIMEOUT_MS, maxBuffer: READ_BUFFER_SLACK_BYTES, encoding: 'buffer' })
|
|
);
|
|
stdout = Buffer.isBuffer(result.stdout) ? result.stdout : Buffer.from(result.stdout);
|
|
} catch (err) {
|
|
if ((err as { code?: unknown }).code === TRANSCRIPT_NOT_FOUND_EXIT) return null;
|
|
throw new RemoteFileAccessError(
|
|
`remote host ${remote.label || remote.host} unreachable: ${describeExecError(err)}`
|
|
);
|
|
}
|
|
const path = parseRemoteClaudeTranscriptLocate(stdout);
|
|
if (!path) throw new RemoteFileAccessError('unexpected output from the remote transcript lookup');
|
|
return path;
|
|
}
|
|
|
|
/** The requests a read script may take: the first per relPath, valid ones only. */
|
|
function validEditSourceRequests(requests: readonly EditSourceRequest[]): Map<string, EditSourceRequest> {
|
|
const byRelPath = new Map<string, EditSourceRequest>();
|
|
for (const request of requests) {
|
|
if (!request || typeof request.relPath !== 'string') continue;
|
|
if (!EDIT_SOURCE_REL_PATH_PATTERN.test(request.relPath)) continue;
|
|
if (!isByteCount(request.offset)) continue;
|
|
if (request.len !== undefined && !isByteCount(request.len)) continue;
|
|
if (byRelPath.has(request.relPath)) continue;
|
|
byRelPath.set(request.relPath, request);
|
|
}
|
|
return byRelPath;
|
|
}
|
|
|
|
/** One ssh round trip of an edit-history read ({@link planRemoteEditSourceRounds}). */
|
|
export interface RemoteEditSourceRound {
|
|
requests: EditSourceRequest[];
|
|
/** It reads the main transcript: the first round trip of a read does. */
|
|
main: boolean;
|
|
/** It takes the subagent files after this name ('' = from the first), in byte order. */
|
|
after: string;
|
|
/** It takes them up to and including this name ('' = to the last). */
|
|
upTo: string;
|
|
}
|
|
|
|
/**
|
|
* The ssh round trips a read takes. Up to `perRound` subagent requests fit one; more are sorted
|
|
* and cut into groups of `perRound`, or the ssh line would hit `E2BIG` and the session would
|
|
* stay `unavailable`: the cursors have no cap and the stored conversation is read first, so
|
|
* not even a `/clear` would get past it.
|
|
*
|
|
* ⚠️ The cuts are NAME RANGES, not only groups of requests. The script takes a file that no
|
|
* `case` line names for a new one (its tail, its meta), so a plain group would read every
|
|
* file of the other groups as new. Each round trip walks only the names between its cuts; the
|
|
* ranges cover every possible name exactly once, so a new file lands in one of them and the
|
|
* merged result is the one a single script would give (the fuzz test compares it with the
|
|
* local reader).
|
|
*/
|
|
export function planRemoteEditSourceRounds(
|
|
requests: readonly EditSourceRequest[],
|
|
perRound: number = EDIT_SOURCE_REQUESTS_PER_ROUND_TRIP
|
|
): RemoteEditSourceRound[] {
|
|
const valid = validEditSourceRequests(requests);
|
|
const subagents = [...valid.values()].filter((request) => request.relPath !== 'main');
|
|
if (subagents.length <= perRound) return [{ requests: [...valid.values()], main: true, after: '', upTo: '' }];
|
|
|
|
const main = valid.get('main');
|
|
const fileName = (request: EditSourceRequest): string => request.relPath.slice('subagents/'.length);
|
|
subagents.sort((a, b) => (a.relPath < b.relPath ? -1 : 1));
|
|
const rounds: RemoteEditSourceRound[] = [];
|
|
for (let i = 0; i < subagents.length; i += perRound) {
|
|
const group = subagents.slice(i, i + perRound);
|
|
rounds.push({
|
|
requests: i === 0 && main ? [main, ...group] : group,
|
|
main: i === 0,
|
|
after: i === 0 ? '' : fileName(subagents[i - 1]),
|
|
upTo: i + perRound < subagents.length ? fileName(group[group.length - 1]) : '',
|
|
});
|
|
}
|
|
return rounds;
|
|
}
|
|
|
|
/** What {@link buildRemoteClaudeEditSourcesCommand} takes beyond the local reader's options. */
|
|
export interface RemoteEditSourceCommandOptions extends EditSourceReadOptions {
|
|
/** Which part of a split read this command is; without it, all of it. */
|
|
round?: Pick<RemoteEditSourceRound, 'main' | 'after' | 'upTo'>;
|
|
/** Meta bytes this command may send; without it, {@link EDIT_SOURCE_META_BUDGET_BYTES}. */
|
|
metaBudget?: number;
|
|
}
|
|
|
|
/**
|
|
* Script for one round trip of a read of a remote claude session's main transcript at
|
|
* `mainPath` and its subagent transcripts (`<mainPath without .jsonl>/subagents/agent-<id>.jsonl`),
|
|
* with exactly the local reader's rules (`readClaudeEditSources`):
|
|
*
|
|
* - a request without `len` reads from `offset` (clamped to the size);
|
|
* - a request with `len` is a block that ends at `offset + len`: short of budget, its end;
|
|
* - a file without a request (one the caller has not seen) reads its last bytes, and a new
|
|
* subagent's `.meta.json` (≤ {@link EDIT_SOURCE_META_MAX_BYTES}, outside the budget) comes
|
|
* along as its own record; with `onlyRequested` such files are left out.
|
|
*
|
|
* `budgetBytes` is shared by all transcript files, main first, then the subagents in byte
|
|
* order. A file that gets none of it still produces a zero-length record, because the caller
|
|
* needs its size. Exit {@link TRANSCRIPT_NOT_FOUND_EXIT} when `mainPath` is not a file. A read
|
|
* with more requests than fit one script is several of these ({@link planRemoteEditSourceRounds}).
|
|
*
|
|
* Output: `\0<transcriptId>\0`, then per record `<relPath>\0<size>\0<start>\0<len>\0`
|
|
* followed by exactly `<len>` raw bytes. Should a file shrink between `wc -c` and the read,
|
|
* the slice is NUL-padded to `<len>` so the framing never shifts.
|
|
*
|
|
* Inputs never reach the script unchecked: the path via {@link isRemoteClaudeTranscriptPath}
|
|
* (and shellescaped), relPaths via {@link EDIT_SOURCE_REL_PATH_PATTERN}, offsets and lengths as
|
|
* plain digits; anything invalid is dropped. A subagent file whose name the pattern would
|
|
* reject is skipped on the remote side too. Same `sh` + `LC_ALL=C` rules as the lookup.
|
|
*/
|
|
export function buildRemoteClaudeEditSourcesCommand(
|
|
mainPath: string,
|
|
requests: readonly EditSourceRequest[],
|
|
budgetBytes: number,
|
|
opts: RemoteEditSourceCommandOptions = {}
|
|
): string {
|
|
if (!isRemoteClaudeTranscriptPath(mainPath)) return `exit ${TRANSCRIPT_NOT_FOUND_EXIT}`;
|
|
const transcriptId = mainPath.slice(mainPath.lastIndexOf('/') + 1, -'.jsonl'.length);
|
|
|
|
const byRelPath = validEditSourceRequests(requests);
|
|
const budget = Number.isFinite(budgetBytes) ? Math.max(0, Math.floor(budgetBytes)) : 0;
|
|
const requestArgs = (request: EditSourceRequest): string =>
|
|
`r ${request.offset} ${request.len === undefined ? "''" : String(request.len)}`;
|
|
const { main: readsMain, after, upTo } = opts.round ?? { main: true, after: '', upTo: '' };
|
|
|
|
const main = byRelPath.get('main');
|
|
const mainLine = !readsMain
|
|
? ''
|
|
: main
|
|
? `emit main "$best" ${requestArgs(main)}`
|
|
: opts.onlyRequested
|
|
? ''
|
|
: 'emit main "$best" t 0 \'\'';
|
|
// The names come from validated relPaths, and `$0` stays a string: every name starts `agent-`.
|
|
const rangeFilter =
|
|
after || upTo
|
|
? ` | awk -v after=${shellescape(after)} -v upto=${shellescape(upTo)} '$0 > after && (upto == "" || $0 <= upto)'`
|
|
: '';
|
|
const subagentCases = [...byRelPath.entries()]
|
|
.filter(([relPath]) => relPath !== 'main')
|
|
.map(([relPath, request]) => {
|
|
const name = relPath.slice('subagents/'.length);
|
|
return ` ${shellescape(name)}) emit "subagents/$b" "$f" ${requestArgs(request)} ;;`;
|
|
});
|
|
const newSubagent = opts.onlyRequested
|
|
? [' *) ;;']
|
|
: [
|
|
' *) emit "subagents/$b" "$f" t 0 \'\'',
|
|
' m=${f%.jsonl}.meta.json',
|
|
' if [ -f "$m" ]; then',
|
|
' fsize "$m"',
|
|
` mn=$s; [ "$mn" -le ${EDIT_SOURCE_META_MAX_BYTES} ] || mn=${EDIT_SOURCE_META_MAX_BYTES}`,
|
|
' if [ "$mn" -le "$mbudget" ]; then',
|
|
' mbudget=$((mbudget - mn))',
|
|
' printf \'%s\\0%s\\0%s\\0%s\\0\' "subagents/${b%.jsonl}.meta.json" "$s" 0 "$mn"',
|
|
' [ "$mn" -eq 0 ] || bytes "$m" 0 "$mn"',
|
|
' fi',
|
|
' fi ;;',
|
|
];
|
|
|
|
const script = [
|
|
'LC_ALL=C',
|
|
'export LC_ALL',
|
|
`best=${shellescape(mainPath)}`,
|
|
`[ -f "$best" ] || exit ${TRANSCRIPT_NOT_FOUND_EXIT}`,
|
|
`printf '\\0%s\\0' ${shellescape(transcriptId)}`,
|
|
`budget=${budget}`,
|
|
`mbudget=${opts.metaBudget ?? EDIT_SOURCE_META_BUDGET_BYTES}`,
|
|
// Size via `wc -c <` (POSIX; BSD pads it with spaces, hence the tr).
|
|
'fsize() {',
|
|
' s=$(wc -c < "$1" 2>/dev/null | tr -d \' \\t\\n\')',
|
|
" case $s in ''|*[!0-9]*) s=0 ;; esac",
|
|
'}',
|
|
// Exactly $3 bytes of $1 from offset $2, NUL-padded if the file shrank meanwhile.
|
|
'bytes() {',
|
|
' { tail -c +$(($2 + 1)) "$1" 2>/dev/null | head -c "$3"; head -c "$3" /dev/zero; } | head -c "$3"',
|
|
'}',
|
|
// emit <relPath> <file> <r|t> <offset> <len|''>: r = a request, t = the tail of a new file.
|
|
'emit() {',
|
|
' fsize "$2"',
|
|
' if [ "$3" = r ] && [ -n "$5" ]; then',
|
|
' bs=$4; [ "$bs" -le "$s" ] || bs=$s',
|
|
' be=$(($4 + $5)); [ "$be" -le "$s" ] || be=$s',
|
|
' n=$((be - bs)); [ "$n" -le "$budget" ] || n=$budget',
|
|
' start=$((be - n))',
|
|
' elif [ "$3" = r ]; then',
|
|
' start=$4; [ "$start" -le "$s" ] || start=$s',
|
|
' n=$((s - start)); [ "$n" -le "$budget" ] || n=$budget',
|
|
' else',
|
|
' n=$s; [ "$n" -le "$budget" ] || n=$budget',
|
|
' start=$((s - n))',
|
|
' fi',
|
|
' budget=$((budget - n))',
|
|
' printf \'%s\\0%s\\0%s\\0%s\\0\' "$1" "$s" "$start" "$n"',
|
|
' [ "$n" -eq 0 ] || bytes "$2" "$start" "$n"',
|
|
'}',
|
|
// The valid names, one per line in byte order (the glob's order under `C`). Nothing but
|
|
// `[A-Za-z0-9_.-]` is left, so the unquoted `$(…)` below splits on them and globs nothing.
|
|
// A function, because bash 3.2 (macOS `sh`) cannot parse a `case` inside `$(…)`.
|
|
'subagent_names() {',
|
|
' for f in "${best%.jsonl}"/subagents/agent-*.jsonl; do',
|
|
' b=${f##*/}',
|
|
' case $b in agent-.jsonl|agent-*[!A-Za-z0-9_-]*.jsonl) continue ;; esac',
|
|
' printf \'%s\\n\' "$b"',
|
|
' done',
|
|
'}',
|
|
mainLine,
|
|
`for b in $(subagent_names${rangeFilter}); do`,
|
|
' f=${best%.jsonl}/subagents/$b',
|
|
' [ -f "$f" ] || continue',
|
|
' case $b in',
|
|
...subagentCases,
|
|
...newSubagent,
|
|
' esac',
|
|
'done',
|
|
].join('\n');
|
|
return `sh -c ${shellescape(script)}`;
|
|
}
|
|
|
|
/**
|
|
* Parse {@link buildRemoteClaudeEditSourcesCommand} output, byte-exact on the Buffer (a chunk
|
|
* may end mid UTF-8 sequence, so nothing here measures decoded strings). Everything before
|
|
* the first NUL is rc-file chatter. `null` = protocol broken: a missing or malformed header
|
|
* field, an unknown relPath, a slice outside the file or a body shorter than its length.
|
|
*/
|
|
export function parseRemoteClaudeEditSources(stdout: Buffer): EditSources | null {
|
|
let pos = stdout.indexOf(0);
|
|
if (pos === -1) return null;
|
|
pos += 1;
|
|
|
|
const readField = (): string | null => {
|
|
const end = stdout.indexOf(0, pos);
|
|
if (end === -1) return null;
|
|
const field = stdout.toString('utf8', pos, end);
|
|
pos = end + 1;
|
|
return field;
|
|
};
|
|
const readNumber = (): number | null => {
|
|
const field = readField();
|
|
if (field === null || !/^\d+$/.test(field)) return null;
|
|
const value = Number(field);
|
|
return Number.isSafeInteger(value) ? value : null;
|
|
};
|
|
|
|
const transcriptId = readField();
|
|
if (!transcriptId || !CLAUDE_TRANSCRIPT_ID_PATTERN.test(transcriptId)) return null;
|
|
|
|
const files: EditSources['files'] = [];
|
|
const metas: Record<string, string> = {};
|
|
while (pos < stdout.length) {
|
|
const relPath = readField();
|
|
const size = readNumber();
|
|
const startOffset = readNumber();
|
|
const len = readNumber();
|
|
if (relPath === null || size === null || startOffset === null || len === null) return null;
|
|
if (startOffset + len > size || pos + len > stdout.length) return null;
|
|
const chunk = stdout.subarray(pos, pos + len);
|
|
pos += len;
|
|
|
|
const meta = EDIT_SOURCE_META_REL_PATH_PATTERN.exec(relPath);
|
|
if (meta) {
|
|
metas[meta[1]] = chunk.toString('utf8');
|
|
} else if (EDIT_SOURCE_REL_PATH_PATTERN.test(relPath)) {
|
|
files.push({ relPath, size, startOffset, chunk });
|
|
} else {
|
|
return null;
|
|
}
|
|
}
|
|
return { transcriptId, files, metas };
|
|
}
|
|
|
|
/**
|
|
* Runs one read command on the host: its stdout, at most `maxBytes`, or null for "no such
|
|
* transcript" ({@link TRANSCRIPT_NOT_FOUND_EXIT}).
|
|
*/
|
|
export type RemoteEditSourceRunner = (command: string, maxBytes: number) => Promise<Buffer | null>;
|
|
|
|
/**
|
|
* Run the round trips of a read ({@link planRemoteEditSourceRounds}) one after the other and
|
|
* merge them into what one read returns. Budget and meta budget carry over: a later round trip
|
|
* gets what the earlier ones left, and still lists its files, because a requested file missing
|
|
* from the result counts as deleted and loses its cursor. So a read is all or nothing: a failed
|
|
* round trip fails it, and nothing of it reaches the store.
|
|
*/
|
|
export async function readRemoteEditSourceRounds(
|
|
mainPath: string,
|
|
requests: readonly EditSourceRequest[],
|
|
budgetBytes: number,
|
|
opts: EditSourceReadOptions,
|
|
run: RemoteEditSourceRunner,
|
|
perRound?: number
|
|
): Promise<EditSources | null> {
|
|
let budget = Number.isFinite(budgetBytes) ? Math.max(0, Math.floor(budgetBytes)) : 0;
|
|
let metaBudget = EDIT_SOURCE_META_BUDGET_BYTES;
|
|
const merged: EditSources = { transcriptId: '', files: [], metas: {} };
|
|
for (const round of planRemoteEditSourceRounds(requests, perRound)) {
|
|
const stdout = await run(
|
|
buildRemoteClaudeEditSourcesCommand(mainPath, round.requests, budget, { ...opts, round, metaBudget }),
|
|
budget + metaBudget + EDIT_SOURCE_HEADER_SLACK_BYTES
|
|
);
|
|
if (!stdout) return null;
|
|
const parsed = parseRemoteClaudeEditSources(stdout);
|
|
if (!parsed || !mainPath.endsWith(`/${parsed.transcriptId}.jsonl`)) {
|
|
throw new RemoteFileAccessError('unexpected output from the remote edit-history read');
|
|
}
|
|
merged.transcriptId = parsed.transcriptId;
|
|
merged.files.push(...parsed.files);
|
|
Object.assign(merged.metas, parsed.metas);
|
|
for (const file of parsed.files) budget -= file.chunk.length;
|
|
for (const text of Object.values(parsed.metas)) metaBudget = Math.max(0, metaBudget - Buffer.byteLength(text));
|
|
}
|
|
return merged;
|
|
}
|
|
|
|
/**
|
|
* Read a remote claude session's transcripts (see {@link readRemoteEditSourceRounds}), the
|
|
* remote twin of `readClaudeEditSources`. `null` when `mainPath` does not exist; an unreachable
|
|
* host, an ssh failure or broken output is a `RemoteFileAccessError`. Each round trip takes its
|
|
* own slot of the ssh limiter. Never wakes.
|
|
*/
|
|
export async function remoteReadClaudeEditSources(
|
|
remote: SessionRemote,
|
|
mainPath: string,
|
|
requests: readonly EditSourceRequest[],
|
|
budgetBytes: number,
|
|
opts: EditSourceReadOptions = {}
|
|
): Promise<EditSources | null> {
|
|
assertNotUnderTest();
|
|
return readRemoteEditSourceRounds(mainPath, requests, budgetBytes, opts, async (command, maxBytes) => {
|
|
try {
|
|
const result = await runWithRemoteSshLimit(() =>
|
|
execAsync(buildRemoteEditHistoryCommand(remote, command), {
|
|
timeout: remoteEditSourcesTimeoutMs(maxBytes),
|
|
maxBuffer: maxBytes,
|
|
encoding: 'buffer',
|
|
})
|
|
);
|
|
return Buffer.isBuffer(result.stdout) ? result.stdout : Buffer.from(result.stdout);
|
|
} catch (err) {
|
|
if ((err as { code?: unknown }).code === TRANSCRIPT_NOT_FOUND_EXIT) return null;
|
|
throw new RemoteFileAccessError(
|
|
`remote host ${remote.label || remote.host} unreachable: ${describeExecError(err)}`
|
|
);
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Command that writes a remote file's bytes to stdout.
|
|
*
|
|
* ⚠️ Range reads use `tail -c +N | head -c L` (both POSIX, constant memory) because
|
|
* the alternative — `dd bs=1` — issues one read syscall per byte and would make video
|
|
* seeking unusable. The trade-off is that a `tail` failure (the file vanished
|
|
* mid-request) reports `head`'s exit status, i.e. a short body on an already-sent
|
|
* 206; the client retries. The uncompressed path (`cat`) reports its own failure
|
|
* correctly, so the streaming error path is still covered by the normal case.
|
|
*/
|
|
export function buildRemoteReadCommand(remotePath: string, range?: { start: number; end: number }): string {
|
|
const quoted = shellescape(remotePath);
|
|
if (!range) return `cat ${quoted}`;
|
|
const length = range.end - range.start + 1;
|
|
return `tail -c +${range.start + 1} ${quoted} | head -c ${length}`;
|
|
}
|
|
|
|
export interface RemoteFileStream {
|
|
/** The remote file's bytes, streamed from the ssh child's stdout. */
|
|
stream: Readable;
|
|
/**
|
|
* Abort the transfer and reap the ssh child. The caller MUST call this when the
|
|
* HTTP request ends — especially on a client disconnect — or the `ssh` process
|
|
* keeps running (and holding a connection open) after nobody is reading it.
|
|
*/
|
|
close(): void;
|
|
}
|
|
|
|
/**
|
|
* Stream a remote file (optionally a byte range) as a Node Readable.
|
|
*
|
|
* Nothing is buffered in server memory: the bytes go from `ssh`'s stdout straight to
|
|
* the HTTP response, which is what makes a multi-GB remote video cost one pipe.
|
|
*/
|
|
export function remoteCreateReadStream(
|
|
remote: SessionRemote,
|
|
remotePath: string,
|
|
range?: { start: number; end: number }
|
|
): RemoteFileStream {
|
|
if (process.env.VITEST) {
|
|
// Same rule as the buffered calls, in stream form: the consumer sees the error
|
|
// through the stream's normal failure path instead of a connection attempt.
|
|
const stream = new PassThrough();
|
|
process.nextTick(() => stream.destroy(new RemoteFileAccessError('remote file access is disabled under test')));
|
|
return { stream, close: () => stream.destroy() };
|
|
}
|
|
const command = buildRemoteFileCommand(remote, buildRemoteReadCommand(remotePath, range));
|
|
const child = spawn(command, { shell: true, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
|
|
let stderr = '';
|
|
child.stderr?.on('data', (chunk: Buffer) => {
|
|
if (stderr.length < 2000) stderr += chunk.toString();
|
|
});
|
|
|
|
const stream = child.stdout;
|
|
let ended = false;
|
|
stream.on('end', () => {
|
|
ended = true;
|
|
});
|
|
stream.on('error', () => {
|
|
ended = true;
|
|
});
|
|
|
|
child.on('error', (err: Error) => {
|
|
stream.destroy(err);
|
|
});
|
|
child.on('close', (code: number | null) => {
|
|
// Only a truncated transfer is an error. A non-zero exit AFTER the body finished
|
|
// (e.g. a signal delivered as the last byte was flushed) must not destroy an
|
|
// already-complete response, or the browser reports a broken body for a file it
|
|
// received in full.
|
|
if (ended || code === 0 || code === null) return;
|
|
const detail = stderr.trim().split('\n')[0];
|
|
stream.destroy(new RemoteFileAccessError(`remote read failed (ssh exit ${code})${detail ? `: ${detail}` : ''}`));
|
|
});
|
|
|
|
return {
|
|
stream,
|
|
close(): void {
|
|
if (!stream.destroyed) stream.destroy();
|
|
child.kill('SIGTERM');
|
|
},
|
|
};
|
|
}
|
|
|
|
/**
|
|
* First useful line of an exec/stderr error, for a user-facing message.
|
|
*
|
|
* ⚠️ Never Node's `err.message`: for a failed `exec` it is `Command failed: <the whole
|
|
* ssh line>`, which carries the identity-file path and the probe script, and this
|
|
* string goes out in a 502 body. stderr, the timeout flag and the exit/spawn code are
|
|
* everything a user can act on.
|
|
*/
|
|
function describeExecError(err: unknown): string {
|
|
if (typeof err === 'object' && err !== null) {
|
|
const record = err as { stderr?: unknown; code?: unknown; killed?: unknown };
|
|
const stderr =
|
|
typeof record.stderr === 'string' ? record.stderr : Buffer.isBuffer(record.stderr) ? String(record.stderr) : '';
|
|
const line = stderr
|
|
.split('\n')
|
|
.map((entry) => entry.trim())
|
|
.find((entry) => entry.length > 0);
|
|
if (line) return line.slice(0, 300);
|
|
if (record.killed) return 'timed out';
|
|
if (typeof record.code === 'number') return `ssh exit ${record.code}`;
|
|
if (typeof record.code === 'string') return `ssh could not be started (${record.code})`;
|
|
}
|
|
return 'unknown error';
|
|
}
|