test: skip symlink tests when the OS refuses symlink creation

Adds a trySymlink helper that logs the existing skip notice and returns
false on EPERM/EACCES/ENOSYS, and uses it where tests created symlinks
unguarded. Non-admin Windows accounts without Developer Mode now skip
these cases instead of failing.
This commit is contained in:
Brian committed 2026-10-26 15:08:05 -04:00
1 parent 25278e7d7b
commit 09de2c247a
12 files changed
+52 -21

No files matched your search

+2 -1
View File
@@ -17,6 +17,7 @@ const os = require('os');
const path = require('path');
const { spawnSync } = require('child_process');
const { resolvePython } = require('./lib/helpers/windows-test-env');
const { trySymlink } = require('./lib/helpers/symlink-support');
const repoRoot = path.resolve(__dirname, '..');
const GUARD = path.join(repoRoot, 'hooks', 'hermes', 'config-protection.py');
@@ -111,7 +112,7 @@ run('blocks existing case-variant protected names on every filesystem', () => {
run('blocks a dangling symlink to a protected name', () => {
const dangling = path.join(tmp, 'eslint.config.cjs');
try { fs.unlinkSync(dangling); } catch { /* not present yet */ }
fs.symlinkSync(path.join(tmp, 'does-not-exist-at-all'), dangling);
if (!trySymlink(path.join(tmp, 'does-not-exist-at-all'), dangling)) return;
// Sanity: the link itself exists (lstat succeeds) but resolves nowhere —
// os.path.exists() is False here, which is exactly the bypass being pinned.
assert.ok(fs.lstatSync(dangling).isSymbolicLink(), 'symlink must exist for the case to be real');
+6 -5
View File
@@ -4,6 +4,7 @@ const assert = require('assert');
const fs = require('fs');
const os = require('os');
const path = require('path');
const { trySymlink } = require('./helpers/symlink-support');
const {
beginLegacySyncState,
@@ -179,7 +180,7 @@ function runTests() {
fs.writeFileSync(outsidePath, '<!-- BEGIN ECC -->\n# Outside\n<!-- END ECC -->\n');
fs.rmSync(agentsPath);
fs.symlinkSync(outsidePath, agentsPath);
if (!trySymlink(outsidePath, agentsPath)) return;
const symlinkResult = uninstallLegacyCodexSync({ codexHome });
assert.strictEqual(symlinkResult.status, 'partial');
assert.ok(symlinkResult.retainedPaths.includes(agentsPath));
@@ -237,7 +238,7 @@ function runTests() {
const linkedPath = path.join(codexHome, 'prompts', 'ecc-plan.md');
fs.mkdirSync(path.dirname(linkedPath), { recursive: true });
fs.writeFileSync(outsidePath, '# Outside\n');
fs.symlinkSync(outsidePath, linkedPath);
if (!trySymlink(outsidePath, linkedPath)) return;
const statePath = beginLegacySyncState({
codexHome,
backupDir: path.join(codexHome, 'backups', 'ecc-test'),
@@ -257,7 +258,7 @@ function runTests() {
const outsideDir = path.join(homeDir, 'outside');
fs.mkdirSync(codexHome, { recursive: true });
fs.mkdirSync(outsideDir, { recursive: true });
fs.symlinkSync(outsideDir, path.join(codexHome, 'prompts'));
if (!trySymlink(outsideDir, path.join(codexHome, 'prompts'))) return;
const statePath = beginLegacySyncState({
codexHome,
backupDir: path.join(codexHome, 'backups', 'ecc-test'),
@@ -289,7 +290,7 @@ function runTests() {
fs.writeFileSync(promptPath, '# ECC generated prompt\n');
finalizeLegacySyncState({ statePath });
fs.rmSync(promptPath);
fs.symlinkSync(outsidePath, promptPath);
if (!trySymlink(outsidePath, promptPath)) return;
const result = uninstallLegacyCodexSync({ codexHome });
assert.strictEqual(result.status, 'partial');
@@ -481,7 +482,7 @@ function runTests() {
});
recordLegacySyncPath({ statePath, filePath: promptPath });
fs.rmSync(promptPath);
fs.symlinkSync(outsidePath, promptPath);
if (!trySymlink(outsidePath, promptPath)) return;
const result = rollbackLegacyCodexSync({ statePath });
assert.strictEqual(result.status, 'partial');
+3 -2
View File
@@ -6,6 +6,7 @@ const path = require('path');
const { spawnSync } = require('child_process');
const { test, tempDir, cleanup, finish } = require('./helpers');
const gate = require('../../../scripts/lib/eval-harness/gate');
const { trySymlink } = require('../helpers/symlink-support');
const library = path.resolve(__dirname, '../../../scripts/lib/eval-harness');
const refused = error => error.code === 'gate.isolation_required';
const invalidVariant = error => error.code === 'gate.variant_invalid';
@@ -109,11 +110,11 @@ test('symlink manifests and symlink trees cannot hide from digest', () => setup(
const file = path.join(c.variant, 'variant.json');
const outside = path.join(c.root, 'manifest.json');
fs.renameSync(file, outside);
fs.symlinkSync(outside, file);
if (!trySymlink(outside, file)) return;
assert.throws(() => gate.loadVariant(c.variant), invalidVariant);
fs.unlinkSync(file);
fs.renameSync(outside, file);
fs.symlinkSync(c.root, path.join(c.variant, 'link'));
if (!trySymlink(c.root, path.join(c.variant, 'link'))) return;
assert.throws(() => gate.loadVariant(c.variant), invalidVariant);
}));
+20
View File
@@ -0,0 +1,20 @@
'use strict';
const fs = require('fs');
const UNSUPPORTED_CODES = new Set(['EPERM', 'EACCES', 'ENOSYS']);
// Creating symlinks needs Developer Mode or admin rights on Windows. Returns
// false (after logging a skip notice) when the OS refuses; rethrows anything else.
function trySymlink(target, linkPath, type) {
try {
fs.symlinkSync(target, linkPath, type);
return true;
} catch (error) {
if (!UNSUPPORTED_CODES.has(error.code)) throw error;
console.log(' (symlink unsupported on this platform; skipping)');
return false;
}
}
module.exports = { trySymlink };
+5 -4
View File
@@ -5,6 +5,7 @@ const fs = require('fs');
const os = require('os');
const path = require('path');
const { spawnSync } = require('child_process');
const { trySymlink } = require('./helpers/symlink-support');
const {
MAX_DIAGNOSTICS,
@@ -276,7 +277,7 @@ test('never follows a pre-existing destination symlink during create-only public
fs.mkdirSync(notes, { recursive: true });
fs.writeFileSync(outside, 'outside sentinel');
const destination = path.join(notes, 'mem_20260726_01kexample.md');
fs.symlinkSync(outside, destination);
if (!trySymlink(outside, destination)) return;
assert.throws(
() => saveMemory(
@@ -355,7 +356,7 @@ test('rejects a vault path that traverses a symlink before creating directories'
const fixture = createFixture();
const outside = path.join(fixture.root, 'outside');
fs.mkdirSync(outside);
fs.symlinkSync(outside, path.join(fixture.projectRoot, '.ecc'));
if (!trySymlink(outside, path.join(fixture.projectRoot, '.ecc'))) return;
try {
assert.throws(
() => saveMemory(
@@ -377,7 +378,7 @@ test('rejects a symlinked ancestor when roots come back from initializeVault', (
try {
const initialized = initializeVault({ roots: fixture.roots, scopes: ['project'] });
fs.rmSync(path.join(fixture.projectRoot, '.ecc'), { recursive: true, force: true });
fs.symlinkSync(outside, path.join(fixture.projectRoot, '.ecc'));
if (!trySymlink(outside, path.join(fixture.projectRoot, '.ecc'))) return;
assert.throws(
() => saveMemory(
@@ -564,7 +565,7 @@ test('opens regular text files without following a stable symlink', () => {
const link = path.join(root, 'link.md');
try {
fs.writeFileSync(target, 'safe');
fs.symlinkSync(target, link);
if (!trySymlink(target, link)) return;
assert.strictEqual(readRegularTextFile(target, { maxBytes: 16 }), 'safe');
assert.throws(
() => readRegularTextFile(link, { maxBytes: 16 }),
@@ -16,6 +16,7 @@ const { repairInstalledStates, buildDoctorReport } = load(require.resolve('../..
const { createInstallState, readInstallState, writeInstallState } = require('../../scripts/lib/install-state');
const { withOpenCodeInstallLocks } = load(require.resolve('../../scripts/lib/install/opencode-install-lock'));
const { removeVerifiedLegacyFile } = require('../../scripts/lib/install/opencode-legacy-migration');
const { trySymlink } = require('./helpers/symlink-support');
const SOURCE_RELATIVE_PATH = path.join('skills', 'skill-comply', 'SKILL.md');
const SKILL_CONTENT = '---\nname: skill-comply\ndescription: Synthetic migration fixture.\n---\n\n# Inert fixture\n';
@@ -342,7 +343,7 @@ function runTests() {
if (defect === 'missing-digest') delete state.operations[2].contentSha256;
if (defect === 'source-mismatch') fs.writeFileSync(source, 'different trusted source\n');
if (defect === 'source-missing') fs.unlinkSync(source);
if (defect === 'symlink') { fs.unlinkSync(plugin); fs.symlinkSync(source, plugin); }
if (defect === 'symlink') { fs.unlinkSync(plugin); if (!trySymlink(source, plugin)) return; }
if (defect === 'unrecorded-alias') fs.copyFileSync(source, path.join(value.legacyRoot, 'plugins/index.js'));
if (defect === 'merge-json') state.operations[1].kind = 'merge-json';
writeInstallState(value.legacyStatePath, state);
+3 -2
View File
@@ -7,6 +7,7 @@ const fs = require('fs');
const os = require('os');
const path = require('path');
const { spawnSync } = require('child_process');
const { trySymlink } = require('./helpers/symlink-support');
const {
createStateStore,
@@ -399,7 +400,7 @@ async function runTests() {
try {
fs.writeFileSync(targetPath, 'do not overwrite');
fs.symlinkSync(targetPath, dbPath);
if (!trySymlink(targetPath, dbPath)) return;
await assert.rejects(
() => createStateStore({ dbPath }),
@@ -419,7 +420,7 @@ async function runTests() {
try {
fs.mkdirSync(outsideDir);
fs.symlinkSync(outsideDir, linkedParent, process.platform === 'win32' ? 'junction' : 'dir');
if (!trySymlink(outsideDir, linkedParent, process.platform === 'win32' ? 'junction' : 'dir')) return;
await assert.rejects(
() => createStateStore({ dbPath }),
+2 -1
View File
@@ -9,6 +9,7 @@ const { pathToFileURL } = require('url');
const { createManifestInstallPlan, applyInstallPlan, previewInstallPlan } = require('../../scripts/lib/install-executor');
const { withHookConsent } = require('../../scripts/lib/install/hook-consent');
const { repairInstalledStates } = require('../../scripts/lib/install-lifecycle');
const { trySymlink } = require('../lib/helpers/symlink-support');
const repo = path.resolve(__dirname, '../..');
let passed = 0;
let failed = 0;
@@ -186,7 +187,7 @@ for (const scope of ['package.json', 'lib/package.json']) {
test(`symlinked ${scope} refuses before mutation`, f => {
const file = path.join(f.scripts, scope); const outside = path.join(f.dir, 'user-package.json');
f.write(outside, '{"type":"commonjs"}\n'); fs.mkdirSync(path.dirname(file), { recursive: true });
fs.symlinkSync(outside, file); const before = snapshot(f.dir);
if (!trySymlink(outside, file)) return; const before = snapshot(f.dir);
assert.notStrictEqual(f.install().status, 0); assert.deepStrictEqual(snapshot(f.dir), before);
});
}
+2 -1
View File
@@ -9,6 +9,7 @@ const { normalizeManifest, buildInventory, collectResources, collectTaskFiles, r
const now = '2026-09-08T06:30:00.000Z';
const task = (id, paths, extra = {}) => ({ id, repoId: 'repo', paths, ...extra });
const fixture = () => ({ version: 1, repositories: [{ id: 'repo', sources: { 'src/a.js': "require('../lib/b')", 'lib/b.js': '' } }], tasks: [task('a', ['src/a.js']), task('b', ['lib/b.js'])], leases: [] });
const { trySymlink } = require('../lib/helpers/symlink-support');
const run = value => buildInventory(value, { now });
test('direct import warns when exact-path baseline would miss it; deterministic JSON', () => {
@@ -87,7 +88,7 @@ test('task file adapter reads structured status, labels mtime, skips symlinks an
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'coordination-test-'));
try {
fs.mkdirSync(path.join(dir, 'worker')); fs.writeFileSync(path.join(dir, 'worker', 'STATUS.md'), '- State: running\n- Updated: 2026-09-08T06:29:00Z\n');
fs.symlinkSync(path.join(dir, 'worker'), path.join(dir, 'linked'));
if (!trySymlink(path.join(dir, 'worker'), path.join(dir, 'linked'))) return;
const r = collectTaskFiles(dir); assert.equal(r.tasks.length, 1); assert.equal(r.tasks[0].status, 'running');
assert.ok(r.tasks[0].statusFileModifiedAt); assert.equal(r.tasks[0].heartbeatAt, '2026-09-08T06:29:00Z');
fs.writeFileSync(path.join(dir, 'large.json'), ' '.repeat(1024 * 1024 + 1));
+2 -1
View File
@@ -7,6 +7,7 @@ const path = require('path');
const { spawn, spawnSync } = require('child_process');
const { PassThrough } = require('stream');
const { pathToFileURL } = require('url');
const { trySymlink } = require('../lib/helpers/symlink-support');
const SERVER = path.join(__dirname, '..', '..', 'scripts', 'memory-mcp.mjs');
const {
@@ -450,7 +451,7 @@ async function main() {
await test('starts when the npm bin invokes the server through a symlink', async () => {
const binRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-memory-bin-'));
const binPath = path.join(binRoot, 'ecc-memory-mcp');
fs.symlinkSync(SERVER, binPath);
if (!trySymlink(SERVER, binPath)) return;
try {
await withClient(async client => {
const tools = await client.listTools();
+2 -1
View File
@@ -11,6 +11,7 @@ const scriptPath = path.join(repoRoot, 'skills/master-agreement-generator/script
const templatePath = path.join(repoRoot, 'skills/master-agreement-generator/references/master-template.example.md');
const specPath = path.join(repoRoot, 'skills/master-agreement-generator/references/spec.example.json');
const builder = require(scriptPath);
const { trySymlink } = require('../lib/helpers/symlink-support');
let passed = 0;
let failed = 0;
@@ -215,7 +216,7 @@ for (const extension of ['md', 'docx']) {
fs.mkdirSync(fixture.outDir);
const target = path.join(fixture.artifacts, 'external');
if (!dangling) fs.writeFileSync(target, 'do not overwrite');
fs.symlinkSync(target, path.join(fixture.outDir, `Acme MASTER.${extension}`), 'file');
if (!trySymlink(target, path.join(fixture.outDir, `Acme MASTER.${extension}`), 'file')) return;
const other = extension === 'md' ? 'docx' : 'md';
fs.writeFileSync(path.join(fixture.outDir, `Acme MASTER.${other}`), 'existing output');
const before = snapshot(fixture.artifacts);
@@ -8,6 +8,7 @@ const { spawnSync } = require('node:child_process');
const { test } = require('node:test');
const script = path.resolve(__dirname, '../../skills/frontend-design-direction/scripts/check-evidence.js');
const { checkEvidence } = require(script);
const { trySymlink } = require('../lib/helpers/symlink-support');
function report() {
const observation = { status: 'pass', evidence: 'Observed in the local test run.' };
@@ -118,8 +119,8 @@ test('CLI requires a nonempty local artifact and fails safely on invalid input',
assert.equal(run(input).status, 1, 'empty artifact must fail');
fs.writeFileSync(path.join(dir, 'render.png'), 'nonempty local artifact');
fs.writeFileSync(path.join(external, 'render.png'), 'nonempty external artifact');
fs.symlinkSync(path.join(dir, 'render.png'), path.join(dir, 'linked.png'));
fs.symlinkSync(path.join(external, 'render.png'), path.join(dir, 'outside'));
if (!trySymlink(path.join(dir, 'render.png'), path.join(dir, 'linked.png'))) return;
if (!trySymlink(path.join(external, 'render.png'), path.join(dir, 'outside'))) return;
fs.symlinkSync(external, path.join(dir, 'external-dir'), 'junction');
for (const artifact of ['.', 'https://example.com/render.png', path.join(dir, 'render.png'),
'../render.png', 'C:\\render.png', 'linked.png', 'outside', 'external-dir/render.png']) {