Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 65 additions & 7 deletions lib/dev/deps/gem_skill_linker.rb
Original file line numberDiff line numberDiff line change
Expand Up@@ -23,27 +23,58 @@ class GemSkillLinker
SKILLS_SUBDIR = "skills"
AGENT_SKILLS_SUBDIRS = [".agents", "skills"].freeze

# Env overrides a harness (e.g. a sandboxed agent session) may have
# exported into dev's own environment, redirecting bundler to an
# ephemeral gem cache. The `bundle list` child gets them explicitly
# unset so paths resolve from the project's canonical bundler config —
# dev never runs under bundler itself, so these unsets are its
# equivalent of Bundler.original_env (dev#89).
HARNESS_ENV_SCRUB = [
"BUNDLE_PATH",
"BUNDLE_APP_CONFIG",
"BUNDLE_BIN",
"GEM_HOME",
"GEM_PATH",
"RUBYOPT",
"RUBYLIB",
].to_h { |name| [name, nil] }.freeze

# @param project_root [Pathname, String] repo root (Gemfile + link target)
# @param skills_dir [Pathname, String, nil] override for tests; defaults
# to <project_root>/.agents/skills
def initialize(project_root:, skills_dir: nil)
# @param tmpdir [Pathname, String] ephemeral temp root that links must
# never target; defaults to Dir.tmpdir (override for tests, whose
# fixture gem trees themselves live under the real temp dir)
def initialize(project_root:, skills_dir: nil, tmpdir: Dir.tmpdir)
@project_root = Pathname(project_root)
@skills_dir = Pathname(skills_dir || @project_root.join(*AGENT_SKILLS_SUBDIRS))
@skill_installer = SkillInstaller.new(skills_dir: @skills_dir)
@tmpdir_roots = tmpdir_roots(Pathname(tmpdir))
end

# Scan the locked gem set for shipped skills and refresh the project's
# links: install one per skill found, prune gem links whose gem left the
# lock. Never raises — skill links are hygiene riding a dependency
# install, and hygiene must not block correctness (failures are reported
# on stderr).
# lock. A skill that resolves under the temp dir is never linked (warned
# and skipped — a persistent link to purgeable state silently dangles
# later), but its gem still counts as present for pruning, so an
# ephemeral resolution cannot delete a durable link minted earlier.
# Never raises — skill links are hygiene riding a dependency install,
# and hygiene must not block correctness (failures are reported on
# stderr).
#
# @return [void]
def link_all
return unless gemfile_path.exist?

expected = expected_links
expected.each { |name, skill_dir| @skill_installer.install(name, skill_dir) }
expected.each do |name, skill_dir|
if ephemeral?(skill_dir)
$stderr.puts "dev: warning: not linking #{name} — #{skill_dir} is under the temp dir " \
"and would dangle once it is purged."
else
@skill_installer.install(name, skill_dir)
end
end
prune_stale_links(expected.keys)
rescue StandardError => e
$stderr.puts "dev: warning: could not refresh gem skill links (#{e.message})."
Expand DownExpand Up@@ -84,12 +115,15 @@ def gem_roots

# Runs under the project's shadowenv for the same reason as
# BundlerIntegration: the dev process's own PATH is the invoking
# service's, which on headless boxes carries the wrong Ruby.
# service's, which on headless boxes carries the wrong Ruby. Harness
# bundler/gem overrides are scrubbed from the child env (see
# HARNESS_ENV_SCRUB) so a sandboxed session cannot redirect the
# resolution into its ephemeral cache.
#
# @return [Array<Pathname>] install paths of every gem in the bundle
def bundled_gem_paths
out, err, status = Open3.capture3(
{ "BUNDLE_GEMFILE" => gemfile_path.to_s },
HARNESS_ENV_SCRUB.merge("BUNDLE_GEMFILE" => gemfile_path.to_s),
"shadowenv", "exec", "--", "bundle", "list", "--paths",
chdir: @project_root.to_s,
)
Expand DownExpand Up@@ -128,6 +162,30 @@ def locked_gem_names
names.uniq
end

# The temp root in both its raw and fully-resolved forms — on macOS
# Dir.tmpdir is under /var/... while bundler reports the
# /private/var/... realpath, so containment must check both spellings.
#
# @param tmpdir [Pathname]
# @return [Array<Pathname>]
def tmpdir_roots(tmpdir)
expanded = tmpdir.expand_path
roots = [expanded]
roots << expanded.realpath if expanded.exist?
roots.uniq
end

# Whether a resolved skill directory lives under the temp dir — the
# signature of a harness resolving the bundle into its own purgeable
# cache, whatever produced it.
#
# @param path [Pathname]
# @return [Boolean]
def ephemeral?(path)
resolved = path.exist? ? path.realpath : path.expand_path
@tmpdir_roots.any? { |root| resolved.to_s.start_with?("#{root}#{File::SEPARATOR}") }
end

# Remove gem links that no current gem accounts for (the gem left the
# lock; its tree may still exist on disk, so broken-link pruning alone
# would miss it). Only `gem-`-prefixed symlinks are candidates —
Expand Down
133 changes: 123 additions & 10 deletions test/dev/deps/gem_skill_linker_test.rb
Original file line numberDiff line numberDiff line change
Expand Up@@ -50,22 +50,31 @@ def build_gem(gems_root, dir_name, skills: [])

# `bundle list` must run under the project's shadowenv — same reasoning as
# BundlerIntegration: the dev process's PATH is the invoking service's,
# which on headless boxes carries the wrong Ruby.
# which on headless boxes carries the wrong Ruby — with harness bundler
# overrides scrubbed from the child env.
def stub_bundle_list(project, paths)
env = Dev::Deps::GemSkillLinker::HARNESS_ENV_SCRUB.merge("BUNDLE_GEMFILE" => (project / "Gemfile").to_s)
Open3.stubs(:capture3)
.with({ "BUNDLE_GEMFILE" => (project / "Gemfile").to_s },
"shadowenv", "exec", "--", "bundle", "list", "--paths", chdir: project.to_s)
.with(env, "shadowenv", "exec", "--", "bundle", "list", "--paths", chdir: project.to_s)
.returns([paths.map { |p| "#{p}\n" }.join, "", stub(success?: true)])
end

# Linker under test. The real Dir.tmpdir contains these tests' own fixture
# trees, so every linker gets a tmpdir override pointing inside the fixture
# dir — gems built by build_gem under `gems/` then read as durable, and a
# test opts into ephemerality by building under `<dir>/tmp`.
def build_linker(project, dir)
Dev::Deps::GemSkillLinker.new(project_root: project, tmpdir: Pathname(dir) / "tmp")
end

test "links a locked gem's shipped skills as gem-<gem>--<skill>" do
Given "a locked gem whose tree ships a skill"
dir = Dir.mktmpdir("dev-gem-skill-test-")
project, gems = build_project(dir)
rspock = build_gem(gems, "rspock-1.2.0", skills: ["rspock"])
minitest = build_gem(gems, "minitest-5.25.0")
stub_bundle_list(project, [rspock, minitest])
linker = Dev::Deps::GemSkillLinker.new(project_root: project)
linker = build_linker(project, dir)

When "linking"
linker.link_all
Expand All@@ -85,7 +94,7 @@ def stub_bundle_list(project, paths)
project, gems = build_project(dir)
reporters = build_gem(gems, "minitest-reporters-1.7.1", skills: ["reporting"])
stub_bundle_list(project, [reporters])
linker = Dev::Deps::GemSkillLinker.new(project_root: project)
linker = build_linker(project, dir)

When "linking"
linker.link_all
Expand All@@ -104,7 +113,7 @@ def stub_bundle_list(project, paths)
project, gems = build_project(dir)
stray = build_gem(gems, "stray-9.9.9", skills: ["stray"])
stub_bundle_list(project, [stray])
linker = Dev::Deps::GemSkillLinker.new(project_root: project)
linker = build_linker(project, dir)

When "linking"
linker.link_all
Expand All@@ -128,7 +137,7 @@ def stub_bundle_list(project, paths)
File.symlink(departed / "skills" / "departed", skills_dir / "gem-departed--departed")
File.symlink(gems, skills_dir / "my-own-link")
(skills_dir / "notes.md").write("mine\n")
linker = Dev::Deps::GemSkillLinker.new(project_root: project)
linker = build_linker(project, dir)

When "linking"
linker.link_all
Expand All@@ -149,7 +158,7 @@ def stub_bundle_list(project, paths)
project = Pathname(dir) / "repo"
FileUtils.mkdir_p(project)
Open3.expects(:capture3).never
linker = Dev::Deps::GemSkillLinker.new(project_root: project)
linker = build_linker(project, dir)

When "linking"
linker.link_all
Expand All@@ -169,7 +178,7 @@ def stub_bundle_list(project, paths)
skills_dir = project / ".agents" / "skills"
FileUtils.mkdir_p(skills_dir)
FileUtils.chmod(0o000, skills_dir)
linker = Dev::Deps::GemSkillLinker.new(project_root: project)
linker = build_linker(project, dir)
old_stderr = $stderr
$stderr = StringIO.new

Expand All@@ -185,12 +194,116 @@ def stub_bundle_list(project, paths)
FileUtils.rm_rf(dir)
end

# Pins the exact scrub set rather than referencing HARNESS_ENV_SCRUB: a
# sandboxed session (Cursor sandbox cache, dev#89) leaks these overrides
# into dev's env, and dropping any of them from the scrub would silently
# re-open the leak.
test "bundle list runs with harness bundler and gem overrides explicitly unset" do
Given "a project"
dir = Dir.mktmpdir("dev-gem-skill-test-")
project, = build_project(dir)
linker = build_linker(project, dir)

When "linking"
linker.link_all

Then "every harness override is nil'd in the child env"
1 * Open3.capture3(
{
"BUNDLE_PATH" => nil,
"BUNDLE_APP_CONFIG" => nil,
"BUNDLE_BIN" => nil,
"GEM_HOME" => nil,
"GEM_PATH" => nil,
"RUBYOPT" => nil,
"RUBYLIB" => nil,
"BUNDLE_GEMFILE" => (project / "Gemfile").to_s,
},
"shadowenv", "exec", "--", "bundle", "list", "--paths", chdir: project.to_s
) >> ["", "", stub(success?: true)]

Cleanup
FileUtils.rm_rf(dir)
end

test "refuses to link a skill resolved under the temp dir and warns" do
Given "a locked gem whose tree resolves into the ephemeral temp dir"
dir = Dir.mktmpdir("dev-gem-skill-test-")
project, = build_project(dir)
ephemeral = build_gem(Pathname(dir) / "tmp" / "gems", "rspock-1.2.0", skills: ["rspock"])
stub_bundle_list(project, [ephemeral])
linker = build_linker(project, dir)
old_stderr = $stderr
$stderr = StringIO.new

When "linking"
linker.link_all

Then "no link is created and the skip is warned"
!File.exist?(project / ".agents" / "skills" / "gem-rspock--rspock")
$stderr.string.include?("not linking gem-rspock--rspock")

Cleanup
$stderr = old_stderr
FileUtils.rm_rf(dir)
end

test "an ephemeral resolution does not prune the durable link it shadows" do
Given "a durable link for a gem that now resolves into the temp dir"
dir = Dir.mktmpdir("dev-gem-skill-test-")
project, gems = build_project(dir)
durable = build_gem(gems, "rspock-1.2.0", skills: ["rspock"])
skills_dir = project / ".agents" / "skills"
FileUtils.mkdir_p(skills_dir)
File.symlink(durable / "skills" / "rspock", skills_dir / "gem-rspock--rspock")
ephemeral = build_gem(Pathname(dir) / "tmp" / "gems", "rspock-1.2.0", skills: ["rspock"])
stub_bundle_list(project, [ephemeral])
linker = build_linker(project, dir)
old_stderr = $stderr
$stderr = StringIO.new

When "linking"
linker.link_all

Then "the durable link survives, still pointing at its durable target"
File.symlink?(skills_dir / "gem-rspock--rspock")
File.readlink(skills_dir / "gem-rspock--rspock") == (durable / "skills" / "rspock").to_s

Cleanup
$stderr = old_stderr
FileUtils.rm_rf(dir)
end

test "temp dir containment sees through symlinked temp roots" do
Given "a tmpdir override that is a symlink to the dir the gem resolves under"
dir = Dir.mktmpdir("dev-gem-skill-test-")
project, = build_project(dir)
real_tmp = Pathname(dir) / "tmp"
ephemeral = build_gem(real_tmp / "gems", "rspock-1.2.0", skills: ["rspock"])
tmp_alias = Pathname(dir) / "tmp-alias"
File.symlink(real_tmp, tmp_alias)
stub_bundle_list(project, [ephemeral])
linker = Dev::Deps::GemSkillLinker.new(project_root: project, tmpdir: tmp_alias)
old_stderr = $stderr
$stderr = StringIO.new

When "linking"
linker.link_all

Then "the path is recognized as ephemeral and never links"
!File.exist?(project / ".agents" / "skills" / "gem-rspock--rspock")

Cleanup
$stderr = old_stderr
FileUtils.rm_rf(dir)
end

test "a failing bundle list warns instead of failing the install" do
Given "bundler erroring out"
dir = Dir.mktmpdir("dev-gem-skill-test-")
project, = build_project(dir)
Open3.stubs(:capture3).returns(["", "bundler exploded", stub(success?: false)])
linker = Dev::Deps::GemSkillLinker.new(project_root: project)
linker = build_linker(project, dir)
old_stderr = $stderr
$stderr = StringIO.new

Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 65 additions & 7 deletions lib/dev/deps/gem_skill_linker.rb
Original file line numberDiff line numberDiff line change
Expand Up@@ -23,27 +23,58 @@ class GemSkillLinker
SKILLS_SUBDIR = "skills"
AGENT_SKILLS_SUBDIRS = [".agents", "skills"].freeze

# Env overrides a harness (e.g. a sandboxed agent session) may have
# exported into dev's own environment, redirecting bundler to an
# ephemeral gem cache. The `bundle list` child gets them explicitly
# unset so paths resolve from the project's canonical bundler config —
# dev never runs under bundler itself, so these unsets are its
# equivalent of Bundler.original_env (dev#89).
HARNESS_ENV_SCRUB = [
"BUNDLE_PATH",
"BUNDLE_APP_CONFIG",
"BUNDLE_BIN",
"GEM_HOME",
"GEM_PATH",
"RUBYOPT",
"RUBYLIB",
].to_h { |name| [name, nil] }.freeze

# @param project_root [Pathname, String] repo root (Gemfile + link target)
# @param skills_dir [Pathname, String, nil] override for tests; defaults
# to <project_root>/.agents/skills
def initialize(project_root:, skills_dir: nil)
# @param tmpdir [Pathname, String] ephemeral temp root that links must
# never target; defaults to Dir.tmpdir (override for tests, whose
# fixture gem trees themselves live under the real temp dir)
def initialize(project_root:, skills_dir: nil, tmpdir: Dir.tmpdir)
@project_root = Pathname(project_root)
@skills_dir = Pathname(skills_dir || @project_root.join(*AGENT_SKILLS_SUBDIRS))
@skill_installer = SkillInstaller.new(skills_dir: @skills_dir)
@tmpdir_roots = tmpdir_roots(Pathname(tmpdir))
end

# Scan the locked gem set for shipped skills and refresh the project's
# links: install one per skill found, prune gem links whose gem left the
# lock. Never raises — skill links are hygiene riding a dependency
# install, and hygiene must not block correctness (failures are reported
# on stderr).
# lock. A skill that resolves under the temp dir is never linked (warned
# and skipped — a persistent link to purgeable state silently dangles
# later), but its gem still counts as present for pruning, so an
# ephemeral resolution cannot delete a durable link minted earlier.
# Never raises — skill links are hygiene riding a dependency install,
# and hygiene must not block correctness (failures are reported on
# stderr).
#
# @return [void]
def link_all
return unless gemfile_path.exist?

expected = expected_links
expected.each { |name, skill_dir| @skill_installer.install(name, skill_dir) }
expected.each do |name, skill_dir|
if ephemeral?(skill_dir)
$stderr.puts "dev: warning: not linking #{name} — #{skill_dir} is under the temp dir " \
"and would dangle once it is purged."
else
@skill_installer.install(name, skill_dir)
end
end
prune_stale_links(expected.keys)
rescue StandardError => e
$stderr.puts "dev: warning: could not refresh gem skill links (#{e.message})."
Expand DownExpand Up@@ -84,12 +115,15 @@ def gem_roots

# Runs under the project's shadowenv for the same reason as
# BundlerIntegration: the dev process's own PATH is the invoking
# service's, which on headless boxes carries the wrong Ruby.
# service's, which on headless boxes carries the wrong Ruby. Harness
# bundler/gem overrides are scrubbed from the child env (see
# HARNESS_ENV_SCRUB) so a sandboxed session cannot redirect the
# resolution into its ephemeral cache.
#
# @return [Array<Pathname>] install paths of every gem in the bundle
def bundled_gem_paths
out, err, status = Open3.capture3(
{ "BUNDLE_GEMFILE" => gemfile_path.to_s },
HARNESS_ENV_SCRUB.merge("BUNDLE_GEMFILE" => gemfile_path.to_s),
"shadowenv", "exec", "--", "bundle", "list", "--paths",
chdir: @project_root.to_s,
)
Expand DownExpand Up@@ -128,6 +162,30 @@ def locked_gem_names
names.uniq
end

# The temp root in both its raw and fully-resolved forms — on macOS
# Dir.tmpdir is under /var/... while bundler reports the
# /private/var/... realpath, so containment must check both spellings.
#
# @param tmpdir [Pathname]
# @return [Array<Pathname>]
def tmpdir_roots(tmpdir)
expanded = tmpdir.expand_path
roots = [expanded]
roots << expanded.realpath if expanded.exist?
roots.uniq
end

# Whether a resolved skill directory lives under the temp dir — the
# signature of a harness resolving the bundle into its own purgeable
# cache, whatever produced it.
#
# @param path [Pathname]
# @return [Boolean]
def ephemeral?(path)
resolved = path.exist? ? path.realpath : path.expand_path
@tmpdir_roots.any? { |root| resolved.to_s.start_with?("#{root}#{File::SEPARATOR}") }
end

# Remove gem links that no current gem accounts for (the gem left the
# lock; its tree may still exist on disk, so broken-link pruning alone
# would miss it). Only `gem-`-prefixed symlinks are candidates —
Expand Down
133 changes: 123 additions & 10 deletions test/dev/deps/gem_skill_linker_test.rb
Original file line numberDiff line numberDiff line change
Expand Up@@ -50,22 +50,31 @@ def build_gem(gems_root, dir_name, skills: [])

# `bundle list` must run under the project's shadowenv — same reasoning as
# BundlerIntegration: the dev process's PATH is the invoking service's,
# which on headless boxes carries the wrong Ruby.
# which on headless boxes carries the wrong Ruby — with harness bundler
# overrides scrubbed from the child env.
def stub_bundle_list(project, paths)
env = Dev::Deps::GemSkillLinker::HARNESS_ENV_SCRUB.merge("BUNDLE_GEMFILE" => (project / "Gemfile").to_s)
Open3.stubs(:capture3)
.with({ "BUNDLE_GEMFILE" => (project / "Gemfile").to_s },
"shadowenv", "exec", "--", "bundle", "list", "--paths", chdir: project.to_s)
.with(env, "shadowenv", "exec", "--", "bundle", "list", "--paths", chdir: project.to_s)
.returns([paths.map { |p| "#{p}\n" }.join, "", stub(success?: true)])
end

# Linker under test. The real Dir.tmpdir contains these tests' own fixture
# trees, so every linker gets a tmpdir override pointing inside the fixture
# dir — gems built by build_gem under `gems/` then read as durable, and a
# test opts into ephemerality by building under `<dir>/tmp`.
def build_linker(project, dir)
Dev::Deps::GemSkillLinker.new(project_root: project, tmpdir: Pathname(dir) / "tmp")
end

test "links a locked gem's shipped skills as gem-<gem>--<skill>" do
Given "a locked gem whose tree ships a skill"
dir = Dir.mktmpdir("dev-gem-skill-test-")
project, gems = build_project(dir)
rspock = build_gem(gems, "rspock-1.2.0", skills: ["rspock"])
minitest = build_gem(gems, "minitest-5.25.0")
stub_bundle_list(project, [rspock, minitest])
linker = Dev::Deps::GemSkillLinker.new(project_root: project)
linker = build_linker(project, dir)

When "linking"
linker.link_all
Expand All@@ -85,7 +94,7 @@ def stub_bundle_list(project, paths)
project, gems = build_project(dir)
reporters = build_gem(gems, "minitest-reporters-1.7.1", skills: ["reporting"])
stub_bundle_list(project, [reporters])
linker = Dev::Deps::GemSkillLinker.new(project_root: project)
linker = build_linker(project, dir)

When "linking"
linker.link_all
Expand All@@ -104,7 +113,7 @@ def stub_bundle_list(project, paths)
project, gems = build_project(dir)
stray = build_gem(gems, "stray-9.9.9", skills: ["stray"])
stub_bundle_list(project, [stray])
linker = Dev::Deps::GemSkillLinker.new(project_root: project)
linker = build_linker(project, dir)

When "linking"
linker.link_all
Expand All@@ -128,7 +137,7 @@ def stub_bundle_list(project, paths)
File.symlink(departed / "skills" / "departed", skills_dir / "gem-departed--departed")
File.symlink(gems, skills_dir / "my-own-link")
(skills_dir / "notes.md").write("mine\n")
linker = Dev::Deps::GemSkillLinker.new(project_root: project)
linker = build_linker(project, dir)

When "linking"
linker.link_all
Expand All@@ -149,7 +158,7 @@ def stub_bundle_list(project, paths)
project = Pathname(dir) / "repo"
FileUtils.mkdir_p(project)
Open3.expects(:capture3).never
linker = Dev::Deps::GemSkillLinker.new(project_root: project)
linker = build_linker(project, dir)

When "linking"
linker.link_all
Expand All@@ -169,7 +178,7 @@ def stub_bundle_list(project, paths)
skills_dir = project / ".agents" / "skills"
FileUtils.mkdir_p(skills_dir)
FileUtils.chmod(0o000, skills_dir)
linker = Dev::Deps::GemSkillLinker.new(project_root: project)
linker = build_linker(project, dir)
old_stderr = $stderr
$stderr = StringIO.new

Expand All@@ -185,12 +194,116 @@ def stub_bundle_list(project, paths)
FileUtils.rm_rf(dir)
end

# Pins the exact scrub set rather than referencing HARNESS_ENV_SCRUB: a
# sandboxed session (Cursor sandbox cache, dev#89) leaks these overrides
# into dev's env, and dropping any of them from the scrub would silently
# re-open the leak.
test "bundle list runs with harness bundler and gem overrides explicitly unset" do
Given "a project"
dir = Dir.mktmpdir("dev-gem-skill-test-")
project, = build_project(dir)
linker = build_linker(project, dir)

When "linking"
linker.link_all

Then "every harness override is nil'd in the child env"
1 * Open3.capture3(
{
"BUNDLE_PATH" => nil,
"BUNDLE_APP_CONFIG" => nil,
"BUNDLE_BIN" => nil,
"GEM_HOME" => nil,
"GEM_PATH" => nil,
"RUBYOPT" => nil,
"RUBYLIB" => nil,
"BUNDLE_GEMFILE" => (project / "Gemfile").to_s,
},
"shadowenv", "exec", "--", "bundle", "list", "--paths", chdir: project.to_s
) >> ["", "", stub(success?: true)]

Cleanup
FileUtils.rm_rf(dir)
end

test "refuses to link a skill resolved under the temp dir and warns" do
Given "a locked gem whose tree resolves into the ephemeral temp dir"
dir = Dir.mktmpdir("dev-gem-skill-test-")
project, = build_project(dir)
ephemeral = build_gem(Pathname(dir) / "tmp" / "gems", "rspock-1.2.0", skills: ["rspock"])
stub_bundle_list(project, [ephemeral])
linker = build_linker(project, dir)
old_stderr = $stderr
$stderr = StringIO.new

When "linking"
linker.link_all

Then "no link is created and the skip is warned"
!File.exist?(project / ".agents" / "skills" / "gem-rspock--rspock")
$stderr.string.include?("not linking gem-rspock--rspock")

Cleanup
$stderr = old_stderr
FileUtils.rm_rf(dir)
end

test "an ephemeral resolution does not prune the durable link it shadows" do
Given "a durable link for a gem that now resolves into the temp dir"
dir = Dir.mktmpdir("dev-gem-skill-test-")
project, gems = build_project(dir)
durable = build_gem(gems, "rspock-1.2.0", skills: ["rspock"])
skills_dir = project / ".agents" / "skills"
FileUtils.mkdir_p(skills_dir)
File.symlink(durable / "skills" / "rspock", skills_dir / "gem-rspock--rspock")
ephemeral = build_gem(Pathname(dir) / "tmp" / "gems", "rspock-1.2.0", skills: ["rspock"])
stub_bundle_list(project, [ephemeral])
linker = build_linker(project, dir)
old_stderr = $stderr
$stderr = StringIO.new

When "linking"
linker.link_all

Then "the durable link survives, still pointing at its durable target"
File.symlink?(skills_dir / "gem-rspock--rspock")
File.readlink(skills_dir / "gem-rspock--rspock") == (durable / "skills" / "rspock").to_s

Cleanup
$stderr = old_stderr
FileUtils.rm_rf(dir)
end

test "temp dir containment sees through symlinked temp roots" do
Given "a tmpdir override that is a symlink to the dir the gem resolves under"
dir = Dir.mktmpdir("dev-gem-skill-test-")
project, = build_project(dir)
real_tmp = Pathname(dir) / "tmp"
ephemeral = build_gem(real_tmp / "gems", "rspock-1.2.0", skills: ["rspock"])
tmp_alias = Pathname(dir) / "tmp-alias"
File.symlink(real_tmp, tmp_alias)
stub_bundle_list(project, [ephemeral])
linker = Dev::Deps::GemSkillLinker.new(project_root: project, tmpdir: tmp_alias)
old_stderr = $stderr
$stderr = StringIO.new

When "linking"
linker.link_all

Then "the path is recognized as ephemeral and never links"
!File.exist?(project / ".agents" / "skills" / "gem-rspock--rspock")

Cleanup
$stderr = old_stderr
FileUtils.rm_rf(dir)
end

test "a failing bundle list warns instead of failing the install" do
Given "bundler erroring out"
dir = Dir.mktmpdir("dev-gem-skill-test-")
project, = build_project(dir)
Open3.stubs(:capture3).returns(["", "bundler exploded", stub(success?: false)])
linker = Dev::Deps::GemSkillLinker.new(project_root: project)
linker = build_linker(project, dir)
old_stderr = $stderr
$stderr = StringIO.new

Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 65 additions & 7 deletions lib/dev/deps/gem_skill_linker.rb
Original file line numberDiff line numberDiff line change
Expand Up@@ -23,27 +23,58 @@ class GemSkillLinker
SKILLS_SUBDIR = "skills"
AGENT_SKILLS_SUBDIRS = [".agents", "skills"].freeze

# Env overrides a harness (e.g. a sandboxed agent session) may have
# exported into dev's own environment, redirecting bundler to an
# ephemeral gem cache. The `bundle list` child gets them explicitly
# unset so paths resolve from the project's canonical bundler config —
# dev never runs under bundler itself, so these unsets are its
# equivalent of Bundler.original_env (dev#89).
HARNESS_ENV_SCRUB = [
"BUNDLE_PATH",
"BUNDLE_APP_CONFIG",
"BUNDLE_BIN",
"GEM_HOME",
"GEM_PATH",
"RUBYOPT",
"RUBYLIB",
].to_h { |name| [name, nil] }.freeze

# @param project_root [Pathname, String] repo root (Gemfile + link target)
# @param skills_dir [Pathname, String, nil] override for tests; defaults
# to <project_root>/.agents/skills
def initialize(project_root:, skills_dir: nil)
# @param tmpdir [Pathname, String] ephemeral temp root that links must
# never target; defaults to Dir.tmpdir (override for tests, whose
# fixture gem trees themselves live under the real temp dir)
def initialize(project_root:, skills_dir: nil, tmpdir: Dir.tmpdir)
@project_root = Pathname(project_root)
@skills_dir = Pathname(skills_dir || @project_root.join(*AGENT_SKILLS_SUBDIRS))
@skill_installer = SkillInstaller.new(skills_dir: @skills_dir)
@tmpdir_roots = tmpdir_roots(Pathname(tmpdir))
end

# Scan the locked gem set for shipped skills and refresh the project's
# links: install one per skill found, prune gem links whose gem left the
# lock. Never raises — skill links are hygiene riding a dependency
# install, and hygiene must not block correctness (failures are reported
# on stderr).
# lock. A skill that resolves under the temp dir is never linked (warned
# and skipped — a persistent link to purgeable state silently dangles
# later), but its gem still counts as present for pruning, so an
# ephemeral resolution cannot delete a durable link minted earlier.
# Never raises — skill links are hygiene riding a dependency install,
# and hygiene must not block correctness (failures are reported on
# stderr).
#
# @return [void]
def link_all
return unless gemfile_path.exist?

expected = expected_links
expected.each { |name, skill_dir| @skill_installer.install(name, skill_dir) }
expected.each do |name, skill_dir|
if ephemeral?(skill_dir)
$stderr.puts "dev: warning: not linking #{name} — #{skill_dir} is under the temp dir " \
"and would dangle once it is purged."
else
@skill_installer.install(name, skill_dir)
end
end
prune_stale_links(expected.keys)
rescue StandardError => e
$stderr.puts "dev: warning: could not refresh gem skill links (#{e.message})."
Expand DownExpand Up@@ -84,12 +115,15 @@ def gem_roots

# Runs under the project's shadowenv for the same reason as
# BundlerIntegration: the dev process's own PATH is the invoking
# service's, which on headless boxes carries the wrong Ruby.
# service's, which on headless boxes carries the wrong Ruby. Harness
# bundler/gem overrides are scrubbed from the child env (see
# HARNESS_ENV_SCRUB) so a sandboxed session cannot redirect the
# resolution into its ephemeral cache.
#
# @return [Array<Pathname>] install paths of every gem in the bundle
def bundled_gem_paths
out, err, status = Open3.capture3(
{ "BUNDLE_GEMFILE" => gemfile_path.to_s },
HARNESS_ENV_SCRUB.merge("BUNDLE_GEMFILE" => gemfile_path.to_s),
"shadowenv", "exec", "--", "bundle", "list", "--paths",
chdir: @project_root.to_s,
)
Expand DownExpand Up@@ -128,6 +162,30 @@ def locked_gem_names
names.uniq
end

# The temp root in both its raw and fully-resolved forms — on macOS
# Dir.tmpdir is under /var/... while bundler reports the
# /private/var/... realpath, so containment must check both spellings.
#
# @param tmpdir [Pathname]
# @return [Array<Pathname>]
def tmpdir_roots(tmpdir)
expanded = tmpdir.expand_path
roots = [expanded]
roots << expanded.realpath if expanded.exist?
roots.uniq
end

# Whether a resolved skill directory lives under the temp dir — the
# signature of a harness resolving the bundle into its own purgeable
# cache, whatever produced it.
#
# @param path [Pathname]
# @return [Boolean]
def ephemeral?(path)
resolved = path.exist? ? path.realpath : path.expand_path
@tmpdir_roots.any? { |root| resolved.to_s.start_with?("#{root}#{File::SEPARATOR}") }
end

# Remove gem links that no current gem accounts for (the gem left the
# lock; its tree may still exist on disk, so broken-link pruning alone
# would miss it). Only `gem-`-prefixed symlinks are candidates —
Expand Down
133 changes: 123 additions & 10 deletions test/dev/deps/gem_skill_linker_test.rb
Original file line numberDiff line numberDiff line change
Expand Up@@ -50,22 +50,31 @@ def build_gem(gems_root, dir_name, skills: [])

# `bundle list` must run under the project's shadowenv — same reasoning as
# BundlerIntegration: the dev process's PATH is the invoking service's,
# which on headless boxes carries the wrong Ruby.
# which on headless boxes carries the wrong Ruby — with harness bundler
# overrides scrubbed from the child env.
def stub_bundle_list(project, paths)
env = Dev::Deps::GemSkillLinker::HARNESS_ENV_SCRUB.merge("BUNDLE_GEMFILE" => (project / "Gemfile").to_s)
Open3.stubs(:capture3)
.with({ "BUNDLE_GEMFILE" => (project / "Gemfile").to_s },
"shadowenv", "exec", "--", "bundle", "list", "--paths", chdir: project.to_s)
.with(env, "shadowenv", "exec", "--", "bundle", "list", "--paths", chdir: project.to_s)
.returns([paths.map { |p| "#{p}\n" }.join, "", stub(success?: true)])
end

# Linker under test. The real Dir.tmpdir contains these tests' own fixture
# trees, so every linker gets a tmpdir override pointing inside the fixture
# dir — gems built by build_gem under `gems/` then read as durable, and a
# test opts into ephemerality by building under `<dir>/tmp`.
def build_linker(project, dir)
Dev::Deps::GemSkillLinker.new(project_root: project, tmpdir: Pathname(dir) / "tmp")
end

test "links a locked gem's shipped skills as gem-<gem>--<skill>" do
Given "a locked gem whose tree ships a skill"
dir = Dir.mktmpdir("dev-gem-skill-test-")
project, gems = build_project(dir)
rspock = build_gem(gems, "rspock-1.2.0", skills: ["rspock"])
minitest = build_gem(gems, "minitest-5.25.0")
stub_bundle_list(project, [rspock, minitest])
linker = Dev::Deps::GemSkillLinker.new(project_root: project)
linker = build_linker(project, dir)

When "linking"
linker.link_all
Expand All@@ -85,7 +94,7 @@ def stub_bundle_list(project, paths)
project, gems = build_project(dir)
reporters = build_gem(gems, "minitest-reporters-1.7.1", skills: ["reporting"])
stub_bundle_list(project, [reporters])
linker = Dev::Deps::GemSkillLinker.new(project_root: project)
linker = build_linker(project, dir)

When "linking"
linker.link_all
Expand All@@ -104,7 +113,7 @@ def stub_bundle_list(project, paths)
project, gems = build_project(dir)
stray = build_gem(gems, "stray-9.9.9", skills: ["stray"])
stub_bundle_list(project, [stray])
linker = Dev::Deps::GemSkillLinker.new(project_root: project)
linker = build_linker(project, dir)

When "linking"
linker.link_all
Expand All@@ -128,7 +137,7 @@ def stub_bundle_list(project, paths)
File.symlink(departed / "skills" / "departed", skills_dir / "gem-departed--departed")
File.symlink(gems, skills_dir / "my-own-link")
(skills_dir / "notes.md").write("mine\n")
linker = Dev::Deps::GemSkillLinker.new(project_root: project)
linker = build_linker(project, dir)

When "linking"
linker.link_all
Expand All@@ -149,7 +158,7 @@ def stub_bundle_list(project, paths)
project = Pathname(dir) / "repo"
FileUtils.mkdir_p(project)
Open3.expects(:capture3).never
linker = Dev::Deps::GemSkillLinker.new(project_root: project)
linker = build_linker(project, dir)

When "linking"
linker.link_all
Expand All@@ -169,7 +178,7 @@ def stub_bundle_list(project, paths)
skills_dir = project / ".agents" / "skills"
FileUtils.mkdir_p(skills_dir)
FileUtils.chmod(0o000, skills_dir)
linker = Dev::Deps::GemSkillLinker.new(project_root: project)
linker = build_linker(project, dir)
old_stderr = $stderr
$stderr = StringIO.new

Expand All@@ -185,12 +194,116 @@ def stub_bundle_list(project, paths)
FileUtils.rm_rf(dir)
end

# Pins the exact scrub set rather than referencing HARNESS_ENV_SCRUB: a
# sandboxed session (Cursor sandbox cache, dev#89) leaks these overrides
# into dev's env, and dropping any of them from the scrub would silently
# re-open the leak.
test "bundle list runs with harness bundler and gem overrides explicitly unset" do
Given "a project"
dir = Dir.mktmpdir("dev-gem-skill-test-")
project, = build_project(dir)
linker = build_linker(project, dir)

When "linking"
linker.link_all

Then "every harness override is nil'd in the child env"
1 * Open3.capture3(
{
"BUNDLE_PATH" => nil,
"BUNDLE_APP_CONFIG" => nil,
"BUNDLE_BIN" => nil,
"GEM_HOME" => nil,
"GEM_PATH" => nil,
"RUBYOPT" => nil,
"RUBYLIB" => nil,
"BUNDLE_GEMFILE" => (project / "Gemfile").to_s,
},
"shadowenv", "exec", "--", "bundle", "list", "--paths", chdir: project.to_s
) >> ["", "", stub(success?: true)]

Cleanup
FileUtils.rm_rf(dir)
end

test "refuses to link a skill resolved under the temp dir and warns" do
Given "a locked gem whose tree resolves into the ephemeral temp dir"
dir = Dir.mktmpdir("dev-gem-skill-test-")
project, = build_project(dir)
ephemeral = build_gem(Pathname(dir) / "tmp" / "gems", "rspock-1.2.0", skills: ["rspock"])
stub_bundle_list(project, [ephemeral])
linker = build_linker(project, dir)
old_stderr = $stderr
$stderr = StringIO.new

When "linking"
linker.link_all

Then "no link is created and the skip is warned"
!File.exist?(project / ".agents" / "skills" / "gem-rspock--rspock")
$stderr.string.include?("not linking gem-rspock--rspock")

Cleanup
$stderr = old_stderr
FileUtils.rm_rf(dir)
end

test "an ephemeral resolution does not prune the durable link it shadows" do
Given "a durable link for a gem that now resolves into the temp dir"
dir = Dir.mktmpdir("dev-gem-skill-test-")
project, gems = build_project(dir)
durable = build_gem(gems, "rspock-1.2.0", skills: ["rspock"])
skills_dir = project / ".agents" / "skills"
FileUtils.mkdir_p(skills_dir)
File.symlink(durable / "skills" / "rspock", skills_dir / "gem-rspock--rspock")
ephemeral = build_gem(Pathname(dir) / "tmp" / "gems", "rspock-1.2.0", skills: ["rspock"])
stub_bundle_list(project, [ephemeral])
linker = build_linker(project, dir)
old_stderr = $stderr
$stderr = StringIO.new

When "linking"
linker.link_all

Then "the durable link survives, still pointing at its durable target"
File.symlink?(skills_dir / "gem-rspock--rspock")
File.readlink(skills_dir / "gem-rspock--rspock") == (durable / "skills" / "rspock").to_s

Cleanup
$stderr = old_stderr
FileUtils.rm_rf(dir)
end

test "temp dir containment sees through symlinked temp roots" do
Given "a tmpdir override that is a symlink to the dir the gem resolves under"
dir = Dir.mktmpdir("dev-gem-skill-test-")
project, = build_project(dir)
real_tmp = Pathname(dir) / "tmp"
ephemeral = build_gem(real_tmp / "gems", "rspock-1.2.0", skills: ["rspock"])
tmp_alias = Pathname(dir) / "tmp-alias"
File.symlink(real_tmp, tmp_alias)
stub_bundle_list(project, [ephemeral])
linker = Dev::Deps::GemSkillLinker.new(project_root: project, tmpdir: tmp_alias)
old_stderr = $stderr
$stderr = StringIO.new

When "linking"
linker.link_all

Then "the path is recognized as ephemeral and never links"
!File.exist?(project / ".agents" / "skills" / "gem-rspock--rspock")

Cleanup
$stderr = old_stderr
FileUtils.rm_rf(dir)
end

test "a failing bundle list warns instead of failing the install" do
Given "bundler erroring out"
dir = Dir.mktmpdir("dev-gem-skill-test-")
project, = build_project(dir)
Open3.stubs(:capture3).returns(["", "bundler exploded", stub(success?: false)])
linker = Dev::Deps::GemSkillLinker.new(project_root: project)
linker = build_linker(project, dir)
old_stderr = $stderr
$stderr = StringIO.new

Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 65 additions & 7 deletions lib/dev/deps/gem_skill_linker.rb
Original file line numberDiff line numberDiff line change
Expand Up@@ -23,27 +23,58 @@ class GemSkillLinker
SKILLS_SUBDIR = "skills"
AGENT_SKILLS_SUBDIRS = [".agents", "skills"].freeze

# Env overrides a harness (e.g. a sandboxed agent session) may have
# exported into dev's own environment, redirecting bundler to an
# ephemeral gem cache. The `bundle list` child gets them explicitly
# unset so paths resolve from the project's canonical bundler config —
# dev never runs under bundler itself, so these unsets are its
# equivalent of Bundler.original_env (dev#89).
HARNESS_ENV_SCRUB = [
"BUNDLE_PATH",
"BUNDLE_APP_CONFIG",
"BUNDLE_BIN",
"GEM_HOME",
"GEM_PATH",
"RUBYOPT",
"RUBYLIB",
].to_h { |name| [name, nil] }.freeze

# @param project_root [Pathname, String] repo root (Gemfile + link target)
# @param skills_dir [Pathname, String, nil] override for tests; defaults
# to <project_root>/.agents/skills
def initialize(project_root:, skills_dir: nil)
# @param tmpdir [Pathname, String] ephemeral temp root that links must
# never target; defaults to Dir.tmpdir (override for tests, whose
# fixture gem trees themselves live under the real temp dir)
def initialize(project_root:, skills_dir: nil, tmpdir: Dir.tmpdir)
@project_root = Pathname(project_root)
@skills_dir = Pathname(skills_dir || @project_root.join(*AGENT_SKILLS_SUBDIRS))
@skill_installer = SkillInstaller.new(skills_dir: @skills_dir)
@tmpdir_roots = tmpdir_roots(Pathname(tmpdir))
end

# Scan the locked gem set for shipped skills and refresh the project's
# links: install one per skill found, prune gem links whose gem left the
# lock. Never raises — skill links are hygiene riding a dependency
# install, and hygiene must not block correctness (failures are reported
# on stderr).
# lock. A skill that resolves under the temp dir is never linked (warned
# and skipped — a persistent link to purgeable state silently dangles
# later), but its gem still counts as present for pruning, so an
# ephemeral resolution cannot delete a durable link minted earlier.
# Never raises — skill links are hygiene riding a dependency install,
# and hygiene must not block correctness (failures are reported on
# stderr).
#
# @return [void]
def link_all
return unless gemfile_path.exist?

expected = expected_links
expected.each { |name, skill_dir| @skill_installer.install(name, skill_dir) }
expected.each do |name, skill_dir|
if ephemeral?(skill_dir)
$stderr.puts "dev: warning: not linking #{name} — #{skill_dir} is under the temp dir " \
"and would dangle once it is purged."
else
@skill_installer.install(name, skill_dir)
end
end
prune_stale_links(expected.keys)
rescue StandardError => e
$stderr.puts "dev: warning: could not refresh gem skill links (#{e.message})."
Expand DownExpand Up@@ -84,12 +115,15 @@ def gem_roots

# Runs under the project's shadowenv for the same reason as
# BundlerIntegration: the dev process's own PATH is the invoking
# service's, which on headless boxes carries the wrong Ruby.
# service's, which on headless boxes carries the wrong Ruby. Harness
# bundler/gem overrides are scrubbed from the child env (see
# HARNESS_ENV_SCRUB) so a sandboxed session cannot redirect the
# resolution into its ephemeral cache.
#
# @return [Array<Pathname>] install paths of every gem in the bundle
def bundled_gem_paths
out, err, status = Open3.capture3(
{ "BUNDLE_GEMFILE" => gemfile_path.to_s },
HARNESS_ENV_SCRUB.merge("BUNDLE_GEMFILE" => gemfile_path.to_s),
"shadowenv", "exec", "--", "bundle", "list", "--paths",
chdir: @project_root.to_s,
)
Expand DownExpand Up@@ -128,6 +162,30 @@ def locked_gem_names
names.uniq
end

# The temp root in both its raw and fully-resolved forms — on macOS
# Dir.tmpdir is under /var/... while bundler reports the
# /private/var/... realpath, so containment must check both spellings.
#
# @param tmpdir [Pathname]
# @return [Array<Pathname>]
def tmpdir_roots(tmpdir)
expanded = tmpdir.expand_path
roots = [expanded]
roots << expanded.realpath if expanded.exist?
roots.uniq
end

# Whether a resolved skill directory lives under the temp dir — the
# signature of a harness resolving the bundle into its own purgeable
# cache, whatever produced it.
#
# @param path [Pathname]
# @return [Boolean]
def ephemeral?(path)
resolved = path.exist? ? path.realpath : path.expand_path
@tmpdir_roots.any? { |root| resolved.to_s.start_with?("#{root}#{File::SEPARATOR}") }
end

# Remove gem links that no current gem accounts for (the gem left the
# lock; its tree may still exist on disk, so broken-link pruning alone
# would miss it). Only `gem-`-prefixed symlinks are candidates —
Expand Down
133 changes: 123 additions & 10 deletions test/dev/deps/gem_skill_linker_test.rb
Original file line numberDiff line numberDiff line change
Expand Up@@ -50,22 +50,31 @@ def build_gem(gems_root, dir_name, skills: [])

# `bundle list` must run under the project's shadowenv — same reasoning as
# BundlerIntegration: the dev process's PATH is the invoking service's,
# which on headless boxes carries the wrong Ruby.
# which on headless boxes carries the wrong Ruby — with harness bundler
# overrides scrubbed from the child env.
def stub_bundle_list(project, paths)
env = Dev::Deps::GemSkillLinker::HARNESS_ENV_SCRUB.merge("BUNDLE_GEMFILE" => (project / "Gemfile").to_s)
Open3.stubs(:capture3)
.with({ "BUNDLE_GEMFILE" => (project / "Gemfile").to_s },
"shadowenv", "exec", "--", "bundle", "list", "--paths", chdir: project.to_s)
.with(env, "shadowenv", "exec", "--", "bundle", "list", "--paths", chdir: project.to_s)
.returns([paths.map { |p| "#{p}\n" }.join, "", stub(success?: true)])
end

# Linker under test. The real Dir.tmpdir contains these tests' own fixture
# trees, so every linker gets a tmpdir override pointing inside the fixture
# dir — gems built by build_gem under `gems/` then read as durable, and a
# test opts into ephemerality by building under `<dir>/tmp`.
def build_linker(project, dir)
Dev::Deps::GemSkillLinker.new(project_root: project, tmpdir: Pathname(dir) / "tmp")
end

test "links a locked gem's shipped skills as gem-<gem>--<skill>" do
Given "a locked gem whose tree ships a skill"
dir = Dir.mktmpdir("dev-gem-skill-test-")
project, gems = build_project(dir)
rspock = build_gem(gems, "rspock-1.2.0", skills: ["rspock"])
minitest = build_gem(gems, "minitest-5.25.0")
stub_bundle_list(project, [rspock, minitest])
linker = Dev::Deps::GemSkillLinker.new(project_root: project)
linker = build_linker(project, dir)

When "linking"
linker.link_all
Expand All@@ -85,7 +94,7 @@ def stub_bundle_list(project, paths)
project, gems = build_project(dir)
reporters = build_gem(gems, "minitest-reporters-1.7.1", skills: ["reporting"])
stub_bundle_list(project, [reporters])
linker = Dev::Deps::GemSkillLinker.new(project_root: project)
linker = build_linker(project, dir)

When "linking"
linker.link_all
Expand All@@ -104,7 +113,7 @@ def stub_bundle_list(project, paths)
project, gems = build_project(dir)
stray = build_gem(gems, "stray-9.9.9", skills: ["stray"])
stub_bundle_list(project, [stray])
linker = Dev::Deps::GemSkillLinker.new(project_root: project)
linker = build_linker(project, dir)

When "linking"
linker.link_all
Expand All@@ -128,7 +137,7 @@ def stub_bundle_list(project, paths)
File.symlink(departed / "skills" / "departed", skills_dir / "gem-departed--departed")
File.symlink(gems, skills_dir / "my-own-link")
(skills_dir / "notes.md").write("mine\n")
linker = Dev::Deps::GemSkillLinker.new(project_root: project)
linker = build_linker(project, dir)

When "linking"
linker.link_all
Expand All@@ -149,7 +158,7 @@ def stub_bundle_list(project, paths)
project = Pathname(dir) / "repo"
FileUtils.mkdir_p(project)
Open3.expects(:capture3).never
linker = Dev::Deps::GemSkillLinker.new(project_root: project)
linker = build_linker(project, dir)

When "linking"
linker.link_all
Expand All@@ -169,7 +178,7 @@ def stub_bundle_list(project, paths)
skills_dir = project / ".agents" / "skills"
FileUtils.mkdir_p(skills_dir)
FileUtils.chmod(0o000, skills_dir)
linker = Dev::Deps::GemSkillLinker.new(project_root: project)
linker = build_linker(project, dir)
old_stderr = $stderr
$stderr = StringIO.new

Expand All@@ -185,12 +194,116 @@ def stub_bundle_list(project, paths)
FileUtils.rm_rf(dir)
end

# Pins the exact scrub set rather than referencing HARNESS_ENV_SCRUB: a
# sandboxed session (Cursor sandbox cache, dev#89) leaks these overrides
# into dev's env, and dropping any of them from the scrub would silently
# re-open the leak.
test "bundle list runs with harness bundler and gem overrides explicitly unset" do
Given "a project"
dir = Dir.mktmpdir("dev-gem-skill-test-")
project, = build_project(dir)
linker = build_linker(project, dir)

When "linking"
linker.link_all

Then "every harness override is nil'd in the child env"
1 * Open3.capture3(
{
"BUNDLE_PATH" => nil,
"BUNDLE_APP_CONFIG" => nil,
"BUNDLE_BIN" => nil,
"GEM_HOME" => nil,
"GEM_PATH" => nil,
"RUBYOPT" => nil,
"RUBYLIB" => nil,
"BUNDLE_GEMFILE" => (project / "Gemfile").to_s,
},
"shadowenv", "exec", "--", "bundle", "list", "--paths", chdir: project.to_s
) >> ["", "", stub(success?: true)]

Cleanup
FileUtils.rm_rf(dir)
end

test "refuses to link a skill resolved under the temp dir and warns" do
Given "a locked gem whose tree resolves into the ephemeral temp dir"
dir = Dir.mktmpdir("dev-gem-skill-test-")
project, = build_project(dir)
ephemeral = build_gem(Pathname(dir) / "tmp" / "gems", "rspock-1.2.0", skills: ["rspock"])
stub_bundle_list(project, [ephemeral])
linker = build_linker(project, dir)
old_stderr = $stderr
$stderr = StringIO.new

When "linking"
linker.link_all

Then "no link is created and the skip is warned"
!File.exist?(project / ".agents" / "skills" / "gem-rspock--rspock")
$stderr.string.include?("not linking gem-rspock--rspock")

Cleanup
$stderr = old_stderr
FileUtils.rm_rf(dir)
end

test "an ephemeral resolution does not prune the durable link it shadows" do
Given "a durable link for a gem that now resolves into the temp dir"
dir = Dir.mktmpdir("dev-gem-skill-test-")
project, gems = build_project(dir)
durable = build_gem(gems, "rspock-1.2.0", skills: ["rspock"])
skills_dir = project / ".agents" / "skills"
FileUtils.mkdir_p(skills_dir)
File.symlink(durable / "skills" / "rspock", skills_dir / "gem-rspock--rspock")
ephemeral = build_gem(Pathname(dir) / "tmp" / "gems", "rspock-1.2.0", skills: ["rspock"])
stub_bundle_list(project, [ephemeral])
linker = build_linker(project, dir)
old_stderr = $stderr
$stderr = StringIO.new

When "linking"
linker.link_all

Then "the durable link survives, still pointing at its durable target"
File.symlink?(skills_dir / "gem-rspock--rspock")
File.readlink(skills_dir / "gem-rspock--rspock") == (durable / "skills" / "rspock").to_s

Cleanup
$stderr = old_stderr
FileUtils.rm_rf(dir)
end

test "temp dir containment sees through symlinked temp roots" do
Given "a tmpdir override that is a symlink to the dir the gem resolves under"
dir = Dir.mktmpdir("dev-gem-skill-test-")
project, = build_project(dir)
real_tmp = Pathname(dir) / "tmp"
ephemeral = build_gem(real_tmp / "gems", "rspock-1.2.0", skills: ["rspock"])
tmp_alias = Pathname(dir) / "tmp-alias"
File.symlink(real_tmp, tmp_alias)
stub_bundle_list(project, [ephemeral])
linker = Dev::Deps::GemSkillLinker.new(project_root: project, tmpdir: tmp_alias)
old_stderr = $stderr
$stderr = StringIO.new

When "linking"
linker.link_all

Then "the path is recognized as ephemeral and never links"
!File.exist?(project / ".agents" / "skills" / "gem-rspock--rspock")

Cleanup
$stderr = old_stderr
FileUtils.rm_rf(dir)
end

test "a failing bundle list warns instead of failing the install" do
Given "bundler erroring out"
dir = Dir.mktmpdir("dev-gem-skill-test-")
project, = build_project(dir)
Open3.stubs(:capture3).returns(["", "bundler exploded", stub(success?: false)])
linker = Dev::Deps::GemSkillLinker.new(project_root: project)
linker = build_linker(project, dir)
old_stderr = $stderr
$stderr = StringIO.new

Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 65 additions & 7 deletions lib/dev/deps/gem_skill_linker.rb
Original file line numberDiff line numberDiff line change
Expand Up@@ -23,27 +23,58 @@ class GemSkillLinker
SKILLS_SUBDIR = "skills"
AGENT_SKILLS_SUBDIRS = [".agents", "skills"].freeze

# Env overrides a harness (e.g. a sandboxed agent session) may have
# exported into dev's own environment, redirecting bundler to an
# ephemeral gem cache. The `bundle list` child gets them explicitly
# unset so paths resolve from the project's canonical bundler config —
# dev never runs under bundler itself, so these unsets are its
# equivalent of Bundler.original_env (dev#89).
HARNESS_ENV_SCRUB = [
"BUNDLE_PATH",
"BUNDLE_APP_CONFIG",
"BUNDLE_BIN",
"GEM_HOME",
"GEM_PATH",
"RUBYOPT",
"RUBYLIB",
].to_h { |name| [name, nil] }.freeze

# @param project_root [Pathname, String] repo root (Gemfile + link target)
# @param skills_dir [Pathname, String, nil] override for tests; defaults
# to <project_root>/.agents/skills
def initialize(project_root:, skills_dir: nil)
# @param tmpdir [Pathname, String] ephemeral temp root that links must
# never target; defaults to Dir.tmpdir (override for tests, whose
# fixture gem trees themselves live under the real temp dir)
def initialize(project_root:, skills_dir: nil, tmpdir: Dir.tmpdir)
@project_root = Pathname(project_root)
@skills_dir = Pathname(skills_dir || @project_root.join(*AGENT_SKILLS_SUBDIRS))
@skill_installer = SkillInstaller.new(skills_dir: @skills_dir)
@tmpdir_roots = tmpdir_roots(Pathname(tmpdir))
end

# Scan the locked gem set for shipped skills and refresh the project's
# links: install one per skill found, prune gem links whose gem left the
# lock. Never raises — skill links are hygiene riding a dependency
# install, and hygiene must not block correctness (failures are reported
# on stderr).
# lock. A skill that resolves under the temp dir is never linked (warned
# and skipped — a persistent link to purgeable state silently dangles
# later), but its gem still counts as present for pruning, so an
# ephemeral resolution cannot delete a durable link minted earlier.
# Never raises — skill links are hygiene riding a dependency install,
# and hygiene must not block correctness (failures are reported on
# stderr).
#
# @return [void]
def link_all
return unless gemfile_path.exist?

expected = expected_links
expected.each { |name, skill_dir| @skill_installer.install(name, skill_dir) }
expected.each do |name, skill_dir|
if ephemeral?(skill_dir)
$stderr.puts "dev: warning: not linking #{name} — #{skill_dir} is under the temp dir " \
"and would dangle once it is purged."
else
@skill_installer.install(name, skill_dir)
end
end
prune_stale_links(expected.keys)
rescue StandardError => e
$stderr.puts "dev: warning: could not refresh gem skill links (#{e.message})."
Expand DownExpand Up@@ -84,12 +115,15 @@ def gem_roots

# Runs under the project's shadowenv for the same reason as
# BundlerIntegration: the dev process's own PATH is the invoking
# service's, which on headless boxes carries the wrong Ruby.
# service's, which on headless boxes carries the wrong Ruby. Harness
# bundler/gem overrides are scrubbed from the child env (see
# HARNESS_ENV_SCRUB) so a sandboxed session cannot redirect the
# resolution into its ephemeral cache.
#
# @return [Array<Pathname>] install paths of every gem in the bundle
def bundled_gem_paths
out, err, status = Open3.capture3(
{ "BUNDLE_GEMFILE" => gemfile_path.to_s },
HARNESS_ENV_SCRUB.merge("BUNDLE_GEMFILE" => gemfile_path.to_s),
"shadowenv", "exec", "--", "bundle", "list", "--paths",
chdir: @project_root.to_s,
)
Expand DownExpand Up@@ -128,6 +162,30 @@ def locked_gem_names
names.uniq
end

# The temp root in both its raw and fully-resolved forms — on macOS
# Dir.tmpdir is under /var/... while bundler reports the
# /private/var/... realpath, so containment must check both spellings.
#
# @param tmpdir [Pathname]
# @return [Array<Pathname>]
def tmpdir_roots(tmpdir)
expanded = tmpdir.expand_path
roots = [expanded]
roots << expanded.realpath if expanded.exist?
roots.uniq
end

# Whether a resolved skill directory lives under the temp dir — the
# signature of a harness resolving the bundle into its own purgeable
# cache, whatever produced it.
#
# @param path [Pathname]
# @return [Boolean]
def ephemeral?(path)
resolved = path.exist? ? path.realpath : path.expand_path
@tmpdir_roots.any? { |root| resolved.to_s.start_with?("#{root}#{File::SEPARATOR}") }
end

# Remove gem links that no current gem accounts for (the gem left the
# lock; its tree may still exist on disk, so broken-link pruning alone
# would miss it). Only `gem-`-prefixed symlinks are candidates —
Expand Down
133 changes: 123 additions & 10 deletions test/dev/deps/gem_skill_linker_test.rb
Original file line numberDiff line numberDiff line change
Expand Up@@ -50,22 +50,31 @@ def build_gem(gems_root, dir_name, skills: [])

# `bundle list` must run under the project's shadowenv — same reasoning as
# BundlerIntegration: the dev process's PATH is the invoking service's,
# which on headless boxes carries the wrong Ruby.
# which on headless boxes carries the wrong Ruby — with harness bundler
# overrides scrubbed from the child env.
def stub_bundle_list(project, paths)
env = Dev::Deps::GemSkillLinker::HARNESS_ENV_SCRUB.merge("BUNDLE_GEMFILE" => (project / "Gemfile").to_s)
Open3.stubs(:capture3)
.with({ "BUNDLE_GEMFILE" => (project / "Gemfile").to_s },
"shadowenv", "exec", "--", "bundle", "list", "--paths", chdir: project.to_s)
.with(env, "shadowenv", "exec", "--", "bundle", "list", "--paths", chdir: project.to_s)
.returns([paths.map { |p| "#{p}\n" }.join, "", stub(success?: true)])
end

# Linker under test. The real Dir.tmpdir contains these tests' own fixture
# trees, so every linker gets a tmpdir override pointing inside the fixture
# dir — gems built by build_gem under `gems/` then read as durable, and a
# test opts into ephemerality by building under `<dir>/tmp`.
def build_linker(project, dir)
Dev::Deps::GemSkillLinker.new(project_root: project, tmpdir: Pathname(dir) / "tmp")
end

test "links a locked gem's shipped skills as gem-<gem>--<skill>" do
Given "a locked gem whose tree ships a skill"
dir = Dir.mktmpdir("dev-gem-skill-test-")
project, gems = build_project(dir)
rspock = build_gem(gems, "rspock-1.2.0", skills: ["rspock"])
minitest = build_gem(gems, "minitest-5.25.0")
stub_bundle_list(project, [rspock, minitest])
linker = Dev::Deps::GemSkillLinker.new(project_root: project)
linker = build_linker(project, dir)

When "linking"
linker.link_all
Expand All@@ -85,7 +94,7 @@ def stub_bundle_list(project, paths)
project, gems = build_project(dir)
reporters = build_gem(gems, "minitest-reporters-1.7.1", skills: ["reporting"])
stub_bundle_list(project, [reporters])
linker = Dev::Deps::GemSkillLinker.new(project_root: project)
linker = build_linker(project, dir)

When "linking"
linker.link_all
Expand All@@ -104,7 +113,7 @@ def stub_bundle_list(project, paths)
project, gems = build_project(dir)
stray = build_gem(gems, "stray-9.9.9", skills: ["stray"])
stub_bundle_list(project, [stray])
linker = Dev::Deps::GemSkillLinker.new(project_root: project)
linker = build_linker(project, dir)

When "linking"
linker.link_all
Expand All@@ -128,7 +137,7 @@ def stub_bundle_list(project, paths)
File.symlink(departed / "skills" / "departed", skills_dir / "gem-departed--departed")
File.symlink(gems, skills_dir / "my-own-link")
(skills_dir / "notes.md").write("mine\n")
linker = Dev::Deps::GemSkillLinker.new(project_root: project)
linker = build_linker(project, dir)

When "linking"
linker.link_all
Expand All@@ -149,7 +158,7 @@ def stub_bundle_list(project, paths)
project = Pathname(dir) / "repo"
FileUtils.mkdir_p(project)
Open3.expects(:capture3).never
linker = Dev::Deps::GemSkillLinker.new(project_root: project)
linker = build_linker(project, dir)

When "linking"
linker.link_all
Expand All@@ -169,7 +178,7 @@ def stub_bundle_list(project, paths)
skills_dir = project / ".agents" / "skills"
FileUtils.mkdir_p(skills_dir)
FileUtils.chmod(0o000, skills_dir)
linker = Dev::Deps::GemSkillLinker.new(project_root: project)
linker = build_linker(project, dir)
old_stderr = $stderr
$stderr = StringIO.new

Expand All@@ -185,12 +194,116 @@ def stub_bundle_list(project, paths)
FileUtils.rm_rf(dir)
end

# Pins the exact scrub set rather than referencing HARNESS_ENV_SCRUB: a
# sandboxed session (Cursor sandbox cache, dev#89) leaks these overrides
# into dev's env, and dropping any of them from the scrub would silently
# re-open the leak.
test "bundle list runs with harness bundler and gem overrides explicitly unset" do
Given "a project"
dir = Dir.mktmpdir("dev-gem-skill-test-")
project, = build_project(dir)
linker = build_linker(project, dir)

When "linking"
linker.link_all

Then "every harness override is nil'd in the child env"
1 * Open3.capture3(
{
"BUNDLE_PATH" => nil,
"BUNDLE_APP_CONFIG" => nil,
"BUNDLE_BIN" => nil,
"GEM_HOME" => nil,
"GEM_PATH" => nil,
"RUBYOPT" => nil,
"RUBYLIB" => nil,
"BUNDLE_GEMFILE" => (project / "Gemfile").to_s,
},
"shadowenv", "exec", "--", "bundle", "list", "--paths", chdir: project.to_s
) >> ["", "", stub(success?: true)]

Cleanup
FileUtils.rm_rf(dir)
end

test "refuses to link a skill resolved under the temp dir and warns" do
Given "a locked gem whose tree resolves into the ephemeral temp dir"
dir = Dir.mktmpdir("dev-gem-skill-test-")
project, = build_project(dir)
ephemeral = build_gem(Pathname(dir) / "tmp" / "gems", "rspock-1.2.0", skills: ["rspock"])
stub_bundle_list(project, [ephemeral])
linker = build_linker(project, dir)
old_stderr = $stderr
$stderr = StringIO.new

When "linking"
linker.link_all

Then "no link is created and the skip is warned"
!File.exist?(project / ".agents" / "skills" / "gem-rspock--rspock")
$stderr.string.include?("not linking gem-rspock--rspock")

Cleanup
$stderr = old_stderr
FileUtils.rm_rf(dir)
end

test "an ephemeral resolution does not prune the durable link it shadows" do
Given "a durable link for a gem that now resolves into the temp dir"
dir = Dir.mktmpdir("dev-gem-skill-test-")
project, gems = build_project(dir)
durable = build_gem(gems, "rspock-1.2.0", skills: ["rspock"])
skills_dir = project / ".agents" / "skills"
FileUtils.mkdir_p(skills_dir)
File.symlink(durable / "skills" / "rspock", skills_dir / "gem-rspock--rspock")
ephemeral = build_gem(Pathname(dir) / "tmp" / "gems", "rspock-1.2.0", skills: ["rspock"])
stub_bundle_list(project, [ephemeral])
linker = build_linker(project, dir)
old_stderr = $stderr
$stderr = StringIO.new

When "linking"
linker.link_all

Then "the durable link survives, still pointing at its durable target"
File.symlink?(skills_dir / "gem-rspock--rspock")
File.readlink(skills_dir / "gem-rspock--rspock") == (durable / "skills" / "rspock").to_s

Cleanup
$stderr = old_stderr
FileUtils.rm_rf(dir)
end

test "temp dir containment sees through symlinked temp roots" do
Given "a tmpdir override that is a symlink to the dir the gem resolves under"
dir = Dir.mktmpdir("dev-gem-skill-test-")
project, = build_project(dir)
real_tmp = Pathname(dir) / "tmp"
ephemeral = build_gem(real_tmp / "gems", "rspock-1.2.0", skills: ["rspock"])
tmp_alias = Pathname(dir) / "tmp-alias"
File.symlink(real_tmp, tmp_alias)
stub_bundle_list(project, [ephemeral])
linker = Dev::Deps::GemSkillLinker.new(project_root: project, tmpdir: tmp_alias)
old_stderr = $stderr
$stderr = StringIO.new

When "linking"
linker.link_all

Then "the path is recognized as ephemeral and never links"
!File.exist?(project / ".agents" / "skills" / "gem-rspock--rspock")

Cleanup
$stderr = old_stderr
FileUtils.rm_rf(dir)
end

test "a failing bundle list warns instead of failing the install" do
Given "bundler erroring out"
dir = Dir.mktmpdir("dev-gem-skill-test-")
project, = build_project(dir)
Open3.stubs(:capture3).returns(["", "bundler exploded", stub(success?: false)])
linker = Dev::Deps::GemSkillLinker.new(project_root: project)
linker = build_linker(project, dir)
old_stderr = $stderr
$stderr = StringIO.new

Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 65 additions & 7 deletions lib/dev/deps/gem_skill_linker.rb
Original file line numberDiff line numberDiff line change
Expand Up@@ -23,27 +23,58 @@ class GemSkillLinker
SKILLS_SUBDIR = "skills"
AGENT_SKILLS_SUBDIRS = [".agents", "skills"].freeze

# Env overrides a harness (e.g. a sandboxed agent session) may have
# exported into dev's own environment, redirecting bundler to an
# ephemeral gem cache. The `bundle list` child gets them explicitly
# unset so paths resolve from the project's canonical bundler config —
# dev never runs under bundler itself, so these unsets are its
# equivalent of Bundler.original_env (dev#89).
HARNESS_ENV_SCRUB = [
"BUNDLE_PATH",
"BUNDLE_APP_CONFIG",
"BUNDLE_BIN",
"GEM_HOME",
"GEM_PATH",
"RUBYOPT",
"RUBYLIB",
].to_h { |name| [name, nil] }.freeze

# @param project_root [Pathname, String] repo root (Gemfile + link target)
# @param skills_dir [Pathname, String, nil] override for tests; defaults
# to <project_root>/.agents/skills
def initialize(project_root:, skills_dir: nil)
# @param tmpdir [Pathname, String] ephemeral temp root that links must
# never target; defaults to Dir.tmpdir (override for tests, whose
# fixture gem trees themselves live under the real temp dir)
def initialize(project_root:, skills_dir: nil, tmpdir: Dir.tmpdir)
@project_root = Pathname(project_root)
@skills_dir = Pathname(skills_dir || @project_root.join(*AGENT_SKILLS_SUBDIRS))
@skill_installer = SkillInstaller.new(skills_dir: @skills_dir)
@tmpdir_roots = tmpdir_roots(Pathname(tmpdir))
end

# Scan the locked gem set for shipped skills and refresh the project's
# links: install one per skill found, prune gem links whose gem left the
# lock. Never raises — skill links are hygiene riding a dependency
# install, and hygiene must not block correctness (failures are reported
# on stderr).
# lock. A skill that resolves under the temp dir is never linked (warned
# and skipped — a persistent link to purgeable state silently dangles
# later), but its gem still counts as present for pruning, so an
# ephemeral resolution cannot delete a durable link minted earlier.
# Never raises — skill links are hygiene riding a dependency install,
# and hygiene must not block correctness (failures are reported on
# stderr).
#
# @return [void]
def link_all
return unless gemfile_path.exist?

expected = expected_links
expected.each { |name, skill_dir| @skill_installer.install(name, skill_dir) }
expected.each do |name, skill_dir|
if ephemeral?(skill_dir)
$stderr.puts "dev: warning: not linking #{name} — #{skill_dir} is under the temp dir " \
"and would dangle once it is purged."
else
@skill_installer.install(name, skill_dir)
end
end
prune_stale_links(expected.keys)
rescue StandardError => e
$stderr.puts "dev: warning: could not refresh gem skill links (#{e.message})."
Expand DownExpand Up@@ -84,12 +115,15 @@ def gem_roots

# Runs under the project's shadowenv for the same reason as
# BundlerIntegration: the dev process's own PATH is the invoking
# service's, which on headless boxes carries the wrong Ruby.
# service's, which on headless boxes carries the wrong Ruby. Harness
# bundler/gem overrides are scrubbed from the child env (see
# HARNESS_ENV_SCRUB) so a sandboxed session cannot redirect the
# resolution into its ephemeral cache.
#
# @return [Array<Pathname>] install paths of every gem in the bundle
def bundled_gem_paths
out, err, status = Open3.capture3(
{ "BUNDLE_GEMFILE" => gemfile_path.to_s },
HARNESS_ENV_SCRUB.merge("BUNDLE_GEMFILE" => gemfile_path.to_s),
"shadowenv", "exec", "--", "bundle", "list", "--paths",
chdir: @project_root.to_s,
)
Expand DownExpand Up@@ -128,6 +162,30 @@ def locked_gem_names
names.uniq
end

# The temp root in both its raw and fully-resolved forms — on macOS
# Dir.tmpdir is under /var/... while bundler reports the
# /private/var/... realpath, so containment must check both spellings.
#
# @param tmpdir [Pathname]
# @return [Array<Pathname>]
def tmpdir_roots(tmpdir)
expanded = tmpdir.expand_path
roots = [expanded]
roots << expanded.realpath if expanded.exist?
roots.uniq
end

# Whether a resolved skill directory lives under the temp dir — the
# signature of a harness resolving the bundle into its own purgeable
# cache, whatever produced it.
#
# @param path [Pathname]
# @return [Boolean]
def ephemeral?(path)
resolved = path.exist? ? path.realpath : path.expand_path
@tmpdir_roots.any? { |root| resolved.to_s.start_with?("#{root}#{File::SEPARATOR}") }
end

# Remove gem links that no current gem accounts for (the gem left the
# lock; its tree may still exist on disk, so broken-link pruning alone
# would miss it). Only `gem-`-prefixed symlinks are candidates —
Expand Down
133 changes: 123 additions & 10 deletions test/dev/deps/gem_skill_linker_test.rb
Original file line numberDiff line numberDiff line change
Expand Up@@ -50,22 +50,31 @@ def build_gem(gems_root, dir_name, skills: [])

# `bundle list` must run under the project's shadowenv — same reasoning as
# BundlerIntegration: the dev process's PATH is the invoking service's,
# which on headless boxes carries the wrong Ruby.
# which on headless boxes carries the wrong Ruby — with harness bundler
# overrides scrubbed from the child env.
def stub_bundle_list(project, paths)
env = Dev::Deps::GemSkillLinker::HARNESS_ENV_SCRUB.merge("BUNDLE_GEMFILE" => (project / "Gemfile").to_s)
Open3.stubs(:capture3)
.with({ "BUNDLE_GEMFILE" => (project / "Gemfile").to_s },
"shadowenv", "exec", "--", "bundle", "list", "--paths", chdir: project.to_s)
.with(env, "shadowenv", "exec", "--", "bundle", "list", "--paths", chdir: project.to_s)
.returns([paths.map { |p| "#{p}\n" }.join, "", stub(success?: true)])
end

# Linker under test. The real Dir.tmpdir contains these tests' own fixture
# trees, so every linker gets a tmpdir override pointing inside the fixture
# dir — gems built by build_gem under `gems/` then read as durable, and a
# test opts into ephemerality by building under `<dir>/tmp`.
def build_linker(project, dir)
Dev::Deps::GemSkillLinker.new(project_root: project, tmpdir: Pathname(dir) / "tmp")
end

test "links a locked gem's shipped skills as gem-<gem>--<skill>" do
Given "a locked gem whose tree ships a skill"
dir = Dir.mktmpdir("dev-gem-skill-test-")
project, gems = build_project(dir)
rspock = build_gem(gems, "rspock-1.2.0", skills: ["rspock"])
minitest = build_gem(gems, "minitest-5.25.0")
stub_bundle_list(project, [rspock, minitest])
linker = Dev::Deps::GemSkillLinker.new(project_root: project)
linker = build_linker(project, dir)

When "linking"
linker.link_all
Expand All@@ -85,7 +94,7 @@ def stub_bundle_list(project, paths)
project, gems = build_project(dir)
reporters = build_gem(gems, "minitest-reporters-1.7.1", skills: ["reporting"])
stub_bundle_list(project, [reporters])
linker = Dev::Deps::GemSkillLinker.new(project_root: project)
linker = build_linker(project, dir)

When "linking"
linker.link_all
Expand All@@ -104,7 +113,7 @@ def stub_bundle_list(project, paths)
project, gems = build_project(dir)
stray = build_gem(gems, "stray-9.9.9", skills: ["stray"])
stub_bundle_list(project, [stray])
linker = Dev::Deps::GemSkillLinker.new(project_root: project)
linker = build_linker(project, dir)

When "linking"
linker.link_all
Expand All@@ -128,7 +137,7 @@ def stub_bundle_list(project, paths)
File.symlink(departed / "skills" / "departed", skills_dir / "gem-departed--departed")
File.symlink(gems, skills_dir / "my-own-link")
(skills_dir / "notes.md").write("mine\n")
linker = Dev::Deps::GemSkillLinker.new(project_root: project)
linker = build_linker(project, dir)

When "linking"
linker.link_all
Expand All@@ -149,7 +158,7 @@ def stub_bundle_list(project, paths)
project = Pathname(dir) / "repo"
FileUtils.mkdir_p(project)
Open3.expects(:capture3).never
linker = Dev::Deps::GemSkillLinker.new(project_root: project)
linker = build_linker(project, dir)

When "linking"
linker.link_all
Expand All@@ -169,7 +178,7 @@ def stub_bundle_list(project, paths)
skills_dir = project / ".agents" / "skills"
FileUtils.mkdir_p(skills_dir)
FileUtils.chmod(0o000, skills_dir)
linker = Dev::Deps::GemSkillLinker.new(project_root: project)
linker = build_linker(project, dir)
old_stderr = $stderr
$stderr = StringIO.new

Expand All@@ -185,12 +194,116 @@ def stub_bundle_list(project, paths)
FileUtils.rm_rf(dir)
end

# Pins the exact scrub set rather than referencing HARNESS_ENV_SCRUB: a
# sandboxed session (Cursor sandbox cache, dev#89) leaks these overrides
# into dev's env, and dropping any of them from the scrub would silently
# re-open the leak.
test "bundle list runs with harness bundler and gem overrides explicitly unset" do
Given "a project"
dir = Dir.mktmpdir("dev-gem-skill-test-")
project, = build_project(dir)
linker = build_linker(project, dir)

When "linking"
linker.link_all

Then "every harness override is nil'd in the child env"
1 * Open3.capture3(
{
"BUNDLE_PATH" => nil,
"BUNDLE_APP_CONFIG" => nil,
"BUNDLE_BIN" => nil,
"GEM_HOME" => nil,
"GEM_PATH" => nil,
"RUBYOPT" => nil,
"RUBYLIB" => nil,
"BUNDLE_GEMFILE" => (project / "Gemfile").to_s,
},
"shadowenv", "exec", "--", "bundle", "list", "--paths", chdir: project.to_s
) >> ["", "", stub(success?: true)]

Cleanup
FileUtils.rm_rf(dir)
end

test "refuses to link a skill resolved under the temp dir and warns" do
Given "a locked gem whose tree resolves into the ephemeral temp dir"
dir = Dir.mktmpdir("dev-gem-skill-test-")
project, = build_project(dir)
ephemeral = build_gem(Pathname(dir) / "tmp" / "gems", "rspock-1.2.0", skills: ["rspock"])
stub_bundle_list(project, [ephemeral])
linker = build_linker(project, dir)
old_stderr = $stderr
$stderr = StringIO.new

When "linking"
linker.link_all

Then "no link is created and the skip is warned"
!File.exist?(project / ".agents" / "skills" / "gem-rspock--rspock")
$stderr.string.include?("not linking gem-rspock--rspock")

Cleanup
$stderr = old_stderr
FileUtils.rm_rf(dir)
end

test "an ephemeral resolution does not prune the durable link it shadows" do
Given "a durable link for a gem that now resolves into the temp dir"
dir = Dir.mktmpdir("dev-gem-skill-test-")
project, gems = build_project(dir)
durable = build_gem(gems, "rspock-1.2.0", skills: ["rspock"])
skills_dir = project / ".agents" / "skills"
FileUtils.mkdir_p(skills_dir)
File.symlink(durable / "skills" / "rspock", skills_dir / "gem-rspock--rspock")
ephemeral = build_gem(Pathname(dir) / "tmp" / "gems", "rspock-1.2.0", skills: ["rspock"])
stub_bundle_list(project, [ephemeral])
linker = build_linker(project, dir)
old_stderr = $stderr
$stderr = StringIO.new

When "linking"
linker.link_all

Then "the durable link survives, still pointing at its durable target"
File.symlink?(skills_dir / "gem-rspock--rspock")
File.readlink(skills_dir / "gem-rspock--rspock") == (durable / "skills" / "rspock").to_s

Cleanup
$stderr = old_stderr
FileUtils.rm_rf(dir)
end

test "temp dir containment sees through symlinked temp roots" do
Given "a tmpdir override that is a symlink to the dir the gem resolves under"
dir = Dir.mktmpdir("dev-gem-skill-test-")
project, = build_project(dir)
real_tmp = Pathname(dir) / "tmp"
ephemeral = build_gem(real_tmp / "gems", "rspock-1.2.0", skills: ["rspock"])
tmp_alias = Pathname(dir) / "tmp-alias"
File.symlink(real_tmp, tmp_alias)
stub_bundle_list(project, [ephemeral])
linker = Dev::Deps::GemSkillLinker.new(project_root: project, tmpdir: tmp_alias)
old_stderr = $stderr
$stderr = StringIO.new

When "linking"
linker.link_all

Then "the path is recognized as ephemeral and never links"
!File.exist?(project / ".agents" / "skills" / "gem-rspock--rspock")

Cleanup
$stderr = old_stderr
FileUtils.rm_rf(dir)
end

test "a failing bundle list warns instead of failing the install" do
Given "bundler erroring out"
dir = Dir.mktmpdir("dev-gem-skill-test-")
project, = build_project(dir)
Open3.stubs(:capture3).returns(["", "bundler exploded", stub(success?: false)])
linker = Dev::Deps::GemSkillLinker.new(project_root: project)
linker = build_linker(project, dir)
old_stderr = $stderr
$stderr = StringIO.new

Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 65 additions & 7 deletions lib/dev/deps/gem_skill_linker.rb
Original file line numberDiff line numberDiff line change
Expand Up@@ -23,27 +23,58 @@ class GemSkillLinker
SKILLS_SUBDIR = "skills"
AGENT_SKILLS_SUBDIRS = [".agents", "skills"].freeze

# Env overrides a harness (e.g. a sandboxed agent session) may have
# exported into dev's own environment, redirecting bundler to an
# ephemeral gem cache. The `bundle list` child gets them explicitly
# unset so paths resolve from the project's canonical bundler config —
# dev never runs under bundler itself, so these unsets are its
# equivalent of Bundler.original_env (dev#89).
HARNESS_ENV_SCRUB = [
"BUNDLE_PATH",
"BUNDLE_APP_CONFIG",
"BUNDLE_BIN",
"GEM_HOME",
"GEM_PATH",
"RUBYOPT",
"RUBYLIB",
].to_h { |name| [name, nil] }.freeze

# @param project_root [Pathname, String] repo root (Gemfile + link target)
# @param skills_dir [Pathname, String, nil] override for tests; defaults
# to <project_root>/.agents/skills
def initialize(project_root:, skills_dir: nil)
# @param tmpdir [Pathname, String] ephemeral temp root that links must
# never target; defaults to Dir.tmpdir (override for tests, whose
# fixture gem trees themselves live under the real temp dir)
def initialize(project_root:, skills_dir: nil, tmpdir: Dir.tmpdir)
@project_root = Pathname(project_root)
@skills_dir = Pathname(skills_dir || @project_root.join(*AGENT_SKILLS_SUBDIRS))
@skill_installer = SkillInstaller.new(skills_dir: @skills_dir)
@tmpdir_roots = tmpdir_roots(Pathname(tmpdir))
end

# Scan the locked gem set for shipped skills and refresh the project's
# links: install one per skill found, prune gem links whose gem left the
# lock. Never raises — skill links are hygiene riding a dependency
# install, and hygiene must not block correctness (failures are reported
# on stderr).
# lock. A skill that resolves under the temp dir is never linked (warned
# and skipped — a persistent link to purgeable state silently dangles
# later), but its gem still counts as present for pruning, so an
# ephemeral resolution cannot delete a durable link minted earlier.
# Never raises — skill links are hygiene riding a dependency install,
# and hygiene must not block correctness (failures are reported on
# stderr).
#
# @return [void]
def link_all
return unless gemfile_path.exist?

expected = expected_links
expected.each { |name, skill_dir| @skill_installer.install(name, skill_dir) }
expected.each do |name, skill_dir|
if ephemeral?(skill_dir)
$stderr.puts "dev: warning: not linking #{name} — #{skill_dir} is under the temp dir " \
"and would dangle once it is purged."
else
@skill_installer.install(name, skill_dir)
end
end
prune_stale_links(expected.keys)
rescue StandardError => e
$stderr.puts "dev: warning: could not refresh gem skill links (#{e.message})."
Expand DownExpand Up@@ -84,12 +115,15 @@ def gem_roots

# Runs under the project's shadowenv for the same reason as
# BundlerIntegration: the dev process's own PATH is the invoking
# service's, which on headless boxes carries the wrong Ruby.
# service's, which on headless boxes carries the wrong Ruby. Harness
# bundler/gem overrides are scrubbed from the child env (see
# HARNESS_ENV_SCRUB) so a sandboxed session cannot redirect the
# resolution into its ephemeral cache.
#
# @return [Array<Pathname>] install paths of every gem in the bundle
def bundled_gem_paths
out, err, status = Open3.capture3(
{ "BUNDLE_GEMFILE" => gemfile_path.to_s },
HARNESS_ENV_SCRUB.merge("BUNDLE_GEMFILE" => gemfile_path.to_s),
"shadowenv", "exec", "--", "bundle", "list", "--paths",
chdir: @project_root.to_s,
)
Expand DownExpand Up@@ -128,6 +162,30 @@ def locked_gem_names
names.uniq
end

# The temp root in both its raw and fully-resolved forms — on macOS
# Dir.tmpdir is under /var/... while bundler reports the
# /private/var/... realpath, so containment must check both spellings.
#
# @param tmpdir [Pathname]
# @return [Array<Pathname>]
def tmpdir_roots(tmpdir)
expanded = tmpdir.expand_path
roots = [expanded]
roots << expanded.realpath if expanded.exist?
roots.uniq
end

# Whether a resolved skill directory lives under the temp dir — the
# signature of a harness resolving the bundle into its own purgeable
# cache, whatever produced it.
#
# @param path [Pathname]
# @return [Boolean]
def ephemeral?(path)
resolved = path.exist? ? path.realpath : path.expand_path
@tmpdir_roots.any? { |root| resolved.to_s.start_with?("#{root}#{File::SEPARATOR}") }
end

# Remove gem links that no current gem accounts for (the gem left the
# lock; its tree may still exist on disk, so broken-link pruning alone
# would miss it). Only `gem-`-prefixed symlinks are candidates —
Expand Down
133 changes: 123 additions & 10 deletions test/dev/deps/gem_skill_linker_test.rb
Original file line numberDiff line numberDiff line change
Expand Up@@ -50,22 +50,31 @@ def build_gem(gems_root, dir_name, skills: [])

# `bundle list` must run under the project's shadowenv — same reasoning as
# BundlerIntegration: the dev process's PATH is the invoking service's,
# which on headless boxes carries the wrong Ruby.
# which on headless boxes carries the wrong Ruby — with harness bundler
# overrides scrubbed from the child env.
def stub_bundle_list(project, paths)
env = Dev::Deps::GemSkillLinker::HARNESS_ENV_SCRUB.merge("BUNDLE_GEMFILE" => (project / "Gemfile").to_s)
Open3.stubs(:capture3)
.with({ "BUNDLE_GEMFILE" => (project / "Gemfile").to_s },
"shadowenv", "exec", "--", "bundle", "list", "--paths", chdir: project.to_s)
.with(env, "shadowenv", "exec", "--", "bundle", "list", "--paths", chdir: project.to_s)
.returns([paths.map { |p| "#{p}\n" }.join, "", stub(success?: true)])
end

# Linker under test. The real Dir.tmpdir contains these tests' own fixture
# trees, so every linker gets a tmpdir override pointing inside the fixture
# dir — gems built by build_gem under `gems/` then read as durable, and a
# test opts into ephemerality by building under `<dir>/tmp`.
def build_linker(project, dir)
Dev::Deps::GemSkillLinker.new(project_root: project, tmpdir: Pathname(dir) / "tmp")
end

test "links a locked gem's shipped skills as gem-<gem>--<skill>" do
Given "a locked gem whose tree ships a skill"
dir = Dir.mktmpdir("dev-gem-skill-test-")
project, gems = build_project(dir)
rspock = build_gem(gems, "rspock-1.2.0", skills: ["rspock"])
minitest = build_gem(gems, "minitest-5.25.0")
stub_bundle_list(project, [rspock, minitest])
linker = Dev::Deps::GemSkillLinker.new(project_root: project)
linker = build_linker(project, dir)

When "linking"
linker.link_all
Expand All@@ -85,7 +94,7 @@ def stub_bundle_list(project, paths)
project, gems = build_project(dir)
reporters = build_gem(gems, "minitest-reporters-1.7.1", skills: ["reporting"])
stub_bundle_list(project, [reporters])
linker = Dev::Deps::GemSkillLinker.new(project_root: project)
linker = build_linker(project, dir)

When "linking"
linker.link_all
Expand All@@ -104,7 +113,7 @@ def stub_bundle_list(project, paths)
project, gems = build_project(dir)
stray = build_gem(gems, "stray-9.9.9", skills: ["stray"])
stub_bundle_list(project, [stray])
linker = Dev::Deps::GemSkillLinker.new(project_root: project)
linker = build_linker(project, dir)

When "linking"
linker.link_all
Expand All@@ -128,7 +137,7 @@ def stub_bundle_list(project, paths)
File.symlink(departed / "skills" / "departed", skills_dir / "gem-departed--departed")
File.symlink(gems, skills_dir / "my-own-link")
(skills_dir / "notes.md").write("mine\n")
linker = Dev::Deps::GemSkillLinker.new(project_root: project)
linker = build_linker(project, dir)

When "linking"
linker.link_all
Expand All@@ -149,7 +158,7 @@ def stub_bundle_list(project, paths)
project = Pathname(dir) / "repo"
FileUtils.mkdir_p(project)
Open3.expects(:capture3).never
linker = Dev::Deps::GemSkillLinker.new(project_root: project)
linker = build_linker(project, dir)

When "linking"
linker.link_all
Expand All@@ -169,7 +178,7 @@ def stub_bundle_list(project, paths)
skills_dir = project / ".agents" / "skills"
FileUtils.mkdir_p(skills_dir)
FileUtils.chmod(0o000, skills_dir)
linker = Dev::Deps::GemSkillLinker.new(project_root: project)
linker = build_linker(project, dir)
old_stderr = $stderr
$stderr = StringIO.new

Expand All@@ -185,12 +194,116 @@ def stub_bundle_list(project, paths)
FileUtils.rm_rf(dir)
end

# Pins the exact scrub set rather than referencing HARNESS_ENV_SCRUB: a
# sandboxed session (Cursor sandbox cache, dev#89) leaks these overrides
# into dev's env, and dropping any of them from the scrub would silently
# re-open the leak.
test "bundle list runs with harness bundler and gem overrides explicitly unset" do
Given "a project"
dir = Dir.mktmpdir("dev-gem-skill-test-")
project, = build_project(dir)
linker = build_linker(project, dir)

When "linking"
linker.link_all

Then "every harness override is nil'd in the child env"
1 * Open3.capture3(
{
"BUNDLE_PATH" => nil,
"BUNDLE_APP_CONFIG" => nil,
"BUNDLE_BIN" => nil,
"GEM_HOME" => nil,
"GEM_PATH" => nil,
"RUBYOPT" => nil,
"RUBYLIB" => nil,
"BUNDLE_GEMFILE" => (project / "Gemfile").to_s,
},
"shadowenv", "exec", "--", "bundle", "list", "--paths", chdir: project.to_s
) >> ["", "", stub(success?: true)]

Cleanup
FileUtils.rm_rf(dir)
end

test "refuses to link a skill resolved under the temp dir and warns" do
Given "a locked gem whose tree resolves into the ephemeral temp dir"
dir = Dir.mktmpdir("dev-gem-skill-test-")
project, = build_project(dir)
ephemeral = build_gem(Pathname(dir) / "tmp" / "gems", "rspock-1.2.0", skills: ["rspock"])
stub_bundle_list(project, [ephemeral])
linker = build_linker(project, dir)
old_stderr = $stderr
$stderr = StringIO.new

When "linking"
linker.link_all

Then "no link is created and the skip is warned"
!File.exist?(project / ".agents" / "skills" / "gem-rspock--rspock")
$stderr.string.include?("not linking gem-rspock--rspock")

Cleanup
$stderr = old_stderr
FileUtils.rm_rf(dir)
end

test "an ephemeral resolution does not prune the durable link it shadows" do
Given "a durable link for a gem that now resolves into the temp dir"
dir = Dir.mktmpdir("dev-gem-skill-test-")
project, gems = build_project(dir)
durable = build_gem(gems, "rspock-1.2.0", skills: ["rspock"])
skills_dir = project / ".agents" / "skills"
FileUtils.mkdir_p(skills_dir)
File.symlink(durable / "skills" / "rspock", skills_dir / "gem-rspock--rspock")
ephemeral = build_gem(Pathname(dir) / "tmp" / "gems", "rspock-1.2.0", skills: ["rspock"])
stub_bundle_list(project, [ephemeral])
linker = build_linker(project, dir)
old_stderr = $stderr
$stderr = StringIO.new

When "linking"
linker.link_all

Then "the durable link survives, still pointing at its durable target"
File.symlink?(skills_dir / "gem-rspock--rspock")
File.readlink(skills_dir / "gem-rspock--rspock") == (durable / "skills" / "rspock").to_s

Cleanup
$stderr = old_stderr
FileUtils.rm_rf(dir)
end

test "temp dir containment sees through symlinked temp roots" do
Given "a tmpdir override that is a symlink to the dir the gem resolves under"
dir = Dir.mktmpdir("dev-gem-skill-test-")
project, = build_project(dir)
real_tmp = Pathname(dir) / "tmp"
ephemeral = build_gem(real_tmp / "gems", "rspock-1.2.0", skills: ["rspock"])
tmp_alias = Pathname(dir) / "tmp-alias"
File.symlink(real_tmp, tmp_alias)
stub_bundle_list(project, [ephemeral])
linker = Dev::Deps::GemSkillLinker.new(project_root: project, tmpdir: tmp_alias)
old_stderr = $stderr
$stderr = StringIO.new

When "linking"
linker.link_all

Then "the path is recognized as ephemeral and never links"
!File.exist?(project / ".agents" / "skills" / "gem-rspock--rspock")

Cleanup
$stderr = old_stderr
FileUtils.rm_rf(dir)
end

test "a failing bundle list warns instead of failing the install" do
Given "bundler erroring out"
dir = Dir.mktmpdir("dev-gem-skill-test-")
project, = build_project(dir)
Open3.stubs(:capture3).returns(["", "bundler exploded", stub(success?: false)])
linker = Dev::Deps::GemSkillLinker.new(project_root: project)
linker = build_linker(project, dir)
old_stderr = $stderr
$stderr = StringIO.new

Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 65 additions & 7 deletions lib/dev/deps/gem_skill_linker.rb
Original file line numberDiff line numberDiff line change
Expand Up@@ -23,27 +23,58 @@ class GemSkillLinker
SKILLS_SUBDIR = "skills"
AGENT_SKILLS_SUBDIRS = [".agents", "skills"].freeze

# Env overrides a harness (e.g. a sandboxed agent session) may have
# exported into dev's own environment, redirecting bundler to an
# ephemeral gem cache. The `bundle list` child gets them explicitly
# unset so paths resolve from the project's canonical bundler config —
# dev never runs under bundler itself, so these unsets are its
# equivalent of Bundler.original_env (dev#89).
HARNESS_ENV_SCRUB = [
"BUNDLE_PATH",
"BUNDLE_APP_CONFIG",
"BUNDLE_BIN",
"GEM_HOME",
"GEM_PATH",
"RUBYOPT",
"RUBYLIB",
].to_h { |name| [name, nil] }.freeze

# @param project_root [Pathname, String] repo root (Gemfile + link target)
# @param skills_dir [Pathname, String, nil] override for tests; defaults
# to <project_root>/.agents/skills
def initialize(project_root:, skills_dir: nil)
# @param tmpdir [Pathname, String] ephemeral temp root that links must
# never target; defaults to Dir.tmpdir (override for tests, whose
# fixture gem trees themselves live under the real temp dir)
def initialize(project_root:, skills_dir: nil, tmpdir: Dir.tmpdir)
@project_root = Pathname(project_root)
@skills_dir = Pathname(skills_dir || @project_root.join(*AGENT_SKILLS_SUBDIRS))
@skill_installer = SkillInstaller.new(skills_dir: @skills_dir)
@tmpdir_roots = tmpdir_roots(Pathname(tmpdir))
end

# Scan the locked gem set for shipped skills and refresh the project's
# links: install one per skill found, prune gem links whose gem left the
# lock. Never raises — skill links are hygiene riding a dependency
# install, and hygiene must not block correctness (failures are reported
# on stderr).
# lock. A skill that resolves under the temp dir is never linked (warned
# and skipped — a persistent link to purgeable state silently dangles
# later), but its gem still counts as present for pruning, so an
# ephemeral resolution cannot delete a durable link minted earlier.
# Never raises — skill links are hygiene riding a dependency install,
# and hygiene must not block correctness (failures are reported on
# stderr).
#
# @return [void]
def link_all
return unless gemfile_path.exist?

expected = expected_links
expected.each { |name, skill_dir| @skill_installer.install(name, skill_dir) }
expected.each do |name, skill_dir|
if ephemeral?(skill_dir)
$stderr.puts "dev: warning: not linking #{name} — #{skill_dir} is under the temp dir " \
"and would dangle once it is purged."
else
@skill_installer.install(name, skill_dir)
end
end
prune_stale_links(expected.keys)
rescue StandardError => e
$stderr.puts "dev: warning: could not refresh gem skill links (#{e.message})."
Expand DownExpand Up@@ -84,12 +115,15 @@ def gem_roots

# Runs under the project's shadowenv for the same reason as
# BundlerIntegration: the dev process's own PATH is the invoking
# service's, which on headless boxes carries the wrong Ruby.
# service's, which on headless boxes carries the wrong Ruby. Harness
# bundler/gem overrides are scrubbed from the child env (see
# HARNESS_ENV_SCRUB) so a sandboxed session cannot redirect the
# resolution into its ephemeral cache.
#
# @return [Array<Pathname>] install paths of every gem in the bundle
def bundled_gem_paths
out, err, status = Open3.capture3(
{ "BUNDLE_GEMFILE" => gemfile_path.to_s },
HARNESS_ENV_SCRUB.merge("BUNDLE_GEMFILE" => gemfile_path.to_s),
"shadowenv", "exec", "--", "bundle", "list", "--paths",
chdir: @project_root.to_s,
)
Expand DownExpand Up@@ -128,6 +162,30 @@ def locked_gem_names
names.uniq
end

# The temp root in both its raw and fully-resolved forms — on macOS
# Dir.tmpdir is under /var/... while bundler reports the
# /private/var/... realpath, so containment must check both spellings.
#
# @param tmpdir [Pathname]
# @return [Array<Pathname>]
def tmpdir_roots(tmpdir)
expanded = tmpdir.expand_path
roots = [expanded]
roots << expanded.realpath if expanded.exist?
roots.uniq
end

# Whether a resolved skill directory lives under the temp dir — the
# signature of a harness resolving the bundle into its own purgeable
# cache, whatever produced it.
#
# @param path [Pathname]
# @return [Boolean]
def ephemeral?(path)
resolved = path.exist? ? path.realpath : path.expand_path
@tmpdir_roots.any? { |root| resolved.to_s.start_with?("#{root}#{File::SEPARATOR}") }
end

# Remove gem links that no current gem accounts for (the gem left the
# lock; its tree may still exist on disk, so broken-link pruning alone
# would miss it). Only `gem-`-prefixed symlinks are candidates —
Expand Down
133 changes: 123 additions & 10 deletions test/dev/deps/gem_skill_linker_test.rb
Original file line numberDiff line numberDiff line change
Expand Up@@ -50,22 +50,31 @@ def build_gem(gems_root, dir_name, skills: [])

# `bundle list` must run under the project's shadowenv — same reasoning as
# BundlerIntegration: the dev process's PATH is the invoking service's,
# which on headless boxes carries the wrong Ruby.
# which on headless boxes carries the wrong Ruby — with harness bundler
# overrides scrubbed from the child env.
def stub_bundle_list(project, paths)
env = Dev::Deps::GemSkillLinker::HARNESS_ENV_SCRUB.merge("BUNDLE_GEMFILE" => (project / "Gemfile").to_s)
Open3.stubs(:capture3)
.with({ "BUNDLE_GEMFILE" => (project / "Gemfile").to_s },
"shadowenv", "exec", "--", "bundle", "list", "--paths", chdir: project.to_s)
.with(env, "shadowenv", "exec", "--", "bundle", "list", "--paths", chdir: project.to_s)
.returns([paths.map { |p| "#{p}\n" }.join, "", stub(success?: true)])
end

# Linker under test. The real Dir.tmpdir contains these tests' own fixture
# trees, so every linker gets a tmpdir override pointing inside the fixture
# dir — gems built by build_gem under `gems/` then read as durable, and a
# test opts into ephemerality by building under `<dir>/tmp`.
def build_linker(project, dir)
Dev::Deps::GemSkillLinker.new(project_root: project, tmpdir: Pathname(dir) / "tmp")
end

test "links a locked gem's shipped skills as gem-<gem>--<skill>" do
Given "a locked gem whose tree ships a skill"
dir = Dir.mktmpdir("dev-gem-skill-test-")
project, gems = build_project(dir)
rspock = build_gem(gems, "rspock-1.2.0", skills: ["rspock"])
minitest = build_gem(gems, "minitest-5.25.0")
stub_bundle_list(project, [rspock, minitest])
linker = Dev::Deps::GemSkillLinker.new(project_root: project)
linker = build_linker(project, dir)

When "linking"
linker.link_all
Expand All@@ -85,7 +94,7 @@ def stub_bundle_list(project, paths)
project, gems = build_project(dir)
reporters = build_gem(gems, "minitest-reporters-1.7.1", skills: ["reporting"])
stub_bundle_list(project, [reporters])
linker = Dev::Deps::GemSkillLinker.new(project_root: project)
linker = build_linker(project, dir)

When "linking"
linker.link_all
Expand All@@ -104,7 +113,7 @@ def stub_bundle_list(project, paths)
project, gems = build_project(dir)
stray = build_gem(gems, "stray-9.9.9", skills: ["stray"])
stub_bundle_list(project, [stray])
linker = Dev::Deps::GemSkillLinker.new(project_root: project)
linker = build_linker(project, dir)

When "linking"
linker.link_all
Expand All@@ -128,7 +137,7 @@ def stub_bundle_list(project, paths)
File.symlink(departed / "skills" / "departed", skills_dir / "gem-departed--departed")
File.symlink(gems, skills_dir / "my-own-link")
(skills_dir / "notes.md").write("mine\n")
linker = Dev::Deps::GemSkillLinker.new(project_root: project)
linker = build_linker(project, dir)

When "linking"
linker.link_all
Expand All@@ -149,7 +158,7 @@ def stub_bundle_list(project, paths)
project = Pathname(dir) / "repo"
FileUtils.mkdir_p(project)
Open3.expects(:capture3).never
linker = Dev::Deps::GemSkillLinker.new(project_root: project)
linker = build_linker(project, dir)

When "linking"
linker.link_all
Expand All@@ -169,7 +178,7 @@ def stub_bundle_list(project, paths)
skills_dir = project / ".agents" / "skills"
FileUtils.mkdir_p(skills_dir)
FileUtils.chmod(0o000, skills_dir)
linker = Dev::Deps::GemSkillLinker.new(project_root: project)
linker = build_linker(project, dir)
old_stderr = $stderr
$stderr = StringIO.new

Expand All@@ -185,12 +194,116 @@ def stub_bundle_list(project, paths)
FileUtils.rm_rf(dir)
end

# Pins the exact scrub set rather than referencing HARNESS_ENV_SCRUB: a
# sandboxed session (Cursor sandbox cache, dev#89) leaks these overrides
# into dev's env, and dropping any of them from the scrub would silently
# re-open the leak.
test "bundle list runs with harness bundler and gem overrides explicitly unset" do
Given "a project"
dir = Dir.mktmpdir("dev-gem-skill-test-")
project, = build_project(dir)
linker = build_linker(project, dir)

When "linking"
linker.link_all

Then "every harness override is nil'd in the child env"
1 * Open3.capture3(
{
"BUNDLE_PATH" => nil,
"BUNDLE_APP_CONFIG" => nil,
"BUNDLE_BIN" => nil,
"GEM_HOME" => nil,
"GEM_PATH" => nil,
"RUBYOPT" => nil,
"RUBYLIB" => nil,
"BUNDLE_GEMFILE" => (project / "Gemfile").to_s,
},
"shadowenv", "exec", "--", "bundle", "list", "--paths", chdir: project.to_s
) >> ["", "", stub(success?: true)]

Cleanup
FileUtils.rm_rf(dir)
end

test "refuses to link a skill resolved under the temp dir and warns" do
Given "a locked gem whose tree resolves into the ephemeral temp dir"
dir = Dir.mktmpdir("dev-gem-skill-test-")
project, = build_project(dir)
ephemeral = build_gem(Pathname(dir) / "tmp" / "gems", "rspock-1.2.0", skills: ["rspock"])
stub_bundle_list(project, [ephemeral])
linker = build_linker(project, dir)
old_stderr = $stderr
$stderr = StringIO.new

When "linking"
linker.link_all

Then "no link is created and the skip is warned"
!File.exist?(project / ".agents" / "skills" / "gem-rspock--rspock")
$stderr.string.include?("not linking gem-rspock--rspock")

Cleanup
$stderr = old_stderr
FileUtils.rm_rf(dir)
end

test "an ephemeral resolution does not prune the durable link it shadows" do
Given "a durable link for a gem that now resolves into the temp dir"
dir = Dir.mktmpdir("dev-gem-skill-test-")
project, gems = build_project(dir)
durable = build_gem(gems, "rspock-1.2.0", skills: ["rspock"])
skills_dir = project / ".agents" / "skills"
FileUtils.mkdir_p(skills_dir)
File.symlink(durable / "skills" / "rspock", skills_dir / "gem-rspock--rspock")
ephemeral = build_gem(Pathname(dir) / "tmp" / "gems", "rspock-1.2.0", skills: ["rspock"])
stub_bundle_list(project, [ephemeral])
linker = build_linker(project, dir)
old_stderr = $stderr
$stderr = StringIO.new

When "linking"
linker.link_all

Then "the durable link survives, still pointing at its durable target"
File.symlink?(skills_dir / "gem-rspock--rspock")
File.readlink(skills_dir / "gem-rspock--rspock") == (durable / "skills" / "rspock").to_s

Cleanup
$stderr = old_stderr
FileUtils.rm_rf(dir)
end

test "temp dir containment sees through symlinked temp roots" do
Given "a tmpdir override that is a symlink to the dir the gem resolves under"
dir = Dir.mktmpdir("dev-gem-skill-test-")
project, = build_project(dir)
real_tmp = Pathname(dir) / "tmp"
ephemeral = build_gem(real_tmp / "gems", "rspock-1.2.0", skills: ["rspock"])
tmp_alias = Pathname(dir) / "tmp-alias"
File.symlink(real_tmp, tmp_alias)
stub_bundle_list(project, [ephemeral])
linker = Dev::Deps::GemSkillLinker.new(project_root: project, tmpdir: tmp_alias)
old_stderr = $stderr
$stderr = StringIO.new

When "linking"
linker.link_all

Then "the path is recognized as ephemeral and never links"
!File.exist?(project / ".agents" / "skills" / "gem-rspock--rspock")

Cleanup
$stderr = old_stderr
FileUtils.rm_rf(dir)
end

test "a failing bundle list warns instead of failing the install" do
Given "bundler erroring out"
dir = Dir.mktmpdir("dev-gem-skill-test-")
project, = build_project(dir)
Open3.stubs(:capture3).returns(["", "bundler exploded", stub(success?: false)])
linker = Dev::Deps::GemSkillLinker.new(project_root: project)
linker = build_linker(project, dir)
old_stderr = $stderr
$stderr = StringIO.new

Expand Down
Loading