* feat: implement credential proxy for enhanced container environment isolation Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: address PR review — bind proxy to loopback, scope OAuth injection, add tests - Bind credential proxy to 127.0.0.1 instead of 0.0.0.0 (security) - OAuth mode: only inject Authorization on token exchange endpoint - Add 5 integration tests for credential-proxy.ts - Remove dangling comment - Extract host gateway into container-runtime.ts abstraction - Update Apple Container skill for credential proxy compatibility Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: scope OAuth token injection by header presence instead of path Path-based matching missed auth probe requests the CLI sends before the token exchange. Now the proxy replaces Authorization only when the container actually sends one, leaving x-api-key-only requests (post-exchange) untouched. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: bind credential proxy to docker0 bridge IP on Linux On bare-metal Linux Docker, containers reach the host via the bridge IP (e.g. 172.17.0.1), not loopback. Detect the docker0 interface address via os.networkInterfaces() and bind there instead of 0.0.0.0, so the proxy is reachable by containers but not exposed to the LAN. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: bind credential proxy to loopback on WSL WSL uses Docker Desktop with the same VM routing as macOS, so 127.0.0.1 is correct and secure. Without this, the fallback to 0.0.0.0 was triggered because WSL has no docker0 interface. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: detect WSL via /proc instead of env var WSL_DISTRO_NAME isn't set under systemd. Use /proc/sys/fs/binfmt_misc/WSLInterop which is always present on WSL. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
211 lines
5.2 KiB
TypeScript
211 lines
5.2 KiB
TypeScript
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
|
|
import { EventEmitter } from 'events';
|
|
import { PassThrough } from 'stream';
|
|
|
|
// Sentinel markers must match container-runner.ts
|
|
const OUTPUT_START_MARKER = '---NANOCLAW_OUTPUT_START---';
|
|
const OUTPUT_END_MARKER = '---NANOCLAW_OUTPUT_END---';
|
|
|
|
// Mock config
|
|
vi.mock('./config.js', () => ({
|
|
CONTAINER_IMAGE: 'nanoclaw-agent:latest',
|
|
CONTAINER_MAX_OUTPUT_SIZE: 10485760,
|
|
CONTAINER_TIMEOUT: 1800000, // 30min
|
|
CREDENTIAL_PROXY_PORT: 3001,
|
|
DATA_DIR: '/tmp/nanoclaw-test-data',
|
|
GROUPS_DIR: '/tmp/nanoclaw-test-groups',
|
|
IDLE_TIMEOUT: 1800000, // 30min
|
|
TIMEZONE: 'America/Los_Angeles',
|
|
}));
|
|
|
|
// Mock logger
|
|
vi.mock('./logger.js', () => ({
|
|
logger: {
|
|
debug: vi.fn(),
|
|
info: vi.fn(),
|
|
warn: vi.fn(),
|
|
error: vi.fn(),
|
|
},
|
|
}));
|
|
|
|
// Mock fs
|
|
vi.mock('fs', async () => {
|
|
const actual = await vi.importActual<typeof import('fs')>('fs');
|
|
return {
|
|
...actual,
|
|
default: {
|
|
...actual,
|
|
existsSync: vi.fn(() => false),
|
|
mkdirSync: vi.fn(),
|
|
writeFileSync: vi.fn(),
|
|
readFileSync: vi.fn(() => ''),
|
|
readdirSync: vi.fn(() => []),
|
|
statSync: vi.fn(() => ({ isDirectory: () => false })),
|
|
copyFileSync: vi.fn(),
|
|
},
|
|
};
|
|
});
|
|
|
|
// Mock mount-security
|
|
vi.mock('./mount-security.js', () => ({
|
|
validateAdditionalMounts: vi.fn(() => []),
|
|
}));
|
|
|
|
// Create a controllable fake ChildProcess
|
|
function createFakeProcess() {
|
|
const proc = new EventEmitter() as EventEmitter & {
|
|
stdin: PassThrough;
|
|
stdout: PassThrough;
|
|
stderr: PassThrough;
|
|
kill: ReturnType<typeof vi.fn>;
|
|
pid: number;
|
|
};
|
|
proc.stdin = new PassThrough();
|
|
proc.stdout = new PassThrough();
|
|
proc.stderr = new PassThrough();
|
|
proc.kill = vi.fn();
|
|
proc.pid = 12345;
|
|
return proc;
|
|
}
|
|
|
|
let fakeProc: ReturnType<typeof createFakeProcess>;
|
|
|
|
// Mock child_process.spawn
|
|
vi.mock('child_process', async () => {
|
|
const actual =
|
|
await vi.importActual<typeof import('child_process')>('child_process');
|
|
return {
|
|
...actual,
|
|
spawn: vi.fn(() => fakeProc),
|
|
exec: vi.fn(
|
|
(_cmd: string, _opts: unknown, cb?: (err: Error | null) => void) => {
|
|
if (cb) cb(null);
|
|
return new EventEmitter();
|
|
},
|
|
),
|
|
};
|
|
});
|
|
|
|
import { runContainerAgent, ContainerOutput } from './container-runner.js';
|
|
import type { RegisteredGroup } from './types.js';
|
|
|
|
const testGroup: RegisteredGroup = {
|
|
name: 'Test Group',
|
|
folder: 'test-group',
|
|
trigger: '@Andy',
|
|
added_at: new Date().toISOString(),
|
|
};
|
|
|
|
const testInput = {
|
|
prompt: 'Hello',
|
|
groupFolder: 'test-group',
|
|
chatJid: 'test@g.us',
|
|
isMain: false,
|
|
};
|
|
|
|
function emitOutputMarker(
|
|
proc: ReturnType<typeof createFakeProcess>,
|
|
output: ContainerOutput,
|
|
) {
|
|
const json = JSON.stringify(output);
|
|
proc.stdout.push(`${OUTPUT_START_MARKER}\n${json}\n${OUTPUT_END_MARKER}\n`);
|
|
}
|
|
|
|
describe('container-runner timeout behavior', () => {
|
|
beforeEach(() => {
|
|
vi.useFakeTimers();
|
|
fakeProc = createFakeProcess();
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
it('timeout after output resolves as success', async () => {
|
|
const onOutput = vi.fn(async () => {});
|
|
const resultPromise = runContainerAgent(
|
|
testGroup,
|
|
testInput,
|
|
() => {},
|
|
onOutput,
|
|
);
|
|
|
|
// Emit output with a result
|
|
emitOutputMarker(fakeProc, {
|
|
status: 'success',
|
|
result: 'Here is my response',
|
|
newSessionId: 'session-123',
|
|
});
|
|
|
|
// Let output processing settle
|
|
await vi.advanceTimersByTimeAsync(10);
|
|
|
|
// Fire the hard timeout (IDLE_TIMEOUT + 30s = 1830000ms)
|
|
await vi.advanceTimersByTimeAsync(1830000);
|
|
|
|
// Emit close event (as if container was stopped by the timeout)
|
|
fakeProc.emit('close', 137);
|
|
|
|
// Let the promise resolve
|
|
await vi.advanceTimersByTimeAsync(10);
|
|
|
|
const result = await resultPromise;
|
|
expect(result.status).toBe('success');
|
|
expect(result.newSessionId).toBe('session-123');
|
|
expect(onOutput).toHaveBeenCalledWith(
|
|
expect.objectContaining({ result: 'Here is my response' }),
|
|
);
|
|
});
|
|
|
|
it('timeout with no output resolves as error', async () => {
|
|
const onOutput = vi.fn(async () => {});
|
|
const resultPromise = runContainerAgent(
|
|
testGroup,
|
|
testInput,
|
|
() => {},
|
|
onOutput,
|
|
);
|
|
|
|
// No output emitted — fire the hard timeout
|
|
await vi.advanceTimersByTimeAsync(1830000);
|
|
|
|
// Emit close event
|
|
fakeProc.emit('close', 137);
|
|
|
|
await vi.advanceTimersByTimeAsync(10);
|
|
|
|
const result = await resultPromise;
|
|
expect(result.status).toBe('error');
|
|
expect(result.error).toContain('timed out');
|
|
expect(onOutput).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('normal exit after output resolves as success', async () => {
|
|
const onOutput = vi.fn(async () => {});
|
|
const resultPromise = runContainerAgent(
|
|
testGroup,
|
|
testInput,
|
|
() => {},
|
|
onOutput,
|
|
);
|
|
|
|
// Emit output
|
|
emitOutputMarker(fakeProc, {
|
|
status: 'success',
|
|
result: 'Done',
|
|
newSessionId: 'session-456',
|
|
});
|
|
|
|
await vi.advanceTimersByTimeAsync(10);
|
|
|
|
// Normal exit (no timeout)
|
|
fakeProc.emit('close', 0);
|
|
|
|
await vi.advanceTimersByTimeAsync(10);
|
|
|
|
const result = await resultPromise;
|
|
expect(result.status).toBe('success');
|
|
expect(result.newSessionId).toBe('session-456');
|
|
});
|
|
});
|