Conversation
Introduce a new workspace package @relayfile/local-mount that implements a symlink/copy mount for project directories with .agentignore/.agentreadonly semantics. Exports createSymlinkMount, readAgentDotfiles and launchOnMount and includes unit tests for dotfile parsing, mount behavior, sync-back, and launching a CLI inside the mount. Also adds package.json and tsconfig for the package, updates root workspaces and build/typecheck scripts to include the new package, adds .npm-cache to .gitignore, and makes packages/cli/scripts/run.js executable.
Include the new packages/local-mount package in the GitHub Actions publish pipeline. Changes add local-mount to the input defaults, packagePaths for version bumps, the build step and npm CI, artifact retention and publish matrix entries, path output mapping, staged git add for release commits, and update the release docs/install/publish commands to list @relayfile/local-mount. Ensures local-mount is built, versioned and published alongside other packages.
There was a problem hiding this comment.
Pull request overview
Adds a new workspace package, @relayfile/local-mount, to create a local “mounted” mirror of a project directory with .agentignore / .agentreadonly semantics, optionally run a CLI inside the mount, and sync writable changes back.
Changes:
- Introduces
@relayfile/local-mountwithcreateSymlinkMount,readAgentDotfiles, andlaunchOnMount, plus Vitest unit tests. - Updates root workspace configuration and scripts to include the new package.
- Minor repo housekeeping: ignore
.npm-cache/and makepackages/cli/scripts/run.jsexecutable (shebang).
Reviewed changes
Copilot reviewed 10 out of 13 changed files in this pull request and generated 6 comments.
Show a summary per file
| File | Description |
|---|---|
| packages/local-mount/tsconfig.json | TS build config for the new package. |
| packages/local-mount/src/symlink-mount.ts | Implements the mount + sync-back logic and README generation. |
| packages/local-mount/src/symlink-mount.test.ts | Unit tests for mount behavior, exclusions, and sync-back. |
| packages/local-mount/src/launch.ts | Spawns a CLI inside the mount, forwards signals, syncs back, cleans up. |
| packages/local-mount/src/launch.test.ts | Unit tests for launchOnMount. |
| packages/local-mount/src/index.ts | Public exports for the new package. |
| packages/local-mount/src/dotfiles.ts | Reads .agentignore / .agentreadonly (and agent-specific variants). |
| packages/local-mount/src/dotfiles.test.ts | Unit tests for dotfile parsing/merging. |
| packages/local-mount/package.json | Package metadata, dependencies, and scripts. |
| packages/cli/scripts/run.js | Adds a shebang (and intended executability) for CLI launcher script. |
| package.json | Adds packages/local-mount workspace and includes it in build/typecheck scripts. |
| package-lock.json | Updates workspace entries and dependency resolution for the new package. |
| .gitignore | Ignores .npm-cache/. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| removeMountDir(resolvedMountDir); | ||
| mkdirSync(resolvedMountDir, { recursive: true }); | ||
| const realMountDir = realpathSync(resolvedMountDir); |
There was a problem hiding this comment.
removeMountDir(resolvedMountDir) unconditionally rmSync(..., {recursive:true, force:true}) on whatever path the caller passes. With a bad mountDir (e.g. / or another important directory), this can delete arbitrary data. Add a safety guard (e.g. require mountDir to be within a dedicated temp base dir, refuse to delete root/drive roots, and/or only delete if a mount marker file created by this library is present; otherwise throw).
There was a problem hiding this comment.
Fixed in working tree. createSymlinkMount now calls assertMountDirSafeToRemove before removeMountDir: it refuses filesystem roots, anything that overlaps projectDir (in either direction), non-directories, and any existing directory that does not contain the marker file .relayfile-local-mount. The marker is written by this library when a mount is created (and skipped during sync-back), so only directories that we created previously can be reused as mountDir. (packages/local-mount/src/symlink-mount.ts)
| if (options.agentName) { | ||
| ignoredPatterns.push( | ||
| ...loadPatternsFromFile(path.join(resolvedProjectDir, `.${options.agentName}.agentignore`)) | ||
| ); | ||
| readonlyPatterns.push( | ||
| ...loadPatternsFromFile(path.join(resolvedProjectDir, `.${options.agentName}.agentreadonly`)) | ||
| ); |
There was a problem hiding this comment.
readAgentDotfiles builds per-agent dotfile paths using options.agentName directly. If agentName contains path separators (e.g. ../), this can escape projectDir and read arbitrary files. Validate/sanitize agentName (e.g. allow only [A-Za-z0-9_-], reject / and \\, and/or enforce path.basename(agentName) === agentName) before using it in path.join.
There was a problem hiding this comment.
Fixed. readAgentDotfiles now passes agentName through sanitizeAgentName, which requires it to match /^[A-Za-z0-9_-]+$/ and path.basename(name) === name — otherwise we throw before any path join. (packages/local-mount/src/dotfiles.ts)
| function hasSameContent(left: string, right: string): boolean { | ||
| try { | ||
| const leftContent = readFileSync(left); | ||
| const rightContent = readFileSync(right); | ||
| return leftContent.equals(rightContent); | ||
| } catch { | ||
| return false; | ||
| } | ||
| } |
There was a problem hiding this comment.
hasSameContent() reads both files fully into memory to decide whether to sync. For large workspaces/files this can be a significant CPU/memory cost during syncBack(). Consider a cheaper short-circuit first (compare stat.size, maybe mtimeMs) and/or use a streaming chunked comparison so you don't load whole files into RAM.
There was a problem hiding this comment.
Partially fixed. hasSameContent now short-circuits on stat.size mismatch before reading either file, which handles the common "file was appended to / truncated" case cheaply. I left the final byte comparison as-is rather than streaming: agent mounts are dominated by source code where readFileSync is fine, and a streaming comparison is a noticeable complexity bump for a sync-back path that already iterates the tree. Happy to revisit if we see real workloads bitten by this. (packages/local-mount/src/symlink-mount.ts)
| Changes to read-only files will be automatically reverted. | ||
| Do not attempt to chmod files - permissions will be restored. |
There was a problem hiding this comment.
buildMountReadme() claims read-only changes will be "automatically reverted" and permissions "restored", but the implementation only sets mode at mount creation and then skips syncing read-only paths back; it doesn't revert edits inside the mount or re-apply perms. Update the README text to match actual behavior (e.g. changes to read-only files are discarded/not synced and may be overwritten when the mount is recreated).
| Changes to read-only files will be automatically reverted. | |
| Do not attempt to chmod files - permissions will be restored. | |
| Changes to read-only files are not synced back to the source project. | |
| Edits or permission changes to read-only files inside this mount may be discarded or overwritten when the mount is recreated. |
There was a problem hiding this comment.
Fixed. buildMountReadme now says changes to read-only files are not synced back and may be discarded/overwritten when the mount is recreated, matching the actual sync-back behavior. Took the suggested wording verbatim. (packages/local-mount/src/symlink-mount.ts)
| export function createSymlinkMount( | ||
| projectDir: string, | ||
| mountDir: string, | ||
| options: SymlinkMountOptions | ||
| ): SymlinkMountHandle { |
There was a problem hiding this comment.
createSymlinkMount doesn't create any symlinks (it copies files with copyFileSync and creates directories). The name is likely to mislead API consumers about semantics and performance. Consider renaming to something like createCopyMount/createMirrorMount, or (if symlinks are intended) implement actual symlink creation for non-readonly/writable files.
There was a problem hiding this comment.
Deferring. The name createSymlinkMount is preserved verbatim from the agent-relay source we're extracting from — the point of this PR is to move the primitive into a shared package without changing its public API, so that agent-relay and the workforce CLI can both drop in on it. Renaming here would force a coordinated rename across both consumers, which is out of scope for the extraction. Agreed the name is aspirational; if/when we actually implement symlinks (writable files as symlinks, readonly as copies) the name will start matching, and until then we can revisit in a follow-up if it causes real confusion.
| const result = await launchOnMount({ | ||
| cli: '/bin/sh', | ||
| projectDir, | ||
| mountDir, | ||
| args: ['-c', 'echo hi > file.txt'], | ||
| onAfterSync: (n) => { |
There was a problem hiding this comment.
These tests hard-code cli: '/bin/sh', which will fail on Windows (and some restricted environments). If this package is expected to be cross-platform, use process.execPath with node -e (or conditionally skip/adjust on win32).
There was a problem hiding this comment.
Deferring. The package targets POSIX shells today — both siblings that will depend on it (agent-relay, workforce CLI) are POSIX-only and launchOnMount itself spawns /bin/sh-style pipelines in its typical callers. Making the tests cross-platform would be half a solution without also making the production code cross-platform, which is a larger piece of work and isn't on the roadmap for this extraction. Filed mentally as a follow-up for when we want Windows support.
| }, | ||
| "publishConfig": { | ||
| "access": "public", | ||
| "provenance": true |
There was a problem hiding this comment.
Remove this as it actually breaks using provenance to publish
There was a problem hiding this comment.
Fixed. Dropped "provenance": true from publishConfig — publishConfig.access: public stays. (packages/local-mount/package.json)
Validate and sanitize agent names, tighten mount safety checks, and adjust sync behavior. Changes include: - Remove `provenance` from publishConfig in package.json. - Add sanitizeAgentName to validate agentName (only [A-Za-z0-9_-]) and use it when loading agent ignore/readonly files to prevent path traversal. - Ensure opts.onAfterSync is awaited inside the sync-finalize block so it runs before cleanup. - Add mount marker file and assertMountDirSafeToRemove to refuse filesystem roots, overlapping project dirs, non-directories, or existing dirs that lack the marker (prevents accidental data loss). - Write a mount marker when creating the mount and exclude the marker from sync/lookups. - Optimize file comparison by checking file sizes before reading full contents. - Update mount README text to clarify that read-only files are not synced back and edits may be discarded. These changes reduce risk of accidental data removal, prevent unsafe agent name usage, and make sync/cleanup ordering and behavior more robust.
Introduce a new workspace package @relayfile/local-mount that implements a symlink/copy mount for project directories with .agentignore/.agentreadonly semantics. Exports createSymlinkMount, readAgentDotfiles and launchOnMount and includes unit tests for dotfile parsing, mount behavior, sync-back, and launching a CLI inside the mount. Also adds package.json and tsconfig for the package, updates root workspaces and build/typecheck scripts to include the new package, adds .npm-cache to .gitignore, and makes packages/cli/scripts/run.js executable.