moving
This commit is contained in:
@@ -3,6 +3,7 @@ import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import os from 'node:os';
|
||||
import { WorkspaceManager, type WorkspaceConfig } from '../src/workspace-manager.js';
|
||||
import { MultiMindCache } from '../src/multi-mind-cache.js';
|
||||
|
||||
describe('WorkspaceManager', () => {
|
||||
let tmpDir: string;
|
||||
@@ -17,6 +18,22 @@ describe('WorkspaceManager', () => {
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('workspace root', () => {
|
||||
it('rejects a pre-existing workspaces junction that escapes the data directory', () => {
|
||||
const workspacesDir = path.join(tmpDir, 'workspaces');
|
||||
const outsideDir = fs.mkdtempSync(path.join(os.tmpdir(), 'waggle-ws-root-outside-'));
|
||||
fs.rmSync(workspacesDir, { recursive: true, force: true });
|
||||
fs.symlinkSync(outsideDir, workspacesDir, process.platform === 'win32' ? 'junction' : 'dir');
|
||||
|
||||
try {
|
||||
expect(() => new WorkspaceManager(tmpDir)).toThrow(/workspace root/i);
|
||||
} finally {
|
||||
fs.unlinkSync(workspacesDir);
|
||||
fs.rmSync(outsideDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('create', () => {
|
||||
it('creates workspace with directory, config, mind file, and sessions dir', () => {
|
||||
const ws = manager.create({ name: 'My Project', group: 'Work' });
|
||||
@@ -50,6 +67,34 @@ describe('WorkspaceManager', () => {
|
||||
it('returns empty array when no workspaces exist', () => {
|
||||
expect(manager.list()).toEqual([]);
|
||||
});
|
||||
|
||||
it('omits a workspace whose config is a hard link to an outside file', () => {
|
||||
manager.create({ name: 'Linked Config', group: 'Work' });
|
||||
const configPath = path.join(tmpDir, 'workspaces', 'linked-config', 'workspace.json');
|
||||
const outsidePath = path.join(tmpDir, 'outside-workspace.json');
|
||||
fs.writeFileSync(outsidePath, JSON.stringify({
|
||||
id: 'linked-config',
|
||||
name: 'OUTSIDE-SECRET',
|
||||
group: 'Work',
|
||||
created: new Date().toISOString(),
|
||||
}));
|
||||
fs.unlinkSync(configPath);
|
||||
fs.linkSync(outsidePath, configPath);
|
||||
|
||||
expect(manager.get('linked-config')).toBeNull();
|
||||
expect(manager.list().some((workspace) => workspace.id === 'linked-config')).toBe(false);
|
||||
expect(fs.readFileSync(outsidePath, 'utf8')).toContain('OUTSIDE-SECRET');
|
||||
});
|
||||
|
||||
it('omits a workspace whose config identity does not match its directory', () => {
|
||||
manager.create({ name: 'Expected Config', group: 'Work' });
|
||||
const configPath = path.join(tmpDir, 'workspaces', 'expected-config', 'workspace.json');
|
||||
const config = JSON.parse(fs.readFileSync(configPath, 'utf8')) as WorkspaceConfig;
|
||||
fs.writeFileSync(configPath, JSON.stringify({ ...config, id: 'different-config' }));
|
||||
|
||||
expect(manager.get('expected-config')).toBeNull();
|
||||
expect(manager.list().some((workspace) => workspace.id === 'different-config')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('listByGroup', () => {
|
||||
@@ -158,6 +203,33 @@ describe('WorkspaceManager', () => {
|
||||
const wsDir = path.join(tmpDir, 'workspaces', 'to-delete');
|
||||
expect(fs.existsSync(wsDir)).toBe(false);
|
||||
});
|
||||
|
||||
it.each(['', '.', '..', '../escape', 'nested/escape', 'nested\\escape', 'C:\\escape'])(
|
||||
'rejects unsafe workspace id %j without deleting outside the workspace root',
|
||||
(id) => {
|
||||
const sentinel = path.join(tmpDir, 'sentinel.txt');
|
||||
fs.writeFileSync(sentinel, 'preserve me');
|
||||
|
||||
expect(() => manager.delete(id)).toThrow(/invalid workspace id/i);
|
||||
expect(fs.readFileSync(sentinel, 'utf8')).toBe('preserve me');
|
||||
expect(fs.statSync(path.join(tmpDir, 'workspaces')).isDirectory()).toBe(true);
|
||||
},
|
||||
);
|
||||
|
||||
it('preserves an on-disk directory whose workspace config cannot be validated', () => {
|
||||
manager.create({ name: 'Untrusted Delete', group: 'Work' });
|
||||
const workspaceDir = path.join(tmpDir, 'workspaces', 'untrusted-delete');
|
||||
const configPath = path.join(workspaceDir, 'workspace.json');
|
||||
const outsidePath = path.join(tmpDir, 'outside-delete.json');
|
||||
fs.writeFileSync(outsidePath, JSON.stringify({ id: 'untrusted-delete' }));
|
||||
fs.unlinkSync(configPath);
|
||||
fs.linkSync(outsidePath, configPath);
|
||||
|
||||
manager.delete('untrusted-delete');
|
||||
|
||||
expect(fs.statSync(workspaceDir).isDirectory()).toBe(true);
|
||||
expect(fs.readFileSync(outsidePath, 'utf8')).toContain('untrusted-delete');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getMindPath', () => {
|
||||
@@ -167,6 +239,95 @@ describe('WorkspaceManager', () => {
|
||||
const mindPath = manager.getMindPath('mind-test');
|
||||
expect(mindPath).toBe(path.join(tmpDir, 'workspaces', 'mind-test', 'workspace.mind'));
|
||||
});
|
||||
|
||||
it.each(['missing-workspace', '..', '../escape', 'C:\\escape']) (
|
||||
'rejects invalid or missing workspace id %s before resolving a mind path',
|
||||
(id) => {
|
||||
expect(() => manager.getMindPath(id)).toThrow(/workspace/i);
|
||||
},
|
||||
);
|
||||
|
||||
it('rejects an on-disk workspace whose config identity does not match', () => {
|
||||
manager.create({ name: 'Expected Workspace', group: 'Work' });
|
||||
const configPath = path.join(tmpDir, 'workspaces', 'expected-workspace', 'workspace.json');
|
||||
const config = JSON.parse(fs.readFileSync(configPath, 'utf8')) as WorkspaceConfig;
|
||||
fs.writeFileSync(configPath, JSON.stringify({ ...config, id: 'different-workspace' }));
|
||||
|
||||
expect(() => manager.getMindPath('expected-workspace')).toThrow(/config id/i);
|
||||
});
|
||||
|
||||
it('rejects a dangling mind junction before opening its target', () => {
|
||||
manager.create({ name: 'Linked Mind', group: 'Work' });
|
||||
const mindPath = path.join(tmpDir, 'workspaces', 'linked-mind', 'workspace.mind');
|
||||
const missingTarget = path.join(tmpDir, 'missing-mind-target');
|
||||
fs.unlinkSync(mindPath);
|
||||
fs.symlinkSync(missingTarget, mindPath, process.platform === 'win32' ? 'junction' : 'dir');
|
||||
|
||||
expect(() => manager.getMindPath('linked-mind')).toThrow(/regular file/i);
|
||||
expect(fs.existsSync(missingTarget)).toBe(false);
|
||||
fs.unlinkSync(mindPath);
|
||||
});
|
||||
|
||||
it('rejects a mind file with another hard-link', () => {
|
||||
manager.create({ name: 'Hard Linked Mind', group: 'Work' });
|
||||
const mindPath = path.join(tmpDir, 'workspaces', 'hard-linked-mind', 'workspace.mind');
|
||||
const outsidePath = path.join(tmpDir, 'outside.mind');
|
||||
fs.writeFileSync(outsidePath, 'outside sentinel');
|
||||
fs.unlinkSync(mindPath);
|
||||
fs.linkSync(outsidePath, mindPath);
|
||||
|
||||
expect(() => manager.getMindPath('hard-linked-mind')).toThrow(/regular file/i);
|
||||
expect(fs.readFileSync(outsidePath, 'utf8')).toBe('outside sentinel');
|
||||
});
|
||||
|
||||
it('allows a valid workspace to recreate a missing mind inside its directory', () => {
|
||||
manager.create({ name: 'Missing Mind', group: 'Work' });
|
||||
const mindPath = path.join(tmpDir, 'workspaces', 'missing-mind', 'workspace.mind');
|
||||
fs.unlinkSync(mindPath);
|
||||
expect(manager.getMindPath('missing-mind')).toBe(mindPath);
|
||||
|
||||
const cache = new MultiMindCache({
|
||||
maxOpen: 2,
|
||||
getMindPath: id => manager.getMindPath(id),
|
||||
allowedRoot: path.join(tmpDir, 'workspaces'),
|
||||
});
|
||||
expect(cache.getOrOpen('missing-mind')).not.toBeNull();
|
||||
expect(fs.statSync(mindPath).isFile()).toBe(true);
|
||||
cache.closeAll();
|
||||
});
|
||||
|
||||
it('contains resolver failures and rejects a post-resolution junction swap', () => {
|
||||
const throwingCache = new MultiMindCache({
|
||||
maxOpen: 2,
|
||||
getMindPath: () => { throw new Error('unsafe workspace'); },
|
||||
allowedRoot: path.join(tmpDir, 'workspaces'),
|
||||
});
|
||||
expect(throwingCache.getOrOpen('missing')).toBeNull();
|
||||
|
||||
manager.create({ name: 'Swap Target', group: 'Work' });
|
||||
const workspaceDir = path.join(tmpDir, 'workspaces', 'swap-target');
|
||||
const outsideDir = fs.mkdtempSync(path.join(os.tmpdir(), 'waggle-ws-swap-'));
|
||||
const outsideMind = path.join(outsideDir, 'workspace.mind');
|
||||
fs.writeFileSync(outsideMind, 'outside sentinel');
|
||||
const cache = new MultiMindCache({
|
||||
maxOpen: 2,
|
||||
allowedRoot: path.join(tmpDir, 'workspaces'),
|
||||
getMindPath: id => {
|
||||
const resolved = manager.getMindPath(id);
|
||||
fs.rmSync(workspaceDir, { recursive: true, force: true });
|
||||
fs.symlinkSync(outsideDir, workspaceDir, process.platform === 'win32' ? 'junction' : 'dir');
|
||||
return resolved;
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
expect(cache.getOrOpen('swap-target')).toBeNull();
|
||||
expect(fs.readFileSync(outsideMind, 'utf8')).toBe('outside sentinel');
|
||||
} finally {
|
||||
fs.unlinkSync(workspaceDir);
|
||||
fs.rmSync(outsideDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('listGroups', () => {
|
||||
@@ -294,6 +455,96 @@ describe('WorkspaceManager', () => {
|
||||
|
||||
// Reverse-ported from OSS hive-mind (oss-drift triage R4, 2026-06-11).
|
||||
describe('ensure', () => {
|
||||
it('rejects a pre-existing workspace junction without writing through it', () => {
|
||||
const outsideDir = fs.mkdtempSync(path.join(os.tmpdir(), 'waggle-ws-outside-'));
|
||||
const outsideConfig = path.join(outsideDir, 'workspace.json');
|
||||
const outsideMind = path.join(outsideDir, 'workspace.mind');
|
||||
fs.writeFileSync(outsideConfig, 'outside config');
|
||||
fs.writeFileSync(outsideMind, 'outside mind');
|
||||
const linkPath = path.join(tmpDir, 'workspaces', 'escape');
|
||||
fs.symlinkSync(outsideDir, linkPath, process.platform === 'win32' ? 'junction' : 'dir');
|
||||
|
||||
try {
|
||||
expect(() => manager.ensure('escape')).toThrow(/already exists|valid workspace/i);
|
||||
expect(fs.readFileSync(outsideConfig, 'utf8')).toBe('outside config');
|
||||
expect(fs.readFileSync(outsideMind, 'utf8')).toBe('outside mind');
|
||||
expect(fs.existsSync(path.join(outsideDir, 'sessions'))).toBe(false);
|
||||
} finally {
|
||||
fs.unlinkSync(linkPath);
|
||||
fs.rmSync(outsideDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it.each([
|
||||
['malformed', '{"id":'],
|
||||
['mismatched', JSON.stringify({ id: 'other-workspace' })],
|
||||
])('preserves an existing %s workspace when ensure cannot validate it', (_label, rawConfig) => {
|
||||
const wsDir = path.join(tmpDir, 'workspaces', 'victim');
|
||||
fs.mkdirSync(wsDir);
|
||||
const configPath = path.join(wsDir, 'workspace.json');
|
||||
const mindPath = path.join(wsDir, 'workspace.mind');
|
||||
fs.writeFileSync(configPath, rawConfig);
|
||||
fs.writeFileSync(mindPath, 'mind sentinel');
|
||||
|
||||
expect(() => manager.ensure('victim')).toThrow(/already exists|valid workspace/i);
|
||||
expect(fs.readFileSync(configPath, 'utf8')).toBe(rawConfig);
|
||||
expect(fs.readFileSync(mindPath, 'utf8')).toBe('mind sentinel');
|
||||
expect(fs.existsSync(path.join(wsDir, 'sessions'))).toBe(false);
|
||||
});
|
||||
|
||||
it.each([false, true])(
|
||||
'adopts an empty legacy directory (sessions subdirectory: %s)',
|
||||
(withSessions) => {
|
||||
const workspaceDir = path.join(tmpDir, 'workspaces', 'default');
|
||||
const sessionsDir = path.join(workspaceDir, 'sessions');
|
||||
fs.mkdirSync(withSessions ? sessionsDir : workspaceDir, { recursive: true });
|
||||
|
||||
const workspace = manager.ensure('default', { name: 'Legacy Default', group: 'Work' });
|
||||
|
||||
expect(workspace.id).toBe('default');
|
||||
expect(fs.statSync(sessionsDir).isDirectory()).toBe(true);
|
||||
expect(fs.existsSync(path.join(workspaceDir, 'workspace.mind'))).toBe(true);
|
||||
expect(manager.get('default')).toEqual(workspace);
|
||||
},
|
||||
);
|
||||
|
||||
it('does not adopt the legacy empty-directory shape for another workspace id', () => {
|
||||
const workspaceDir = path.join(tmpDir, 'workspaces', 'not-default');
|
||||
fs.mkdirSync(path.join(workspaceDir, 'sessions'), { recursive: true });
|
||||
|
||||
expect(() => manager.ensure('not-default')).toThrow(/already exists|valid workspace/i);
|
||||
expect(fs.readdirSync(workspaceDir)).toEqual(['sessions']);
|
||||
});
|
||||
|
||||
it('rejects a legacy sessions junction and preserves its outside target', () => {
|
||||
const workspaceDir = path.join(tmpDir, 'workspaces', 'default');
|
||||
const outsideDir = fs.mkdtempSync(path.join(os.tmpdir(), 'waggle-legacy-sessions-'));
|
||||
fs.mkdirSync(workspaceDir);
|
||||
fs.symlinkSync(outsideDir, path.join(workspaceDir, 'sessions'), process.platform === 'win32' ? 'junction' : 'dir');
|
||||
|
||||
try {
|
||||
expect(() => manager.ensure('default')).toThrow(/already exists|valid workspace/i);
|
||||
expect(fs.readdirSync(outsideDir)).toEqual([]);
|
||||
} finally {
|
||||
fs.unlinkSync(path.join(workspaceDir, 'sessions'));
|
||||
fs.rmSync(outsideDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it.each([
|
||||
['a legacy session file', 'sessions', 'session.jsonl'],
|
||||
['an unexpected sibling', '', 'unexpected.txt'],
|
||||
])('rejects legacy adoption when the directory contains %s', (_label, childDir, fileName) => {
|
||||
const wsDir = path.join(tmpDir, 'workspaces', 'default');
|
||||
const parent = path.join(wsDir, childDir);
|
||||
fs.mkdirSync(parent, { recursive: true });
|
||||
const sentinel = path.join(parent, fileName);
|
||||
fs.writeFileSync(sentinel, 'preserve me');
|
||||
|
||||
expect(() => manager.ensure('default')).toThrow(/already exists|valid workspace/i);
|
||||
expect(fs.readFileSync(sentinel, 'utf8')).toBe('preserve me');
|
||||
expect(fs.existsSync(path.join(wsDir, 'workspace.mind'))).toBe(false);
|
||||
});
|
||||
it('creates a workspace with the exact supplied id when missing', () => {
|
||||
const ws = manager.ensure('cwd-derived-id');
|
||||
|
||||
|
||||
Reference in New Issue
Block a user