mirror of
https://github.com/nexu-io/open-design.git
synced 2026-06-01 03:14:35 +07:00
* fix(daemon): confine sandbox project and host discovery * fix(daemon): resolve sandbox data dir for toolchain discovery * fix(daemon): resolve sandbox data dir for agent env * fix(daemon): fail fast for sandbox imported folders * test(daemon): assert sandbox imported folder rejection * fix(daemon): keep sandbox import guard at run start * fix(daemon): reject sandbox imported project file roots * fix(daemon): preserve imported project detail roots * test(daemon): expect sandbox profiles to stay scoped * fix(daemon): bypass proxies for agent tool callbacks * test(daemon): isolate media policy route memory extraction * fix(daemon): keep loopback no-proxy scoped to sandbox
221 lines
8.8 KiB
TypeScript
221 lines
8.8 KiB
TypeScript
import { mkdtempSync, rmSync } from 'node:fs';
|
|
import { mkdir, writeFile } from 'node:fs/promises';
|
|
import { tmpdir } from 'node:os';
|
|
import path from 'node:path';
|
|
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
|
|
import {
|
|
assertSandboxProjectRootAvailable,
|
|
detectEntryFile,
|
|
listFiles,
|
|
resolveProjectDir,
|
|
SandboxImportedProjectError,
|
|
} from '../src/projects.js';
|
|
|
|
function withSandboxMode<T>(run: () => T): T {
|
|
const previous = process.env.OD_SANDBOX_MODE;
|
|
process.env.OD_SANDBOX_MODE = '1';
|
|
try {
|
|
return run();
|
|
} finally {
|
|
if (previous == null) delete process.env.OD_SANDBOX_MODE;
|
|
else process.env.OD_SANDBOX_MODE = previous;
|
|
}
|
|
}
|
|
|
|
describe('resolveProjectDir', () => {
|
|
const projectsRoot = '/var/od/projects';
|
|
const projectId = 'proj-abc';
|
|
|
|
it('returns the standard path when no metadata is given', () => {
|
|
expect(resolveProjectDir(projectsRoot, projectId)).toBe(
|
|
path.join(projectsRoot, projectId),
|
|
);
|
|
});
|
|
|
|
it('returns the standard path when metadata has no baseDir', () => {
|
|
expect(resolveProjectDir(projectsRoot, projectId, { kind: 'prototype' })).toBe(
|
|
path.join(projectsRoot, projectId),
|
|
);
|
|
});
|
|
|
|
it('returns metadata.baseDir when set to an absolute path', () => {
|
|
const baseDir = '/Users/me/projects/site';
|
|
expect(
|
|
resolveProjectDir(projectsRoot, projectId, { kind: 'prototype', baseDir }),
|
|
).toBe(path.normalize(baseDir));
|
|
});
|
|
|
|
it('falls back to the standard path when baseDir is relative', () => {
|
|
expect(
|
|
resolveProjectDir(projectsRoot, projectId, {
|
|
kind: 'prototype',
|
|
baseDir: 'relative/site',
|
|
}),
|
|
).toBe(path.join(projectsRoot, projectId));
|
|
});
|
|
|
|
it('throws on an invalid project id only when no baseDir is set', () => {
|
|
// No baseDir → relies on isSafeId
|
|
expect(() => resolveProjectDir(projectsRoot, '../escape')).toThrowError();
|
|
|
|
// baseDir present → project id is not consulted, so a bogus id is fine
|
|
expect(() =>
|
|
resolveProjectDir(projectsRoot, '../escape', {
|
|
kind: 'prototype',
|
|
baseDir: '/Users/me/site',
|
|
}),
|
|
).not.toThrow();
|
|
});
|
|
|
|
it('rejects metadata.baseDir in sandbox mode before resolving a project file root', () => {
|
|
withSandboxMode(() => {
|
|
const baseDir = '/Users/me/projects/site';
|
|
expect(
|
|
() => resolveProjectDir(projectsRoot, projectId, { kind: 'prototype', baseDir }),
|
|
).toThrowError(SandboxImportedProjectError);
|
|
expect(() =>
|
|
assertSandboxProjectRootAvailable({ kind: 'prototype', baseDir }),
|
|
).toThrowError(SandboxImportedProjectError);
|
|
expect(() => resolveProjectDir(projectsRoot, '../escape', {
|
|
kind: 'prototype',
|
|
baseDir,
|
|
})).toThrowError();
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('detectEntryFile', () => {
|
|
let dir = '';
|
|
|
|
beforeEach(() => {
|
|
dir = mkdtempSync(path.join(tmpdir(), 'od-detect-entry-'));
|
|
});
|
|
|
|
afterEach(() => {
|
|
if (dir) rmSync(dir, { recursive: true, force: true });
|
|
});
|
|
|
|
it('returns index.html when present at the root', async () => {
|
|
await writeFile(path.join(dir, 'index.html'), '<!doctype html>');
|
|
await writeFile(path.join(dir, 'about.html'), '<!doctype html>');
|
|
expect(await detectEntryFile(dir)).toBe('index.html');
|
|
});
|
|
|
|
it('returns the first .html file when no index.html is present', async () => {
|
|
await writeFile(path.join(dir, 'about.html'), '<!doctype html>');
|
|
const result = await detectEntryFile(dir);
|
|
expect(result).toBe('about.html');
|
|
});
|
|
|
|
it('returns null when the folder has no html files', async () => {
|
|
await writeFile(path.join(dir, 'README.md'), '# hi');
|
|
expect(await detectEntryFile(dir)).toBeNull();
|
|
});
|
|
|
|
it('returns null when the folder does not exist', async () => {
|
|
const missing = path.join(dir, 'no-such-subdir');
|
|
expect(await detectEntryFile(missing)).toBeNull();
|
|
});
|
|
|
|
it('does not descend into subdirectories', async () => {
|
|
await mkdir(path.join(dir, 'public'));
|
|
await writeFile(path.join(dir, 'public', 'index.html'), '<!doctype html>');
|
|
expect(await detectEntryFile(dir)).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('listFiles with metadata.baseDir', () => {
|
|
let baseDir = '';
|
|
|
|
beforeEach(async () => {
|
|
baseDir = mkdtempSync(path.join(tmpdir(), 'od-list-'));
|
|
await writeFile(path.join(baseDir, 'index.html'), '<!doctype html>');
|
|
await writeFile(path.join(baseDir, 'app.css'), 'body{}');
|
|
await mkdir(path.join(baseDir, 'node_modules', 'react'), { recursive: true });
|
|
await writeFile(path.join(baseDir, 'node_modules', 'react', 'index.js'), '');
|
|
await mkdir(path.join(baseDir, '.git'));
|
|
await writeFile(path.join(baseDir, '.git', 'HEAD'), 'ref: refs/heads/main');
|
|
await mkdir(path.join(baseDir, 'dist'));
|
|
await writeFile(path.join(baseDir, 'dist', 'bundle.js'), '/*compiled*/');
|
|
await mkdir(path.join(baseDir, 'Build', 'DerivedData-KeeTests'), { recursive: true });
|
|
await writeFile(path.join(baseDir, 'Build', 'DerivedData-KeeTests', 'index-store'), '');
|
|
await mkdir(path.join(baseDir, 'vendor', 'package'), { recursive: true });
|
|
await writeFile(path.join(baseDir, 'vendor', 'package', 'generated.js'), '');
|
|
await mkdir(path.join(baseDir, 'Rust', 'KeePassCore', 'target', 'release'), { recursive: true });
|
|
await writeFile(path.join(baseDir, 'Rust', 'KeePassCore', 'target', 'release', 'libkeepass.a'), '');
|
|
await mkdir(path.join(baseDir, 'src'));
|
|
await writeFile(path.join(baseDir, 'src', 'app.ts'), 'export {}');
|
|
});
|
|
|
|
afterEach(() => {
|
|
if (baseDir) rmSync(baseDir, { recursive: true, force: true });
|
|
});
|
|
|
|
it('walks the folder rooted at metadata.baseDir', async () => {
|
|
const files = await listFiles('/unused/projects', 'unused-id', {
|
|
metadata: { kind: 'prototype', baseDir },
|
|
});
|
|
const paths = files.map((f) => f.path).sort();
|
|
expect(paths).toContain('index.html');
|
|
expect(paths).toContain('app.css');
|
|
expect(paths).toContain('src/app.ts');
|
|
});
|
|
|
|
// Regression: callers that pass the metadata object directly as opts
|
|
// (instead of wrapping it in `{ metadata }`) were silently scanning the
|
|
// standard .od/projects/<id>/ instead of the imported folder. Codex
|
|
// review of #624 caught one in chat-route. Lock the contract: when a
|
|
// bare metadata object is passed at the top level, listFiles must
|
|
// ignore it and fall back to the standard project dir — no false
|
|
// positives on a folder the caller didn't ask for.
|
|
it('ignores bare metadata at opts top-level (must be opts.metadata)', async () => {
|
|
// Pass the metadata object directly as opts. With the documented
|
|
// contract this means opts.metadata is undefined, so listFiles
|
|
// resolves to projectsRoot/projectId — which here doesn't exist,
|
|
// so the result must be an empty array, not the contents of baseDir.
|
|
const files = await listFiles('/unused/projects', 'unused-id', {
|
|
kind: 'prototype',
|
|
baseDir,
|
|
} as never);
|
|
expect(files).toEqual([]);
|
|
});
|
|
|
|
it('skips conventional build / install dirs (node_modules, .git, dist)', async () => {
|
|
const files = await listFiles('/unused/projects', 'unused-id', {
|
|
metadata: { kind: 'prototype', baseDir },
|
|
});
|
|
const paths = files.map((f) => f.path);
|
|
expect(paths.some((p) => p.startsWith('node_modules/'))).toBe(false);
|
|
expect(paths.some((p) => p.startsWith('.git/'))).toBe(false);
|
|
expect(paths.some((p) => p.startsWith('dist/'))).toBe(false);
|
|
expect(paths.some((p) => p.startsWith('Build/'))).toBe(false);
|
|
expect(paths.some((p) => p.startsWith('vendor/'))).toBe(false);
|
|
expect(paths.some((p) => p.includes('/target/'))).toBe(false);
|
|
});
|
|
|
|
it('does not skip those dirs for non-baseDir projects (back-compat)', async () => {
|
|
// Without metadata.baseDir, listFiles points at the standard project dir.
|
|
// We don't have one set up for this test — just assert the call doesn't
|
|
// *apply* the skip filter when the metadata is absent. We check this
|
|
// indirectly: passing the same baseDir as a non-baseDir directory
|
|
// (impossible here since listFiles uses standard path). So instead,
|
|
// verify the default path behavior is unchanged: no metadata, no
|
|
// skipDirs, no baseDir resolution.
|
|
const standardDir = mkdtempSync(path.join(tmpdir(), 'od-list-std-'));
|
|
try {
|
|
await mkdir(path.join(standardDir, 'std-project'), { recursive: true });
|
|
await mkdir(path.join(standardDir, 'std-project', 'node_modules'));
|
|
await writeFile(path.join(standardDir, 'std-project', 'node_modules', 'a.js'), '');
|
|
await writeFile(path.join(standardDir, 'std-project', 'main.html'), '');
|
|
|
|
const files = await listFiles(standardDir, 'std-project');
|
|
const paths = files.map((f) => f.path).sort();
|
|
// node_modules contents *do* appear when no skip filter is applied
|
|
expect(paths).toContain('main.html');
|
|
expect(paths).toContain('node_modules/a.js');
|
|
} finally {
|
|
rmSync(standardDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
});
|