Skip to content

fix(daemon): allowlist the active skill root for external_directory - #8178

Merged
AmyShang-alt merged 1 commit into
nexu-io:mainfrom
aron-intframe:fix/skill-root-external-directory-8173
Oct 8, 2026
Merged

AmyShang-alt merged 1 commit into
nexu-io:mainfrom
aron-intframe:fix/skill-root-external-directory-8173

Conversation

@aron-intframe

Copy link
Copy Markdown
Contributor

Fixes #8173

Why

resolveChatExtraAllowedDirs builds the run-scoped allowlist from SKILLS_DIR only, while withSkillRootPreamble() advertises the active skill's own root as the absolute fallback path. A plugin-local SKILL.md loads from the bundled plugin folder, so the four files web-prototype/open-design.json declares in od.context.assets sit outside it: OpenCode correctly sees an external directory, asks, and a headless run reports the ask as a rejected tool call. SKILL_ROOTS is [USER_SKILLS_DIR, SKILLS_DIR], so data-dir skills hit it too.

Passing the turn's active skill dirs keys the allowlist on the roots in play. Codex still gets none, it treats allowlisted dirs as writable (#622).

What users will see

Bundled and user-installed skills read their own side files without a permission prompt. No new setting.

Surface area

  • None

Bug fix verification

  • tests/skill-root-external-directory.test.ts — red before the fix: 0/4 manifest-declared assets allowed; green after: 4/4.
  • tests/chat-route.test.ts → "allowlists the active skill root…" — real /api/chat turn, fake opencode on PATH, reads OPENCODE_CONFIG_CONTENT. Red when activeSkillDirs is dropped at the call site.

Validation

pnpm guard, daemon typecheck, touched suites. chat-route.test.ts aborts mid-file in my sandbox on a better-sqlite3 teardown assertion, identical on unmodified main: my locally built native module, not this change. The new case passes with -t.

Not covered: the lost-prompt-on-restart half of #8173, and the memory-llm BYOK id.

… allowlist

resolveChatExtraAllowedDirs built the allowlist from SKILLS_DIR only, so a
skill installed under the daemon data dir or loaded from a bundled plugin
folder resolved to a root outside it. withSkillRootPreamble still advertises
that root as the absolute fallback path, so OpenCode asked for
external_directory approval on files OD itself pointed the agent at, and a
headless run reported the ask as a rejected tool call.

Pass the turn's active skill dirs so the allowlist is keyed on the roots
actually in play. Codex stays excluded: it treats allowlisted dirs as
writable and skill roots are read-only resources.
@lefarcen lefarcen added risk/high High risk: apps/desktop, daemon, auth, migration, workflows, package deps size/M PR changes 100-300 lines type/bugfix Bug fix labels Sep 15, 2026
@lefarcen

Copy link
Copy Markdown
Contributor

Thanks @aron-intframe — the description clearly connects the permission prompt to the active skill root. The change is now in the review queue.

@lefarcen
lefarcen requested a review from PerishCode September 15, 2026 19:51
@lefarcen lefarcen added the needs-validation Runtime change detected; needs human or /explore agent validation. label Sep 15, 2026
@lefarcen

Copy link
Copy Markdown
Contributor

Queued for QA validation — this change affects a live skill-execution path and needs a manual pass before merge. Nothing needed from you right now; we’ll update here once it is validated.

@PerishCode PerishCode left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

@aron-intframe I verified that the daemon now carries every resolved active skill directory into the run-scoped external-directory allowlist while preserving the Codex exclusion, and that the regression coverage exercises both a user skill through the real chat route and the shipped plugin-local asset shape. The change stays within the existing prompt, staging, and runtime-config seams. Nicely focused fix, and the layered tests make the intended fallback behavior especially clear.

🔁 Powered by Looper · runner=reviewer · agent=codex · An autonomous AI dev team for your GitHub repos.

@aron-intframe

Copy link
Copy Markdown
Contributor Author

Thanks for the approval. I re-ran both of your claims on a clean clone of the reviewed SHA (0d0771d) instead of taking them at face value, and they hold: Node 24.21 / pnpm 10.33.2, full tests/chat-route.test.ts plus the new suite.

      Tests  81 passed (81)
 ✓ tests/skill-root-external-directory.test.ts > run-scoped external_directory allowlist — skill roots > still hands Codex no read-only resource directory 0ms

Falsification, one half at a time. Deleting activeSkillDirs, from the resolveChatExtraAllowedDirs({ ... }) call in apps/daemon/src/server.ts reds only the chat-route case:

 FAIL  tests/chat-route.test.ts > /api/chat > allowlists the active skill root that lives outside the repository skills dir
AssertionError: expected undefined to be truthy

Restoring that and instead dropping the ...(Array.isArray(activeSkillDirs) ? activeSkillDirs : []) spread inside chat-prompt-inputs.ts reds the plugin-local case instead:

 FAIL  tests/skill-root-external-directory.test.ts > run-scoped external_directory allowlist — skill roots > allows every side file a plugin-local skill advertises
AssertionError: expected [] to deeply equal [ …(4) ]
      Tests  1 failed | 2 passed (3)

A correction to my own PR description. I described the new suite as "red before the fix: 0/4 manifest-declared assets allowed". Only one of its three cases is actually falsifiable against the change. ships the web-prototype assets its manifest declares stays green with the fix reverted, because it asserts that the manifest's four od.context.assets entries exist on disk, not that they are allowlisted. It is a fixture guard, not regression cover, so 2 of 3 cases carry the fix.

The caveat I filed does not reproduce. I wrote that chat-route.test.ts aborts mid-file on a better-sqlite3 teardown assertion in my sandbox. On a fresh pnpm install --frozen-lockfile the whole file runs, 81/81 as quoted above, so that was my locally built native module and not something the queued QA pass needs to work around. pnpm --filter @open-design/daemon typecheck is clean on the same checkout.

Where "every resolved active skill directory" has an edge, since QA is about to exercise this path: two other sites deliberately pass an empty list, activeSkillDirs: odNextStrategyRecipe ? [] : activeSkillDirs in the prompt-composer return and activeSkillDirs: [] in the strategyTaskAtStart branch. I read both. The first composes its prompt through composeOdNextStrategyCorePromptV2(recipe) and the second returns prompt: '', so neither routes a skill body through withSkillRootPreamble() and neither advertises an absolute skill root, which keeps the invariant intact. I did not run an OD Next strategy turn end to end, so that one is read, not run.

Also not verified: a real opencode binary. Both tests use a fake agent on PATH and read back OPENCODE_CONFIG_CONTENT, and the allowsRead helper in the new suite mirrors OpenCode's external_directory prefix matching rather than calling OpenCode itself. Nothing needed changing, so no new commits on the branch.

@AmyShang-alt AmyShang-alt added validated Runtime change validated (via /explore Pass or manual QA). and removed needs-validation Runtime change detected; needs human or /explore agent validation. labels Oct 8, 2026
@AmyShang-alt

Copy link
Copy Markdown
Contributor

QA Acceptance Record

@aron-intframe QA passed for the verified scope at head 0d0771d6d829dde46a332c117dba2ff9df9c56ad, following the requester’s explicit acceptance decision.

  • All 84 tests in the touched suites passed; daemon typecheck and repository guard passed. Reverting the fix failed the two targeted regression cases; restoring it passed both.
  • Real OpenCode read bundled skill resources (4/4) and a user-installed skill resource outside the project directory. Unrelated paths and a skill from another project were denied; a missing resource produced an explicit error.
  • Electron development runtime: real OpenAI generation, rendered prototype preview, continued edits, and a subsequent run with Linear bound as the design system succeeded. Claude credit was not required.
  • Packaged installer verification is explicitly deferred by the requester until packaging. Session-restart pending-permission recovery and the separate memory-llm issue are outside this PR’s scope.

Environment record: the QA Electron/web/daemon namespace was stopped, its ports closed, and the disposable runtime data and copied credential removed. Durable evidence and the QA worktree/dependencies were retained. An earlier unsandboxed test invocation touched shared OpenCode log/database/lock state; additional skill-file changes were observed but cannot be attributed. Full restoration is unproven, and no unsafe rollback of concurrent user state was attempted. This remains a separately disclosed local environment incident, not a confirmed PR defect.

Associated acceptance suggestion: perform the planned packaged-app smoke for bundled and user-installed skill resource reads.

@AmyShang-alt
AmyShang-alt added this pull request to the merge queue Oct 8, 2026
Merged via the queue into nexu-io:main with commit e38462f Oct 8, 2026
35 checks passed
@open-design-bot

Copy link
Copy Markdown
Contributor

🎉 📡 You just leveled up to Giotto

Giotto card for @aron-intframe

📡 ✨ Sending steady signals.

🙌 Your contributions are sending a clear signal across the network: you care about making Open Design better. Keep transmitting.

💛 Thanks for helping Open Design move forward. Keep building in the open. 🚀


📊 Rank #206 among 800+ contributors

🔗 Share on X (English) · 分享到 X(中文)

@aron-intframe

Copy link
Copy Markdown
Contributor Author

Thanks for the record. I re-ran it against merged main (e38462f) rather than the reviewed SHA, since 59 commits landed in between and apps/daemon/src/server.ts alone moved 378+/122- around the call site. Your numbers reproduce there.

The test count matches exactly. Node 24.21 / pnpm 10.33.2, both touched suites:

 Test Files  2 passed (2)
      Tests  84 passed (84)
   Duration  268.09s (transform 3.20s, setup 22ms, import 4.14s, tests 263.79s, environment 0ms)

pnpm --filter @open-design/daemon typecheck and pnpm guard are both clean on the same checkout, three of the four files the PR touched are byte-identical to the reviewed head, and in server.ts, which other PRs have since rewritten heavily, the one activeSkillDirs, line survived the drift intact.

"Two targeted regression cases" is the right count, and each half of the fix carries exactly one. Removing activeSkillDirs, from the resolveChatExtraAllowedDirs({ ... }) call in server.ts reds only the chat-route case:

 FAIL  tests/chat-route.test.ts > /api/chat > allowlists the active skill root that lives outside the repository skills dir
AssertionError: expected undefined to be truthy
      Tests  1 failed | 80 skipped (81)

Restoring that and instead dropping the ...(Array.isArray(activeSkillDirs) ? activeSkillDirs : []) spread inside chat-prompt-inputs.ts reds only the other one:

 FAIL  tests/skill-root-external-directory.test.ts > run-scoped external_directory allowlist — skill roots > allows every side file a plugin-local skill advertises
AssertionError: expected [] to deeply equal [ …(4) ]
      Tests  1 failed | 2 passed (3)

The third case in that suite is a fixture guard rather than regression cover, as I corrected earlier, so your count of two is the honest one.

On the deferred packaged smoke, I can rule one failure mode out for you statically. The risk worth naming is that a bundled skill root is unreadable by the external opencode process even once it is allowlisted, because it sits inside app.asar. It does not: ELECTRON_BUILDER_ASAR = false in both tools/pack/src/mac/constants.ts and tools/pack/src/win/constants.ts, asar: false in tools/pack/src/linux.ts, and plugins/_official ships as a whole recursive tree in BUNDLED_RESOURCE_TREES. I called the real copyBundledResourceTrees into a temporary resource root:

staged skill root: <resources>/plugins/_official/examples/web-prototype
declared assets: 4
  PRESENT  ./example.html
  PRESENT  ./assets/template.html
  PRESENT  ./references/checklist.md
  PRESENT  ./references/layouts.md
plain-file assets under the staged skill root: 4/4
skill root is a real directory (not inside app.asar): true

BUNDLED_PLUGINS_DIR resolves through resolveDaemonResourceDir(DAEMON_RESOURCE_ROOT, path.join('plugins', '_official'), ...) to that same <resourcesPath>/plugins/_official, so the root the allowlist receives in a packaged build is a plain directory holding all four declared side files. The smoke still has to confirm the runtime leg, but it is not exposed to an asar-opacity failure.

What I did not verify. There is no real OpenCode or Codex binary in this environment (opencode: NOT on PATH), so your 4/4 real-agent read, the denials for unrelated paths and another project's skill, and the explicit missing-resource error are taken on your word rather than reproduced; the same goes for the Electron dev run with real OpenAI generation. I also cannot speak to the shared OpenCode log/database/lock state your environment note discloses, and I did not attempt to reconstruct it. I found no new defect, so there are no new commits on the branch.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

risk/high High risk: apps/desktop, daemon, auth, migration, workflows, package deps size/M PR changes 100-300 lines type/bugfix Bug fix validated Runtime change validated (via /explore Pass or manual QA).

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug]:  Skill fails with "permission denied" instead of "file not found" when skill data directory doesn't exist

4 participants