diff --git a/package-lock.json b/package-lock.json index 4c132ed..c2756de 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@agent-relay/sandbox", - "version": "0.1.5", + "version": "0.1.6", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@agent-relay/sandbox", - "version": "0.1.5", + "version": "0.1.6", "license": "Apache-2.0", "devDependencies": { "@aws-sdk/client-bedrock-agentcore": "^3.1115.0", diff --git a/package.json b/package.json index 46f6195..187915e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@agent-relay/sandbox", - "version": "0.1.5", + "version": "0.1.6", "description": "Provider-agnostic sandbox runtimes and orchestration for agent workloads.", "license": "Apache-2.0", "type": "module", diff --git a/src/mount-script.test.ts b/src/mount-script.test.ts index ac14e87..0c39c85 100644 --- a/src/mount-script.test.ts +++ b/src/mount-script.test.ts @@ -1,10 +1,17 @@ -import { describe, it } from "node:test"; +import { describe, it, type TestContext } from "node:test"; import { strict as assert } from "node:assert"; +import { spawnSync } from "node:child_process"; +import { chmodSync, existsSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { + buildRelayfileMountCleanupFlushShell, buildRelayfileMountStartShell, buildRelayfileMountFlushShell, buildRelayfileMountInitialSyncShell, + buildRelayfileMountPathArgsShell, + buildRelayfileMountShellTemplate, } from "./mount-script.js"; const TOKEN = "relay_pa_thisisasecrettoken_do_not_leak"; @@ -16,6 +23,54 @@ const BASE = { token: TOKEN, } as const; +function fakeExactMount(t: TestContext): { + binDir: string; + localRoot: string; +} { + const fixtureRoot = mkdtempSync(join(tmpdir(), "sandbox-mount-layout-")); + t.after(() => rmSync(fixtureRoot, { recursive: true, force: true })); + const binDir = join(fixtureRoot, "bin"); + const localRoot = join(fixtureRoot, "workspace"); + const fakeMount = join(binDir, "relayfile-mount"); + + const mkdir = spawnSync("mkdir", ["-p", binDir]); + assert.equal(mkdir.status, 0, mkdir.stderr?.toString()); + writeFileSync( + fakeMount, + `#!/bin/sh +layout="\${RELAYFILE_MOUNT_LOCAL_LAYOUT:-exact}" +if [ "$layout" = scoped ]; then + echo 'unsupported local layout: --local-layout=scoped; use --local-layout=exact' >&2 + exit 1 +fi +local_dir= +while [ "$#" -gt 0 ]; do + case "$1" in + --local-layout) layout="$2"; shift 2 ;; + --local-dir) local_dir="$2"; shift 2 ;; + *) shift ;; + esac +done +if [ "$layout" != exact ] || [ -z "$local_dir" ]; then exit 2; fi +if [ -n "\${FAKE_MOUNT_CALLS:-}" ]; then + printf '%s\n' "$local_dir" >> "$FAKE_MOUNT_CALLS" +fi +if [ -n "\${FAKE_MOUNT_FAIL_LOCAL_DIR:-}" ] && [ "$local_dir" = "$FAKE_MOUNT_FAIL_LOCAL_DIR" ]; then + echo "simulated mount failure: $local_dir" >&2 + exit 23 +fi +mkdir -p "$local_dir" +printf mounted > "$local_dir/.mounted" +`, + ); + chmodSync(fakeMount, 0o755); + return { binDir, localRoot }; +} + +function testShellQuote(value: string): string { + return `'${value.replaceAll("'", "'\\''")}'`; +} + describe("mount-script token ingress", () => { describe("default ('argv') — backwards-compatible with older daemons", () => { it("emits --token in the rendered start command", () => { @@ -75,17 +130,311 @@ describe("mount-script token ingress", () => { }); assert.match(start, /RELAYFILE_MOUNT_CREDS_FILE=/); assert.match(start, /RELAYFILE_MOUNT_TOKEN=/); - assert.match(start, /RELAYFILE_MOUNT_LOCAL_LAYOUT=scoped/); + assert.match(start, /--local-layout 'exact'/); assert.doesNotMatch(start, /--token/); }); - it("keeps RELAYFILE_MOUNT_LOCAL_LAYOUT scoped when only tokenIngress is set", () => { + it("keeps exact local layout when only tokenIngress is set", () => { const start = buildRelayfileMountStartShell({ ...BASE, tokenIngress: "env" }); - assert.match(start, /RELAYFILE_MOUNT_LOCAL_LAYOUT=scoped/); + assert.match(start, /--local-layout 'exact'/); + assert.doesNotMatch(start, /RELAYFILE_MOUNT_LOCAL_LAYOUT=/); }); }); }); +describe("exact local-layout contract", () => { + it("pins the single-path on-disk mirror root explicitly", (t) => { + const { binDir, localRoot } = fakeExactMount(t); + + const shell = buildRelayfileMountFlushShell({ + ...BASE, + localDir: localRoot, + paths: ["/github/repos/acme/cloud/**"], + }); + const result = spawnSync("/bin/sh", ["-c", shell], { + env: { ...process.env, PATH: `${binDir}:${process.env.PATH ?? ""}` }, + encoding: "utf8", + }); + + assert.equal(result.status, 0, result.stderr); + assert.equal( + existsSync(join(localRoot, "github/repos/acme/cloud/.mounted")), + true, + "the exact mirror root must include the normalized remote path", + ); + assert.equal( + existsSync(join(localRoot, ".mounted")), + false, + "a successful process at the unscoped base would silently mirror at the wrong depth", + ); + }); + + it("starts one exact-layout daemon per remote root", () => { + const shell = buildRelayfileMountStartShell({ + ...BASE, + paths: ["/github/repos/acme/cloud/**", "/slack/channels/C123/**"], + }); + + assert.doesNotMatch(shell, /RELAYFILE_MOUNT_LOCAL_LAYOUT=scoped/); + assert.doesNotMatch(shell, /paths-file/); + assert.equal(shell.match(/relayfile-mount --local-layout 'exact'/g)?.length, 2); + assert.match( + shell, + /--local-dir '\/home\/user\/workspace\/github\/repos\/acme\/cloud'.*--remote-path '\/github\/repos\/acme\/cloud'/, + ); + assert.match( + shell, + /--local-dir '\/home\/user\/workspace\/slack\/channels\/C123'.*--remote-path '\/slack\/channels\/C123'/, + ); + }); + + it("does not double-join an already joined local directory", () => { + const shell = buildRelayfileMountStartShell({ + ...BASE, + localDir: "/home/user/workspace/github/repos/acme/cloud/issues/42", + paths: ["/github/repos/acme/cloud/issues/42/**"], + }); + + assert.match( + shell, + /--local-dir '\/home\/user\/workspace\/github\/repos\/acme\/cloud\/issues\/42'/, + ); + assert.doesNotMatch(shell, /issues\/42\/github\/repos/); + }); + + it("fails closed when a remote root could escape the local mount root", () => { + assert.throws( + () => buildRelayfileMountFlushShell({ + ...BASE, + paths: ["/../../tmp/**"], + }), + /traversal segment/, + ); + }); + + it("keeps a multi-root cleanup flush inside one timeout-compatible command", (t) => { + const { binDir, localRoot } = fakeExactMount(t); + const shell = buildRelayfileMountCleanupFlushShell({ + ...BASE, + localDir: localRoot, + paths: ["/github/repos/acme/cloud/**", "/slack/channels/C123/**"], + }); + + assert.match(shell, /^sh -c /); + assert.equal(shell.match(/relayfile-mount "\$1"/g)?.length, 2); + assert.match(shell, /--local-layout/); + assert.match(shell, /workspace\/github\/repos\/acme\/cloud/); + assert.match(shell, /workspace\/slack\/channels\/C123/); + assert.match(shell, /relayfile-mount-cleanup "\$relayfile_mount_flush_mode"$/); + + const result = spawnSync( + "/bin/sh", + ["-c", `relayfile_mount_flush_mode=--once; ${shell}`], + { + env: { ...process.env, PATH: `${binDir}:${process.env.PATH ?? ""}` }, + encoding: "utf8", + }, + ); + assert.equal(result.status, 0, result.stderr); + assert.equal( + existsSync(join(localRoot, "github/repos/acme/cloud/.mounted")), + true, + ); + assert.equal( + existsSync(join(localRoot, "slack/channels/C123/.mounted")), + true, + ); + }); + + it("attempts every cleanup root and returns the first failure", (t) => { + const { binDir, localRoot } = fakeExactMount(t); + const firstRoot = join(localRoot, "github/repos/acme/cloud"); + const laterRoot = join(localRoot, "slack/channels/C123"); + const callsPath = join(localRoot, "cleanup-calls.log"); + const shell = buildRelayfileMountCleanupFlushShell({ + ...BASE, + localDir: localRoot, + paths: ["/github/repos/acme/cloud/**", "/slack/channels/C123/**"], + }); + + const result = spawnSync( + "/bin/sh", + ["-c", `relayfile_mount_flush_mode=--once; ${shell}`], + { + env: { + ...process.env, + PATH: `${binDir}:${process.env.PATH ?? ""}`, + FAKE_MOUNT_CALLS: callsPath, + FAKE_MOUNT_FAIL_LOCAL_DIR: firstRoot, + }, + encoding: "utf8", + }, + ); + + assert.equal(result.status, 23, result.stderr); + assert.equal(existsSync(join(firstRoot, ".mounted")), false); + assert.equal( + existsSync(join(laterRoot, ".mounted")), + true, + "a failure in the first exact root must not skip a later teardown flush", + ); + }); + + it("renders late-bound shell templates as separate exact mounts", (t) => { + const { binDir, localRoot } = fakeExactMount(t); + const template = buildRelayfileMountShellTemplate({}, { + stateDir: join(localRoot, ".state"), + websocket: false, + }); + const values = { + baseUrl: BASE.baseUrl, + workspaceId: BASE.workspaceId, + localDir: localRoot, + token: BASE.token, + }; + let shell = template.flushShellTemplate; + for (const [key, value] of Object.entries(values)) { + const placeholder = template.placeholders[key as keyof typeof values]; + shell = shell.replace(testShellQuote(placeholder), testShellQuote(value)); + } + shell = shell.replace( + template.pathArgsPlaceholderArg, + buildRelayfileMountPathArgsShell([ + "/github/repos/acme/cloud/**", + "/slack/channels/C123/**", + ]), + ); + + const result = spawnSync("/bin/sh", ["-c", shell], { + env: { ...process.env, PATH: `${binDir}:${process.env.PATH ?? ""}` }, + encoding: "utf8", + }); + + assert.equal(result.status, 0, result.stderr); + assert.equal( + existsSync(join(localRoot, "github/repos/acme/cloud/.mounted")), + true, + ); + assert.equal( + existsSync(join(localRoot, "slack/channels/C123/.mounted")), + true, + ); + assert.equal(existsSync(join(localRoot, ".mounted")), false); + }); + + it("surfaces late-bound daemon argument validation failures", (t) => { + const { binDir, localRoot } = fakeExactMount(t); + const template = buildRelayfileMountShellTemplate({}, { + stateDir: join(localRoot, ".state"), + websocket: false, + }); + const values = { + baseUrl: BASE.baseUrl, + workspaceId: BASE.workspaceId, + localDir: localRoot, + token: BASE.token, + }; + for (const testCase of [ + { + pathArgs: " --not-a-path '/bad'", + message: /invalid relayfile mount path args/, + }, + { + pathArgs: " --remote-path '/github/../secrets'", + message: /relayfile remote root contains a traversal segment/, + }, + ]) { + let shell = template.startShellTemplate; + for (const [key, value] of Object.entries(values)) { + const placeholder = template.placeholders[key as keyof typeof values]; + shell = shell.replace(testShellQuote(placeholder), testShellQuote(value)); + } + shell = shell.replace(template.pathArgsPlaceholderArg, testCase.pathArgs); + + const result = spawnSync("/bin/sh", ["-c", shell], { + env: { ...process.env, PATH: `${binDir}:${process.env.PATH ?? ""}` }, + encoding: "utf8", + }); + + assert.equal(result.status, 2); + assert.match(result.stderr, testCase.message); + } + }); + + it("does not mutate an embedding shell's positional parameters", (t) => { + const { binDir, localRoot } = fakeExactMount(t); + const template = buildRelayfileMountShellTemplate({}, { + stateDir: join(localRoot, ".state"), + websocket: false, + }); + const values = { + baseUrl: BASE.baseUrl, + workspaceId: BASE.workspaceId, + localDir: localRoot, + token: BASE.token, + }; + let shell = template.flushShellTemplate; + for (const [key, value] of Object.entries(values)) { + const placeholder = template.placeholders[key as keyof typeof values]; + shell = shell.replace(testShellQuote(placeholder), testShellQuote(value)); + } + shell = shell.replace( + template.pathArgsPlaceholderArg, + buildRelayfileMountPathArgsShell(["/slack/channels/C123/**"]), + ); + + const result = spawnSync( + "/bin/sh", + [ + "-c", + `set -- original arguments; ${shell}; [ "$#" -eq 2 ] && [ "$1" = original ] && [ "$2" = arguments ]`, + ], + { + env: { ...process.env, PATH: `${binDir}:${process.env.PATH ?? ""}` }, + encoding: "utf8", + }, + ); + + assert.equal(result.status, 0, result.stderr); + }); + + it("does not double-join a late-bound already joined local directory", (t) => { + const { binDir, localRoot } = fakeExactMount(t); + const joinedRoot = join(localRoot, "github/repos/acme/cloud"); + const template = buildRelayfileMountShellTemplate({}, { + stateDir: join(localRoot, ".state"), + websocket: false, + }); + const values = { + baseUrl: BASE.baseUrl, + workspaceId: BASE.workspaceId, + localDir: joinedRoot, + token: BASE.token, + }; + let shell = template.flushShellTemplate; + for (const [key, value] of Object.entries(values)) { + const placeholder = template.placeholders[key as keyof typeof values]; + shell = shell.replace(testShellQuote(placeholder), testShellQuote(value)); + } + shell = shell.replace( + template.pathArgsPlaceholderArg, + buildRelayfileMountPathArgsShell(["/github/repos/acme/cloud/**"]), + ); + + const result = spawnSync("/bin/sh", ["-c", shell], { + env: { ...process.env, PATH: `${binDir}:${process.env.PATH ?? ""}` }, + encoding: "utf8", + }); + + assert.equal(result.status, 0, result.stderr); + assert.equal(existsSync(join(joinedRoot, ".mounted")), true); + assert.equal( + existsSync(join(joinedRoot, "github/repos/acme/cloud/.mounted")), + false, + ); + }); +}); + /** * The idle watchdog arms itself with `set -- [...];` — the exact * list of paths whose mtime it reads as "the sync is still making progress". @@ -170,6 +519,15 @@ describe("initial-sync idle watchdog progress files", () => { const armed = armedProgressFiles(shell); assert.equal(armed.length, 2); + assert.equal(shell.match(/relayfile-mount --once --local-layout 'exact'/g)?.length, 2); + assert.match( + shell, + /--local-dir '\/home\/user\/workspace\/github\/agentworkforce'.*--remote-path '\/github\/agentworkforce'/, + ); + assert.match( + shell, + /--local-dir '\/home\/user\/workspace\/slack\/C0BBTBC1RCM'.*--remote-path '\/slack\/C0BBTBC1RCM'/, + ); for (const file of armed) { assert.ok( shell.includes(`--state-file '${file}'`), diff --git a/src/mount-script.ts b/src/mount-script.ts index 9403f99..280d73f 100644 --- a/src/mount-script.ts +++ b/src/mount-script.ts @@ -50,10 +50,9 @@ export type RelayfileMountShellOptions = { * Path to a JSON creds file (`{"token": "relay_pa_…", "mintedAt"?, "expiresAt"?}`) * the daemon re-reads on 401 so a refreshed token heals the mount without a * restart. Passed as the RELAYFILE_MOUNT_CREDS_FILE env var rather than a - * `--creds-file` flag for the same version-skew reason as - * RELAYFILE_MOUNT_LOCAL_LAYOUT above: pre-creds binaries reject an unknown - * flag but ignore the env var, so one spelling works across every binary a - * snapshot may carry. + * `--creds-file` flag because pre-creds binaries reject an unknown flag but + * ignore the env var, so one spelling works across every binary a snapshot + * may carry. */ credsFilePath?: string; /** @@ -65,8 +64,8 @@ export type RelayfileMountShellOptions = { * for backwards compatibility with binaries that only read `--token`. * - `'env'`: rendered as an `env RELAYFILE_MOUNT_TOKEN=` prefix, * omitted from argv entirely. Requires a daemon build that reads the - * `RELAYFILE_MOUNT_TOKEN` env var. Follow the same version-skew probe - * pattern as RELAYFILE_MOUNT_LOCAL_LAYOUT before flipping to `'env'`. + * `RELAYFILE_MOUNT_TOKEN` env var. Confirm that capability before + * flipping the default to `'env'`. * * The credentials-in-argv exposure was tracked as AgentWorkforce/sandbox#21. */ @@ -74,53 +73,43 @@ export type RelayfileMountShellOptions = { }; /** - * Pin the daemon's local layout to `scoped` (remote path appended under - * --local-dir) on every invocation. + * The local-layout value and --local-dir are one contract. * - * Newer daemon releases made the layout explicit: they default to `exact` - * (--local-dir IS the mirror root) and hard-error on multiple --remote-path - * values unless `--local-layout=scoped`. All builders in this module - * pre-compute an UNSCOPED local dir (see `unscopedLocalDir`) and rely on the - * daemon appending the remote path, which is what older binaries did - * implicitly. Without this pin, a newer binary breaks two ways: multi-path - * mounts fail at startup, and single-path mounts silently mirror at the wrong - * depth. + * Every invocation uses explicit `exact` layout, so --local-dir is the final + * on-disk mirror root. For a remote root such as `/github/repos/acme/cloud`, + * builders first recover the unscoped base (for callers that already passed + * the joined path) and then append that remote root themselves. Multi-path + * mounts run one process per remote root because exact layout intentionally + * rejects repeated --remote-path values. * - * Pinned via env var rather than the `--local-layout` flag on purpose: older - * binaries reject the unknown flag but ignore the env var, while newer ones - * read RELAYFILE_MOUNT_LOCAL_LAYOUT as the flag default. One spelling - * therefore yields an identical on-disk layout across every binary version a - * sandbox image may carry — which matters because the image and this code are - * versioned independently. The pathless case is safe: scoped layout with - * remote path "/" is a no-op join (normalizeMountRemotePath("/") → localDir). - * - * Spelled `env VAR=… relayfile-mount` (not the bare `VAR=… relayfile-mount` - * shell form) because initial-sync commands get wrapped by coreutils - * `timeout`, which execs its argument instead of shell-parsing it — a bare - * assignment prefix would make `timeout '20s' VAR=… relayfile-mount` fail - * with "failed to run command". `env` is a real executable, so the same - * prefix composes under `timeout`, `nohup`, and direct execution alike. + * The flag is deliberate. A binary too old to understand explicit layout + * now fails loudly instead of ignoring an env var and silently mirroring at + * the wrong depth. Every production image currently in scope (v0.10.35+) has + * the explicit layout contract. */ -const SCOPED_LOCAL_LAYOUT_ENV = "env RELAYFILE_MOUNT_LOCAL_LAYOUT=scoped "; +const EXACT_LOCAL_LAYOUT_ARG = `--local-layout ${shellQuote("exact")}`; /** - * `env`-prefix for every relayfile-mount invocation: always pins the scoped - * local layout, and when the caller provides a creds file, also points the - * daemon at it via RELAYFILE_MOUNT_CREDS_FILE (see `credsFilePath` docs for - * the version-skew rationale). When `tokenIngress === 'env'`, prepends - * `RELAYFILE_MOUNT_TOKEN=` so the launch token never enters argv. + * Optional `env` prefix for relayfile-mount invocations. When the caller + * provides a creds file, points the daemon at it via + * RELAYFILE_MOUNT_CREDS_FILE (see `credsFilePath` docs for the version-skew + * rationale). When `tokenIngress === 'env'`, adds + * RELAYFILE_MOUNT_TOKEN= so the launch token never enters argv. + * The explicit `env` executable is required because initial-sync commands can + * sit directly behind coreutils `timeout`, which does not shell-parse a bare + * `VAR=value` assignment. */ function mountEnvPrefix( opts: Pick, ): string { - const parts = ["RELAYFILE_MOUNT_LOCAL_LAYOUT=scoped"]; + const parts: string[] = []; if (opts.credsFilePath) { parts.push(`RELAYFILE_MOUNT_CREDS_FILE=${shellQuote(opts.credsFilePath)}`); } if (opts.tokenIngress === "env") { parts.push(`RELAYFILE_MOUNT_TOKEN=${shellQuote(opts.token)}`); } - return `env ${parts.join(" ")} `; + return parts.length > 0 ? `env ${parts.join(" ")} ` : ""; } export type RelayfileMountInitialSyncOptions = RelayfileMountShellOptions & { @@ -180,31 +169,19 @@ const DEFAULT_TEMPLATE_PLACEHOLDERS: RelayfileMountShellTemplate["placeholders"] */ export function buildRelayfileMountStartShell(opts: RelayfileMountDaemonOptions): string { const scopedRoots = scopedRemoteRoots(opts.paths ?? [], { allowProviderRoot: true }); - const localDir = unscopedLocalDir(opts.localDir, scopedRoots); - const args = buildMountArgs({ ...opts, localDir, paths: scopedRoots }); + if (scopedRoots.length > 1) { + return buildRelayfileMountMultiStartShell({ ...opts, paths: scopedRoots }); + } + const [mount] = exactMounts(opts.localDir, scopedRoots); + const args = buildMountArgs({ ...opts, ...mount }); const interval = opts.interval ?? "1s"; const logPath = opts.logPath ?? "/tmp/relayfile-mount.log"; - const startShell = [ + return [ `${mountEnvPrefix(opts)}nohup relayfile-mount`, ...args, `--interval ${shellQuote(interval)}`, `> ${shellQuote(logPath)} 2>&1 & echo $!`, ].join(" "); - if (scopedRoots.length <= 1) { - return startShell; - } - // `paths-file` is the new-daemon sentinel: the release that added it also - // added repeated `--remote-path` support. The Go flag package prints - // `-paths-file` in help while the command accepts `--paths-file`, so probe - // for the flag name without assuming dash style. - return [ - "if relayfile-mount --help 2>&1 | grep -q -- 'paths-file'; then", - `${startShell};`, - "else", - "echo 'relayfile-mount multi-path filters unsupported; starting one daemon per remote path' >&2;", - `${buildRelayfileMountFallbackStartShell({ ...opts, paths: scopedRoots })};`, - "fi", - ].join(" "); } /** @@ -218,9 +195,11 @@ export function buildRelayfileMountStartShell(opts: RelayfileMountDaemonOptions) */ export function buildRelayfileMountFlushShell(opts: RelayfileMountShellOptions): string { const scopedRoots = scopedRemoteRoots(opts.paths ?? [], { allowProviderRoot: true }); - const localDir = unscopedLocalDir(opts.localDir, scopedRoots); - const args = buildMountArgs({ ...opts, localDir, paths: scopedRoots }); - return [`${mountEnvPrefix(opts)}relayfile-mount --once`, ...args].join(" "); + const commands = exactMounts(opts.localDir, scopedRoots).map((mount) => [ + `${mountEnvPrefix(opts)}relayfile-mount --once`, + ...buildMountArgs({ ...opts, ...mount }), + ].join(" ")); + return composeMountCommands(commands); } /** @@ -243,12 +222,28 @@ export function buildRelayfileMountCleanupFlushShell( opts: RelayfileMountShellOptions, ): string { const scopedRoots = scopedRemoteRoots(opts.paths ?? [], { allowProviderRoot: true }); - const localDir = unscopedLocalDir(opts.localDir, scopedRoots); - const args = buildMountArgs({ ...opts, localDir, paths: scopedRoots }); - return [ - `${mountEnvPrefix(opts)}relayfile-mount "$relayfile_mount_flush_mode"`, - ...args, - ].join(" "); + const mounts = exactMounts(opts.localDir, scopedRoots); + if (mounts.length === 1) { + return [ + `${mountEnvPrefix(opts)}relayfile-mount "$relayfile_mount_flush_mode"`, + ...buildMountArgs({ ...opts, ...mounts[0]! }), + ].join(" "); + } + const commands = mounts.map((mount) => [ + `${mountEnvPrefix(opts)}relayfile-mount "$1"`, + ...buildMountArgs({ ...opts, ...mount }), + ].join(" ")); + const script = [ + "relayfile_mount_flush_status=0", + ...commands.map((command) => [ + `${command} || {`, + "relayfile_mount_flush_code=$?;", + 'if [ "$relayfile_mount_flush_status" -eq 0 ]; then relayfile_mount_flush_status=$relayfile_mount_flush_code; fi;', + "}", + ].join(" ")), + 'exit "$relayfile_mount_flush_status"', + ].join("; "); + return `sh -c ${shellQuote(script)} relayfile-mount-cleanup "$relayfile_mount_flush_mode"`; } export function buildRelayfileMountInitialSyncShell( @@ -471,14 +466,15 @@ export function buildRelayfileMountShellTemplate( }; const pathArgsPlaceholderArg = buildMountPathArg(resolved.pathArgs); const pathArgTemplate = buildMountPathArg(resolved.path); - const startShellWithoutPaths = buildRelayfileMountStartShell(baseOpts); - const flushShellWithoutPaths = buildRelayfileMountFlushShell(baseOpts); return { - startShellTemplate: insertStartTemplatePathArgs( - startShellWithoutPaths, + startShellTemplate: buildDynamicMountStartTemplate( + baseOpts, + pathArgsPlaceholderArg, + ), + flushShellTemplate: buildDynamicMountOnceTemplate( + baseOpts, pathArgsPlaceholderArg, ), - flushShellTemplate: `${flushShellWithoutPaths}${pathArgsPlaceholderArg}`, pathArgsPlaceholderArg, pathArgTemplate, placeholders: resolved, @@ -487,6 +483,7 @@ export function buildRelayfileMountShellTemplate( function buildMountArgs(opts: RelayfileMountShellOptions): string[] { return [ + EXACT_LOCAL_LAYOUT_ARG, `--base-url ${shellQuote(opts.baseUrl)}`, `--workspace ${shellQuote(opts.workspaceId)}`, `--local-dir ${shellQuote(opts.localDir)}`, @@ -532,38 +529,23 @@ function initialSyncStateFile( } function initialSyncStateFiles(opts: RelayfileMountInitialSyncOptions): string[] { - const roots = scopedRemoteRoots(opts.paths ?? []); + const roots = scopedRemoteRoots(opts.paths ?? [], { allowProviderRoot: true }); if (roots.length > 0) { - const localRoot = unscopedLocalDir(opts.localDir, roots); - return roots.map((remoteRoot) => initialSyncStateFile(opts, remoteRoot, localRoot)); + return exactMounts(opts.localDir, roots).map((mount) => + initialSyncStateFile(opts, mount.paths[0]!, mount.localDir) + ); } - - // The single-command branch can still carry a provider-root scope such as - // `/github/**`. Include every effective command root in the identity rather - // than collapsing those mounts onto the same nominal `/` checkpoint. - const commandRoots = scopedRemoteRoots(opts.paths ?? [], { allowProviderRoot: true }); - const remoteRoot = commandRoots.length > 0 ? commandRoots.join("\0") : "/"; - const localRoot = unscopedLocalDir(opts.localDir, commandRoots); - return [initialSyncStateFile(opts, remoteRoot, localRoot)]; + const [mount] = exactMounts(opts.localDir, []); + return [initialSyncStateFile(opts, "/", mount!.localDir)]; } function buildInitialSyncCommands(opts: RelayfileMountInitialSyncOptions): string[] { - const roots = scopedRemoteRoots(opts.paths ?? []); + const roots = scopedRemoteRoots(opts.paths ?? [], { allowProviderRoot: true }); const stateFiles = initialSyncStateFiles(opts); - if (roots.length === 0) { - // Pin the unscoped sync's private state exactly as the scoped branch - // below does, so `initialSyncProgressFiles` can name the file the sync - // actually writes. - return [ - `${buildRelayfileMountFlushShell(opts)} --state-file ${shellQuote(stateFiles[0]!)}`, - ]; - } - const localDir = unscopedLocalDir(opts.localDir, roots); - return roots - .map((remoteRoot, index) => { + return exactMounts(opts.localDir, roots) + .map((mount, index) => { const args = [ - ...buildMountArgs({ ...opts, localDir, paths: [] }), - `--remote-path ${shellQuote(remoteRoot)}`, + ...buildMountArgs({ ...opts, ...mount }), `--state-file ${shellQuote(stateFiles[index]!)}`, ]; return [`${mountEnvPrefix(opts)}relayfile-mount --once`, ...args].join(" "); @@ -655,6 +637,9 @@ function scopedRemoteRoot( if (!normalized || normalized === "/" || normalized.includes("*")) { return null; } + if (normalized.slice(1).split("/").some((segment) => segment === "." || segment === "..")) { + throw new Error(`relayfile remote root contains a traversal segment: ${path}`); + } if (!options.allowProviderRoot && normalized.slice(1).split("/").length < 2) { return null; } @@ -688,22 +673,219 @@ function unscopedLocalDir(localRoot: string, remoteRoots: readonly string[]): st return normalizedRoot || "/"; } +type ExactMount = Pick & { + paths: readonly string[]; +}; + +/** + * Resolve the final exact-layout mount root(s). + * + * Callers are allowed to pass either the unscoped base (`/workspace`) or an + * already joined single-path directory (`/workspace/github/repos/acme/app`). + * Recovering the base first prevents a double append, then every remote root + * gets its own exact-layout invocation and final on-disk directory. + */ +function exactMounts(localRoot: string, remoteRoots: readonly string[]): ExactMount[] { + const unscopedRoot = unscopedLocalDir(localRoot, remoteRoots); + if (remoteRoots.length === 0) { + return [{ localDir: unscopedRoot, paths: [] }]; + } + return remoteRoots.map((remoteRoot) => { + const localDir = posixPath.join( + unscopedRoot, + remoteRoot.replace(/^\/+/, ""), + ); + const localPrefix = unscopedRoot === "/" ? "/" : `${unscopedRoot}/`; + if (localDir !== unscopedRoot && !localDir.startsWith(localPrefix)) { + throw new Error(`relayfile remote root escapes local mount root: ${remoteRoot}`); + } + return { localDir, paths: [remoteRoot] }; + }); +} + +/** + * Resolve the public on-disk roots owned by the exact-layout mount processes. + * Lifecycle consumers use this same computation for timeout budgets and + * `.relay` observability so command generation and teardown cannot disagree + * about where a mount's public state lives. + */ +export function resolveRelayfileMountExactLayout( + opts: Pick, +): { baseLocalDir: string; mountLocalDirs: string[] } { + const remoteRoots = scopedRemoteRoots(opts.paths ?? [], { allowProviderRoot: true }); + const mounts = exactMounts(opts.localDir, remoteRoots); + return { + baseLocalDir: unscopedLocalDir(opts.localDir, remoteRoots), + mountLocalDirs: mounts.map((mount) => mount.localDir), + }; +} + +function composeMountCommands(commands: readonly string[]): string { + if (commands.length === 1) { + return commands[0]!; + } + return `sh -c ${shellQuote(commands.join(" && "))}`; +} + function buildMountPathArg(path: string): string { return ` --remote-path ${shellQuote(path)}`; } -function insertStartTemplatePathArgs(shell: string, pathArgsPlaceholderArg: string): string { - return shell.replace(" --interval ", `${pathArgsPlaceholderArg} --interval `); +const DYNAMIC_LOCAL_DIR = "__relayfile_dynamic_local_dir__"; +const DYNAMIC_REMOTE_PATH = "__relayfile_dynamic_remote_path__"; + +function dynamicMountArgs( + opts: RelayfileMountShellOptions, + includeRemotePath: boolean, +): string[] { + return buildMountArgs({ + ...opts, + localDir: DYNAMIC_LOCAL_DIR, + paths: includeRemotePath ? [DYNAMIC_REMOTE_PATH] : [], + }).map((arg) => arg + .replace(shellQuote(opts.baseUrl), '"$relayfile_mount_base_url"') + .replace(shellQuote(opts.workspaceId), '"$relayfile_mount_workspace_id"') + .replace(shellQuote(opts.token), '"$relayfile_mount_token"') + .replace(shellQuote(DYNAMIC_LOCAL_DIR), '"$relayfile_mount_local_dir"') + .replace(shellQuote(DYNAMIC_REMOTE_PATH), '"$relayfile_mount_remote_path"')); +} + +function dynamicMountTemplateSetup(opts: RelayfileMountShellOptions): string[] { + return [ + `relayfile_mount_base_url=${shellQuote(opts.baseUrl)};`, + `relayfile_mount_workspace_id=${shellQuote(opts.workspaceId)};`, + `relayfile_mount_local_root=${shellQuote(opts.localDir)};`, + `relayfile_mount_token=${shellQuote(opts.token)};`, + ]; +} + +function dynamicMountPathSetup(): string[] { + return [ + 'relayfile_mount_remote_path="$2";', + "shift 2;", + 'relayfile_mount_local_dir="${relayfile_mount_local_root%/}/${relayfile_mount_remote_path#/}";', + ]; +} + +/** + * Validate late-bound `--remote-path ` pairs before daemon startup and + * recover an unscoped base when the rendered localDir already ends with one + * of those roots. Validation runs in a command-substitution subshell, so its + * `shift` calls do not consume the execution pass's positional arguments. + * Callers also wrap the whole template in a subshell, keeping the initial + * `set --` private from the embedding shell. + */ +function dynamicMountPreflight(pathArgsPlaceholderArg: string): string[] { + return [ + "relayfile_mount_preflight() {", + 'relayfile_mount_preflight_root="$1";', + "shift;", + 'while [ "$#" -gt 0 ]; do', + 'if [ "$#" -lt 2 ] || [ "$1" != "--remote-path" ]; then echo "invalid relayfile mount path args" >&2; exit 2; fi;', + 'relayfile_mount_remote_path="$2";', + 'case "/${relayfile_mount_remote_path#/}/" in */../*|*/./*) echo "relayfile remote root contains a traversal segment" >&2; exit 2 ;; esac;', + 'relayfile_mount_remote_suffix="${relayfile_mount_remote_path#/}";', + 'relayfile_mount_remote_suffix="${relayfile_mount_remote_suffix%/}";', + 'case "$relayfile_mount_preflight_root" in', + '"$relayfile_mount_remote_suffix") relayfile_mount_preflight_root=/ ;;', + '*/"$relayfile_mount_remote_suffix") relayfile_mount_preflight_root="${relayfile_mount_preflight_root%"/$relayfile_mount_remote_suffix"}"; [ -n "$relayfile_mount_preflight_root" ] || relayfile_mount_preflight_root=/ ;;', + '*/"$relayfile_mount_remote_suffix"/*) relayfile_mount_preflight_root="${relayfile_mount_preflight_root%%"/$relayfile_mount_remote_suffix/"*}"; [ -n "$relayfile_mount_preflight_root" ] || relayfile_mount_preflight_root=/ ;;', + "esac;", + "shift 2;", + "done;", + 'printf \'%s\\n\' "$relayfile_mount_preflight_root";', + "};", + `set --${pathArgsPlaceholderArg};`, + 'relayfile_mount_validated_local_root=$(relayfile_mount_preflight "$relayfile_mount_local_root" "$@") || exit $?;', + 'relayfile_mount_local_root="$relayfile_mount_validated_local_root";', + ]; +} + +/** + * The shell-template consumer supplies its remote roots after this package is + * built, so it cannot use the static exactMounts helper. Parse the same + * repeated `--remote-path ` pairs in the rendered shell and apply the + * identical base/root join before launching one exact-layout daemon per root. + */ +function buildDynamicMountStartTemplate( + opts: RelayfileMountDaemonOptions, + pathArgsPlaceholderArg: string, +): string { + const interval = opts.interval ?? "1s"; + const logPath = opts.logPath ?? "/tmp/relayfile-mount.log"; + const pathlessStart = [ + `${mountEnvPrefix(opts)}nohup relayfile-mount`, + ...dynamicMountArgs(opts, false), + `--interval ${shellQuote(interval)}`, + `> ${shellQuote(logPath)} 2>&1 & echo $!`, + ].join(" "); + const dynamicStart = [ + `${mountEnvPrefix(opts)}relayfile-mount`, + ...dynamicMountArgs(opts, true), + `--interval ${shellQuote(interval)}`, + `>> ${shellQuote(logPath)} 2>&1 &`, + 'relayfile_mount_pids="$relayfile_mount_pids $!";', + ].join(" "); + return [ + "(", + ...dynamicMountTemplateSetup(opts), + ...dynamicMountPreflight(pathArgsPlaceholderArg), + 'if [ "$#" -eq 0 ]; then', + 'relayfile_mount_local_dir="$relayfile_mount_local_root";', + `${pathlessStart};`, + "else", + "(", + "relayfile_mount_pids='';", + 'while [ "$#" -gt 0 ]; do', + 'if [ "$#" -lt 2 ] || [ "$1" != "--remote-path" ]; then echo "invalid relayfile mount path args" >&2; exit 2; fi;', + ...dynamicMountPathSetup(), + dynamicStart, + "done;", + "trap 'kill $relayfile_mount_pids 2>/dev/null || true; wait' INT TERM EXIT;", + "wait", + `) >/dev/null 2>&1 & echo $!;`, + "fi;", + ")", + ].join(" "); +} + +function buildDynamicMountOnceTemplate( + opts: RelayfileMountShellOptions, + pathArgsPlaceholderArg: string, +): string { + const pathlessOnce = [ + `${mountEnvPrefix(opts)}relayfile-mount --once`, + ...dynamicMountArgs(opts, false), + ].join(" "); + const dynamicOnce = [ + `${mountEnvPrefix(opts)}relayfile-mount --once`, + ...dynamicMountArgs(opts, true), + ].join(" "); + return [ + "(", + ...dynamicMountTemplateSetup(opts), + ...dynamicMountPreflight(pathArgsPlaceholderArg), + 'if [ "$#" -eq 0 ]; then', + 'relayfile_mount_local_dir="$relayfile_mount_local_root";', + `${pathlessOnce};`, + "else", + 'while [ "$#" -gt 0 ]; do', + 'if [ "$#" -lt 2 ] || [ "$1" != "--remote-path" ]; then echo "invalid relayfile mount path args" >&2; exit 2; fi;', + ...dynamicMountPathSetup(), + `${dynamicOnce} || exit $?;`, + "done;", + "fi;", + ")", + ].join(" "); } -function buildRelayfileMountFallbackStartShell(opts: RelayfileMountDaemonOptions): string { +function buildRelayfileMountMultiStartShell(opts: RelayfileMountDaemonOptions): string { const roots = scopedRemoteRoots(opts.paths ?? [], { allowProviderRoot: true }); - const localDir = unscopedLocalDir(opts.localDir, roots); const interval = opts.interval ?? "1s"; const logPath = opts.logPath ?? "/tmp/relayfile-mount.log"; - const starts = roots.map((root) => [ + const starts = exactMounts(opts.localDir, roots).map((mount) => [ `${mountEnvPrefix(opts)}relayfile-mount`, - ...buildMountArgs({ ...opts, localDir, paths: [root] }), + ...buildMountArgs({ ...opts, ...mount }), `--interval ${shellQuote(interval)}`, `>> ${shellQuote(logPath)} 2>&1 &`, "relayfile_mount_pids=\"$relayfile_mount_pids $!\";", diff --git a/src/orchestrator.lifecycle.test.ts b/src/orchestrator.lifecycle.test.ts new file mode 100644 index 0000000..660b5e0 --- /dev/null +++ b/src/orchestrator.lifecycle.test.ts @@ -0,0 +1,107 @@ +import { strict as assert } from "node:assert"; +import { spawnSync } from "node:child_process"; +import { + chmodSync, + existsSync, + mkdtempSync, + mkdirSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, it, type TestContext } from "node:test"; + +import { + buildRelayfileMountCleanupInvocationShell, + buildRelayfileMountLifecycleShell, +} from "./orchestrator.js"; + +const POSIX_SHELL = existsSync("/bin/dash") ? "/bin/dash" : "/bin/sh"; + +function shellQuote(value: string): string { + return `'${value.replaceAll("'", "'\\''")}'`; +} + +function fakeLifecycleMount(t: TestContext): { binDir: string; localRoot: string } { + const fixtureRoot = mkdtempSync(join(tmpdir(), "sandbox-mount-lifecycle-")); + t.after(() => rmSync(fixtureRoot, { recursive: true, force: true })); + const binDir = join(fixtureRoot, "bin"); + const localRoot = join(fixtureRoot, "workspace"); + mkdirSync(binDir, { recursive: true }); + const fakeMount = join(binDir, "relayfile-mount"); + writeFileSync( + fakeMount, + `#!/bin/sh +if [ "\${1:-}" = --help ]; then + echo ' --flush-outbox-once' + echo ' --push-local-once' + exit 0 +fi +local_dir= +while [ "$#" -gt 0 ]; do + case "$1" in + --local-dir) local_dir="$2"; shift 2 ;; + *) shift ;; + esac +done +if [ -z "$local_dir" ]; then exit 2; fi +mkdir -p "$local_dir" +exit 0 +`, + ); + chmodSync(fakeMount, 0o755); + return { binDir, localRoot }; +} + +describe("relayfile exact-root lifecycle observability", () => { + it("aggregates writeback state and receipt scans from joined mount roots", (t) => { + const { binDir, localRoot } = fakeLifecycleMount(t); + const slackRoot = join(localRoot, "slack/channels/C123"); + const commandRoot = join(slackRoot, "messages"); + const stateDir = join(slackRoot, ".relay"); + const outboxDir = join(stateDir, "outbox"); + const lifecycle = buildRelayfileMountLifecycleShell({ + localDir: localRoot, + mount: { + baseUrl: "https://relayfile.example", + workspaceId: "wsp_abc", + stateDir: join(localRoot, ".state"), + token: "relay_pa_test", + paths: ["/github/repos/acme/cloud/**", "/slack/channels/C123/**"], + websocket: false, + }, + commandRootLocalDirs: [commandRoot], + cleanupStatusMessage: "relayfile.mount.cleanup", + }); + const cleanup = buildRelayfileMountCleanupInvocationShell({ pid: "test" }); + assert.match( + lifecycle, + /timeout 150s sh -c/, + "the outer cleanup budget must scale across two sequential exact roots", + ); + const script = [ + lifecycle, + 'touch -t 200001010000 "$RELAYFILE_MOUNT_FLUSH_MARKER"', + `mkdir -p ${shellQuote(join(outboxDir, "pending"))} ${shellQuote(join(outboxDir, "acked"))} ${shellQuote(commandRoot)}`, + `printf '%s' '{"pendingWriteback":3,"states":{"hasPendingWriteback":true,"outboxNeedsAttention":true}}' > ${shellQuote(join(stateDir, "state.json"))}`, + `printf '%s' '{"schemaVersion":2,"dispatchReceipts":true}' > ${shellQuote(join(outboxDir, "capabilities.json"))}`, + `printf '%s' '{"remotePath":"/slack/channels/C123/messages/draft.json","needsAttention":true}' > ${shellQuote(join(outboxDir, "pending", "command.json"))}`, + `printf '%s' '{"text":"hello"}' > ${shellQuote(join(commandRoot, "draft.json"))}`, + cleanup, + 'exit "$MOUNT_EXIT"', + ].join("\n"); + + const result = spawnSync(POSIX_SHELL, ["-c", script], { + env: { ...process.env, PATH: `${binDir}:${process.env.PATH ?? ""}` }, + encoding: "utf8", + }); + + assert.equal(result.status, 0, result.stderr); + assert.match(result.stderr, /"pendingWriteback":3/); + assert.match(result.stderr, /"hasPendingWriteback":true/); + assert.match(result.stderr, /"outboxNeedsAttention":true/); + assert.match(result.stderr, /"commandDraftWrittenThisRun":true/); + assert.match(result.stderr, /"commandDraftsUndeliverable":1/); + }); +}); diff --git a/src/orchestrator.ts b/src/orchestrator.ts index a871cab..a3089a1 100644 --- a/src/orchestrator.ts +++ b/src/orchestrator.ts @@ -8,6 +8,7 @@ import { buildRelayfileMountInitialSyncStatusShell, buildRelayfileMountStartShell, parseRelayfileMountInitialSyncStatus, + resolveRelayfileMountExactLayout, type RelayfileMountDaemonOptions, type RelayfileMountShellOptions, } from "./mount-script.js"; @@ -374,7 +375,9 @@ export function buildRelayfileMountLifecycleShell( // (RELAYFILE_OUTBOX_TIMEOUT, default 60s) or cleanup can SIGKILL a slow but // healthy writeback before the independent outbox drain finishes. const sync = buildRelayfileMountCleanupFlushShell(config); - const flushTimeoutSeconds = options.flushTimeoutSeconds ?? 75; + const exactLayout = resolveRelayfileMountExactLayout(config); + const flushTimeoutSeconds = (options.flushTimeoutSeconds ?? 75) + * Math.max(1, exactLayout.mountLocalDirs.length); const initialSyncIdleTimeoutSeconds = relayfileBootstrapIdleTimeoutSeconds( options.initialSyncIdleTimeoutSeconds ?? 90, ); @@ -397,10 +400,13 @@ export function buildRelayfileMountLifecycleShell( // full reconcile" sticky loop. Value is a Go duration string ("s"); // RELAYFILE_BOOTSTRAP_TIMEOUT is left UNSET (0 = unbounded while making // progress) - a hard total cap could kill a legitimately long resumable - // pull. The `$(${start})` subshell and the initial-sync block below both + // pull. The `$( ${start})` command substitution and initial-sync block both // inherit this export. ...relayfileBootstrapIdleTimeoutEnvShell(initialSyncIdleTimeoutSeconds), - `if ! RELAYFILE_MOUNT_PID=$(${start}); then`, + // Keep whitespace after `$(`: multi-root start begins with `(`, and dash + // otherwise tokenizes `$((` as arithmetic expansion instead of a command + // substitution containing a subshell. + `if ! RELAYFILE_MOUNT_PID=$( ${start}); then`, " echo '[relayfile-mount] failed to start daemon' >&2", " exit 1", "fi", @@ -514,13 +520,13 @@ function cleanupStatusShell(message: string | undefined): string { * teardown via `node` (NOT sed/grep: it needs same-record multi-field * correlation — remotePath ∧ dispatchStatus ∧ opId ∧ needsAttention from ONE * durable-outbox record — which cross-record grep cannot do safely). Reads - * ONLY `/.relay/outbox/{acked,pending}` (O(outbox), no mirror walk) - * + the command roots, so it stays off the full-tree reconcile path that can - * time out on large mirrors. Prints a single integer (undeliverable count) to stdout on - * success; prints NOTHING and exits non-zero on ANY error / precondition - * violation, so the caller leaves the signal empty → the TS gate reads it as - * null and falls back to the outbox-pending signals (feature-detect; can never - * false-fire from this path). + * ONLY each exact mount root's `.relay/outbox/{acked,pending}` (O(outbox), no + * mirror walk) + the command roots, so it stays off the full-tree reconcile + * path that can time out on large mirrors. Prints a single integer + * (undeliverable count) to stdout on success; prints NOTHING and exits non-zero + * on ANY error / precondition violation, so the caller leaves the signal empty + * → the TS gate reads it as null and falls back to the outbox-pending signals + * (feature-detect; can never false-fire from this path). * * Undeliverable = a THIS-run command draft (newer than the flush marker) whose * derived remotePath has NO acked-succeeded receipt AND is either (a) in @@ -532,13 +538,9 @@ function cleanupStatusShell(message: string | undefined): string { * BENIGN in-flight — once an opId is committed, the server owns delivery and * sandbox teardown cannot orphan it, so it does NOT count. * - * remotePath derivation assumes this module's invariant: `--local-dir` is the - * UNSCOPED workspace root, so a draft sits at its full provider-rooted path - * under localDir and `remotePath == "/" + rel(localDir, draftPath)` (the bare - * strip equals relayfile's `normalizeRemotePath(remoteRoot + "/" + rel(...))` - * because remoteRoot is "/" relative to the unscoped root). If a draft is NOT - * under localDir (someone scoped the mount later), the invariant is broken and - * the program bails to null rather than emit wrong paths that would false-fire. + * remotePath derivation uses the recovered unscoped base while outbox reads use + * the joined exact roots. This preserves full remote paths in receipt matching + * without probing the obsolete base `.relay` directory. */ const WRITEBACK_RECEIPT_SCAN_PROGRAM = `"use strict"; const fs = require("fs"); @@ -579,10 +581,17 @@ function readRecords(dir) { } try { const argv = process.argv.slice(2); - const localDir = (argv[0] || "").replace(/\\/+$/g, ""); + const baseLocalDir = (argv[0] || "").replace(/\\/+$/g, "") || "/"; const marker = argv[1] || ""; - const roots = argv.slice(2); - if (!localDir || !marker || roots.length === 0) process.exit(1); + let mountLocalDirs; + try { mountLocalDirs = JSON.parse(argv[2] || "[]"); } catch (e) { process.exit(1); } + const roots = argv.slice(3); + if (!Array.isArray(mountLocalDirs) || mountLocalDirs.length === 0 || !marker || roots.length === 0) process.exit(1); + mountLocalDirs = mountLocalDirs.map((p) => String(p).replace(/\\/+$/g, "") || "/"); + const belongsToMount = (p) => mountLocalDirs.some((mountDir) => + mountDir === "/" ? p.charAt(0) === "/" : p === mountDir || p.indexOf(mountDir + "/") === 0 + ); + if (roots.some((root) => !belongsToMount(root))) process.exit(1); const markerMtime = statMtime(marker); if (markerMtime === null) process.exit(1); const drafts = []; @@ -596,13 +605,12 @@ try { drafts.push(f); } } - const prefix = localDir + "/"; + const prefix = baseLocalDir === "/" ? "/" : baseLocalDir + "/"; const draftRemotePaths = []; for (const f of drafts) { if (f.indexOf(prefix) !== 0) process.exit(1); - draftRemotePaths.push(normalizeRemotePath(f.slice(localDir.length))); + draftRemotePaths.push(normalizeRemotePath(f.slice(baseLocalDir.length))); } - const outbox = path.join(localDir, ".relay", "outbox"); // RECEIPT CAPABILITY DETECT (load-bearing — without it this gate false-fires // on every older daemon). The positive gate is valid ONLY on a mount whose // outbox emits adapter-dispatch receipts. Daemons that support receipts @@ -620,21 +628,26 @@ try { // outbox-dir setup (which --flush-outbox-once calls even with empty pending). // Require BOTH dispatchReceipts===true AND schemaVersion>=2 (the version guard // is forward-safe). Absent / parse-fail / not-enabled → treated as absent. - let dispatchReceiptsActive = false; - try { - const cap = JSON.parse(fs.readFileSync(path.join(outbox, "capabilities.json"), "utf8")); - dispatchReceiptsActive = !!( - cap && - cap.dispatchReceipts === true && - typeof cap.schemaVersion === "number" && - cap.schemaVersion >= 2 - ); - } catch (e) { - dispatchReceiptsActive = false; + const acked = []; + const pending = []; + for (const mountLocalDir of mountLocalDirs) { + const outbox = path.join(mountLocalDir, ".relay", "outbox"); + let dispatchReceiptsActive = false; + try { + const cap = JSON.parse(fs.readFileSync(path.join(outbox, "capabilities.json"), "utf8")); + dispatchReceiptsActive = !!( + cap && + cap.dispatchReceipts === true && + typeof cap.schemaVersion === "number" && + cap.schemaVersion >= 2 + ); + } catch (e) { + dispatchReceiptsActive = false; + } + if (!dispatchReceiptsActive) process.exit(1); + acked.push(...readRecords(path.join(outbox, "acked"))); + pending.push(...readRecords(path.join(outbox, "pending"))); } - if (!dispatchReceiptsActive) process.exit(1); - const acked = readRecords(path.join(outbox, "acked")); - const pending = readRecords(path.join(outbox, "pending")); const ackedByRemote = new Map(); for (const r of acked) { if (!r || !r.remotePath) continue; @@ -666,11 +679,12 @@ try { * Compute the writeback-delivery signals into shell vars the cleanup-status * printf emits: * - * - `relayfile_mount_pending_writeback`: the canonical undelivered count from - * `/.relay/state.json` (the mount/outbox public status file — the - * public state lives under localDir, NOT `--state-dir`). Parsed with `sed` - * (no `jq` dependency); absent/unparsable → 0. A stamped `revision` is NOT - * read here — it is not proof of delivery. + * - `relayfile_mount_pending_writeback`: the sum of canonical undelivered + * counts from every exact mount root's `.relay/state.json` (the mount/outbox + * public status files live under each final `--local-dir`, NOT + * `--state-dir`). Parsed with `sed` (no `jq` dependency); + * absent/unparsable → 0. A stamped `revision` is NOT read here — it is not + * proof of delivery. * - `relayfile_mount_has_pending_writeback` / `relayfile_mount_outbox_needs_attention`: * the unified pending + needs-attention flags from `states` in the same * `.relay/state.json`. `states.hasPendingWriteback` is set by the daemon for @@ -700,15 +714,22 @@ try { function writebackUndeliveredSignalShell( options: RelayfileMountLifecycleShellOptions, ): string { - const stateJson = `${options.localDir.replace(/\/+$/u, "")}/.relay/state.json`; - const lines: string[] = [ - ` if [ -f ${shellQuote(stateJson)} ]; then`, - ` relayfile_mount_pending_writeback=$(sed -n 's/.*"pendingWriteback":[[:space:]]*\\([0-9][0-9]*\\).*/\\1/p' ${shellQuote(stateJson)} 2>/dev/null | head -n 1)`, - ' if [ -z "$relayfile_mount_pending_writeback" ]; then relayfile_mount_pending_writeback=0; fi', - ` if grep -Eq '"hasPendingWriteback":[[:space:]]*true' ${shellQuote(stateJson)} 2>/dev/null; then relayfile_mount_has_pending_writeback=true; fi`, - ` if grep -Eq '"outboxNeedsAttention":[[:space:]]*true' ${shellQuote(stateJson)} 2>/dev/null; then relayfile_mount_outbox_needs_attention=true; fi`, - " fi", - ]; + const exactLayout = resolveRelayfileMountExactLayout({ + localDir: options.localDir, + paths: options.mount?.paths, + }); + const lines: string[] = []; + for (const mountLocalDir of exactLayout.mountLocalDirs) { + const stateJson = `${mountLocalDir.replace(/\/+$/u, "")}/.relay/state.json`; + lines.push( + ` if [ -f ${shellQuote(stateJson)} ]; then`, + ` relayfile_mount_root_pending_writeback=$(sed -n 's/.*"pendingWriteback":[[:space:]]*\\([0-9][0-9]*\\).*/\\1/p' ${shellQuote(stateJson)} 2>/dev/null | head -n 1)`, + ' if [ -n "$relayfile_mount_root_pending_writeback" ]; then relayfile_mount_pending_writeback=$((relayfile_mount_pending_writeback + relayfile_mount_root_pending_writeback)); fi', + ` if grep -Eq '"hasPendingWriteback":[[:space:]]*true' ${shellQuote(stateJson)} 2>/dev/null; then relayfile_mount_has_pending_writeback=true; fi`, + ` if grep -Eq '"outboxNeedsAttention":[[:space:]]*true' ${shellQuote(stateJson)} 2>/dev/null; then relayfile_mount_outbox_needs_attention=true; fi`, + " fi", + ); + } const commandRoots = (options.commandRootLocalDirs ?? []).filter( (dir) => dir.trim().length > 0, ); @@ -730,6 +751,13 @@ function writebackUndeliveredSignalShell( // protecting: node-absent / mktemp-fail / any program error → the var stays // empty → the TS gate reads null → falls back to the outbox-pending signals. // The `|| true` + 2>/dev/null guarantee it never perturbs the flush exit code. + const receiptMountDirs = exactLayout.mountLocalDirs.filter((mountLocalDir) => { + const root = mountLocalDir.replace(/\/+$/u, "") || "/"; + return commandRoots.some((commandRoot) => { + const candidate = commandRoot.replace(/\/+$/u, "") || "/"; + return root === "/" || candidate === root || candidate.startsWith(`${root}/`); + }); + }); lines.push( ' if [ -n "${RELAYFILE_MOUNT_FLUSH_MARKER:-}" ] && command -v node >/dev/null 2>&1; then', " relayfile_mount_receipt_scan=$(mktemp /tmp/relayfile-receipt-scan.XXXXXX 2>/dev/null || true)", @@ -737,7 +765,7 @@ function writebackUndeliveredSignalShell( ` cat > "$relayfile_mount_receipt_scan" <<'RELAYFILE_RECEIPT_SCAN_EOF'`, WRITEBACK_RECEIPT_SCAN_PROGRAM, "RELAYFILE_RECEIPT_SCAN_EOF", - ` relayfile_mount_command_drafts_undeliverable=$(node "$relayfile_mount_receipt_scan" ${shellQuote(options.localDir)} "$RELAYFILE_MOUNT_FLUSH_MARKER" ${quoted} 2>/dev/null || true)`, + ` relayfile_mount_command_drafts_undeliverable=$(node "$relayfile_mount_receipt_scan" ${shellQuote(exactLayout.baseLocalDir)} "$RELAYFILE_MOUNT_FLUSH_MARKER" ${shellQuote(JSON.stringify(receiptMountDirs))} ${quoted} 2>/dev/null || true)`, ' rm -f "$relayfile_mount_receipt_scan" 2>/dev/null || true', " fi", " fi", @@ -774,7 +802,7 @@ function buildInitialSyncBlock(initialSync: string, continueOnFailure: boolean): } return [ `if ! ${initialSync} >> /tmp/relayfile-mount.log 2>&1; then`, - " echo '[relayfile-mount] scoped initial sync failed; continuing without preloaded reads' >&2", + " echo '[relayfile-mount] path-filtered initial sync failed; continuing without preloaded reads' >&2", "fi", ].join("\n"); }