Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 9 additions & 2 deletions src/providers/docker.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { createServer } from 'node:net';
import { commandExists, run, runJson } from '../process.js';
import { ProviderUnavailableError, OwnershipError } from '../errors.js';
import { ProcessError, ProviderUnavailableError, OwnershipError } from '../errors.js';
import { canonicalResourceRefs, createMetadata, deriveWorkspaceIdentity, labelHash, providerLabels, readCleanupMetadata, readMetadata, workspaceName, WORKSPACE_RUNTIME } from '../metadata.js';
import { createWorkspaceSecrets, getWorkspaceToken, rotateWorkspaceCredentials, selectGrantedCredentials } from '../auth.js';
import { requireDockerEgress, validateImage } from '../policy.js';
Expand Down Expand Up @@ -420,5 +420,12 @@ function readDockerLabel(labels, key) {
}

function isDockerNotFound(error) {
return /No such object|No such container|No such volume|not found/i.test(error instanceof Error ? error.message : String(error));
if (!(error instanceof ProcessError)
|| error.kind !== 'exit'
|| !Number.isInteger(error.exitCode)
|| error.exitCode === 0
|| error.truncated) return false;
const output = `${error.stderr}\n${error.stdout}`;
return /\bNo such (?:object|container|volume|network)\b/i.test(output)
|| /\b(?:container|volume|network)\b.*\bnot found\b/i.test(output);
}
57 changes: 55 additions & 2 deletions src/providers/docker.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,20 +2,28 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { mkdir, mkdtemp, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { ProcessError } from '../errors.js';

const processMocks = vi.hoisted(() => ({ commandExists: vi.fn(() => true), run: vi.fn(), runJson: vi.fn() }));
vi.mock('../process.js', async (importOriginal) => ({ ...await importOriginal(), ...processMocks }));
const { readPolicy } = await import('../policy.js');
const { createDockerProvider } = await import('./docker.js');

const dockerExitError = (stderr, options = {}) => new ProcessError(`docker failed with ${options.exitCode ?? 1}: ${stderr}`, {
kind: options.kind ?? 'exit',
exitCode: options.exitCode ?? 1,
stderr,
truncated: options.truncated ?? false,
});

describe('Docker provider security and transactions', () => {
let stateDirectory;
beforeEach(async () => {
stateDirectory = await mkdtemp(join(tmpdir(), 'workspace-docker-test-'));
process.env.OPENCHAMBER_WORKSPACE_STATE_DIR = stateDirectory;
processMocks.commandExists.mockReturnValue(true);
processMocks.run.mockReset().mockResolvedValue({ stdout: '', stderr: '' });
processMocks.runJson.mockReset().mockRejectedValue(new Error('not found'));
processMocks.runJson.mockReset().mockRejectedValue(dockerExitError('Error: No such object: workspace-resource'));
});
afterEach(async () => {
delete process.env.OPENCHAMBER_WORKSPACE_STATE_DIR;
Expand Down Expand Up @@ -68,6 +76,51 @@ describe('Docker provider security and transactions', () => {
expect(JSON.stringify(commands)).not.toContain('OPENCODE_AUTH_CONTENT=');
});

it('treats Docker 20.10 missing-network diagnostics as authoritative absence', async () => {
const sourceDirectory = join(stateDirectory, 'source');
await mkdir(sourceDirectory);
const { provider, info } = configured(sourceDirectory);
processMocks.runJson.mockImplementation(async (_binary, args) => {
if (args[0] === 'network') throw dockerExitError(`Error: No such network: ${args.at(-1)}`);
if (args[0] === 'volume') throw dockerExitError(`Error: No such volume: ${args.at(-1)}`);
throw dockerExitError(`Error: No such object: ${args.at(-1)}`);
});
processMocks.run.mockImplementation(async (_binary, args) => {
if (args[0] === 'network' && args[1] === 'create') throw new Error('network creation reached');
return { stdout: '', stderr: '' };
});

await expect(provider.create(info)).rejects.toThrow('network creation reached');
expect(processMocks.run).toHaveBeenCalledWith('docker', expect.arrayContaining(['network', 'create', info.extra.resourceRefs.network]), expect.any(Object));
});

it('treats current Docker missing-volume diagnostics as idempotent cleanup', async () => {
const { provider, info } = configured();
processMocks.run.mockImplementation(async (_binary, args) => {
if (args[0] === 'volume' && args[1] === 'rm') {
throw dockerExitError(`Error response from daemon: get ${args.at(-1)}: no such volume`);
}
return { stdout: '', stderr: '' };
});

await expect(provider.remove(info)).resolves.toMatchObject({ ok: true, remainingResources: [] });
});

it.each([
['plain errors', new Error('network not found')],
['timeouts', dockerExitError('network not found', { kind: 'timeout', exitCode: null })],
['unrelated daemon failures', dockerExitError('permission denied')],
['truncated diagnostics', dockerExitError('Error: No such network: workspace-resource', { truncated: true })],
])('does not treat %s as authoritative Docker absence', async (_label, failure) => {
const sourceDirectory = join(stateDirectory, 'source');
await mkdir(sourceDirectory);
const { provider, info } = configured(sourceDirectory);
processMocks.runJson.mockRejectedValue(failure);

await expect(provider.create(info)).rejects.toBe(failure);
expect(processMocks.run.mock.calls.some(([, args]) => args[0] === 'network' && args[1] === 'create')).toBe(false);
});

it('runs short-lived seed helpers as the unprivileged runtime user', async () => {
const sourceDirectory = join(stateDirectory, 'source');
await mkdir(sourceDirectory);
Expand Down Expand Up @@ -101,7 +154,7 @@ describe('Docker provider security and transactions', () => {
await mkdir(sourceDirectory);
const { provider, info } = configuredWithProxy('https://proxy.example.com:8443', sourceDirectory);

await expect(provider.create(info)).rejects.toThrow(/not found/);
await expect(provider.create(info)).rejects.toThrow(/No such object/);
const gateway = processMocks.run.mock.calls.map(([, args]) => args).find((args) => args.includes(info.extra.resourceRefs.gateway) && args.includes('OPENCHAMBER_PROXY_TLS=true'));
expect(gateway).toEqual(expect.arrayContaining([
'OPENCHAMBER_PROXY_HOST=proxy.example.com', 'OPENCHAMBER_PROXY_PORT=8443',
Expand Down
Loading