"use strict";
/**
 * WorkspaceClient — agent-side workspace relay connection
 *
 * Connects to the platform's /jailbox Socket.IO namespace.
 * Sends MCP tool calls (read_file, write_file, list_directory)
 * and receives results from the buyer's CLI.
 */
Object.defineProperty(exports, "__esModule", { value: true });
exports.WorkspaceClient = void 0;
const crypto_1 = require("crypto");
const socket_io_client_1 = require("socket.io-client");
const reconnect_config_js_1 = require("../chat/reconnect-config.js");
/**
 * Validate that a workspace path is safe: relative, no `..` segments, no leading `/`.
 * Throws if invalid.
 *
 * NOTE: this is a client-side ADVISORY pre-flight check only. The serving side
 * (BuyerWorkspace.resolveSafe) independently sanitizes every path with a
 * resolve-and-prefix check and is the authoritative traversal guard — never
 * rely on this function alone.
 */
function assertSafePath(path) {
    if (!path || typeof path !== 'string') {
        throw new Error('Workspace path must be a non-empty string');
    }
    if (path.startsWith('/')) {
        throw new Error(`Workspace path must be relative, not absolute: "${path}"`);
    }
    const parts = path.split(/[\\/]/);
    if (parts.some(p => p === '..')) {
        throw new Error(`Workspace path must not contain ".." traversal: "${path}"`);
    }
}
const MAX_RESULT_SIZE = 10 * 1024 * 1024; // 10MB per item
// Audit 2026-06-02 M-SDK-ddos-1: per-item 10MB was per-item only — a hostile
// relay could send 10000 items of 9.9MB each. Bound the aggregate too.
const MAX_RESULT_AGGREGATE = Number(process.env.J41_WORKSPACE_MAX_RESULT_AGGREGATE ?? 40 * 1024 * 1024); // 40MB total
const MAX_RESULT_ITEMS = Number(process.env.J41_WORKSPACE_MAX_RESULT_ITEMS ?? 1024);
// Audit 2026-06-02 L-SDK-ddos-2: workspace reconnect cycles cap. Same pattern
// as chat/client.ts (MAX_RECONNECT_CYCLES=3) — without this, a hostile relay
// can drive infinite reconnect attempts and pin client CPU/network.
const MAX_RECONNECT_CYCLES = Number(process.env.J41_WORKSPACE_MAX_RECONNECT_CYCLES ?? 3);
function validateMcpResult(data) {
    if (!data || typeof data.id !== 'string')
        return null;
    if (typeof data.success !== 'boolean')
        return null;
    if (data.success) {
        if (!data.result || !Array.isArray(data.result.content))
            return null;
        if (data.result.content.length > MAX_RESULT_ITEMS)
            return null;
        let aggregate = 0;
        for (const item of data.result.content) {
            if (typeof item?.type !== 'string' || typeof item?.text !== 'string')
                return null;
            if (item.text.length > MAX_RESULT_SIZE)
                return null;
            aggregate += item.text.length;
            if (aggregate > MAX_RESULT_AGGREGATE)
                return null;
        }
    }
    else {
        if (data.error !== undefined && typeof data.error !== 'string')
            return null;
    }
    return data;
}
class WorkspaceClient {
    config;
    socket = null;
    pendingRequests = new Map();
    statusHandler = null;
    disconnectHandler = null;
    _connected = false;
    _jobId = null;
    _excludedFiles = [];
    _reconnectCycles = 0;
    _stats = {
        filesRead: 0,
        filesWritten: 0,
        listDirectoryCalls: 0,
        connectedAt: 0,
    };
    constructor(config) {
        this.config = config;
    }
    /**
     * Connect to the workspace relay for a specific job.
     * Gets a one-time connect token via REST, then connects Socket.IO.
     * Resolves when the buyer's CLI is connected (status: active).
     */
    async connect(jobId) {
        this._jobId = jobId;
        // Step 1: Get connect token via REST — use live token getter (C1 fix)
        const tokenController = new AbortController();
        const tokenTimer = setTimeout(() => tokenController.abort(), 15_000);
        let tokenRes;
        try {
            tokenRes = await fetch(`${this.config.apiUrl}/v1/jailbox/${jobId}/connect-token`, {
                headers: {
                    'Cookie': `verus_session=${this.config.getSessionToken()}`,
                },
                credentials: 'include',
                signal: tokenController.signal,
            });
        }
        catch (err) {
            if (err.name === 'AbortError') {
                throw new Error('Workspace connect-token request timed out after 15s');
            }
            throw err;
        }
        finally {
            clearTimeout(tokenTimer);
        }
        if (!tokenRes.ok) {
            const err = await tokenRes.json().catch(() => ({}));
            throw new Error(err.error?.message || `Failed to get connect token: ${tokenRes.status}`);
        }
        const { data } = await tokenRes.json();
        const { token, wsUrl } = data;
        // Validate wsUrl matches apiUrl origin
        const apiOrigin = new URL(this.config.apiUrl);
        let wsOrigin;
        try {
            wsOrigin = new URL(wsUrl);
        }
        catch {
            throw new Error('Relay returned invalid wsUrl');
        }
        if (wsOrigin.hostname !== apiOrigin.hostname) {
            throw new Error(`Relay wsUrl hostname mismatch: expected ${apiOrigin.hostname}, got ${wsOrigin.hostname}`);
        }
        // Enforce secure transport
        if (wsOrigin.protocol !== 'https:' && wsOrigin.protocol !== 'wss:') {
            if (apiOrigin.protocol === 'https:') {
                throw new Error('Relay returned insecure wsUrl but apiUrl uses HTTPS');
            }
            console.warn('[WorkspaceClient] WARNING: wsUrl uses insecure transport');
        }
        // Step 2: Extract origin from wsUrl (strip /ws path if present)
        const origin = wsUrl.replace(/\/ws\/?$/, '');
        // Step 3: Connect Socket.IO to /jailbox namespace
        return new Promise((resolve, reject) => {
            let settled = false;
            // Audit 2026-06-02 M-SDK-ddos-4 mirror: cap inbound Socket.IO message
            // size on the workspace transport as well.
            this.socket = (0, socket_io_client_1.io)(origin + '/jailbox', {
                path: '/ws',
                auth: { type: 'agent', token },
                transports: ['websocket', 'polling'],
                ...{ maxPayload: Number(process.env.J41_WORKSPACE_MAX_MESSAGE_BYTES ?? 4 * 1024 * 1024) },
                ...reconnect_config_js_1.RECONNECT_CONFIG,
            });
            this.socket.on('connect', () => {
                this._connected = true;
                this._reconnectCycles = 0;
                this._stats.connectedAt = Date.now();
                // Don't resolve yet — wait for workspace to be active
            });
            this.socket.on('reconnect_attempt', () => {
                this._reconnectCycles++;
                if (this._reconnectCycles > MAX_RECONNECT_CYCLES) {
                    // Audit L-SDK-ddos-2: hard-stop further reconnect attempts after
                    // MAX_RECONNECT_CYCLES so a hostile relay can't drive infinite
                    // reconnect loops. Disconnect explicitly; `disconnectHandler` is
                    // fired by the disconnect event.
                    console.warn(`[WorkspaceClient] Exceeded ${MAX_RECONNECT_CYCLES} reconnect cycles — giving up`);
                    this.socket?.disconnect();
                }
            });
            this.socket.on('connect_error', (err) => {
                if (!settled) {
                    settled = true;
                    reject(new Error(`Workspace connection failed: ${err.message}`));
                }
            });
            // MCP results from buyer's CLI
            this.socket.on('mcp:result', (data) => {
                const validated = validateMcpResult(data);
                if (!validated) {
                    console.warn('[WorkspaceClient] Received invalid mcp:result, ignoring');
                    return;
                }
                const pending = this.pendingRequests.get(validated.id);
                if (pending) {
                    clearTimeout(pending.timeout);
                    this.pendingRequests.delete(validated.id);
                    if (validated.success) {
                        pending.resolve(validated.result);
                    }
                    else {
                        pending.reject(new Error(validated.error || 'Tool call failed'));
                    }
                }
            });
            // Status changes
            this.socket.on('jailbox:status_changed', (data) => {
                if (data.status === 'active' && !settled) {
                    settled = true;
                    resolve(); // Buyer connected — workspace is ready
                }
                this.statusHandler?.(data.status, data);
                // Auto-cleanup on terminal states
                if (data.status === 'aborted' || data.status === 'completed') {
                    this._connected = false;
                }
            });
            // Exclusion list from relay — files the buyer's SovGuard has blocked
            this.socket.on('jailbox:exclusions', (data) => {
                this._excludedFiles = data.excludedFiles || [];
            });
            this.socket.on('ws:error', (data) => {
                if (!settled) {
                    settled = true;
                    reject(new Error(`Relay error: ${data.message}`));
                }
            });
            this.socket.on('disconnect', (reason) => {
                this._connected = false;
                this.disconnectHandler?.(reason);
            });
            // Re-mark connected on auto-reconnect (Socket.IO handles this)
            this.socket.on('reconnect', () => {
                this._connected = true;
                console.log('[WorkspaceClient] Reconnected to workspace relay');
            });
            // Timeout — if buyer doesn't connect within 5 minutes
            setTimeout(() => {
                if (!settled) {
                    settled = true;
                    reject(new Error('Timeout waiting for buyer to connect workspace CLI'));
                    this.disconnect();
                }
            }, 5 * 60 * 1000);
        });
    }
    /**
     * Low-level: send an MCP tool call and wait for result.
     * The tool name should NOT have workspace_ prefix — use the raw MCP tool name
     * (read_file, write_file, list_directory).
     */
    async sendToolCall(tool, params) {
        if (!this.socket || !this._connected) {
            throw new Error('Workspace not connected');
        }
        const id = `ws-${(0, crypto_1.randomBytes)(8).toString('hex')}`;
        return new Promise((resolve, reject) => {
            // Audit 2026-06-02 M-SDK-ddos-2: write_file had a 2^31 - 1 ms (~24 day)
            // "effectively no timeout" — a hostile relay could pin memory by simply
            // never responding. Cap at J41_WORKSPACE_WRITE_TIMEOUT_MS (10 min default).
            const writeTimeoutMs = Number(process.env.J41_WORKSPACE_WRITE_TIMEOUT_MS ?? 10 * 60 * 1000);
            const timeoutMs = tool === 'write_file' ? writeTimeoutMs : 30_000;
            const timeout = setTimeout(() => {
                this.pendingRequests.delete(id);
                reject(new Error(`Tool call timeout: ${tool}`));
            }, timeoutMs);
            this.pendingRequests.set(id, { resolve, reject, timeout });
            this.socket.emit('mcp:call', { id, tool, params });
        });
    }
    // ── High-level tool methods ───────────────────────────────────
    async listDirectory(path = '.') {
        assertSafePath(path);
        const result = await this.sendToolCall('list_directory', { path });
        this._stats.listDirectoryCalls++;
        try {
            const parsed = JSON.parse(result.content[0].text);
            if (!Array.isArray(parsed)) {
                throw new Error('listDirectory result is not an array');
            }
            return parsed;
        }
        catch (e) {
            throw new Error(`Failed to parse listDirectory response: ${e.message}`);
        }
    }
    async readFile(path) {
        assertSafePath(path);
        const result = await this.sendToolCall('read_file', { path });
        this._stats.filesRead++;
        if (!result?.content?.[0]?.text) {
            throw new Error(`readFile: unexpected MCP result format for path "${path}"`);
        }
        return result.content[0].text;
    }
    async writeFile(path, content) {
        assertSafePath(path);
        const result = await this.sendToolCall('write_file', { path, content });
        this._stats.filesWritten++;
        if (!result?.content?.[0]?.text) {
            throw new Error(`writeFile: unexpected MCP result format for path "${path}"`);
        }
        return result.content[0].text;
    }
    /** Signal to the buyer that the agent's work is complete */
    signalDone() {
        this.socket?.emit('jailbox:agent_done');
    }
    /** Get workspace usage stats for attestation */
    getStats() {
        return {
            filesRead: this._stats.filesRead,
            filesWritten: this._stats.filesWritten,
            listDirectoryCalls: this._stats.listDirectoryCalls,
            duration: this._stats.connectedAt
                ? Math.floor((Date.now() - this._stats.connectedAt) / 1000)
                : 0,
        };
    }
    // ── Event handlers ────────────────────────────────────────────
    onStatusChanged(handler) {
        this.statusHandler = handler;
    }
    onDisconnected(handler) {
        this.disconnectHandler = handler;
    }
    // ── Tool definitions for executor injection ───────────────────
    getAvailableTools() {
        return [
            {
                type: 'function',
                function: {
                    name: 'workspace_list_directory',
                    description: 'List files and directories in the buyer\'s project',
                    parameters: {
                        type: 'object',
                        properties: { path: { type: 'string', description: 'Relative path (default: root)' } },
                    },
                },
            },
            {
                type: 'function',
                function: {
                    name: 'workspace_read_file',
                    description: 'Read a file from the buyer\'s project',
                    parameters: {
                        type: 'object',
                        properties: { path: { type: 'string', description: 'Relative path to file' } },
                        required: ['path'],
                    },
                },
            },
            {
                type: 'function',
                function: {
                    name: 'workspace_write_file',
                    description: 'Write content to a file in the buyer\'s project',
                    parameters: {
                        type: 'object',
                        properties: {
                            path: { type: 'string', description: 'Relative path to file' },
                            content: { type: 'string', description: 'File content to write' },
                        },
                        required: ['path', 'content'],
                    },
                },
            },
        ];
    }
    // ── Lifecycle ─────────────────────────────────────────────────
    get isConnected() {
        return this._connected;
    }
    get jobId() {
        return this._jobId;
    }
    /** Files excluded by buyer's SovGuard (sent by relay on connect) */
    get excludedFiles() {
        return [...this._excludedFiles];
    }
    /** Send keepalive ping to prevent relay idle timeout */
    ping() {
        if (this.socket && this._connected) {
            this.socket.emit('jailbox:ping');
        }
    }
    disconnect() {
        this._stats = { filesRead: 0, filesWritten: 0, listDirectoryCalls: 0, connectedAt: 0 };
        for (const [, pending] of this.pendingRequests) {
            clearTimeout(pending.timeout);
            pending.reject(new Error('Workspace disconnected'));
        }
        this.pendingRequests.clear();
        this.socket?.disconnect();
        this.socket = null;
        this._connected = false;
        this._jobId = null;
    }
}
exports.WorkspaceClient = WorkspaceClient;
//# sourceMappingURL=client.js.map