SPB Git

Repository Public

lab-sandbox.test.ts 28.2 KB 323 lines typescript

1// V8 hardening — every process of the API that runs strategy code is confined by the OS, proven through the REAL entry points:2//   spot signal   agents/live.ts        runSignal3//   perp signal   agents/perp-signal.ts runPerpSignal4//   spot backtest agents/strategies.ts  describe + backtest          (candles: files exported by the API, no database URL)5//   perp backtest agents/perp-strategies.ts describePerp + perpBacktest6//   research      research/evaluate.ts  the submission pipeline, through the runner routes7// A hostile strategy.py (lab-hostile.ts) attempts every forbidden access at import and inside target_weights; the laboratory8// copy used here has its purity lint DISABLED, so the only thing between the strategy and the machine is the sandbox.9// Stand-ins: two local TCP listeners play the wallet API and the signer relay, a Unix socket plays a local service, a file10// outside plays a secret / an .env file, a process started with a canary in its environment plays the API process itself.11// The same strategy, run once WITHOUT the sandbox (development switch) against the local stand-ins only, shows that the12// probes do detect a leak when there is one. Skipped where sandbox-exec or the laboratory's virtualenv do not exist.13import { spawn, type ChildProcess } from 'node:child_process';14import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, realpathSync, writeFileSync } from 'node:fs';15import { createServer, type Server } from 'node:net';16import { homedir, tmpdir } from 'node:os';17import path from 'node:path';18import { randomUUID } from 'node:crypto';19import { afterAll, beforeAll, describe, expect, it } from 'vitest';20import { labSandboxProfile } from '@spw/core';21import { addAgent, ownerCookie, world, type World } from './alerts-helpers.ts';22import { bars, CONTROL_MASK, controlsOk, decodeError, decodeWeights, fixtureLab, hostileStrategy, leaks, REAL_PYTHON, sha256, type Targets } from './lab-hostile.ts';23import { REPO_ROOT } from './test-db.ts';2425const READY = process.platform === 'darwin' && existsSync('/usr/bin/sandbox-exec') && existsSync(REAL_PYTHON);26const ENV_CANARY = 'CANARY-ENV-2c9e41b7a6f0', SECRET = 'runner-secret-'.padEnd(48, 'y'), ORIGIN = 'https://wallet.test';27let w: World, sql: any, app: any, cookie = '';28let lab: typeof import('../src/agents/lab.ts'), live: typeof import('../src/agents/live.ts'), perpSignal: typeof import('../src/agents/perp-signal.ts');29let strategies: typeof import('../src/agents/strategies.ts'), perpStrategies: typeof import('../src/agents/perp-strategies.ts');30let fx: ReturnType<typeof fixtureLab>, targets: Targets, envHolder: ChildProcess | undefined, servers: Server[] = [], scratchRoot = '';31const connections = { api: 0, signer: 0, unix: 0 };32const spotBars = { BTC: bars(120, 11, 60_000), ETH: bars(120, 23, 3000) };33const perpSeries = (seed: number, p0: number) => { const b = bars(120, seed, p0); return { bars: b, funding: b.map((x) => ({ ts: x.ts, rate: 0.00001, premium: null })) }; };34const add = (rel: string, o: Partial<Parameters<typeof hostileStrategy>[0]> = {}) => fx.addStrategy(rel, hostileStrategy({ name: rel.split('/')[0]!, version: rel.split('/')[1]!, kind: 'spot', universe: ['BTC', 'ETH'], behaviour: 'weights', targets, ...o }));35const noLeak = (m: { importMask: number; callMask: number }, where: string): void => {36  expect(controlsOk(m.importMask), `${where}: the controls must work at import (mask ${m.importMask.toString(16)})`).toBe(true);37  expect(controlsOk(m.callMask), `${where}: the controls must work inside target_weights (mask ${m.callMask.toString(16)})`).toBe(true);38  expect(leaks(m.importMask), `${where}: at import`).toEqual([]);39  expect(leaks(m.callMask), `${where}: inside target_weights`).toEqual([]);40};4142beforeAll(async () => {43  if (!READY) return;44  scratchRoot = realpathSync(mkdtempSync(path.join(tmpdir(), 'spw-lab-canary-'))); // (before TMPDIR moves: a Unix socket path must stay short)45  // a private temporary root for this file: the scratch directories of ITS calls are then the only spw-lab-* entries in it46  // (other test files run laboratory processes at the same time in the shared one)47  process.env.TMPDIR = realpathSync(mkdtempSync(path.join(tmpdir(), 'spw-lab-tmproot-')));48  // the fixture laboratory must be chosen before any module of the API is imported (RESEARCH_DIR is read at load)49  fx = fixtureLab({ lint: 'off' });50  Object.assign(process.env, { SPW_RESEARCH_DIR: fx.dir, SPW_RESEARCH_PYTHON: fx.python, SPW_RESEARCH_RUNNER_SECRET: SECRET, SPW_RESEARCH_LAB_COMMIT: '' });51  delete process.env.SPW_LAB_SANDBOX;52  const canary = path.join(scratchRoot, 'secret.txt'), envFile = path.join(scratchRoot, '.env');53  writeFileSync(canary, 'CANARY-SECRET-0123456789'); writeFileSync(envFile, 'SPW_SIGNER_SECRET=canary-not-a-real-secret\n');54  const listen = (key: keyof typeof connections, where: number | string) => new Promise<Server>((resolve) => { const s = createServer((c) => { connections[key] += 1; c.destroy(); });55    if (typeof where === 'string') s.listen(where, () => resolve(s)); else s.listen(where, '127.0.0.1', () => resolve(s)); });56  const api = await listen('api', 0), signer = await listen('signer', 0), unixSocket = path.join(scratchRoot, 'svc.sock'), unix = await listen('unix', unixSocket);57  servers = [api, signer, unix];58  envHolder = spawn(process.execPath, ['-e', 'setTimeout(() => {}, 600000)'], { env: { PATH: process.env.PATH ?? '', SPW_SIGNER_SECRET_CANARY: ENV_CANARY }, stdio: 'ignore' });59  await new Promise((r) => setTimeout(r, 300));60  const other = fx.addStrategy('zz_other/1', readFileSync(path.join(REPO_ROOT, 'research/strategies/ma_trend/3/strategy.py'), 'utf8'));61  targets = { home: homedir(), repo: REPO_ROOT, canary, envFile, outside: path.join(scratchRoot, 'written-by-the-strategy.txt'), labSrc: path.join(fx.dir, 'src'), otherStrategy: other.dir,62    apiPort: (api.address() as { port: number }).port, signerPort: (signer.address() as { port: number }).port, unixSocket, envPid: envHolder.pid!, envCanary: ENV_CANARY, safeOnly: false };6364  w = await world('lab_sandbox'); sql = w.sql;65  const H = 3_600_000, rows: Record<string, unknown>[] = [];66  for (const [sym, seed, p0] of [['BTC', 11, 60_000], ['ETH', 23, 3000]] as const) for (const b of bars(1000, seed, p0)) rows.push({ source: 'coinbase', symbol: sym, interval: '1h', ts: new Date(b.ts - H), open: b.open, high: b.high, low: b.low, close: b.close, volume: b.volume });67  for (let i = 0; i < rows.length; i += 500) await sql`insert into candles ${sql(rows.slice(i, i + 500))}`;68  lab = await import('../src/agents/lab.ts'); live = await import('../src/agents/live.ts'); perpSignal = await import('../src/agents/perp-signal.ts');69  strategies = await import('../src/agents/strategies.ts'); perpStrategies = await import('../src/agents/perp-strategies.ts');70  app = await (await import('../src/server.ts')).buildServer();71  cookie = await ownerCookie(sql);72}, 120_000);73afterAll(async () => { for (const s of servers) s.close(); envHolder?.kill('SIGKILL'); await app?.close(); await sql?.end(); });7475describe.skipIf(!READY)('strategy sandbox — the health check', () => {76  it('reports sandbox-exec, confined; the profile is deny-default with no network rule, no fork, exec of the interpreter only', async () => {77    const st = await lab.labStatus(true);78    expect(st, JSON.stringify(st)).toMatchObject({ mode: 'sandbox-exec', ok: true, reason: null });79    const p = labSandboxProfile({ scratch: '/private/tmp/x', readable: ['/opt/lab/src'], exec: ['/opt/py/bin/python3.12'] });80    expect(p).toMatch(/^\(version 1\)\n\(deny default\)\n\(allow process-exec\* \(literal "\/opt\/py\/bin\/python3\.12"\)\)\n/);81    expect(p).not.toMatch(/network|process-fork|mach-lookup|\(allow default\)|homebrew|\/usr\/bin|"\/bin"|\/private\/etc\/ssl/);82    expect(p).toContain('(deny sysctl-read (sysctl-name-prefix "kern.proc"))');83    expect(p).toContain('(deny process-info*)');84    expect(p.match(/file-write\*/g)).toHaveLength(2);                    // /dev/null and the scratch directory85    expect(() => labSandboxProfile({ scratch: '/tmp/a"b', readable: [], exec: ['/x'] })).toThrow(/not a plain absolute path/);86    expect(() => labSandboxProfile({ scratch: '/tmp/x', readable: [], exec: [] })).toThrow(/interpreter/);87  });88});8990describe.skipIf(!READY)('strategy sandbox — a hostile strategy through each real entry point', () => {91  it('spot signal (runSignal): nothing leaks at import nor inside target_weights; the controls work', async () => {92    const s = add('zz_hostile/1');93    const weights = await live.runSignal({ path: s.path, sha256: s.sha256 }, spotBars, '1h');94    noLeak(decodeWeights(weights, ['BTC', 'ETH']), 'spot signal');95  });9697  it('perp signal (runPerpSignal): the same', async () => {98    const s = add('zz_hostile_perp/1', { kind: 'perp' });99    const sig = await perpSignal.runPerpSignal({ path: s.path, sha256: s.sha256 }, { BTC: perpSeries(5, 60_000), ETH: perpSeries(9, 3000) } as never, '1h');100    noLeak(decodeWeights(sig.weights, ['BTC', 'ETH']), 'perp signal');101  });102103  it('spot backtest (describe + backtest): the candles arrive as files, the process has no database URL, nothing leaks', async () => {104    const s = add('zz_hostile/2', { behaviour: 'raise' });105    const info = await strategies.describe('zz_hostile/2');               // import-time probes ran here too (sandboxed describe)106    expect(info.sha256).toBe(s.sha256);107    const agentId = await addAgent(sql, { name: 'Hostile spot', state: 'CREATED', universe: ['BTC', 'ETH'] }), vid = randomUUID();108    await sql`insert into strategy_versions (id, agent_id, name, version, code_sha256, path, meta) values (${vid}, ${agentId}, 'zz_hostile', '2', ${s.sha256}, ${s.path}, '{}')`;109    await strategies.backtest(vid, info, ['BTC', 'ETH']);110    const [row] = await sql`select status, notes, report from strategy_versions where id = ${vid}`;111    expect(row.report).toBeNull();112    const m = decodeError(row.notes);113    expect(m, row.notes).not.toBeNull();114    noLeak(m!, 'spot backtest');115  }, 120_000);116117  it('perp backtest (describePerp + perpBacktest): the same, on the bundle prepared by the API', async () => {118    const s = add('zz_hostile_perp/2', { kind: 'perp', behaviour: 'raise' });119    const info = await perpStrategies.describePerp('zz_hostile_perp/2');120    const H = 3_600_000, series = (seed: number, p0: number) => { const b = bars(600, seed, p0);121      return { candles: b.map((x) => [x.ts - H, x.open.toFixed(4), x.high.toFixed(4), x.low.toFixed(4), x.close.toFixed(4), x.volume.toFixed(3)] as [number, string, string, string, string, string]), funding: b.map((x) => [x.ts - H, '0.00001250', null] as [number, string, string | null]) }; };122    const data: Record<string, ReturnType<typeof series>> = { BTC: series(5, 60_000), ETH: series(9, 3000) };123    const agentId = await addAgent(sql, { name: 'Hostile perp', state: 'CREATED', universe: ['BTC', 'ETH'] }), vid = randomUUID();124    await sql`insert into strategy_versions (id, agent_id, name, version, code_sha256, path, meta) values (${vid}, ${agentId}, 'zz_hostile_perp', '2', ${s.sha256}, ${s.path}, '{}')`;125    await perpStrategies.perpBacktest(vid, info, ['BTC', 'ETH'], '1h', { data: { instrument: async (id: string) => ({ venue: 'hyperliquid', symbol: id.split(':')[1]!, maxLeverage: '40', lotSize: '0.0001' }) },126      store: { now: () => Date.now(), backfill: async () => {}, exact: async (inst: { symbol: string }) => data[inst.symbol]! } } as never);127    const [row] = await sql`select notes, report from strategy_versions where id = ${vid}`;128    expect(row.report).toBeNull();129    const m = decodeError(row.notes);130    expect(m, row.notes).not.toBeNull();131    noLeak(m!, 'perp backtest');132  }, 120_000);133134  it('research evaluation (the runner routes → evaluateSubmission): a submitted hostile strategy is described and backtested confined', async () => {135    const owner = async (method: string, url: string, body?: unknown) => { const headers: Record<string, string> = { cookie };136      if (method !== 'GET') Object.assign(headers, { origin: ORIGIN, 'x-spw-csrf': '1', 'content-type': 'application/json' });137      const r = await app.inject({ method, url: `/api/v1${url}`, headers, payload: body === undefined ? undefined : JSON.stringify(body) }); return { status: r.statusCode, json: r.json() }; };138    const runner = async (url: string, body: unknown, token?: string) => { const r = await app.inject({ method: 'POST', url: `/api/v1/research/runner${url}`, headers: { 'content-type': 'application/json', authorization: `Bearer ${SECRET}`, ...(token ? { 'x-spw-job-token': token } : {}) }, payload: JSON.stringify(body) });139      return { status: r.statusCode, json: (() => { try { return r.json(); } catch { return null; } })() }; };140    const waitFor = async (id: string, done: (j: any) => boolean): Promise<any> => { for (let i = 0; i < 900; i++) { const [r] = await sql`select state, reason, dataset, server from research_jobs where id = ${id}`; if (done(r)) return r; await new Promise((res) => setTimeout(res, 200)); } throw new Error('the job did not finish'); };141    const c = await owner('POST', '/research/jobs', { mission: 'Développe une stratégie statistique BTC/ETH et teste-la sur données historiques', universe: ['BTC', 'ETH'] });142    expect(c.status, JSON.stringify(c.json)).toBe(200);143    await waitFor(c.json.id, (j) => j.dataset?.ready === true);144    // (the data set is marked ready a moment before the job is queued for the runner: ask until it is handed out)145    let job: any = null;146    for (let i = 0; i < 100 && !job; i++) { job = (await runner('/claim', { runnerId: 'hostile' })).json?.job ?? null; if (!job) await new Promise((res) => setTimeout(res, 100)); }147    expect(job.id).toBe(c.json.id);148    const source = hostileStrategy({ name: job.strategyName, version: job.strategyVersion, kind: 'spot', universe: ['BTC', 'ETH'], behaviour: 'raise', targets });149    const b64 = (s: string) => Buffer.from(s, 'utf8').toString('base64');150    const result = { outcome: 'submission', usage: { wallMs: 1000, trials: 0, diskMb: 0, modelTokens: 0, modelRequests: 0, modelUsd: null, ledgerTampered: false, sandbox: 'sandbox-exec', agent: 'hostile-test', labCommit: null, exitCode: 0, signal: null },151      archive: { v: 1, files: [{ path: 'strategy.py', b64: b64(source) }, { path: 'README.md', b64: b64('# hostile\n\n**Hypothesis.** none.\n') }], ledger: '' } };152    const post = await runner(`/jobs/${job.id}/result`, result, job.token);153    expect([200, 202], JSON.stringify(post.json)).toContain(post.status);154    const done = await waitFor(c.json.id, (j) => ['submitted', 'failed'].includes(j.state));155    expect(done.state).toBe('failed');                                    // it raises instead of returning weights: there is no report156    expect(done.server.sandbox).toBe('sandbox-exec');157    const m = decodeError(String(done.server.reason));158    expect(m, JSON.stringify(done.server)).not.toBeNull();159    noLeak(m!, 'research evaluation');160  }, 180_000);161162  it('seen from outside: no connection reached the listeners, nothing was written outside, in the laboratory or next to the strategy', () => {163    expect(connections).toEqual({ api: 0, signer: 0, unix: 0 });164    expect(readdirSync(scratchRoot).sort()).toEqual(['.env', 'secret.txt', 'svc.sock']);165    expect(existsSync(path.join(fx.dir, 'src/spq/zz_written_by_strategy.py'))).toBe(false);166    for (const d of ['zz_hostile/1', 'zz_hostile/2', 'zz_hostile_perp/1', 'zz_hostile_perp/2']) expect(readdirSync(path.join(fx.dir, 'strategies', d)), d).toEqual(['strategy.py']);167  });168169  it('the scratch directory of a call is removed afterwards; a second call starts from an empty one', async () => {170    const mine = (): string[] => readdirSync(realpathSync(tmpdir())).filter((n) => /^spw-lab-[A-Za-z0-9]{6}$/.test(n));171    // the evaluation of the previous test drops its scratch directory just after the job reaches its final state: wait for it172    for (let i = 0; i < 100 && mine().length; i++) await new Promise((r) => setTimeout(r, 100));173    expect(mine()).toEqual([]);174    const s = add('zz_hostile/3');175    for (let i = 0; i < 2; i++) noLeak(decodeWeights(await live.runSignal({ path: s.path, sha256: s.sha256 }, spotBars, '1h'), ['BTC', 'ETH']), `call ${i}`);176    expect(mine()).toEqual([]);177  });178});179180describe.skipIf(!READY)('strategy sandbox — limits of one call', () => {181  const direct = (rel: string, o: Partial<Parameters<typeof lab.labExec>[1]> = {}) => { const dir = path.join(fx.dir, 'strategies', rel);182    const input = JSON.stringify({ candles: Object.fromEntries(Object.entries(spotBars).map(([s, b]) => [s, b.map((x) => [Math.floor(x.ts / 1000), x.open, x.high, x.low, x.close, x.volume])])), interval: '1h' });183    return lab.labExec(['spq.signal', '--strategy', dir, '--sha256', sha256(readFileSync(path.join(dir, 'strategy.py'))), '--input', '-'], { timeoutMs: 20_000, stdin: input, strategyDir: dir, ...o }); };184  const alive = (pid: number): boolean => { try { process.kill(pid, 0); return true; } catch { return false; } };185186  it('wall clock: a strategy that never returns is killed at the timeout, and its process is gone', async () => {187    add('zz_sleep/1', { behaviour: 'sleep' });188    const t0 = Date.now(), r = await direct('zz_sleep/1', { timeoutMs: 2500 });189    expect(r.killed).toBe('timeout'); expect(Date.now() - t0).toBeLessThan(8000);190  }, 30_000);191192  it('CPU: the rlimit set by the wrapper is inherited through sandbox-exec and kills a busy loop (SIGXCPU) before the wall clock would', async () => {193    add('zz_burn/1', { behaviour: 'burn' });194    // timeoutMs 15 s → the wrapper sets max(60, 4 × 15) = 60 CPU-seconds; measured directly with a tiny limit instead:195    const { spawnSync } = await import('node:child_process');196    const t0 = Date.now(), r = spawnSync('/bin/sh', ['-c', 'ulimit -t 2 || exit 70; exec "$@"', 'x', REAL_PYTHON, '-c', 'while True: pass'], { encoding: 'utf8' });197    expect(r.signal).toBe('SIGXCPU'); expect(Date.now() - t0).toBeGreaterThan(1500); expect(Date.now() - t0).toBeLessThan(6000);198    const r2 = await direct('zz_burn/1', { timeoutMs: 3000 });           // and through the real runner the wall clock stops it199    expect(r2.killed).toBe('timeout');200  }, 30_000);201202  it('memory: macOS refuses RLIMIT_AS / RLIMIT_DATA / RLIMIT_RSS (measured here) — the watchdog kills a process above its resident-memory ceiling', async () => {203    const { spawnSync } = await import('node:child_process');204    for (const flag of ['-v', '-d', '-m']) { const r = spawnSync('/bin/sh', ['-c', `ulimit ${flag} 1048576`], { encoding: 'utf8' }); expect(r.status, `ulimit ${flag}`).not.toBe(0); expect(r.stderr).toMatch(/cannot modify limit|Invalid argument/); }205    add('zz_memory/1', { behaviour: 'memory' });206    const r = await direct('zz_memory/1', { timeoutMs: 20_000, maxRssMb: 400 });207    expect(r.killed).toBe('memory'); expect(r.ms).toBeLessThan(15_000);208  }, 40_000);209210  it('output: a strategy that floods stdout is cut (the signal runner kills it and holds)', async () => {211    const s = add('zz_flood/1', { behaviour: 'flood' });212    await expect(live.runSignal({ path: s.path, sha256: s.sha256 }, spotBars, '1h')).rejects.toThrow(/signal output too large|unreadable signal|signal exit/);213  }, 30_000);214215  it('no daemon: fork is refused by the kernel, so nothing can outlive a call (the setsid escape of a forking job does not exist here)', async () => {216    const before = new Set(readdirSync(scratchRoot));217    const s = add('zz_hostile/4');218    const m = decodeWeights(await live.runSignal({ path: s.path, sha256: s.sha256 }, spotBars, '1h'), ['BTC', 'ETH']);219    expect(leaks(m.callMask).filter((x) => /fork|spawn|daemon/.test(x))).toEqual([]);220    await new Promise((r) => setTimeout(r, 500));221    expect(new Set(readdirSync(scratchRoot))).toEqual(before);            // the would-be daemon's marker file never appeared222    expect(alive(envHolder!.pid!)).toBe(true);223  });224});225226describe.skipIf(!READY)('strategy sandbox — fail closed', () => {227  it('the probes are real: the same strategy WITHOUT the sandbox (explicit development switch, local stand-ins only) leaks everything it tries', async () => {228    const s = add('zz_unconfined/1', { targets: { ...targets, safeOnly: true, outside: path.join(scratchRoot, 'unconfined-write.txt') } });229    process.env.SPW_LAB_SANDBOX = 'off';230    try {231      expect(await lab.labStatus()).toMatchObject({ mode: 'off', ok: false });232      const m = decodeWeights(await live.runSignal({ path: s.path, sha256: s.sha256 }, spotBars, '1h'), ['BTC', 'ETH']);233      expect(controlsOk(m.callMask)).toBe(true);234      const leaked = leaks(m.callMask);235      for (const must of ['read a canary secret outside', 'read a .env-like secret file outside', 'write outside the scratch directory', 'TCP to the API port (loopback)', 'TCP to the signer relay port (loopback)',236        'Unix socket (PostgreSQL / a local service)', 'fork', 'posix_spawn /bin/sh', 'read another process\'s environment (sysctl kern.procargs2)', 'list processes', 'signal another process',237        'read another strategy\'s directory', 'listen on a socket', 'setsid + fork (daemon)']) expect(leaked, must).toContain(must);238      expect(connections.api).toBeGreaterThan(0); expect(connections.signer).toBeGreaterThan(0);239      expect(existsSync(path.join(scratchRoot, 'unconfined-write.txt'))).toBe(true);240    } finally { delete process.env.SPW_LAB_SANDBOX; connections.api = 0; connections.signer = 0; connections.unix = 0; }241    expect(await lab.labStatus()).toMatchObject({ mode: 'sandbox-exec', ok: true });242  }, 60_000);243244  it('an interpreter that cannot be shown to be confined is not used: no signal (the engine holds), no describe, no backtest — and the alert is raised', async () => {245    // a stand-in "interpreter" that does not behave like one: the self-test cannot see it refused anything246    const fake = path.join(scratchRoot, 'bin'); mkdirSync(fake, { recursive: true });247    const { symlinkSync } = await import('node:fs'); symlinkSync('/bin/echo', path.join(fake, 'python'));248    const s = add('zz_hostile/5'), real = process.env.SPW_RESEARCH_PYTHON;249    process.env.SPW_RESEARCH_PYTHON = path.join(fake, 'python'); lab.resetLabStatus();250    try {251      const st = await lab.labStatus(true);252      expect(st.mode).toBe('unavailable'); expect(st.ok).toBe(false); expect(st.reason).toMatch(/could not be applied|does not confine/);253      await expect(live.runSignal({ path: s.path, sha256: s.sha256 }, spotBars, '1h')).rejects.toThrow(/signal runner could not start: the strategy sandbox is not available/);254      await expect(perpSignal.runPerpSignal({ path: s.path, sha256: s.sha256 }, { BTC: perpSeries(5, 60_000), ETH: perpSeries(9, 3000) } as never, '1h')).rejects.toThrow(/strategy sandbox is not available/);255      await expect(strategies.describe('zz_hostile/5')).rejects.toMatchObject({ code: 'bad_strategy' });256      const r = await strategies.py(['spq.describe', '--strategy', s.dir], { timeoutMs: 10_000, strategyDir: s.dir });257      expect(r).toMatchObject({ code: null, out: '' }); expect(r.err).toMatch(/strategy sandbox is not available/);258      expect((await lab.labStatus()).refused).toBeGreaterThanOrEqual(3);259      // the alerts layer: said once (on the second poll in a row), then "back" once260      const d = w.dispatcher({ lab: () => lab.labStatus() });261      await d.conditions(); w.clock.advance(31_000); await d.conditions(); w.clock.advance(31_000); await d.conditions();262      const down = await w.alerts(`kind = 'system.lab_sandbox_unavailable'`);263      expect(down).toHaveLength(1); expect(down[0].severity).toBe('critical');264      // the monitoring endpoint says the same265      (await import('../src/monitor/routes.ts')).clearMonitorCache();266      const h = (await app.inject({ method: 'GET', url: '/api/v1/monitor/health', headers: { cookie } })).json();267      expect(h.lab).toMatchObject({ mode: 'unavailable', ok: false });268      process.env.SPW_RESEARCH_PYTHON = real; lab.resetLabStatus();269      expect(await lab.labStatus(true)).toMatchObject({ mode: 'sandbox-exec', ok: true });270      w.clock.advance(31_000); await d.conditions();271      expect(await w.alerts(`kind = 'system.lab_sandbox_back'`)).toHaveLength(1);272    } finally { process.env.SPW_RESEARCH_PYTHON = real; lab.resetLabStatus(); }273  }, 60_000);274275  it('a missing interpreter (no virtualenv) is unavailable too, with the reason', async () => {276    const real = process.env.SPW_RESEARCH_PYTHON;277    process.env.SPW_RESEARCH_PYTHON = path.join(scratchRoot, 'nowhere/python'); lab.resetLabStatus();278    try { const st = await lab.labStatus(true); expect(st.mode).toBe('unavailable'); expect(st.reason).toMatch(/interpreter is missing/); }279    finally { process.env.SPW_RESEARCH_PYTHON = real; lab.resetLabStatus(); }280  });281282  it('the development switch is said by the alerts layer every day it is set', async () => {283    const d = w.dispatcher({ lab: async () => ({ ...(await lab.labStatus()), mode: 'off' as const, ok: false }) });284    await d.conditions(); await d.conditions();285    expect(await w.alerts(`kind = 'system.lab_sandbox_off'`)).toHaveLength(1);286  });287});288289// v10 integration: `main` grew (GMX, Solana CCTP, Cardano, Polkadot, the plan executor) after the sandbox branch was cut. This290// runs everywhere (no sandbox needed): a new file that starts a process, or that runs laboratory code without `labExec`, fails here.291describe('one runner: nothing in the API or the research runner starts laboratory code by itself', () => {292  const walk = (dir: string, out: string[] = []): string[] => { for (const e of readdirSync(dir, { withFileTypes: true })) { const p = path.join(dir, e.name); if (e.isDirectory()) walk(p, out); else if (/\.(ts|mts)$/.test(e.name)) out.push(p); } return out; };293  const code = (f: string) => readFileSync(f, 'utf8').replace(/^\s*\/\/.*$/gm, '').replace(/\s\/\/ .*$/gm, '');   // line comments only: route strings contain /* and */294  const rel = (f: string) => path.relative(REPO_ROOT, f);295  it('API: child_process is imported by the runner only (and by the read-only `git rev-parse` of the research routes)', () => {296    const files = walk(path.join(REPO_ROOT, 'services/api/src'));297    expect(files.length).toBeGreaterThan(80);298    const starters = files.filter((f) => /node:child_process|['"]child_process['"]|\bBun\.spawn|\bDeno\.run|new Worker\(/.test(code(f))).map(rel).sort();299    expect(starters).toEqual(['services/api/src/agents/lab.ts', 'services/api/src/research/routes.ts']);300    const routes = code(path.join(REPO_ROOT, 'services/api/src/research/routes.ts'));301    expect(routes.match(/execFileSync\(/g)).toHaveLength(1); expect(routes).toMatch(/execFileSync\('git', \['-C', RESEARCH_DIR, 'rev-parse', 'HEAD'\]/);   // no interpreter, no shell302    for (const f of files) { const c = code(f); expect(c, rel(f)).not.toMatch(/['"`]uv['"`]|uv run|\.venv\/bin\/python(?!['"`]\))|python3?['"`],/); }303    // every laboratory module is started through labExec (directly, or through the two thin wrappers that only add a result shape)304    const callers = files.filter((f) => /['"`]spq\.[a-z_.]+['"`]/.test(code(f))).map(rel).sort();305    expect(callers).toEqual(['services/api/src/agents/live.ts', 'services/api/src/agents/perp-signal.ts', 'services/api/src/agents/perp-strategies.ts', 'services/api/src/agents/strategies.ts',306      'services/api/src/research/dataset.ts', 'services/api/src/research/evaluate.ts']);307    for (const f of callers) expect(code(path.join(REPO_ROOT, f)), f).toMatch(/\blabExec\(|\blabRun\(|\bpy\(/);   // py = the wrapper of strategies.ts (checked below)308    expect(code(path.join(REPO_ROOT, 'services/api/src/research/lab.ts'))).toMatch(/return labExec\(args, o\)/);309    expect(code(path.join(REPO_ROOT, 'services/api/src/agents/strategies.ts'))).toMatch(/return labExec\(args, o\)/);310  });311  it('research runner: processes are started in job.ts only, and the two that run Python or the agent go through the job profile', () => {312    const files = walk(path.join(REPO_ROOT, 'services/research-runner/src'));313    expect(files.filter((f) => /node:child_process/.test(code(f))).map(rel)).toEqual(['services/research-runner/src/job.ts']);314    const job = code(path.join(REPO_ROOT, 'services/research-runner/src/job.ts'));315    expect(job.match(/\bspawn\(/g)).toHaveLength(3);                                                       // `sh` (git/tar helpers), the data import, the agent316    expect(job).toMatch(/const argv = confine\(confined \? prep : null, \[cfg\.python, '-m', 'spq\.data\.hl_import'/);317    expect(job).toMatch(/const argv = confine\(confined \? p\.profile : null, agentArgv\(cfg, job, rp\)\)/);318    expect(job.match(/sandboxProfile\(/g)!.length).toBeGreaterThanOrEqual(2);                             // both use the fixed profile of @spw/core (no kern.procargs2)319  });320});321322void CONTROL_MASK;323
Files main
Menu

Theme