Skip to content

Add @relayfile/local-mount package#47

Merged
willwashburn merged 4 commits intomainfrom
local-mount-package
Apr 18, 2026
Merged

Add @relayfile/local-mount package#47
willwashburn merged 4 commits intomainfrom
local-mount-package

Conversation

@willwashburn
Copy link
Copy Markdown
Member

@willwashburn willwashburn commented Apr 18, 2026

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.


Open in Devin Review

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.
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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-mount with createSymlinkMount, readAgentDotfiles, and launchOnMount, plus Vitest unit tests.
  • Updates root workspace configuration and scripts to include the new package.
  • Minor repo housekeeping: ignore .npm-cache/ and make packages/cli/scripts/run.js executable (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.

Comment on lines +67 to +69
removeMountDir(resolvedMountDir);
mkdirSync(resolvedMountDir, { recursive: true });
const realMountDir = realpathSync(resolvedMountDir);
Copy link

Copilot AI Apr 18, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)

Comment on lines +50 to +56
if (options.agentName) {
ignoredPatterns.push(
...loadPatternsFromFile(path.join(resolvedProjectDir, `.${options.agentName}.agentignore`))
);
readonlyPatterns.push(
...loadPatternsFromFile(path.join(resolvedProjectDir, `.${options.agentName}.agentreadonly`))
);
Copy link

Copilot AI Apr 18, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)

Comment on lines +284 to +292
function hasSameContent(left: string, right: string): boolean {
try {
const leftContent = readFileSync(left);
const rightContent = readFileSync(right);
return leftContent.equals(rightContent);
} catch {
return false;
}
}
Copy link

Copilot AI Apr 18, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)

Comment on lines +445 to +446
Changes to read-only files will be automatically reverted.
Do not attempt to chmod files - permissions will be restored.
Copy link

Copilot AI Apr 18, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Suggested change
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.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)

Comment on lines +38 to +42
export function createSymlinkMount(
projectDir: string,
mountDir: string,
options: SymlinkMountOptions
): SymlinkMountHandle {
Copy link

Copilot AI Apr 18, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +34 to +39
const result = await launchOnMount({
cli: '/bin/sh',
projectDir,
mountDir,
args: ['-c', 'echo hi > file.txt'],
onAfterSync: (n) => {
Copy link

Copilot AI Apr 18, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

devin-ai-integration[bot]

This comment was marked as resolved.

Comment thread packages/local-mount/package.json Outdated
},
"publishConfig": {
"access": "public",
"provenance": true
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remove this as it actually breaks using provenance to publish

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed. Dropped "provenance": true from publishConfigpublishConfig.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.
devin-ai-integration[bot]

This comment was marked as resolved.

@willwashburn willwashburn merged commit 3d9dd62 into main Apr 18, 2026
10 of 11 checks passed
@willwashburn willwashburn deleted the local-mount-package branch April 18, 2026 20:15
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants