Skip to content

fix(paths): findMarkdownFilesRecursive follows symlinks#1503

Open
blankse wants to merge 1 commit into
coleam00:devfrom
blankse:fix/1501-symlinked-commands
Open

fix(paths): findMarkdownFilesRecursive follows symlinks#1503
blankse wants to merge 1 commit into
coleam00:devfrom
blankse:fix/1501-symlinked-commands

Conversation

@blankse
Copy link
Copy Markdown
Contributor

@blankse blankse commented Apr 30, 2026

Summary

  • Problem: Commands placed into ~/.archon/commands/ (or <repo>/.archon/commands/) as symlinks are silently ignored. A workflow node command: foo fails with Command prompt not found: foo.md even though the symlink target is a perfectly readable .md file.
  • Why it matters: Blocks any team-shared commands setup where a checkout of a shared repo is exposed to Archon via symlinks (the natural pattern). Discovered while building team-wide command sharing for the data-factory team — workflow YAMLs work via symlink, but commands silently don't.
  • What changed: findMarkdownFilesRecursive in @archon/paths now follows symlinks via stat() per Dirent (matching what loadWorkflowsFromDir in workflow-discovery.ts already does), so symlinked .md files are discovered like regular files. Broken symlinks are skipped silently.
  • What did not change: Behavior for regular files and real directories is unchanged. No call-site changes — every consumer of the function automatically benefits.

UX Journey

Before

Developer                Workflow Engine            findMarkdownFilesRecursive
─────────                ───────────────            ──────────────────────────
ln -s …/foo.md ~/.archon/commands/foo.md
runs `archon /workflow run` ────▶  resolves `command: foo`
                                   walks ~/.archon/commands/
                                                              readdir + entry.isFile() == false (symlink)
                                                              → entry skipped
                                   not found in any scope
                                   ↳ "Command prompt not found: foo.md" [X]

After

Developer                Workflow Engine            findMarkdownFilesRecursive
─────────                ───────────────            ──────────────────────────
ln -s …/foo.md ~/.archon/commands/foo.md
runs `archon /workflow run` ────▶  resolves `command: foo`
                                   walks ~/.archon/commands/
                                                              readdir → entry is symlink
                                                              [stat() follows link] → isFile=true
                                                              → entry included
                                   found, prompt loaded [+]

Architecture Diagram

Before

@archon/workflows ──▶ executor-shared ──▶ findMarkdownFilesRecursive
                                          (rejects symlinked .md files)

After

@archon/workflows ──▶ executor-shared ──▶ findMarkdownFilesRecursive [~]
                                          (stat() per entry, accepts symlinked
                                           .md files, skips broken symlinks)

Connection inventory:

From To Status Notes
findMarkdownFilesRecursive fs/promises.stat new per-entry stat call when Dirent is a symlink
every existing caller of findMarkdownFilesRecursive (unchanged) unchanged call signatures + return values unchanged

Label Snapshot

  • Risk: risk: low
  • Size: size: XS
  • Scope: paths
  • Module: paths:archon-paths

Change Metadata

  • Change type: bug
  • Primary scope: paths

Linked Issue

Validation Evidence

bun --filter @archon/paths test
# 174 pass, 0 fail (4 new tests for the symlink branch)

bun run type-check
# All packages: Exited with code 0

NODE_OPTIONS=--max-old-space-size=8192 bun x eslint packages/paths/src/archon-paths.ts
# Clean, no errors

The four new tests in archon-paths.test.ts exercise:

  1. Symlinked .md file in the search root (the canonical case from the bug).
  2. Mixed regular + symlinked entries in the same directory.
  3. Symlinked subdirectory containing .md files.
  4. Broken symlink — skipped silently rather than crashing the walk.

All describe.skipIf(isWindows) for portability of the symlink syscalls.

Security Impact

  • New permissions/capabilities? No
  • New external network calls? No
  • Secrets/tokens handling changed? No
  • File system access scope changed? No — still walks the same directories the function already walked, just now follows symlinks within them. Symlinks were always traversable to consumers via readFile(); the discovery layer was simply missing them.

Compatibility / Migration

  • Backward compatible? Yes — purely additive (more files discovered, never fewer).
  • Config/env changes? No
  • Database migration needed? No

Human Verification

Side Effects / Blast Radius

  • Affected subsystems/workflows: any Archon path code that calls findMarkdownFilesRecursive — that's the command resolver in @archon/workflows/executor-shared.ts and a couple of bundled-defaults helpers. None of them care whether the source is a regular file or a symlink (they just readFile() the result).
  • Potential unintended effects: a directory containing symlinks to non-.md files won't include them (filter is filename-based, unchanged). A symlinked directory whose name happens to end in .md would now be entered as a directory rather than ignored — extremely unusual but worth noting.
  • Guardrails: existing tests for the function's regular-file and directory-walk paths still pass.

Rollback Plan

  • Fast rollback: git revert <this commit> — single-package, two-file change.
  • Feature flags or config toggles: none introduced.
  • Observable failure symptoms (if rolled back): symlinked commands silently ignored again, same as today.

Risks and Mitigations

  • Risk: extra stat() per Dirent adds I/O overhead in directories with many entries.
    • Mitigation: only paid when the Dirent IS a symlink (regular files / dirs short-circuit on the original Dirent.is*() calls). For typical .archon/commands/ directories this is at most a handful of extra syscalls per workflow run.

Summary by CodeRabbit

  • Bug Fixes
    • Markdown discovery now follows symlinked files and directories and gracefully skips broken symlinks, improving discovery accuracy and avoiding errors during scans.
  • Tests
    • Added regression tests covering symlinked markdown files, symlinked subdirectories, mixed regular/symlinked directory contents, and broken-symlink scenarios (skipped on incompatible platforms).

Review Change Stack

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 30, 2026

📝 Walkthrough

Walkthrough

findMarkdownFilesRecursive was updated to resolve symlink targets via stat(), treating symlinked files and directories according to their targets and skipping broken symlinks. New Windows-skipped tests exercise file symlinks, mixed regular+symlinked files, symlinked directories, and broken symlinks.

Changes

Symlink Resolution Implementation

Layer / File(s) Summary
Follow symlinks in findMarkdownFilesRecursive
packages/paths/src/archon-paths.ts
Added stat to fs/promises imports; when an entry is a symlink call stat() to derive isFile/isDirectory, skip entries that error (broken symlinks), and use resolved flags for recursion and .md collection.

Symlink Behavior Test Suite

Layer / File(s) Summary
Regression tests for symlink discovery
packages/paths/src/archon-paths.test.ts
Added describe.skipIf(isWindows) tests (imports updated) that create temp directories and real .md files, then verify: direct file symlink discovery, coexisting regular+symlinked .md files, recursive discovery into a symlinked directory, and ignoring broken symlinks.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Poem

🐇 I hop through paths both new and old,
A symlink bridge of silver and gold.
With stat I peek where shadows lie,
No broken trails to make me sigh.
Commands found safe — a happy hop!

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately summarizes the main change: making findMarkdownFilesRecursive follow symlinks to discover symlinked .md files.
Description check ✅ Passed The description comprehensively covers all required template sections: summary, UX journey before/after, architecture diagrams, change metadata, linked issues, validation evidence, security impact, compatibility, human verification, side effects, rollback plan, and risks.
Linked Issues check ✅ Passed The PR fully addresses issue #1501 by implementing symlink following in findMarkdownFilesRecursive with stat() per entry, adding comprehensive tests for symlinked files/directories/broken symlinks, and validating the function discovers symlinked .md command files as required.
Out of Scope Changes check ✅ Passed All changes are tightly scoped to fixing issue #1501: symlink support in findMarkdownFilesRecursive (archon-paths.ts) and corresponding test coverage (archon-paths.test.ts). No unrelated changes or modifications to call sites.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
packages/paths/src/archon-paths.ts (1)

231-263: ⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

Guard against symlink cycles before recursing.

Following directory symlinks here can recurse forever if a link points back to an ancestor or otherwise forms a loop, which would hang discovery at startup. Please track visited directory targets before descending.

Suggested direction
+// Pass a visited-set through recursion and key it by target inode/dev (or realpath)
+// so symlink cycles are skipped instead of recursed into repeatedly.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/paths/src/archon-paths.ts` around lines 231 - 263, The recursion can
loop on directory symlink cycles; modify findMarkdownFilesRecursive to accept or
use a Set<string> visitedRealPaths, and before recursing from the block that
handles directories (where you currently call stat(join(...)) for symlinks and
then call findMarkdownFilesRecursive), resolve the directory's real path (e.g.,
via realpath) and check the Set; if present, skip descending, otherwise add the
real path to the Set, call findMarkdownFilesRecursive with the same Set, and
keep it for the duration of discovery (do not rely on local stack-only removal
so shared ancestors remain marked); reference symbols:
findMarkdownFilesRecursive, entry.isSymbolicLink(), stat/join,
currentDepth/maxDepth, and results.push to locate the insertion point.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Outside diff comments:
In `@packages/paths/src/archon-paths.ts`:
- Around line 231-263: The recursion can loop on directory symlink cycles;
modify findMarkdownFilesRecursive to accept or use a Set<string>
visitedRealPaths, and before recursing from the block that handles directories
(where you currently call stat(join(...)) for symlinks and then call
findMarkdownFilesRecursive), resolve the directory's real path (e.g., via
realpath) and check the Set; if present, skip descending, otherwise add the real
path to the Set, call findMarkdownFilesRecursive with the same Set, and keep it
for the duration of discovery (do not rely on local stack-only removal so shared
ancestors remain marked); reference symbols: findMarkdownFilesRecursive,
entry.isSymbolicLink(), stat/join, currentDepth/maxDepth, and results.push to
locate the insertion point.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 2e52455b-e3df-4272-9f47-e5cee41a4dfc

📥 Commits

Reviewing files that changed from the base of the PR and between 2945f2e and 5547559.

📒 Files selected for processing (2)
  • packages/paths/src/archon-paths.test.ts
  • packages/paths/src/archon-paths.ts

@Wirasm
Copy link
Copy Markdown
Collaborator

Wirasm commented May 4, 2026

@blankse related to #1501 — symlink-aware findMarkdownFilesRecursive.

…m00#1501)

`Dirent.isFile()` returns false for symlinks, so symlinked `.md`
files in the search root were silently skipped. This made
team-shared command directories that get exposed to Archon via
symlinks (a `~/.archon/commands/foo.md` → `/path/to/team-repo/...`
pattern) unusable — workflow nodes referencing those commands
failed with `Command prompt not found` even though the file was
present and readable.

Workflow discovery in `loadWorkflowsFromDir` already handles this
correctly by stat()-ing each entry; this brings command discovery
in line.

Implementation: when a `Dirent` is a symlink, follow it with
`stat()` and use the target's `isFile()` / `isDirectory()` for the
branch decision. Broken symlinks are skipped silently (matches the
existing tolerance for missing search dirs at the top of the
function).

Tests cover: symlinked file in the search root, mixed regular +
symlinked entries in the same dir, symlinked subdirectory, broken
symlink. All four exercise the new branch.
@blankse blankse force-pushed the fix/1501-symlinked-commands branch from 0f98fe9 to 6aaaeb9 Compare May 21, 2026 12:19
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

🧹 Nitpick comments (2)
packages/paths/src/archon-paths.ts (1)

246-260: 💤 Low value

Consider guarding against symlink cycles.

Following symlinks for directories opens the door to infinite recursion if a symlinked subdir points to an ancestor (e.g., tempRoot/loop → tempRoot). The previous Dirent.isDirectory() path implicitly avoided this since symlinks were skipped. For the primary use case (team-shared command files), this is unlikely, but a visited-realpath set or a hard recursion-depth cap (independent of maxDepth, which defaults to Infinity) would make the walk robust against accidentally cyclic setups.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/paths/src/archon-paths.ts` around lines 246 - 260, The current
symlink handling (entry.isSymbolicLink() branch using stat(join(fullPath,
entry.name)) and then recursing based on isDir/isFile) can follow cycles; add
cycle protection by tracking resolved realpaths and/or enforcing a hard
recursion cap independent of maxDepth: when you detect a symlink, resolve its
real path (e.g., via realpath on join(fullPath, entry.name)), maintain a Set of
visitedRealpaths and skip recursion if the realpath is already present, or
increment a separate recursionDepth counter and abort further descent when it
exceeds a safe constant; update the logic around entry.isSymbolicLink(),
stat(...), isDir/isFile and any recursive call sites to consult the
visitedRealpaths or recursionDepth before recursing.
packages/paths/src/archon-paths.test.ts (1)

5-5: 💤 Low value

Consolidate the fs/promises imports.

symlink as fsSymlink can be added to the existing fs/promises import at line 5 rather than adding a second import statement for the same module.

♻️ Proposed consolidation
-import { mkdir, rm, writeFile, lstat, readlink } from 'fs/promises';
+import { mkdir, rm, writeFile, lstat, readlink, symlink as fsSymlink } from 'fs/promises';
@@
-import { symlink as fsSymlink } from 'fs/promises';

Also applies to: 41-41

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/paths/src/archon-paths.test.ts` at line 5, Consolidate fs/promises
imports by adding "symlink as fsSymlink" to the existing named import that
already includes mkdir, rm, writeFile, lstat, readlink (replace the separate
second import) so all fs/promises functions are imported from a single
statement; update the import that currently lists mkdir, rm, writeFile, lstat,
readlink to also include symlink as fsSymlink and remove the duplicate import.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@packages/paths/src/archon-paths.test.ts`:
- Line 5: Consolidate fs/promises imports by adding "symlink as fsSymlink" to
the existing named import that already includes mkdir, rm, writeFile, lstat,
readlink (replace the separate second import) so all fs/promises functions are
imported from a single statement; update the import that currently lists mkdir,
rm, writeFile, lstat, readlink to also include symlink as fsSymlink and remove
the duplicate import.

In `@packages/paths/src/archon-paths.ts`:
- Around line 246-260: The current symlink handling (entry.isSymbolicLink()
branch using stat(join(fullPath, entry.name)) and then recursing based on
isDir/isFile) can follow cycles; add cycle protection by tracking resolved
realpaths and/or enforcing a hard recursion cap independent of maxDepth: when
you detect a symlink, resolve its real path (e.g., via realpath on
join(fullPath, entry.name)), maintain a Set of visitedRealpaths and skip
recursion if the realpath is already present, or increment a separate
recursionDepth counter and abort further descent when it exceeds a safe
constant; update the logic around entry.isSymbolicLink(), stat(...),
isDir/isFile and any recursive call sites to consult the visitedRealpaths or
recursionDepth before recursing.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: e4b38726-0dc0-48ef-ae84-ff9b1ae655cc

📥 Commits

Reviewing files that changed from the base of the PR and between 0f98fe9 and 6aaaeb9.

📒 Files selected for processing (2)
  • packages/paths/src/archon-paths.test.ts
  • packages/paths/src/archon-paths.ts

@Wirasm
Copy link
Copy Markdown
Collaborator

Wirasm commented May 25, 2026

Hi @blankse — checking in. It's been a while since the last activity here and the PR is currently UNSTABLE. Are you still planning to address the review feedback, or would you prefer to close in favor of a fresh attempt later? Happy to keep it open if you have a timeline — just want to keep the queue moving. If I don't hear back in ~7 days I'll close as inactive (always feel free to reopen).

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.

Symlinked commands silently ignored: findMarkdownFilesRecursive uses Dirent.isFile() which doesn't match symlinks

2 participants