diff --git a/tests/hermes-hooks.test.js b/tests/hermes-hooks.test.js index f3a48bb56..dcc5f96c7 100644 --- a/tests/hermes-hooks.test.js +++ b/tests/hermes-hooks.test.js @@ -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'); diff --git a/tests/lib/codex-legacy-sync.test.js b/tests/lib/codex-legacy-sync.test.js index a5cc5a4ce..c19e0adfd 100644 --- a/tests/lib/codex-legacy-sync.test.js +++ b/tests/lib/codex-legacy-sync.test.js @@ -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, '\n# Outside\n\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'); diff --git a/tests/lib/eval-harness/security.test.js b/tests/lib/eval-harness/security.test.js index 8b46de5b1..23c7d5ca9 100644 --- a/tests/lib/eval-harness/security.test.js +++ b/tests/lib/eval-harness/security.test.js @@ -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); })); diff --git a/tests/lib/helpers/symlink-support.js b/tests/lib/helpers/symlink-support.js new file mode 100644 index 000000000..956eacf44 --- /dev/null +++ b/tests/lib/helpers/symlink-support.js @@ -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 }; diff --git a/tests/lib/memory-vault.test.js b/tests/lib/memory-vault.test.js index ec37e32fa..3165f9b59 100644 --- a/tests/lib/memory-vault.test.js +++ b/tests/lib/memory-vault.test.js @@ -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 }), diff --git a/tests/lib/opencode-consent-legacy-lock.test.js b/tests/lib/opencode-consent-legacy-lock.test.js index dabfb47a1..4e65cc9e4 100644 --- a/tests/lib/opencode-consent-legacy-lock.test.js +++ b/tests/lib/opencode-consent-legacy-lock.test.js @@ -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); diff --git a/tests/lib/state-store.test.js b/tests/lib/state-store.test.js index a1e2f045c..a0549d964 100644 --- a/tests/lib/state-store.test.js +++ b/tests/lib/state-store.test.js @@ -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 }), diff --git a/tests/scripts/command-runtime.test.js b/tests/scripts/command-runtime.test.js index dc3b6d651..e98c51319 100644 --- a/tests/scripts/command-runtime.test.js +++ b/tests/scripts/command-runtime.test.js @@ -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); }); } diff --git a/tests/scripts/coordination-inventory.test.js b/tests/scripts/coordination-inventory.test.js index ebcdb4728..5dfd152ce 100644 --- a/tests/scripts/coordination-inventory.test.js +++ b/tests/scripts/coordination-inventory.test.js @@ -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)); diff --git a/tests/scripts/memory-mcp.test.js b/tests/scripts/memory-mcp.test.js index bbb43f9bf..b8565fe7a 100644 --- a/tests/scripts/memory-mcp.test.js +++ b/tests/scripts/memory-mcp.test.js @@ -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(); diff --git a/tests/skills/build-agreement.test.js b/tests/skills/build-agreement.test.js index c0a9775f7..940504121 100644 --- a/tests/skills/build-agreement.test.js +++ b/tests/skills/build-agreement.test.js @@ -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); diff --git a/tests/skills/frontend-design-direction.test.js b/tests/skills/frontend-design-direction.test.js index b316f7177..aa95a8094 100644 --- a/tests/skills/frontend-design-direction.test.js +++ b/tests/skills/frontend-design-direction.test.js @@ -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']) {