diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d1bf9fb2..6e4ff853 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -109,7 +109,7 @@ jobs: - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 with: - node-version: 22 + node-version: 22.23.4 - name: Compile anonymous-pipe addon run: | @@ -164,6 +164,8 @@ jobs: runner: [ubuntu-latest, ubuntu-24.04-arm] steps: - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + - name: Verify descendant-reaping decisions + run: python3 tests/test_tool_call_pipes.py - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 with: bun-version: 1.4.2 @@ -209,8 +211,8 @@ jobs: nsjail-ipc-test python3 /tests/supervisor_dns.py docker run --rm --init --security-opt seccomp=unconfined \ --mount "type=bind,source=$GITHUB_WORKSPACE,target=/work,readonly" \ - --entrypoint /usr/local/bin/sandbox-supervisor-policy \ - nsjail-ipc-test bun test /work/api/src/pipe-process.test.ts + --entrypoint /bin/sh \ + nsjail-ipc-test -ec 'test -x /usr/local/bin/tool-call-pipe-bridge; exec /usr/local/bin/sandbox-supervisor-policy bun test /work/api/src/pipe-process.test.ts' docker run --rm --init --security-opt seccomp=unconfined \ --mount "type=bind,source=$GITHUB_WORKSPACE/tests,target=/tests,readonly" \ --entrypoint /usr/local/bin/sandbox-supervisor-policy \ @@ -308,7 +310,7 @@ jobs: strategy: fail-fast: false matrix: - node-version: ['22.21.0', '24.16.0'] + node-version: ['22.23.4', '24.22.1'] defaults: run: working-directory: packages/code @@ -345,7 +347,7 @@ jobs: - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 with: - node-version: 24.16.0 + node-version: 24.22.1 - run: npm ci - run: npm run build - name: Selected project root containment tests @@ -372,7 +374,7 @@ jobs: - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 with: - node-version: 24.16.0 + node-version: 24.22.1 - name: Install bubblewrap run: | sudo apt-get update -qq diff --git a/.github/workflows/extraction.yml b/.github/workflows/extraction.yml new file mode 100644 index 00000000..0d9a529d --- /dev/null +++ b/.github/workflows/extraction.yml @@ -0,0 +1,25 @@ +name: Extraction Service +on: + pull_request: + paths: ['packages/extraction/**', '.github/workflows/extraction.yml'] + push: + branches: [main] + paths: ['packages/extraction/**', '.github/workflows/extraction.yml'] +permissions: + contents: read +jobs: + extraction: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 + - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 + with: + node-version: 24.22.1 + cache: npm + cache-dependency-path: packages/extraction/package-lock.json + - run: npm ci --ignore-scripts + working-directory: packages/extraction + - run: npm run format:check && npm run typecheck && npm test + working-directory: packages/extraction + - run: npm run test:container + working-directory: packages/extraction diff --git a/api/Dockerfile b/api/Dockerfile index c0f50caf..fe8095de 100644 --- a/api/Dockerfile +++ b/api/Dockerfile @@ -35,7 +35,7 @@ COPY api/src/spec-guard.c /tmp/spec-guard.c COPY api/src/tool-call-pipe-bridge.c /tmp/tool-call-pipe-bridge.c COPY api/src/anonymous-pipes.c /tmp/anonymous-pipes.c COPY api/src/supervisor-policy.c /tmp/supervisor-policy.c -COPY --from=node:22-slim /usr/local/include/node /usr/local/include/node +COPY --from=node:22.23.4-slim /usr/local/include/node /usr/local/include/node # KVM/nsjail execution may need read permission on virtiofs-backed rootfs. COPY docker/rootfs-setup.c /tmp/rootfs-setup.c RUN gcc -shared -fPIC -O2 -Wall -Wextra -Werror -I/usr/local/include/node -o /usr/local/lib/anonymous-pipes.node /tmp/anonymous-pipes.c \ @@ -50,7 +50,7 @@ FROM nsjail-builder AS nsjail-ipc-test ENV SANDBOX_PIPE_ADDON=/usr/local/lib/anonymous-pipes.node COPY docker/python-runtime /python-runtime RUN python3 /python-runtime/install.py -COPY --from=node:22-slim /usr/local/bin/node /usr/local/bin/node +COPY --from=node:22.23.4-slim /usr/local/bin/node /usr/local/bin/node COPY --from=oven/bun:1.4.2-debian /usr/local/bin/bun /usr/local/bin/bun # ============================================================================ @@ -139,7 +139,7 @@ RUN chmod +x /usr/sbin/nsjail # The per-invocation broker starts Node after restore, so its OpenSSL process # state is not captured in the MicroVM snapshot. Pulling the Node binary # is ~30 MB and avoids inheriting npm/node_modules from a full apt install. -COPY --from=node:22-slim /usr/local/bin/node /usr/local/bin/node +COPY --from=node:22.23.4-slim /usr/local/bin/node /usr/local/bin/node WORKDIR /sandbox_api diff --git a/api/bun.lock b/api/bun.lock index b2003e30..6e8b5dda 100644 --- a/api/bun.lock +++ b/api/bun.lock @@ -24,6 +24,9 @@ }, }, }, + "overrides": { + "proxy-addr": "2.0.8", + }, "packages": { "@opentelemetry/api": ["@opentelemetry/api@1.9.1", "", {}, "sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q=="], @@ -185,7 +188,7 @@ "prom-client": ["prom-client@15.1.3", "", { "dependencies": { "@opentelemetry/api": "^1.4.0", "tdigest": "^0.1.1" } }, "sha512-6ZiOBfCywsD4k1BN9IX0uZhF+tJkV8q8llP64G5Hajs4JOeVLPCwpPVcpXy3BwYiUGgyJzsJJQeOIv7+hDSq8g=="], - "proxy-addr": ["proxy-addr@2.0.7", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg=="], + "proxy-addr": ["proxy-addr@2.0.8", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-5nnx0yGyVUcY6t9RnWcARWtwT9F1D8O9rt08htPvnd49W1IgZtmLkhu9WfMzQj1cFxjHIO6connUNVW5k7AVyQ=="], "qs": ["qs@6.16.0", "", { "dependencies": { "es-define-property": "^1.0.1", "side-channel": "^1.1.1" } }, "sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA=="], diff --git a/api/package-lock.json b/api/package-lock.json index 8cf48ae4..a2dbad66 100644 --- a/api/package-lock.json +++ b/api/package-lock.json @@ -1008,15 +1008,20 @@ } }, "node_modules/proxy-addr": { - "version": "2.0.7", - "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", - "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.8.tgz", + "integrity": "sha512-5nnx0yGyVUcY6t9RnWcARWtwT9F1D8O9rt08htPvnd49W1IgZtmLkhu9WfMzQj1cFxjHIO6connUNVW5k7AVyQ==", + "license": "MIT", "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" }, "engines": { "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/qs": { @@ -1756,7 +1761,7 @@ "on-finished": "^2.4.1", "once": "^1.4.0", "parseurl": "^1.3.3", - "proxy-addr": "^2.0.7", + "proxy-addr": "2.0.8", "qs": "^6.14.0", "range-parser": "^1.2.1", "router": "^2.2.0", @@ -2009,9 +2014,9 @@ } }, "proxy-addr": { - "version": "2.0.7", - "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", - "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.8.tgz", + "integrity": "sha512-5nnx0yGyVUcY6t9RnWcARWtwT9F1D8O9rt08htPvnd49W1IgZtmLkhu9WfMzQj1cFxjHIO6connUNVW5k7AVyQ==", "requires": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" diff --git a/api/package.json b/api/package.json index a27fd780..4b455865 100644 --- a/api/package.json +++ b/api/package.json @@ -27,5 +27,8 @@ "@types/semver": "^7.5.8", "typescript": "^5.7.3" }, - "license": "Apache-2.0" + "license": "Apache-2.0", + "overrides": { + "proxy-addr": "2.0.8" + } } diff --git a/api/src/api/session-inputs.routes.test.ts b/api/src/api/session-inputs.routes.test.ts index 48fe7d53..0ba649e4 100644 --- a/api/src/api/session-inputs.routes.test.ts +++ b/api/src/api/session-inputs.routes.test.ts @@ -26,6 +26,7 @@ let baseUrl: string; const savedSessionWorkspaceEnabled = config.session_workspace_enabled; beforeAll(async () => { + await fsp.rm(SESSION_INPUT_CACHE_DIR, { recursive: true, force: true }); config.session_workspace_enabled = true; const app = express(); /* Mirror index.ts: no global JSON parser, router mounted under /api/v2. */ diff --git a/api/src/job.ts b/api/src/job.ts index 5ea8d01f..9da597db 100644 --- a/api/src/job.ts +++ b/api/src/job.ts @@ -171,8 +171,11 @@ export function ensureNodeModulesSymlink( if (!nodeModulesPath) return; const linkPath = path.join(submissionDir, 'node_modules'); try { - fs.lstatSync(linkPath); - return; + if (!fs.lstatSync(linkPath).isSymbolicLink()) return; + const target = path.resolve(submissionDir, fs.readlinkSync(linkPath)); + const packageRoot = `${path.resolve(config.packages_directory)}${path.sep}`; + if (!target.startsWith(packageRoot) || target === path.resolve(nodeModulesPath)) return; + fs.unlinkSync(linkPath); } catch (err) { if (errorCode(err) !== 'ENOENT') throw err; } diff --git a/api/src/package-init.test.ts b/api/src/package-init.test.ts new file mode 100644 index 00000000..a9431bd2 --- /dev/null +++ b/api/src/package-init.test.ts @@ -0,0 +1,198 @@ +import { expect, test } from 'bun:test'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; + +const initializer = fs.readFileSync( + path.resolve(__dirname, '../../docker/package-init.sh'), + 'utf8' +); + +for (const oldRuntimeExists of [false, true]) { + test(`cached PVC refreshes Node and Bun launchers with ${ + oldRuntimeExists ? 'retained' : 'removed' + } old runtimes`, () => { + const root = fs.mkdtempSync( + path.join(os.tmpdir(), 'cached-package-init-') + ); + try { + const packages = path.join(root, 'pkgs'); + const workspace = path.join(root, 'workspace'); + const manifest = path.join(root, 'javascript-packages.txt'); + fs.mkdirSync(workspace); + fs.writeFileSync(manifest, 'runtime-fixture@1.0.0\n'); + const runtimes = [ + { + directory: 'node/24.22.1', + binary: 'bin/node', + args: ['hello world', '--version'], + }, + { + directory: 'bun/1.4.2', + binary: 'bun', + args: ['run', 'hello world', '--version'], + }, + ]; + for (const runtime of runtimes) { + const destination = path.join(packages, runtime.directory); + const fixture = path.join( + destination, + 'node_modules/runtime-fixture' + ); + fs.mkdirSync(fixture, { recursive: true }); + fs.mkdirSync( + path.dirname(path.join(destination, runtime.binary)), + { recursive: true } + ); + fs.writeFileSync( + path.join(fixture, 'package.json'), + '{"version": "1.0.0"}' + ); + fs.writeFileSync( + path.join(destination, '.package-installed'), + 'installed' + ); + fs.writeFileSync( + path.join(destination, '.env'), + 'preserve runtime environment' + ); + fs.writeFileSync( + path.join(destination, runtime.binary), + '#!/bin/bash\nprintf "<%s>\\n" "$@"\n', + { mode: 0o755 } + ); + fs.writeFileSync( + path.join(destination, 'run'), + '#!/bin/bash\nexit 99\n', + { mode: 0o755 } + ); + } + const python = path.join(packages, 'python/3.14.4'); + for (const name of [ + 'PIL', + 'markitdown', + 'chdb', + 'statsmodels', + 'rasterio', + ]) { + fs.mkdirSync( + path.join(python, 'lib/python3.14/site-packages', name), + { recursive: true } + ); + } + fs.writeFileSync( + path.join(python, 'lib/python3.14/.sandbox-pipe-runtime-v1'), + 'installed' + ); + fs.writeFileSync( + path.join(python, '.package-installed'), + 'installed' + ); + fs.mkdirSync(path.join(packages, 'bash/5.2.0'), { + recursive: true, + }); + fs.writeFileSync( + path.join(packages, 'bash/5.2.0/.package-installed'), + 'installed' + ); + fs.writeFileSync( + path.join(packages, '.initialized'), + 'original initialization' + ); + + const forbidden = path.join(root, 'forbidden'); + fs.mkdirSync(forbidden); + for (const binary of [ + 'curl', + 'wget', + 'make', + 'npm', + 'tar', + 'unzip', + ]) { + fs.writeFileSync( + path.join(forbidden, binary), + '#!/bin/bash\necho "unexpected reinstall" >&2\nexit 98\n', + { mode: 0o755 } + ); + } + const script = path.join(root, 'package-init.sh'); + fs.writeFileSync( + script, + initializer + .split('/pkgs') + .join(packages) + .split('/mnt/data/node_modules') + .join(path.join(workspace, 'node_modules')) + ); + const env = { + ...process.env, + PATH: `${forbidden}:${process.env.PATH}`, + FORCE_REBUILD: 'false', + PYTHON_VERSION: '3.14.4', + NODE_VERSION: '24.22.1', + BUN_VERSION: '1.4.2', + BASH_PACKAGE_VERSION: '5.2.0', + JS_PACKAGE_MANIFEST: manifest, + }; + for (let pass = 0; pass < 2; pass += 1) { + const initialized = Bun.spawnSync({ + cmd: ['bash', script], + env, + }); + expect(initialized.stderr.toString()).toBe(''); + expect(initialized.exitCode).toBe(0); + expect(initialized.stdout.toString()).toContain( + 'Packages already initialized' + ); + expect( + fs.readFileSync(path.join(packages, '.initialized'), 'utf8') + ).toBe('original initialization'); + for (const runtime of runtimes) { + const destination = path.join(packages, runtime.directory); + const oldModules = path.join( + packages, + runtime.directory.split('/')[0], + '0.0.1/node_modules' + ); + if (oldRuntimeExists) + fs.mkdirSync(oldModules, { recursive: true }); + const link = path.join(workspace, 'node_modules'); + fs.rmSync(link, { force: true }); + fs.symlinkSync(oldModules, link); + const launched = Bun.spawnSync({ + cmd: [ + path.join(destination, 'run'), + 'hello world', + '--version', + ], + }); + expect(launched.stderr.toString()).toBe(''); + expect(launched.exitCode).toBe(0); + expect(launched.stdout.toString()).toBe( + runtime.args.map(arg => `<${arg}>\n`).join('') + ); + expect(fs.readlinkSync(link)).toBe( + path.join(destination, 'node_modules') + ); + expect( + fs.readFileSync( + path.join(destination, '.package-installed'), + 'utf8' + ) + ).toBe('installed'); + expect( + fs.readFileSync(path.join(destination, '.env'), 'utf8') + ).toBe('preserve runtime environment'); + expect( + fs + .readdirSync(destination) + .some(name => name.startsWith('.run.')) + ).toBe(false); + } + } + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } + }); +} diff --git a/api/src/pipe-process.test.ts b/api/src/pipe-process.test.ts index d8eb82b1..3344ee04 100644 --- a/api/src/pipe-process.test.ts +++ b/api/src/pipe-process.test.ts @@ -85,14 +85,15 @@ describe('anonymous process pipes', () => { try { const inode = fs.readlinkSync(`/proc/self/fd/${fd}`); return ownedPipes.has(inode) ? [inode] : []; } catch { return []; } }); - // Bun releases internal duplicates asynchronously after wrapper close. + // Bun may finish releasing internal FD duplicates after child close. + // Allow bounded asynchronous cleanup, but still reject persistent leaks. const deadline = performance.now() + 1000; - let pending = survivors(); - while (pending.length > 0 && performance.now() < deadline) { - await new Promise(resolve => setImmediate(resolve)); - pending = survivors(); + let remaining = survivors(); + while (remaining.length && performance.now() < deadline) { + await new Promise(resolve => setTimeout(resolve, 10)); + remaining = survivors(); } - expect(pending).toEqual([]); + expect(remaining).toEqual([]); } }, 10000); }); diff --git a/api/src/runtime-module-links.test.ts b/api/src/runtime-module-links.test.ts new file mode 100644 index 00000000..6ef754ab --- /dev/null +++ b/api/src/runtime-module-links.test.ts @@ -0,0 +1,207 @@ +import { expect, test } from 'bun:test'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { config } from './config'; +import { ensureNodeModulesSymlink } from './job'; + +function fixture( + run: (workspace: string, packages: string, modules: string) => void +): void { + const root = fs.mkdtempSync( + path.join(os.tmpdir(), 'runtime-module-links-') + ); + const originalPackages = config.packages_directory; + const workspace = path.join(root, 'workspace'); + const packages = path.join(root, 'pkgs'); + const modules = path.join(packages, 'node', '24.22.1', 'node_modules'); + fs.mkdirSync(workspace); + fs.mkdirSync(modules, { recursive: true }); + config.packages_directory = packages; + try { + run(workspace, packages, modules); + } finally { + config.packages_directory = originalPackages; + fs.rmSync(root, { recursive: true, force: true }); + } +} + +const managers = [ + { + name: 'bash job preparation', + prepare: ensureNodeModulesSymlink, + }, +]; +for (const file of ['build-packages.sh', 'docker/package-init.sh']) { + const source = fs + .readFileSync(path.resolve(__dirname, '../..', file), 'utf8') + .replace(/\\(["$])/g, '$1'); + const launchers = + source.match(/MODULE_DIR=.*\nif \[ -d [\s\S]*?\nfi/g) ?? []; + expect(launchers).toHaveLength(2); + for (const [index, launcher] of launchers.entries()) { + managers.push({ + name: `${file} ${index === 0 ? 'Node' : 'Bun'} launcher`, + prepare(workspace: string, modules?: string): void { + const link = path.join(workspace, 'node_modules'); + const script = `SCRIPT_DIR=$1\n${launcher + .split('/mnt/data/node_modules') + .join('"$2"')}`; + const result = Bun.spawnSync({ + cmd: [ + 'bash', + '-c', + script, + 'runtime-launcher', + path.dirname(modules!), + link, + ], + }); + expect(result.exitCode).toBe(0); + }, + }); + } +} + +for (const manager of managers) { + test(`${manager.name}: links bundled modules into a fresh workspace`, () => { + fixture((workspace, _packages, modules) => { + manager.prepare(workspace, modules); + expect(fs.readlinkSync(path.join(workspace, 'node_modules'))).toBe( + modules + ); + }); + }); + + for (const oldRuntimeExists of [false, true]) { + test(`${manager.name}: refreshes a ${ + oldRuntimeExists ? 'retained' : 'removed' + } runtime link`, () => { + fixture((workspace, packages, modules) => { + const oldModules = path.join( + packages, + 'node', + '24.15.0', + 'node_modules' + ); + if (oldRuntimeExists) + fs.mkdirSync(oldModules, { recursive: true }); + fs.symlinkSync( + oldModules, + path.join(workspace, 'node_modules') + ); + manager.prepare(workspace, modules); + expect( + fs.readlinkSync(path.join(workspace, 'node_modules')) + ).toBe(modules); + }); + }); + } + + test(`${manager.name}: leaves the current runtime link unchanged`, () => { + fixture((workspace, _packages, modules) => { + const link = path.join(workspace, 'node_modules'); + fs.symlinkSync(modules, link); + const inode = fs.lstatSync(link).ino; + manager.prepare(workspace, modules); + expect(fs.lstatSync(link).ino).toBe(inode); + }); + }); + + test(`${manager.name}: preserves a user-managed directory`, () => { + fixture((workspace, _packages, modules) => { + const link = path.join(workspace, 'node_modules'); + fs.mkdirSync(link); + fs.writeFileSync(path.join(link, 'user-package'), 'keep'); + manager.prepare(workspace, modules); + expect( + fs.readFileSync(path.join(link, 'user-package'), 'utf8') + ).toBe('keep'); + }); + }); + + test(`${manager.name}: preserves a valid user link sharing the package-root prefix`, () => { + fixture((workspace, packages, modules) => { + const target = `${packages}-other/node_modules`; + const link = path.join(workspace, 'node_modules'); + fs.mkdirSync(target, { recursive: true }); + fs.symlinkSync(target, link); + manager.prepare(workspace, modules); + expect(fs.readlinkSync(link)).toBe(target); + }); + }); + + test(`${manager.name}: preserves a user link with a package-root traversal`, () => { + fixture((workspace, packages, modules) => { + const target = `${packages}/../user/node_modules`; + const link = path.join(workspace, 'node_modules'); + fs.mkdirSync(path.resolve(target), { recursive: true }); + fs.symlinkSync(target, link); + manager.prepare(workspace, modules); + expect(fs.readlinkSync(link)).toBe(target); + }); + }); + + for (const target of [ + '../user/node_modules', + '../pkgs-other/node_modules', + ]) { + test(`${manager.name}: preserves user link ${target}`, () => { + fixture((workspace, _packages, modules) => { + const link = path.join(workspace, 'node_modules'); + fs.symlinkSync(target, link); + manager.prepare(workspace, modules); + expect(fs.readlinkSync(link)).toBe(target); + }); + }); + } +} + +test('restored Node sessions resolve ESM packages after the old runtime is removed', () => { + fixture((workspace, packages, modules) => { + const oldModules = path.join( + packages, + 'node', + '24.15.0', + 'node_modules' + ); + fs.symlinkSync(oldModules, path.join(workspace, 'node_modules')); + const archive = path.join(path.dirname(workspace), 'checkpoint.tar'); + expect( + Bun.spawnSync({ + cmd: ['tar', '-cf', archive, '-C', workspace, '.'], + }).exitCode + ).toBe(0); + fs.rmSync(workspace, { recursive: true }); + fs.mkdirSync(workspace); + expect( + Bun.spawnSync({ cmd: ['tar', '-xf', archive, '-C', workspace] }) + .exitCode + ).toBe(0); + const pkg = path.join(modules, 'runtime-fixture'); + fs.mkdirSync(pkg); + fs.writeFileSync( + path.join(pkg, 'package.json'), + JSON.stringify({ type: 'module', exports: './index.js' }) + ); + fs.writeFileSync( + path.join(pkg, 'index.js'), + 'export default "upgraded";' + ); + + ensureNodeModulesSymlink(workspace, modules); + + const result = Bun.spawnSync({ + cmd: [ + process.env.NODE_RUNTIME_TEST_BINARY ?? 'node', + '--input-type=module', + '-e', + 'import value from "runtime-fixture"; console.log(value);', + ], + cwd: workspace, + }); + expect(result.stderr.toString()).toBe(''); + expect(result.exitCode).toBe(0); + expect(result.stdout.toString().trim()).toBe('upgraded'); + }); +}); diff --git a/api/src/workspace-command.test.ts b/api/src/workspace-command.test.ts index 38936a3c..e2a9eecb 100644 --- a/api/src/workspace-command.test.ts +++ b/api/src/workspace-command.test.ts @@ -1,10 +1,11 @@ -import { mkdtemp, mkdir, realpath, rm, symlink } from 'node:fs/promises'; +import { mkdtemp, mkdir, readFile, readlink, realpath, rm, symlink, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { describe, expect, test } from 'bun:test'; import semver from 'semver'; -import type { Runtime } from './runtime'; +import { config } from './config'; +import { getRuntimes, type Runtime } from './runtime'; import { executeWorkspaceCommand, hasWorkspaceCommandToken, @@ -79,6 +80,80 @@ describe('sandboxed workspace commands', () => { } }); + for (const existingModules of ['absent', 'removed', 'retained', 'directory', 'external']) { + test(`prepares ${existingModules} modules before workspace-command ESM execution`, async () => { + const parent = await mkdtemp(join(tmpdir(), 'workspace-command-modules-')); + const savedPackages = config.packages_directory; + const registered = getRuntimes(); + const savedRuntimes = registered.splice(0); + try { + const packages = join(parent, 'pkgs'); + const root = join(parent, 'workspace'); + const modules = join(packages, 'node/24.22.1/node_modules'); + const link = join(root, 'node_modules'); + await mkdir(join(root, 'src'), { recursive: true }); + await mkdir(join(modules, 'runtime-fixture'), { recursive: true }); + await writeFile(join(modules, 'runtime-fixture/package.json'), JSON.stringify({ type: 'module', exports: './index.js' })); + await writeFile(join(modules, 'runtime-fixture/index.js'), 'export default "upgraded";'); + config.packages_directory = packages; + registered.push({ + ...runtime, + language: 'node', + version: new semver.SemVer('24.22.1'), + pkgdir: join(packages, 'node/24.22.1'), + env_vars: { NODE_PATH: modules }, + }); + let userTarget: string | undefined; + if (existingModules === 'directory') { + await mkdir(link); + await writeFile(join(link, 'keep'), 'user module'); + } else if (existingModules === 'external') { + userTarget = join(packages, '../user/node_modules'); + await mkdir(userTarget, { recursive: true }); + await symlink(userTarget, link); + } else if (existingModules !== 'absent') { + const oldModules = join(packages, 'node/24.15.0/node_modules'); + if (existingModules === 'retained') await mkdir(oldModules, { recursive: true }); + await symlink(oldModules, link); + } + let executed = false; + await executeWorkspaceCommand({ command: 'node app.mjs', cwd: 'src' }, { + workspaceRoot: root, + runtime, + executeNsJail: async options => { + executed = true; + expect(options.extraPkgdirs).toContain(join(packages, 'node/24.22.1')); + expect(options.envVars.NODE_PATH).toBe(modules); + if (existingModules === 'directory') { + expect(await readFile(join(link, 'keep'), 'utf8')).toBe('user module'); + } else if (existingModules === 'external') { + expect(await readlink(link)).toBe(userTarget!); + } else { + expect(await readlink(link)).toBe(modules); + const result = Bun.spawnSync({ + cmd: [process.env.NODE_RUNTIME_TEST_BINARY ?? 'node', '--input-type=module', '-e', + 'import value from "runtime-fixture"; console.log(value);'], + cwd: join(root, 'src'), + }); + expect(result.stderr.toString()).toBe(''); + expect(result.exitCode).toBe(0); + expect(result.stdout.toString().trim()).toBe('upgraded'); + } + return { + stdout: '', stderr: '', code: 0, signal: null, output: '', + memory: null, message: null, status: null, cpu_time: null, wall_time: 1, + }; + }, + }); + expect(executed).toBe(true); + } finally { + registered.splice(0, registered.length, ...savedRuntimes); + config.packages_directory = savedPackages; + await rm(parent, { recursive: true, force: true }); + } + }); + } + test('rejects a cwd symlink that escapes the mounted workspace', async () => { const parent = await mkdtemp(join(tmpdir(), 'workspace-command-')); try { diff --git a/api/src/workspace-command.ts b/api/src/workspace-command.ts index a2cd5339..34180da9 100644 --- a/api/src/workspace-command.ts +++ b/api/src/workspace-command.ts @@ -2,7 +2,7 @@ import * as path from 'node:path'; import * as fsp from 'node:fs/promises'; import { timingSafeEqual } from 'node:crypto'; -import { aggregateBashExtras } from './job'; +import { aggregateBashExtras, ensureNodeModulesSymlink } from './job'; import { execute, type NsJailResult } from './nsjail'; import { getLatestRuntimeMatchingLanguageVersion } from './runtime'; import type { Runtime } from './runtime'; @@ -169,7 +169,9 @@ export async function executeWorkspaceCommand( HOME: '/mnt/data', SANDBOX_LANGUAGE: 'bash', }; - const extraPkgdirs = aggregateBashExtras(runtime.pkgdir, envVars); + const linkTarget: { nodeModulesPath?: string } = {}; + const extraPkgdirs = aggregateBashExtras(runtime.pkgdir, envVars, undefined, linkTarget); + ensureNodeModulesSymlink(workspaceRoot, linkTarget.nodeModulesPath); const relativeCwd = path.relative(workspaceRoot, cwd).split(path.sep).join('/'); const result = await (options.executeNsJail ?? execute)({ command: [ diff --git a/build-packages.sh b/build-packages.sh index 1d635297..59d54750 100755 --- a/build-packages.sh +++ b/build-packages.sh @@ -9,7 +9,7 @@ # # Environment Variables: # PYTHON_VERSION=3.14.4 # Python version to install -# NODE_VERSION=24.15.0 # Node.js version to install +# NODE_VERSION=24.22.1 # Node.js version to install # BUN_VERSION=1.4.2 # Bun version to install # BASH_PACKAGE_VERSION=5.2.0 # Bash package registration version (semver-like x.y.z) # SKIP_PYTHON_PACKAGES=1 # Skip Python pip packages @@ -27,7 +27,7 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" cd "$SCRIPT_DIR" PYTHON_VERSION="${PYTHON_VERSION:-3.14.4}" -NODE_VERSION="${NODE_VERSION:-24.15.0}" +NODE_VERSION="${NODE_VERSION:-24.22.1}" BUN_VERSION="${BUN_VERSION:-1.4.2}" PACKAGES_DIR="./data/pkgs" JS_PACKAGE_MANIFEST="${JS_PACKAGE_MANIFEST:-${SCRIPT_DIR}/javascript-packages.txt}" @@ -271,8 +271,18 @@ EOF" #!/bin/bash SCRIPT_DIR=\"\$(cd \"\$(dirname \"\${BASH_SOURCE[0]}\")\" && pwd)\" MODULE_DIR=\"\${SCRIPT_DIR}/node_modules\" -if [ -d \"\${MODULE_DIR}\" ] && [ ! -e /mnt/data/node_modules ]; then - ln -s \"\${MODULE_DIR}\" /mnt/data/node_modules 2>/dev/null || true +if [ -d \"\${MODULE_DIR}\" ]; then + if [ -L /mnt/data/node_modules ]; then + LINK_TARGET=\"\$(readlink -m /mnt/data/node_modules)\" + if [ \"\${LINK_TARGET}\" != \"\${MODULE_DIR}\" ]; then + case \"\${LINK_TARGET}\" in + \"\${SCRIPT_DIR%/*/*}/\"*/node_modules) + ln -sfnT \"\${MODULE_DIR}\" /mnt/data/node_modules ;; + esac + fi + elif [ ! -e /mnt/data/node_modules ]; then + ln -s \"\${MODULE_DIR}\" /mnt/data/node_modules 2>/dev/null || true + fi fi \"\${SCRIPT_DIR}/bin/node\" \"\$@\" EOF @@ -373,8 +383,18 @@ EOF" #!/bin/bash SCRIPT_DIR=\"\$(cd \"\$(dirname \"\${BASH_SOURCE[0]}\")\" && pwd)\" MODULE_DIR=\"\${SCRIPT_DIR}/node_modules\" -if [ -d \"\${MODULE_DIR}\" ] && [ ! -e /mnt/data/node_modules ]; then - ln -s \"\${MODULE_DIR}\" /mnt/data/node_modules 2>/dev/null || true +if [ -d \"\${MODULE_DIR}\" ]; then + if [ -L /mnt/data/node_modules ]; then + LINK_TARGET=\"\$(readlink -m /mnt/data/node_modules)\" + if [ \"\${LINK_TARGET}\" != \"\${MODULE_DIR}\" ]; then + case \"\${LINK_TARGET}\" in + \"\${SCRIPT_DIR%/*/*}/\"*/node_modules) + ln -sfnT \"\${MODULE_DIR}\" /mnt/data/node_modules ;; + esac + fi + elif [ ! -e /mnt/data/node_modules ]; then + ln -s \"\${MODULE_DIR}\" /mnt/data/node_modules 2>/dev/null || true + fi fi \"\${SCRIPT_DIR}/bun\" run \"\$@\" EOF diff --git a/docker-compose.yaml b/docker-compose.yaml index 60f6ccf4..5b37eaf3 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -15,6 +15,7 @@ services: - CODEAPI_BRIDGE_DYNAMIC_WORKERS=${CODEAPI_BRIDGE_DYNAMIC_WORKERS:-true} - CODEAPI_BRIDGE_MAX_WORKSPACE_LEASE_SLOTS=${CODEAPI_BRIDGE_MAX_WORKSPACE_LEASE_SLOTS:-1} - CODEAPI_BRIDGE_LANE_GIT=${CODEAPI_BRIDGE_LANE_GIT:-false} + - CODEAPI_BRIDGE_IMPORT_FILE_MAX_BYTES=${CODEAPI_BRIDGE_IMPORT_FILE_MAX_BYTES:-104857600} - CODEAPI_BRIDGE_WORKER_ID=${CODEAPI_BRIDGE_WORKER_ID:-} - CODEAPI_BRIDGE_RECOVERY_SERVER_ID=${CODEAPI_BRIDGE_RECOVERY_SERVER_ID:-} - CODEAPI_BRIDGE_ENROLLMENT_TTL_SECONDS=${CODEAPI_BRIDGE_ENROLLMENT_TTL_SECONDS:-0} diff --git a/docker/Dockerfile.worker-sandbox b/docker/Dockerfile.worker-sandbox index 0a56230c..7d0b545a 100644 --- a/docker/Dockerfile.worker-sandbox +++ b/docker/Dockerfile.worker-sandbox @@ -48,7 +48,7 @@ COPY api/src/spec-guard.c /tmp/spec-guard.c COPY api/src/tool-call-pipe-bridge.c /tmp/tool-call-pipe-bridge.c COPY api/src/anonymous-pipes.c /tmp/anonymous-pipes.c COPY api/src/supervisor-policy.c /tmp/supervisor-policy.c -COPY --from=node:22-slim /usr/local/include/node /usr/local/include/node +COPY --from=node:22.23.4-slim /usr/local/include/node /usr/local/include/node # KVM/nsjail execution may need read permission on virtiofs-backed rootfs. COPY docker/rootfs-setup.c /tmp/rootfs-setup.c RUN gcc -shared -fPIC -O2 -Wall -Wextra -Werror -I/usr/local/include/node -o /usr/local/lib/anonymous-pipes.node /tmp/anonymous-pipes.c \ @@ -161,7 +161,7 @@ COPY --from=nsjail-builder /usr/local/bin/sandbox-supervisor-policy /usr/local/b COPY --from=nsjail-builder /usr/local/lib/anonymous-pipes.node /usr/local/lib/anonymous-pipes.node ENV SANDBOX_PIPE_ADDON=/usr/local/lib/anonymous-pipes.node RUN chmod +x /usr/sbin/nsjail -COPY --from=node:22-slim /usr/local/bin/node /usr/local/bin/node +COPY --from=node:22.23.4-slim /usr/local/bin/node /usr/local/bin/node WORKDIR /sandbox_api diff --git a/docker/package-init.sh b/docker/package-init.sh index 4b96305f..2273721c 100644 --- a/docker/package-init.sh +++ b/docker/package-init.sh @@ -20,7 +20,7 @@ PYTHON_ALIAS="python${PYTHON_SITE_VERSION}" # below — that's inherent to installing unpinned packages, not specific to uv. PYTHON_PACKAGE_INSTALLER="${PYTHON_PACKAGE_INSTALLER:-uv}" UV_VERSION="${UV_VERSION:-0.11.26}" -NODE_VERSION="${NODE_VERSION:-24.15.0}" +NODE_VERSION="${NODE_VERSION:-24.22.1}" BUN_VERSION="${BUN_VERSION:-1.4.2}" BASH_PACKAGE_VERSION="${BASH_PACKAGE_VERSION:-5.2.0}" INSTALL_FAILED=false @@ -101,8 +101,64 @@ packages_ready() { [ -f "/pkgs/bash/${BASH_PACKAGE_VERSION}/.package-installed" ] } +write_node_launcher() { + local destination="$1" + local launcher + launcher="$(mktemp "${destination}/.run.XXXXXX")" + cat > "$launcher" << 'EOF' +#!/bin/bash +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +MODULE_DIR="${SCRIPT_DIR}/node_modules" +if [ -d "$MODULE_DIR" ]; then + if [ -L /mnt/data/node_modules ]; then + LINK_TARGET="$(readlink -m /mnt/data/node_modules)" + if [ "$LINK_TARGET" != "$MODULE_DIR" ]; then + case "$LINK_TARGET" in + "${SCRIPT_DIR%/*/*}/"*/node_modules) + ln -sfnT "$MODULE_DIR" /mnt/data/node_modules ;; + esac + fi + elif [ ! -e /mnt/data/node_modules ]; then + ln -s "$MODULE_DIR" /mnt/data/node_modules 2>/dev/null || true + fi +fi +"${SCRIPT_DIR}/bin/node" "$@" +EOF + chmod 0755 "$launcher" + mv -f "$launcher" "${destination}/run" +} + +write_bun_launcher() { + local destination="$1" + local launcher + launcher="$(mktemp "${destination}/.run.XXXXXX")" + cat > "$launcher" << 'EOF' +#!/bin/bash +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +MODULE_DIR="${SCRIPT_DIR}/node_modules" +if [ -d "$MODULE_DIR" ]; then + if [ -L /mnt/data/node_modules ]; then + LINK_TARGET="$(readlink -m /mnt/data/node_modules)" + if [ "$LINK_TARGET" != "$MODULE_DIR" ]; then + case "$LINK_TARGET" in + "${SCRIPT_DIR%/*/*}/"*/node_modules) + ln -sfnT "$MODULE_DIR" /mnt/data/node_modules ;; + esac + fi + elif [ ! -e /mnt/data/node_modules ]; then + ln -s "$MODULE_DIR" /mnt/data/node_modules 2>/dev/null || true + fi +fi +"${SCRIPT_DIR}/bun" run "$@" +EOF + chmod 0755 "$launcher" + mv -f "$launcher" "${destination}/run" +} + if [ -f "$MARKER_FILE" ] && [ "$FORCE_REBUILD" != "true" ]; then if packages_ready; then + write_node_launcher "/pkgs/node/${NODE_VERSION}" + write_bun_launcher "/pkgs/bun/${BUN_VERSION}" echo "Packages already initialized (marker file exists)" echo "Set FORCE_REBUILD=true to force reinstall" echo "" @@ -301,16 +357,7 @@ if [ -n "$NODE_ARCH" ]; then } EOF - cat > "$NODE_DEST/run" << 'EOF' -#!/bin/bash -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -MODULE_DIR="${SCRIPT_DIR}/node_modules" -if [ -d "$MODULE_DIR" ] && [ ! -e /mnt/data/node_modules ]; then - ln -s "$MODULE_DIR" /mnt/data/node_modules 2>/dev/null || true -fi -"${SCRIPT_DIR}/bin/node" "$@" -EOF - chmod +x "$NODE_DEST/run" + write_node_launcher "$NODE_DEST" { echo "PATH=${NODE_DEST}/bin:/usr/local/bin:/usr/local/sbin:/usr/bin:/usr/sbin:/bin:/sbin:." @@ -413,16 +460,7 @@ if [ -n "$BUN_ARCH" ]; then } EOF - cat > "$BUN_DEST/run" << 'EOF' -#!/bin/bash -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -MODULE_DIR="${SCRIPT_DIR}/node_modules" -if [ -d "$MODULE_DIR" ] && [ ! -e /mnt/data/node_modules ]; then - ln -s "$MODULE_DIR" /mnt/data/node_modules 2>/dev/null || true -fi -"${SCRIPT_DIR}/bun" run "$@" -EOF - chmod +x "$BUN_DEST/run" + write_bun_launcher "$BUN_DEST" { echo "PATH=${BUN_DEST}:/usr/local/bin:/usr/local/sbin:/usr/bin:/usr/sbin:/bin:/sbin:." diff --git a/docs/adr/0014-workspace-file-import.md b/docs/adr/0014-workspace-file-import.md new file mode 100644 index 00000000..e1aac4a7 --- /dev/null +++ b/docs/adr/0014-workspace-file-import.md @@ -0,0 +1,65 @@ +# 0014: Import chat uploads into attached workspaces + +Status: Accepted + +## Context + +A LibreChat chat can run on a personal machine through the remote bridge. Files +the user uploads in that chat stay in Code API storage: only programmatic Bash +stages them, per run, in a private scratch directory. `write_file` accepts +1 MiB of UTF-8 text, so binaries such as archives, images, and PDFs cannot reach +the workspace at all. + +## Decision + +Add one additive workspace operation, `import_file`, under protocol version 1. + +- **Request.** `{ path, source: { storage_session_id, id }, bytes, sha256, + overwrite? }` plus the usual workspace selectors. `sha256` is lowercase hex. +- **Ownership.** Code API, not the caller, decides whether `source` may be read. + It applies the `GET /download/:session_id/:fileId` rule for a user-kind + object: the live `session:` key must name the caller's storage key, and an + upload record must exist for that key, session, and object. +- **Grant.** Code API asks the egress gateway for a grant whose only input is + that object, with no upload capacity, two reads, and an expiry of the + request's admission plus execution budget plus 30 seconds. The relayed + request carries the grant and opaque handles in place of the raw ids. A + caller-supplied `egressGrant` is rejected. Synchronous requests revoke the + grant when they finish; durable requests let it expire. +- **Worker.** The worker streams the object from + `LIBRECHAT_CODE_FILE_RELAY_UPSTREAM`, the route programmatic inputs already + use, and holds no storage credential. It applies `write_file`'s path rules, + refuses `.git` segments and their filesystem aliases, refuses symlinked + parents, creates missing parents as 0755, enforces `bytes` while streaming + into a same-directory temporary file, verifies size and SHA-256, syncs, and + installs the file atomically with mode 0644. The size and hash are checked on + the synced temporary file itself. A create links exclusively; an overwrite + rechecks the inspected inode immediately before its rename, as `write_file` + does. +- **Idempotency.** Identical existing bytes return `unchanged: true` and + `bytesWritten: 0` without a transfer. Different bytes return `EDIT_CONFLICT` + unless `overwrite: true`. +- **Limits.** Code API (`CODEAPI_BRIDGE_IMPORT_FILE_MAX_BYTES`) and the worker + (`LIBRECHAT_CODE_IMPORT_FILE_MAX_BYTES`) each enforce a per-file ceiling, + 100 MiB by default, as `WRITE_LIMIT_EXCEEDED`. The worker also requires the + file plus 64 MiB to fit in the target filesystem's free space. +- **Policy.** `import_file` is a file write. It shares the mutation guard with + `write_file`, is never advertised for a read-only root, and a read-only root + refuses it with `WRITE_DISABLED`. +- **Negotiation.** Code API lists `import_file` in + `supportedWorkspaceToolOperations` only when `EGRESS_GATEWAY_URL` is set. A + worker advertises it by default whenever writes are enabled, a relay + upstream is configured, and the Code API listed it; operators opt out with + `--no-workspace-imports` or `LIBRECHAT_CODE_ALLOW_WORKSPACE_IMPORTS=false`. + LibreChat sends it only when advertised. LibreChat releases that predate + LibreChat-AI/LibreChat#17376 reject a worker status listing an unknown + operation, so LibreChat is updated first and the opt-out is the escape + hatch for workers serving an older one. + +## Consequences + +Uploads land in the workspace without a model round trip, and older Code APIs, +workers, and LibreChat releases keep their current behavior. Deployments +without an egress gateway do not offer the operation. Parent directories are +created only beneath a descriptor-held root, as for `write_file`; a legacy +pathname-only root reports a missing parent instead. diff --git a/docs/fork/patches.md b/docs/fork/patches.md index 011553aa..c1b3edd6 100644 --- a/docs/fork/patches.md +++ b/docs/fork/patches.md @@ -158,8 +158,12 @@ States: Active, Review on sync, Draft, History only, Retired. | Retry only refused sandbox connections within an absolute deadline | Active | `39cbcd2`, `15416c4`, `055709d` | HTTP sandbox backend and cancellation | | Preserve early readiness and trusted-worker resource overrides | Active | `8eb246c`, `53ce639`, `b3c2e22` | Existing Helm deployment ownership | | Preserve normal exit during clean tool-pipe shutdown | Active | `api/src/tool-call-pipe-broker.ts`, `tests/pipe_runtime_image.cjs` | Relay-owned request transport | -| Await asynchronous pipe descriptor reclamation in tests | Active | `api/src/pipe-process.test.ts` | Bun FileSink cleanup | +| Await asynchronous pipe descriptor reclamation in tests | Retired | `api/src/pipe-process.test.ts` | Bun FileSink cleanup | | Check bounded NsJail startup failure without diagnostic prose | Active | `tests/supervisor_policy.py` | Upstream startup handshake | +| Cancel abandoned execution and await guest cleanup | Active | `api/src/api/v2.ts`, `service/src/service/router.ts` | Queue cancellation and execution identity ownership | +| Observe one bounded synthetic lifecycle case | Active | `shared/live-cancellation-registration.ts` | Signed manifest binding and disabled-by-default diagnostics | +| Discover metrics across sandbox namespaces | Active | `helm/codeapi/templates/podmonitor.yaml` | Existing Prometheus Operator and restricted scrape peers | +| Exercise upstream descendant cleanup in native CI | Active | Upstream helper `71ef714`; fork bounded negative control | Existing native CI lane | ## Publish exact-SHA UZH images @@ -758,10 +762,16 @@ to disappear within a bounded interval, then still requires an empty set. An intentionally retained synthetic pipe must fail this assertion. No runtime transport implementation or syscall permission changes are part of this patch. -Owned path: `api/src/pipe-process.test.ts`. Retire this test-only patch when +Shared path: `api/src/pipe-process.test.ts`; its native startup-cancellation +cases also belong to the cancellation entry below. Retire only the descriptor +wait hunk when upstream provides equivalent bounded cleanup coverage or the runtime guarantees that its descriptors are reclaimed before the observed close events. +Current disposition: retired in the v1.10.10 integration. Adopt upstream's +bounded descriptor wait without replaying the fork's equivalent polling hunk. +The separate startup-cancellation cases remain owned by the cancellation entry. + ## Check bounded NsJail startup failure without diagnostic prose Required behavior and owned path: @@ -778,6 +788,118 @@ Replay and drop condition: tests. Drop this test-only delta when upstream checks the same bounded failure with a valid startup control and no diagnostic-text assertion. +## Cancel abandoned execution and await guest cleanup + +Required behavior: caller disconnects from regular execution cancel queued or +running work through the existing durable cancellation contract. Admission, +lost-enqueue replies and completion races retain ownership. Guest cleanup waits +for the owned child to exit before deleting its workspace or releasing its +identity. A stalled termination reports failure while retaining that identity. + +Owned paths: `service/src/service/router-cancellation.test.ts`, +`service/src/job-cancellation-commit.test.ts`, `api/src/api/v2-cleanup.test.ts`, +`api/src/job-cleanup.test.ts`, and `api/src/cleanup.integration.test.ts`. + +Shared seams: + +- `service/src/service/router.ts`, `service/src/service/programmatic-router.ts`, + `service/src/job-cancellation.ts`, `service/src/job-cancellation.test.ts`, + `service/src/workers.ts`, and `service/src/types/service.ts` also carry the + public contract, retry/deadline and completion-polling behaviors. +- `api/src/api/v2.ts`, `api/src/job.ts`, `api/src/nsjail.ts`, + `api/src/nsjail.test.ts`, `api/src/workspace-isolation.ts`, and + `api/src/workspace-isolation.test.ts` share cleanup and observation seams. +- `api/src/tool-call-pipe-broker.ts`, `api/src/tool-call-pipe-bridge.c`, + `api/src/pipe-process.test.ts`, `tests/pipe_relay_backpressure.py`, + `tests/pipe_runtime_image.cjs`, and `tests/tool_call_pipes.py` cover graceful + termination, startup signals, blocked writes, EOF and parent-death handling. +- `.github/workflows/ci.yml` runs native tests in its existing NsJail image on + both supported native architectures. The fast API job may skip native cases; + the authoritative native lane requires an executable bridge first. + +Source history: `12aad45` and `31c9ba2`. Upstream v1.10.10 does not replace +these cancellation or broker/relay changes. Keep graceful SIGTERM/143 distinct +from parent-death SIGKILL when reconciling pipe tests. Drop only when upstream +provides equivalent admission cancellation, child reaping and identity-release +ordering with the corresponding behavioral tests. + +## Observe one bounded synthetic lifecycle case + +Required behavior: diagnostics are disabled by default. An explicitly selected, +expiring synthetic case is bound at the gateway to its server-owned execution +and output session, then carried through the signed manifest. The guest emits +only the bounded diagnostic schema and checks real process absence, output +absence and acknowledged identity release. Missing or uncertain evidence fails. +Ordinary execution rejects caller-supplied diagnostic binding. + +Owned paths: `shared/live-cancellation-registration.ts`, +`api/src/live-cancellation-observer.ts`, +`api/src/live-cancellation-observer.test.ts`, and `api/src/config.ts`. + +Shared seams: + +- `service/src/egress-grant.ts` and `service/src/egress-grant.test.ts` own + registration selection and one-shot binding alongside normal egress grants. +- `service/src/execution-manifest.ts`, `service/src/execution-manifest.test.ts`, + `api/src/execution-manifest.ts`, and + `api/src/execution-manifest-request.test.ts` validate the signed optional + session binding without exporting its encrypted write capability. +- `api/src/api/v2.ts`, `api/src/job.ts`, `api/src/nsjail.ts`, and + `api/src/workspace-isolation.ts` share lifecycle facts with cleanup ownership. +- `launcher/src/main.rs` permits the explicit guest diagnostic configuration; + its tests retain the guest environment allowlist. +- `service/rollup.config.js`, `service/tsconfig.json`, and + `service/tsconfig.esm.json` include the shared registration helper alongside + values-free logging and telemetry helpers. +- `helm/codeapi/templates/egress-gateway-deployment.yaml`, + `helm/codeapi/templates/worker-sandbox-deployment.yaml`, + `helm/codeapi/values.yaml`, `helm/codeapi/Chart.yaml`, and + `tests/sandbox_runner_metrics.sh` preserve default-empty gateway/guest + configuration and its render contract. + +Source history: `31c9ba2` and the chart-hook continuation merged by `50cc280`. +Upstream v1.10.10 has no equivalent selected lifecycle observation or signed +binding. Drop only when an accepted upstream mechanism proves the same +cancellation/release facts without widening normal data exposure. This entry +records source behavior, not diagnostic activation or live acceptance. + +## Discover metrics across sandbox namespaces + +Required behavior: the runner PodMonitor selects its configured namespace; +monitors accept discovery labels without changing workload selectors; explicit +scrape peers add only intended ingress while preserving egress. Metrics remain +opt-in. A rendered dashboard is not proof of successful scraping. + +Owned path: `helm/codeapi/templates/podmonitor.yaml`. +Shared paths: `helm/codeapi/templates/network-policy.yaml`, +`helm/codeapi/values.yaml`, `helm/codeapi/README.md`, +`helm/codeapi/Chart.yaml`, and `tests/sandbox_runner_metrics.sh`. + +Upstream v1.10.10 does not include these chart discovery and restricted-peer +changes. Drop when its chart has equivalent namespace/label selection and +opt-in peer behavior protected by render checks. Deployment and dashboard +ownership stay with the external GitOps repositories. + +## Exercise upstream descendant cleanup in native CI + +The controller-death integration test distinguishes a live unadopted descendant +from a process already reaped by its original parent. ECHILD alone is +inconclusive. An absent `/proc/` ends the wait successfully; an adopted +child still requires SIGKILL exit status; a live descendant fails at the deadline. + +Shared path: `tests/tool_call_pipes.py`, which also retains graceful shutdown +assertions. The helper is upstream-owned after integrating v1.10.10; its former +fork port is retired. Owned test path: `tests/test_tool_call_pipes.py`; its synthetic +cases preserve upstream behavior while bounding the absent-PID regression and +avoiding a diagnostic-prose match. `.github/workflows/ci.yml` explicitly invokes +those decision regressions. + +Keep the distinct SIGTERM/143 assertions, parent-death check, safe bounded +negative control and actual CI invocation. Drop this remaining test delta when +upstream bounds the absent-PID negative control without real signals and runs +the same focused cases in its native CI lane. This is a test repair, not a change +to production termination. + ## Retired debris - Merge commit `356123a` is history-only transport for the package-init fix; @@ -788,7 +910,10 @@ Replay and drop condition: - The upstream text describing package-init as a Helm hook was not replayed; the retained Argo-managed Job is the active UZH behavior. -## Coverage check +## Historical coverage check + +The totals below describe their named earlier audits. Current accounting and +post-v1.10.5 additions are recorded in the final section. - Every one of the 23 paths in the active merge-base-to-fork final-tree diff is assigned above. The chart values, package resources, worker deployment, queue @@ -828,3 +953,69 @@ The patch continuation adds `tests/supervisor_policy.py` as a test-only fork delta. The entrypoint fix and guest-addon image coverage are upstream behavior, not new replay units. Existing project-plan and ledger changes remain execution references. The other v1.10.4 fork paths and their dispositions are unchanged. + + +### Lifecycle and metrics continuation accounting + +Baseline fork: `50cc28028a11fa8aff052dc23e389806f1950c10`. Its active surface is +measured from merge base `58a023da4c47452f5e40114934679d0348c39064` (v1.10.5). +The separate reconciliation comparison is upstream stable +`77bcd2ec2e63be523bfab2dfa11159a2ceec0088` (v1.10.10) against the fork final tree. +Incoming upstream-only paths in that comparison are not new fork patches. + +The cancellation, observation, metrics and descendant-test entries account for +later source/test/build/chart additions. Shared files retain each named +behavior; a path's presence here is not semantic equivalence evidence. The +helper is an upstream-equivalent port. The descriptor-wait hunk is a +review-on-sync candidate because v1.10.10 has equivalent bounded cleanup, +while startup-cancellation cases in that file remain active. The retired socket +proxy stays retired. + +`.gitignore` owns private artifact exclusions and `docs/fork/patches.md` owns +this ledger. Active project paths are references, not replay units: + +- `docs/project/2026-09-09-nonfatal-telemetry-plan.md` +- `docs/project/2026-09-17-upstream-v1.1.0-integration-plan.md` +- `docs/project/2026-09-18-upstream-v1.2.0-integration-plan.md` +- `docs/project/2026-09-19-storage-recovery-upstream-v1.4.0-plan.md` +- `docs/project/2026-09-19-storage-reliability-investigation.md` +- `docs/project/2026-10-14-cold-start-latency-plan.md` +- `docs/project/2026-10-18-codeapi-observability-e2e-plan.md` +- `docs/project/upstream-v1.10.4-integration-plan.md` +- `docs/project/2026-10-23-codeapi-reliability-roadmap.md` + +Recompute both comparisons at the reviewed head. Every active path must be +assigned to an entry, shared seam or reference above; unexplained paths hold +ledger qualification. Keep detailed path checks in private review receipts, +not a prose-pinning CI test. + + +### Re-audit basis: upstream v1.10.10 continuation + +- Fork source baseline `50cc28028a11fa8aff052dc23e389806f1950c10`; carried lifecycle + repair `3353cb1b9e257c4894bd0e44b6e5be3a827740b8`. +- Integrated upstream `77bcd2ec2e63be523bfab2dfa11159a2ceec0088`, from previous + upstream ancestor `58a023da4c47452f5e40114934679d0348c39064`. +- Adopt upstream's pinned Node build/runtime versions, restored-module handling, + worker enrollment/recovery, workspace import and separate extraction package. + The latter retains its own validation workflow and standalone Compose file; + no primary deployment is added by this reconciliation. +- Keep the managed HTTP/static/non-dynamic contract. Development Compose retains + upstream's paired/dynamic profile and advertised import capability; actual + workspace dispatch still requires remote-bridge worker selection. Browser + enrollment also requires recovery configuration and an admin token. The + source profiles and acceptance tests are specified in the + [reliability roadmap](../project/2026-10-23-codeapi-reliability-roadmap.md). +- Retire the equivalent descriptor-wait hunk and descendant-helper port. + Preserve startup-cancellation, graceful termination and safe negative-control + assertions. Upstream's dependency pin supersedes the source delta proposed + separately for `proxy-addr`; this ledger does not close a PR. +- All other named fork behaviors remain active: upstream has no equivalent + issuer trust map, values-free sinks, nonfatal telemetry guard, external + controller ownership, missing-bucket readiness, refusal-only retry/deadline, + caller cancellation, selected lifecycle observation or chart scrape peers. + Shared auto-merged seams retain both upstream additions and those behaviors. +- After this merge, both active-surface and reconciliation comparisons use + v1.10.10 as the ancestor. Upstream-only additions are no longer fork patches. + Rebuild path ownership at the final reviewed commit; source CI and review + qualify the candidate separately from image publication and runtime rollout. diff --git a/docs/project/2026-10-23-codeapi-reliability-roadmap.md b/docs/project/2026-10-23-codeapi-reliability-roadmap.md new file mode 100644 index 00000000..421ad717 --- /dev/null +++ b/docs/project/2026-10-23-codeapi-reliability-roadmap.md @@ -0,0 +1,139 @@ +# CodeAPI reliability and upstream maintenance roadmap + +## Approval summary + +Approval mode: executable batch. The user accepted the setup-review recommendations and requested that this roadmap be persisted before execution. The goal is to make the maintained fork easier to upgrade, keep reliability regressions continuously tested, complete the existing staged release, and make consumer acceptance repeatable. + +Start with the smallest source package: retain and explicitly guard the existing native startup-cancellation CI lane, correct the descendant-reaping test race using upstream's helper and regression cases, and update the fork patch ledger to account for the current lifecycle behavior. This package changes tests, CI and documentation, not deployed execution semantics. Continue the existing production release qualification independently, preserving its normal staging-to-production route, recorded human approval, source-only CI and guarded operator/GitOps registration contract. Then reconcile the latest upstream stable release and consolidate the approved consumer verification procedure. Availability and capacity work remains a design decision until service objectives and its cost are accepted. + +The approval covers implementation, focused verification, scoped reviews, commits, ordinary task-branch pushes and draft delivery. Existing named merge/deployment authority survives only for its original targets and conditions. It does not authorize unrelated infrastructure promotion, new production diagnostics, cluster access from CI, new secret scopes, higher capacity or cost, new fork tags/releases, destructive cleanup, or a new release's merge/publication/deployment without its concrete gate. + +The first deliverable is a reviewed draft source package with proof that the native lifecycle tests execute and the orphan-reaping regression distinguishes absent, live and reaped descendants. The complete roadmap finishes only after its source, runtime, consumer, observability and recovery acceptance are recorded at their respective gates. Real-environment observations and operational receipts remain in ignored private project artifacts. + +## Execution details + +### Contracts and continuity + +The maintained behavior remains: upstream history and release versions, fork commits on top, immutable software pins, GitOps workload ownership, KEDA replica ownership, externally synchronized secrets and the existing default execution profile. Reuse the accepted contracts in [the observability and E2E plan](2026-10-18-codeapi-observability-e2e-plan.md), [the upstream integration plan](upstream-v1.10.4-integration-plan.md), and [the storage/recovery plan](2026-09-19-storage-recovery-upstream-v1.4.0-plan.md). Older progress text is historical; current environment evidence belongs in the private continuation. + +No product primitive, API contract, ownership model or runtime behavior changes in the first package. It does not meet the ADR threshold. New availability ownership, storage architecture or public-contract changes re-arm design review before implementation. No UI changes are planned; screenshots do not apply to the source package. + +### Roadmap and ownership + +Execution mode: standard. Main owns integration, authority, cross-repository qualification, production effects and final proof. One bounded native implementation worker may own the test-helper port; main owns CI and ledger integration. Required planner/review passes operate read-only. No new user-visible chat is needed. + +| Milestone | Owner and dependency | Acceptance and delivery boundary | +| --- | --- | --- | +| Lifecycle regression reliability | Main plus bounded test worker; first package | Existing native fork/startup coverage remains required and cannot pass with a missing bridge; descendant-race regression and integration pass; ledger accounts for active fork paths; reviewed task branch and draft PR | +| Complete existing production acceptance | Main; prior STG contract, refreshed whole-release scope and recorded human gate | Qualified source promotion, prerequisite-first GitOps rollout, exact deployed candidate, production consumer/provider artifact proof, metrics/dashboard and runner/scaling/idle verification; cleanup and recovery evidence | +| Upstream stable reconciliation | Main; first source package integrated or deliberately carried into the approved candidate | Compare current stable to the fork, preserve required behavior semantically, retire equivalent patches, run affected checks and stage immutable images before any separately gated production release | +| Repeatable consumer acceptance | Main; existing consumer test draft and source/runtime contracts | Maintained values-free operator entrypoint/runbook, durable paths, clear prerequisites, four scenarios and real-provider smoke with ownership-bound cleanup; no Kubernetes or production credentials in CI | +| Availability and capacity | Main; service-objective decision | Review interruption tolerance, recovery objectives, stable KVM baseline/Spot burst alternatives and storage redundancy; concrete cost and deployment approval precede changes | + +A blocked milestone holds only dependent effects. Continue independent source work and qualification. Preserve branches, historical recovery resources and accepted evidence. Snapshot verification follows its existing acceptance and monitor-lifecycle contract; daily backup and canary ownership remains unchanged. + +### Delegation map + +| Bounded slice | Route and exclusive owner | Dependency and acceptance | +| --- | --- | --- | +| Plan, CI guard/regression wiring and integration | Main; critical-path coupling across the workflow and verification | Planning review, then exact command/source inspection and native tests | +| Descendant helper and focused Python regression | Native worker; only the two named Python test paths | Approved plan; regression catches prior defect and port preserves signal assertions | +| Fork ledger and path accounting | Main; unresolved ownership classification | Exact current comparison refs; every path explicitly classified | +| Release, consumer and availability milestones | Main; future separable work receives bounded ownership before implementation | Prior milestone contracts and each row's acceptance above; no new effects inferred | + +### First package: lifecycle tests and ledger + +Repository target: `uzh-bf/code-interpreter` `main`. Task branch: `rs/codeapi-lifecycle-ci`, under the existing governing checkout's `trees/codeapi-lifecycle-ci`. Artifacts stay in `docs/project/`; this roadmap lands with implementation and remains the shared roadmap for subsequent milestone progress. + +1. Main retains the existing native amd64/arm64 CI lane and adds an executable-bridge preflight before its existing API pipe-process test command. The fast API job may continue skipping optional native cases because the required native lane already executes them. Do not add a duplicate execution lane or compiler dependency. A missing bridge must fail the authoritative native step. Preserve all existing checks and publication behavior. +2. The bounded test worker updates `tests/tool_call_pipes.py` with the descendant-wait helper from upstream commit `71ef714` (reachable from stable `v1.10.10` at `77bcd2ec2e63be523bfab2dfa11159a2ceec0088`) and adds that same immutable commit's focused `tests/test_tool_call_pipes.py` regression coverage. Preserve the fork's graceful SIGTERM/143 assertions. Run `python3 tests/test_tool_call_pipes.py`: an already-reaped absent PID succeeds; a live unadopted PID fails at the bounded deadline; adopted-child exit status is checked. Preserve controller-death SIGKILL checking alongside the graceful SIGTERM assertions. Replace the upstream test's incidental prose regex with an exception-type assertion. Main wires that focused regression into the existing relevant CI job so it cannot silently remain unexecuted. +3. Main updates `docs/fork/patches.md` with caller-disconnect cancellation, guest cleanup/release, bounded disabled-by-default synthetic observation, signed session binding and pipe startup ownership. At baseline fork `50cc28028a11fa8aff052dc23e389806f1950c10`, compare merge base `58a023da4c47452f5e40114934679d0348c39064` to the fork final tree and upstream stable `77bcd2ec2e63be523bfab2dfa11159a2ceec0088` to the fork final tree. Recompute at the final source head. Assign every active path to a behavior, explicit shared seam, documentation reference or byte-equivalent upstream port; hold unexplained paths. Mark old coverage totals as historical instead of asserting obsolete completeness. Preserve historical source evidence and record upstream helper equivalence without retiring distinct assertions. +4. Run the focused checks in a dependency-equipped ephemeral container, inspect the complete diff, commit the qualified slice, complete its applicable simplification/final review, and deliver an ordinary pushed task branch with a draft PR. The package is CI-unblocking and coherent even if its substantive change is small. It creates no deployment or image publication. + +### Verification portfolio + +| Consequential failure | Existing evidence and test obligation | Primary seam | +| --- | --- | --- | +| Startup cancellation silently skipped | None: reuse verified native execution; retain the three tests and require the existing bridge before the authoritative native command | API `pipe-process.test.ts` through the actual CI commands | +| Already reaped descendant falsely fails cleanup | Add new: upstream focused regression; show the prior behavior fails the absent-PID case | `wait_for_killed_descendant` in the existing Python integration test | +| Graceful shutdown contract lost during port | None: preserve fork's SIGTERM/143 assertions; run native integration in its existing container target | `tests/tool_call_pipes.py` and packaged broker/relay | +| CI or publication scope widens | None: workflow syntax and exact diff review; required checks retain their triggers and no deployment/secret capability is added | `.github/workflows/ci.yml` | +| Upstream merge loses fork behavior | None: mechanical active-path accounting plus source review of shared seams; no tests pin prose | `docs/fork/patches.md` against exact Git tree comparisons | +| Green delivery metadata mistaken for runtime acceptance | None: reuse prior proof only for unchanged source/toolchain/environment boundary; bind later rollout to actual operation, images and functional cases | Existing private operator receipts and release contracts | + +### Release qualification and later source work + +Refresh host Git/gh/glab metadata before authoritative checks. A normal release's branch name is not a scope guarantee: inspect the full current target-to-source diff and all contributing validation. Source-only CodeAPI CI cannot qualify unrelated infrastructure changes. Preserve the user's no-Kubernetes-in-CI rule; do not clear a missing gate with a local Pulumi route or new credentials. + +The production sequence retains the previous exact prerequisite, chart, companion scrape-policy and values ordering. Record its current forward/inverse guards privately before applying any previously authorized operator change. A changed release scope or failed condition must be resolved before dependent effects. Source/runtime merge authority is never inferred from reviewer success. + +For upstream reconciliation, refresh the stable release at execution time and compare both the active fork surface and the reconciliation delta. Preserve newer upstream inputs/tests as well as accepted fork semantics. The helper port in the first package should become byte-equivalent upstream behavior when the release is merged, while fork-specific signal and lifecycle tests remain explicit. Do not enable optional upstream services or capabilities as a side effect of the update. Preserve the no-fork-tags policy. + +The consumer workflow must reuse the existing exact Infisical profiles and established connections. Inject approved values only into memory, suppress auth traces, and clean only owned synthetic resources. A missing production session, provider or connection is a concrete blocker; do not create another diagnostic registration or broaden secret scope to evade it. + +Availability work first names the required first-execution latency, interruption tolerance, recovery time and data-loss window. One ready runner, burst pods, node scaling and storage recovery are separate properties. Implement a capacity/HA choice only after its exact scope and cost have been accepted. + +### Research and review + +Evidence for the first source package is the existing code and upstream descendant-adoption correction. Current upstream release and fork overlap were inspected during the preceding review. KEDA's documented distinction between cooldown-to-zero and HPA scaling informs the later capacity assessment; it changes no implementation here. Operational findings stay outside this tracked document. + +Skills: `rs-sliced-development-workflow`, `rs-model-routing`, `rs-upstream-patching`, and `writing-for-agents`; use the existing container tooling for dependency-backed verification. No broad security audit or new dependency is part of this package. + +## Progress + +- User approved the review recommendations and requested persistence followed by execution. +- Roadmap drafted before implementation. Initial planning review requested narrower native-CI scope, pinned upstream test provenance, explicit delegation/test obligations and complete ledger accounting; the draft incorporates those corrections. The same planner approved the corrected draft after focused recheck. +- First package baseline: fork main `50cc28028a11fa8aff052dc23e389806f1950c10`. +- Implemented: upstream descendant-wait helper and four focused regressions, existing native-lane bridge preflight, and current fork behavior/path accounting. +- Focused container regression passes. Removing the absent-PID success branch makes its bounded negative control fail without signaling a real process. Workflow YAML and diff checks pass. Hosted CI passed all fourteen jobs at `3353cb1`, including both native architectures and packaged guest execution. Independent source review passed; the source package was published as draft PR #42. +- Delivery required: verified/reviewed task branch and draft PR for the first package; later milestones retain their concrete merge and deployment gates. +- Private receipt home: the governing checkout's ignored `docs/project/_local/reviews/2026-10-23-setup-review/`, with source-package checks/reviews under this worktree's ignored `docs/project/_local/reviews/`. +- Next action: reconcile the frozen upstream stable release in the same draft, preserving the accepted lifecycle test repair. + + +## Upstream v1.10.10 source continuation + +This implements the already-approved upstream milestone. Keep the existing worktree, branch and draft PR #42; its verified test repair is deliberately carried into the upstream candidate. There is no stacked-PR topology or new repository. The integrated draft will describe the entire maintenance package. The terminal condition is a reviewed, source-qualified draft with passing hosted checks. Marking ready, merging to `main`, publishing commit images and deploying the new candidate retain their named authority and human gates. + +Frozen inputs are fork baseline `50cc28028a11fa8aff052dc23e389806f1950c10`, accepted lifecycle commit `3353cb1b9e257c4894bd0e44b6e5be3a827740b8`, and upstream stable `v1.10.10` at `77bcd2ec2e63be523bfab2dfa11159a2ceec0088`. The previous upstream ancestor is `58a023da4c47452f5e40114934679d0348c39064`. Import upstream's nine commits with a local history-preserving merge, maintaining upstream source and lockfiles. The complete incoming manifest is `git diff --name-only 58a023da4c47452f5e40114934679d0348c39064 77bcd2ec2e63be523bfab2dfa11159a2ceec0088`; this bounds upstream-owned additions without inventing new fork modules. + +### Reconciliation and ownership + +Main owns the local merge, authority, shared seams, ledger and integration. After the reviewed plan is committed, a bounded worker owns only the three Dockerfile conflict regions (`api/Dockerfile`, `docker/Dockerfile.worker-sandbox`, `launcher/Dockerfile`): adopt upstream's pinned Node include image while preserving existing fork comments, readable rootfs/spec-guard setup and broker/relay wiring. Main owns the `api/src/pipe-process.test.ts` overlap: adopt upstream's equivalent bounded descriptor wait, retaining distinct startup-cancellation cases. Main also retains the bounded clock/mocked-kill negative control and exception-type assertion in the new `tests/test_tool_call_pipes.py` add/add conflict. The helper itself is upstream-equivalent and should merge unchanged; preserve graceful SIGTERM/143 separately from parent-death SIGKILL. + +Inspect auto-merged `api/src/job.ts` for restored-module handling alongside cleanup/observation, `service/openapi.yml` and bridge tests for the existing public wire contract, `service/rollup.config.js` for both new upstream protocol and retained shared fork helpers, and Compose/chart values for compatibility. Upstream's optional extraction service and worker enrollment/import code are imported as source; this package adds no deployment, credentials, bridge activation or extraction service. Verify the existing HTTP backend/static-auth/non-dynamic-worker contract and optional feature gates against source. An unresolved departure from these contracts blocks source qualification before publication. Preserve upstream capability additions and their explicit gates; importing source does not prove deployed activation or acceptance. + +The source profiles differ and must be checked separately: + +- Bare service defaults are HTTP backend, static authentication, dynamic workers off, empty bridge token/recovery origin and empty gateway URL (`service/src/config.ts`). Workspace import is unavailable and browser enrollment is disabled. +- Upstream Compose supplies a gateway URL and defaults to paired authentication and dynamic workers on. Preserve this upstream development profile: the import capability is advertised for an enabled bridge, but actual workspace execution still requires the remote-bridge backend and authenticated worker selection (`bridge/selection.ts`, `workspace-tools/router.ts`). Starting Compose is outside this source package. Do not describe its new import capability as inactive. +- The managed execution contract remains explicit HTTP/static/non-dynamic with an empty bridge token. A gateway URL may construct the import helper, but selection rejects workspace dispatch before ownership reads or grant creation. Verify both the source gate and existing selection/import tests. Browser enrollment independently requires paired mode, recovery enabled, dynamic workers and a nonempty admin token; retain its fail-closed test cases. Standalone extraction has its own Compose file and is absent from the primary Compose/chart. + +| Continuation outcome | Exclusive owner | Dependency and acceptance | +| --- | --- | --- | +| Three Dockerfile conflict regions | Native `worker` role | Main starts merge; preserve fork permissions/comments and take pinned upstream Node inputs; worker edits only, main owns index | +| Merge, test conflicts and automatic runtime/build seams | Main | Reviewed continuation; preserve both histories and named source-profile contracts | +| Ledger retirement and complete active-path accounting | Main | Candidate final tree; semantic equivalence and exact source comparisons | +| Hosted CI and integrated source qualification | Main, with read-only simplifier/risk/final roles | Candidate committed; all required source workflows pass and review findings dispositioned | + +Re-audit every active fork-ledger behavior against the final upstream tree. Retire only the equivalent descriptor-wait patch and descendant helper port; retain their distinct fork tests and explicit CI invocation. Preserve issuer-keyed trust, values-free logging, nonfatal telemetry, external replica/secret ownership, missing-bucket readiness, refusal-only retry/deadline handling, cancellation and diagnostic binding. Account for every active path and all automatic overlaps, and preserve the disabled fork Release workflow and exact-SHA image contract. The upstream dependency correction does not by itself authorize closing PR #38. + +### Verification and gates + +Run focused source/format/workflow checks, helper regressions and contract rendering first. Use the existing container/toolchains for dependency-backed checks, with hosted CI for the complete architecture/image matrix. Require API/service/code package tests and builds, Node 22/24 lanes, launcher/native filter and packaged guest-image checks, and deployment configuration/render tests. Retain upstream's updated Node versions and lockfiles; do not install substitute tools or weaken checks to make the merge pass. Source scans verify unchanged optional-service defaults and publication/secret permissions; CI remains source-only without cluster access. Full runtime, consumer and image publication acceptance is separate. + +The feature-wide portfolio adds no duplicate tests by default: + +| Consequential behavior | Obligation | Existing primary seam | +| --- | --- | --- | +| Restored modules use the current runtime without deleting user directories | none; retain and run imported regressions | `api/src/job-helpers.test.ts`, API unit lane and packaged guest execution | +| Enrollment and credential recovery require proof and correct gates | none; run imported service/package suites | `service/src/bridge/browser-enrollment*.test.ts`, pairing tests and `packages/code` enrollment/worker tests | +| Workspace import validates ownership, bounds reads and revokes grants | none; run imported grant/router/worker regressions and selection coverage | `service/src/workspace-tools/import.test.ts`, request/router tests and package workspace-import tests | +| Extraction parses files inside its intended isolation | none; run the new upstream workflow | `.github/workflows/extraction.yml`: formatting, typecheck, unit tests and `npm run test:container` | +| Updated runtimes/builds retain the managed contract | none; run both Node lanes, API/service builds, native/guest-image tests and existing chart/config checks | `.github/workflows/ci.yml` and current render scripts | + +Extend an existing regression only for a demonstrated uncovered failure; name its seam before adding it. The previous fourteen-job run qualifies only the unchanged lifecycle package, not this integration or the new Extraction Service workflow. + +Commit the merged candidate only after exact conflict/auto-merge inspection, staged-data scan and focused checks. Run the simplifier and one independent risk review for the upstream runtime/build/config seams, then the integrated final review over the complete `origin/main..candidate` package. Reuse lifecycle verification only where its tested tree, commands and boundary remain unchanged. Update the same draft PR early enough to obtain hosted checks, reporting pending gates honestly. Allow one initial reviewer report and one focused correction on the same bounded scope; recheck affected verification only. If the correction remains unresolved or needs a new contract decision, record the precise blocker and stop that dependent branch instead of repeating review or weakening acceptance. Prove both the frozen upstream and original fork baseline are ancestors of the candidate and re-run both ledger comparisons at the reviewed head. + +Progress: full lifecycle CI and source review passed at `3353cb1`. The continuation was persisted before the local upstream merge and approved by the planner after clarifying source profiles, the feature-wide test portfolio and exclusive ownership. Upstream source, hosted CI and review qualification remain pending. Production release work remains independently governed by its refreshed whole-diff and recorded approval gates. diff --git a/docs/remote-bridge/README.md b/docs/remote-bridge/README.md index dc7cb98b..40465e00 100644 --- a/docs/remote-bridge/README.md +++ b/docs/remote-bridge/README.md @@ -172,6 +172,11 @@ whitespace-tolerant matching and multi-location replacement (see text, require an existing in-workspace parent directory, reject symlinks, and commit atomically. The worker capability is an enforcement boundary; LibreChat should still route every mutation through its configurable tool-approval hooks. +Writable workers with a file relay upstream also offer `import_file` by +default (opt out with `LIBRECHAT_CODE_ALLOW_WORKSPACE_IMPORTS=false`), which +copies a chat upload the caller owns into the workspace through a one-object +egress grant (ADR 0014). It is a file write: a worker without writes enabled +never advertises it and refuses it with `WRITE_DISABLED`. The workspace root can be an existing project, a Git repository, or an empty directory; Git is not required. This boundary keeps that directory local to the diff --git a/docs/remote-bridge/worker-runbook.md b/docs/remote-bridge/worker-runbook.md index 99f19992..859104c6 100644 --- a/docs/remote-bridge/worker-runbook.md +++ b/docs/remote-bridge/worker-runbook.md @@ -561,6 +561,22 @@ To keep every worktree, add `--no-worktree-retirement` (or plist. - **Worker online but not ready:** check native sandbox preparation, definition validation, setup, quarantine, and readiness logs. +- **Files work but commands return `503 COMMAND_UNAVAILABLE`:** inspect the + worker's stderr/service journal for `workspace_command_unavailable`, and + correlate its `assignmentId` with the bridge request. Updated workers emit + one JSON record for a rejected command or programmatic assignment, with + `stage`, `reason`, workspace/worker/incarnation IDs, mutation uncertainty, + quarantine requirement, and an allowlisted `osCode` when available. Stages + distinguish allocation, sandbox startup, programmatic prerequisites, + credential resolution, command wrapping, dispatch, executor transport, and + cleanup. For example, `sandbox_startup` with `osCode: ENOSPC` points to + storage; `credential_resolution` identifies credential setup rather than + command execution. Missing errno does not rule out either cause. + Commands, arguments, paths, credentials, output, and free-form exception + messages are excluded from these records. Diagnostics stay worker-local; + the bridge response and quarantine/retry behavior are unchanged. A worker + package update is required; a Code API restart alone does not enable this. + These records cover assignment failures, not failures before worker startup. - **Setup repeats on restart:** setup is intentionally per-start; make it idempotent, remove it, or opt in to the bounded `setup.reuse` readiness contract in the [worker package guide](../../packages/code/README.md#reusing-a-prepared-checkout). diff --git a/helm/codeapi/values.yaml b/helm/codeapi/values.yaml index 8930c03b..0eac308d 100644 --- a/helm/codeapi/values.yaml +++ b/helm/codeapi/values.yaml @@ -360,7 +360,7 @@ workerSandbox: tag: latest pullPolicy: Always pythonVersion: "3.14.4" - nodeVersion: "24.15.0" + nodeVersion: "24.22.1" bunVersion: "1.4.2" bashPackageVersion: "5.2.0" backoffLimit: 2 diff --git a/launcher/Dockerfile b/launcher/Dockerfile index 7425d821..4b7b0b95 100644 --- a/launcher/Dockerfile +++ b/launcher/Dockerfile @@ -40,7 +40,7 @@ COPY api/src/spec-guard.c /tmp/spec-guard.c COPY api/src/tool-call-pipe-bridge.c /tmp/tool-call-pipe-bridge.c COPY api/src/anonymous-pipes.c /tmp/anonymous-pipes.c COPY api/src/supervisor-policy.c /tmp/supervisor-policy.c -COPY --from=node:22-slim /usr/local/include/node /usr/local/include/node +COPY --from=node:22.23.4-slim /usr/local/include/node /usr/local/include/node # KVM/nsjail execution may need read permission on virtiofs-backed rootfs. COPY docker/rootfs-setup.c /tmp/rootfs-setup.c RUN gcc -shared -fPIC -O2 -Wall -Wextra -Werror -I/usr/local/include/node -o /usr/local/lib/anonymous-pipes.node /tmp/anonymous-pipes.c \ @@ -80,7 +80,7 @@ COPY --from=nsjail-builder /usr/local/bin/tool-call-pipe-bridge /usr/local/bin/t COPY --from=nsjail-builder /usr/local/bin/sandbox-supervisor-policy /usr/local/bin/sandbox-supervisor-policy COPY --from=nsjail-builder /usr/local/lib/anonymous-pipes.node /usr/local/lib/anonymous-pipes.node ENV SANDBOX_PIPE_ADDON=/usr/local/lib/anonymous-pipes.node -COPY --from=node:22-slim /usr/local/bin/node /usr/local/bin/node +COPY --from=node:22.23.4-slim /usr/local/bin/node /usr/local/bin/node RUN chmod +x /usr/sbin/nsjail WORKDIR /sandbox_api diff --git a/packages/code/Dockerfile b/packages/code/Dockerfile index 93ed9c57..34c54899 100644 --- a/packages/code/Dockerfile +++ b/packages/code/Dockerfile @@ -1,11 +1,11 @@ -FROM node:24-alpine AS build +FROM node:24.22.1-alpine AS build WORKDIR /app COPY package.json package-lock.json tsconfig.json ./ RUN npm ci COPY src ./src RUN npm run build -FROM node:24-alpine +FROM node:24.22.1-alpine ENV NODE_ENV=production RUN apk add --no-cache bash bubblewrap ripgrep socat \ && addgroup -S librechat-code \ diff --git a/packages/code/README.md b/packages/code/README.md index 0437ecfb..8f911996 100644 --- a/packages/code/README.md +++ b/packages/code/README.md @@ -630,6 +630,27 @@ with `--allow-workspace-writes` or `overwrite: false` to require an atomic create that returns `EDIT_CONFLICT` if the target already exists. Code API dispatches that mode only after the worker and server negotiate `create` in `writeFileModes`. + +`import_file` copies one stored chat upload into a writable workspace as a +binary-safe file. It is on by default whenever the worker can perform it: +writes are enabled and `LIBRECHAT_CODE_FILE_RELAY_UPSTREAM` names the +deployment's egress gateway. It is advertised only to a Code API that lists it +in `supportedWorkspaceToolOperations` (one that can mint egress grants). Opt out +with `--no-workspace-imports` or `LIBRECHAT_CODE_ALLOW_WORKSPACE_IMPORTS=false`. +Update LibreChat before workers: LibreChat releases that predate +LibreChat-AI/LibreChat#17376 reject a worker status that lists an operation +they do not know, so a worker serving one of those must opt out. Code API checks that the caller owns the object +and relays a short-lived grant scoped to that object alone; the worker holds no +storage credential. The worker applies `write_file`'s path rules, refuses any +`.git` segment (including case, Unicode-ignorable, trailing-dot and `GIT~1` +aliases) and symlinked parents, creates missing parents as 0755 under a held +root, streams into a same-directory temporary file with `bytes` as a hard cap, +verifies size and SHA-256, syncs, and installs the file atomically with mode +0644. Identical existing bytes return `unchanged: true` without a transfer; +different bytes return `EDIT_CONFLICT` unless `overwrite: true`. A failure +removes the temporary file and any parents it created. The per-file ceiling is +`LIBRECHAT_CODE_IMPORT_FILE_MAX_BYTES` (`--import-file-max-bytes`, default +100 MiB), and the filesystem must keep 64 MiB free after the file lands. `edit_file` accepts either the legacy `oldText`/`newText` pair or an ordered `edits` array; every exact replacement is validated before the updated file is installed as one atomic mutation. Code API dispatches the batch form only after diff --git a/packages/code/package.json b/packages/code/package.json index 95eea841..d3e6395b 100644 --- a/packages/code/package.json +++ b/packages/code/package.json @@ -19,6 +19,10 @@ "types": "./dist/identity.d.ts", "import": "./dist/identity.js" }, + "./browser-enrollment": { + "types": "./dist/browser-enrollment-protocol.d.ts", + "import": "./dist/browser-enrollment-protocol.js" + }, "./worker": { "types": "./dist/worker.d.ts", "import": "./dist/worker.js" diff --git a/packages/code/src/browser-enrollment-protocol.ts b/packages/code/src/browser-enrollment-protocol.ts new file mode 100644 index 00000000..ef1c712b --- /dev/null +++ b/packages/code/src/browser-enrollment-protocol.ts @@ -0,0 +1,253 @@ +import { createHash, createPublicKey, sign, verify } from 'node:crypto'; + +export type BrowserSetupRequest = BrowserEnrollmentRequest; +export type EnrollmentRedemption = BrowserEnrollmentRedemptionContract; +export type EnrollmentApproval = BrowserEnrollmentApproval; + +export const BROWSER_ENROLLMENT_PROTOCOL_VERSION = 1 as const; +export const BROWSER_ENROLLMENT_MAX_TTL_MS = 10 * 60_000; +export const BROWSER_SETUP_PROOF_DOMAIN = 'librechat-code:browser-setup:v1'; +export const BROWSER_ENROLLMENT_REDEMPTION_DOMAIN = 'librechat-code:browser-enrollment-redeem:v1'; + +export interface BrowserEnrollmentRequest { + version: 1; + instance: string; + attemptId: string; + createdAt: string; + publicKey: string; + machineName: string; + workspace: { type: 'private' | 'directory' }; + allowWorkspaceWrites: boolean; + allowWorkspaceCommands: boolean; +} + +export interface BrowserSetupProof { + operation: 'setup.start' | 'setup.poll' | 'setup.cancel' | 'setup.complete'; + instance: string; + attemptId: string; + requestHash: string; + timestamp: string; + nonce: string; +} + +export interface BrowserEnrollmentRedemptionContract { + operation: 'enrollment.redeem'; + serverId: string; + instance: string; + attemptId: string; + requestHash: string; + workerId: string; + expiresAt: string; +} + +export interface BrowserEnrollmentStatusRequest extends BrowserEnrollmentRedemptionContract { + protocolVersion: 1; +} + +export interface BrowserEnrollmentRedemption extends BrowserEnrollmentRedemptionContract { + protocolVersion: 1; + signature: string; +} + +export interface BrowserEnrollmentBinding { + tenantId: string; + principal: { type: 'deployment' | 'tenant' | 'user' | 'role' | 'group'; id: string }; +} + +export interface BrowserEnrollmentApproval extends BrowserEnrollmentRedemptionContract { + protocolVersion: 1; + publicKey: string; + binding: BrowserEnrollmentBinding; +} + +export interface BrowserEnrollmentCapabilities { + browserEnrollmentVersion: 1; + serverId: string; +} + +function record(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function keys(value: Record, names: string[]): boolean { + return Object.keys(value).length === names.length && names.every(name => Object.prototype.hasOwnProperty.call(value, name)); +} + +export function isBrowserEnrollmentTimestamp(value: unknown): value is string { + if (typeof value !== 'string' || value.length !== 24) return false; + const time = Date.parse(value); + return Number.isFinite(time) && new Date(time).toISOString() === value; +} + +export function isBrowserEnrollmentToken(value: unknown): value is string { + return typeof value === 'string' && /^[A-Za-z0-9_-]{43}$/.test(value) && + Buffer.from(value, 'base64url').toString('base64url') === value; +} + +export function isBrowserEnrollmentSignature(value: unknown): value is string { + return typeof value === 'string' && /^[A-Za-z0-9_-]{86}$/.test(value) && + Buffer.from(value, 'base64url').toString('base64url') === value; +} + +export function isBrowserEnrollmentPublicKey(value: unknown): value is string { + if (typeof value !== 'string' || value.length > 4096 || !value.startsWith('-----BEGIN PUBLIC KEY-----\n')) return false; + try { + const key = createPublicKey(value); + return key.asymmetricKeyType === 'ed25519' && key.export({ type: 'spki', format: 'pem' }) === value; + } catch { + return false; + } +} + +export function normalizeBrowserEnrollmentInstance(value: string): string { + if (value.length > 2048 || value !== value.trim()) throw new TypeError('Invalid LibreChat instance URL'); + const url = new URL(value); + if ( + (url.protocol !== 'https:' && !(url.protocol === 'http:' && ['localhost', '127.0.0.1', '[::1]'].includes(url.hostname))) || + url.username !== '' || url.password !== '' || url.search !== '' || url.hash !== '' + ) { + throw new TypeError('LibreChat instance requires HTTPS (or loopback HTTP), without credentials, query, or fragment'); + } + return `${url.origin}${url.pathname.replace(/\/+$/, '')}`; +} + +function instance(value: unknown): value is string { + try { + return typeof value === 'string' && normalizeBrowserEnrollmentInstance(value) === value; + } catch { + return false; + } +} + +function serverOrigin(value: unknown): value is string { + if (typeof value !== 'string' || value.length > 256) return false; + try { + const url = new URL(value); + return url.protocol === 'https:' && url.origin === value && instance(value); + } catch { + return false; + } +} + +function requestHash(value: unknown): value is string { + return typeof value === 'string' && /^[a-f0-9]{64}$/.test(value); +} + +export function isBrowserEnrollmentRequest(value: unknown): value is BrowserEnrollmentRequest { + return record(value) && keys(value, [ + 'version', 'instance', 'attemptId', 'createdAt', 'publicKey', 'machineName', + 'workspace', 'allowWorkspaceWrites', 'allowWorkspaceCommands', + ]) && value.version === 1 && instance(value.instance) && isBrowserEnrollmentToken(value.attemptId) && + isBrowserEnrollmentTimestamp(value.createdAt) && isBrowserEnrollmentPublicKey(value.publicKey) && + typeof value.machineName === 'string' && /^[A-Za-z0-9][A-Za-z0-9 ._()-]{0,79}$/.test(value.machineName) && + value.machineName === value.machineName.trim() && record(value.workspace) && keys(value.workspace, ['type']) && + (value.workspace.type === 'private' || value.workspace.type === 'directory') && + typeof value.allowWorkspaceWrites === 'boolean' && typeof value.allowWorkspaceCommands === 'boolean'; +} + +export function canonicalBrowserEnrollmentRequest(input: BrowserEnrollmentRequest): string { + if (!isBrowserEnrollmentRequest(input)) throw new TypeError('Invalid browser enrollment request'); + return JSON.stringify({ + version: input.version, + instance: input.instance, + attemptId: input.attemptId, + createdAt: input.createdAt, + publicKey: input.publicKey, + machineName: input.machineName, + workspace: { type: input.workspace.type }, + allowWorkspaceWrites: input.allowWorkspaceWrites, + allowWorkspaceCommands: input.allowWorkspaceCommands, + }); +} + +export function hashBrowserEnrollmentRequest(input: BrowserEnrollmentRequest): string { + return createHash('sha256').update(canonicalBrowserEnrollmentRequest(input)).digest('hex'); +} + +export function isBrowserSetupProof(value: unknown): value is BrowserSetupProof { + return record(value) && keys(value, ['operation', 'instance', 'attemptId', 'requestHash', 'timestamp', 'nonce']) && + typeof value.operation === 'string' && ['setup.start', 'setup.poll', 'setup.cancel', 'setup.complete'].includes(value.operation) && + instance(value.instance) && isBrowserEnrollmentToken(value.attemptId) && requestHash(value.requestHash) && + isBrowserEnrollmentTimestamp(value.timestamp) && isBrowserEnrollmentToken(value.nonce); +} + +export function canonicalBrowserSetupProof(input: BrowserSetupProof): string { + if (!isBrowserSetupProof(input)) throw new TypeError('Invalid browser setup proof'); + return [BROWSER_SETUP_PROOF_DOMAIN, input.operation, input.instance, input.attemptId, + input.requestHash, input.timestamp, input.nonce].join('\n'); +} + +export function isBrowserEnrollmentRedemptionContract(value: unknown): value is BrowserEnrollmentRedemptionContract { + return record(value) && value.operation === 'enrollment.redeem' && serverOrigin(value.serverId) && + instance(value.instance) && isBrowserEnrollmentToken(value.attemptId) && requestHash(value.requestHash) && + typeof value.workerId === 'string' && /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(value.workerId) && + isBrowserEnrollmentTimestamp(value.expiresAt); +} + +export function isBrowserEnrollmentBinding(value: unknown): value is BrowserEnrollmentBinding { + return record(value) && keys(value, ['tenantId', 'principal']) && + typeof value.tenantId === 'string' && /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(value.tenantId) && + record(value.principal) && keys(value.principal, ['type', 'id']) && + typeof value.principal.type === 'string' && ['deployment', 'tenant', 'user', 'role', 'group'].includes(value.principal.type) && + typeof value.principal.id === 'string' && /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(value.principal.id); +} + +const CONTRACT_KEYS = ['operation', 'serverId', 'instance', 'attemptId', 'requestHash', 'workerId', 'expiresAt']; + +export function isBrowserEnrollmentApproval(value: unknown): value is BrowserEnrollmentApproval { + return isBrowserEnrollmentRedemptionContract(value) && record(value) && + keys(value, [...CONTRACT_KEYS, 'protocolVersion', 'publicKey', 'binding']) && + value.protocolVersion === 1 && isBrowserEnrollmentPublicKey(value.publicKey) && isBrowserEnrollmentBinding(value.binding); +} + +export function isBrowserEnrollmentStatusRequest(value: unknown): value is BrowserEnrollmentStatusRequest { + return isBrowserEnrollmentRedemptionContract(value) && record(value) && + keys(value, [...CONTRACT_KEYS, 'protocolVersion']) && value.protocolVersion === 1; +} + +export function isBrowserEnrollmentRedemption(value: unknown): value is BrowserEnrollmentRedemption { + return isBrowserEnrollmentRedemptionContract(value) && record(value) && + keys(value, [...CONTRACT_KEYS, 'protocolVersion', 'signature']) && + value.protocolVersion === 1 && isBrowserEnrollmentSignature(value.signature); +} + +export function canonicalBrowserEnrollmentRedemption(input: BrowserEnrollmentRedemptionContract): string { + if (!isBrowserEnrollmentRedemptionContract(input)) throw new TypeError('Invalid browser enrollment redemption'); + return [BROWSER_ENROLLMENT_REDEMPTION_DOMAIN, input.operation, input.serverId, input.instance, + input.attemptId, input.requestHash, input.workerId, input.expiresAt].join('\n'); +} + +export function signBrowserSetupProof(privateKey: string, input: BrowserSetupProof): string { + return sign(null, Buffer.from(canonicalBrowserSetupProof(input)), privateKey).toString('base64url'); +} + +export function verifyBrowserSetupProof(publicKey: string, input: BrowserSetupProof, signature: string): boolean { + try { + return isBrowserEnrollmentPublicKey(publicKey) && isBrowserEnrollmentSignature(signature) && + verify(null, Buffer.from(canonicalBrowserSetupProof(input)), publicKey, Buffer.from(signature, 'base64url')); + } catch { + return false; + } +} + +export function signBrowserEnrollmentRedemption(privateKey: string, input: BrowserEnrollmentRedemptionContract): string { + return sign(null, Buffer.from(canonicalBrowserEnrollmentRedemption(input)), privateKey).toString('base64url'); +} + +export function verifyBrowserEnrollmentRedemption(publicKey: string, input: BrowserEnrollmentRedemptionContract, signature: string): boolean { + try { + return isBrowserEnrollmentPublicKey(publicKey) && isBrowserEnrollmentSignature(signature) && + verify(null, Buffer.from(canonicalBrowserEnrollmentRedemption(input)), publicKey, Buffer.from(signature, 'base64url')); + } catch { + return false; + } +} + +export const browserSetupRequestHash = hashBrowserEnrollmentRequest; +export const canonicalBrowserSetupRequest = canonicalBrowserEnrollmentRequest; +export const canonicalEnrollmentRedemption = canonicalBrowserEnrollmentRedemption; +export const signEnrollmentRedemption = signBrowserEnrollmentRedemption; +export const verifyEnrollmentRedemption = verifyBrowserEnrollmentRedemption; +export const validateBrowserSetupRequest = isBrowserEnrollmentRequest; +export const validateBrowserSetupProof = isBrowserSetupProof; +export const validateEnrollmentRedemption = isBrowserEnrollmentRedemptionContract; diff --git a/packages/code/src/browser-protection.test.ts b/packages/code/src/browser-protection.test.ts new file mode 100644 index 00000000..2da4e27d --- /dev/null +++ b/packages/code/src/browser-protection.test.ts @@ -0,0 +1,31 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { relative, sep } from 'node:path'; +import { SandboxManager } from '@anthropic-ai/sandbox-runtime'; + +import { browserSetupAttemptPath, browserSetupProtectedPaths } from './browser-setup.js'; + +test('browser setup namespace denies journal, lock and crash-left temporaries outside home', async t => { + const attempt = browserSetupAttemptPath('/srv/code-state/profile.json'); + const paths = browserSetupProtectedPaths(attempt); + assert.deepEqual(paths, ['/srv/code-state/profile.json.setup']); + const previous = SandboxManager.getConfig(); + t.after(async () => { + await SandboxManager.reset(); + if (previous) SandboxManager.updateConfig(previous); + }); + SandboxManager.updateConfig({ + network: { allowedDomains: [], deniedDomains: [] }, + filesystem: { denyRead: [], allowRead: [], allowWrite: [], denyWrite: paths }, + credentials: { files: paths.map(path => ({ path, mode: 'deny' as const })) }, + }); + const effective = SandboxManager.getFsReadConfig(); + for (const file of [attempt, `${attempt}.lock`, `${attempt}.0123456789abcdef.tmp`, `${attempt}.future.tmp`]) { + assert.ok(effective.denyOnly.some(directory => { + const child = relative(directory, file); + return child !== '..' && !child.startsWith(`..${sep}`); + }), 'every journal generation must remain beneath an effective directory deny'); + } + assert.deepEqual(browserSetupProtectedPaths(undefined), []); + assert.throws(() => browserSetupProtectedPaths('/srv/code-state/profile.json.setup.json'), /Unsupported setup journal/); +}); diff --git a/packages/code/src/browser-setup-cli.ts b/packages/code/src/browser-setup-cli.ts new file mode 100644 index 00000000..250fcdb7 --- /dev/null +++ b/packages/code/src/browser-setup-cli.ts @@ -0,0 +1,103 @@ +import { spawn } from 'node:child_process'; +import { createHash, createPublicKey } from 'node:crypto'; +import { hostname } from 'node:os'; +import { resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { cancelBrowserSetup, loadBrowserSetupAttempt, setupBrowserWorker, setupInstanceUrl } from './browser-setup.js'; +import { defaultWorkerProfilePath } from './worker-profile.js'; +import { setupWorkspaceDirectory } from './setup.js'; + +/** No shell, token, or inbound callback listener is involved. */ +export async function openSetupBrowser(url: string): Promise { + if (process.env.SSH_CONNECTION || process.env.SSH_TTY || + (process.platform === 'linux' && !process.env.DISPLAY && !process.env.WAYLAND_DISPLAY)) return false; + const command = process.platform === 'darwin' ? 'open' : process.platform === 'linux' ? 'xdg-open' : undefined; + if (!command) return false; + return new Promise(resolve => { + const child = spawn(command, [url], { stdio: 'ignore', shell: false }); + const timer = setTimeout(() => { child.kill(); resolve(false); }, 5000); + child.once('error', () => { clearTimeout(timer); resolve(false); }); + child.once('exit', code => { clearTimeout(timer); resolve(code === 0); }); + }); +} + +export async function runBrowserSetupCommand( + options: Map, + prompts: { + interactive: boolean; + ask(message: string): Promise; + yesNo(message: string): Promise; + signal: AbortSignal; + }, +): Promise { + const profilePath = resolve(String(options.get('--profile') ?? defaultWorkerProfilePath())); + const instance = setupInstanceUrl(String(options.get('--instance'))); + const saved = await loadBrowserSetupAttempt(profilePath); + if (saved && saved.request.instance !== instance) throw new Error('This profile has a setup attempt for another LibreChat instance. Resume the original instance or choose another profile'); + if (options.has('--cancel')) { + await cancelBrowserSetup(profilePath, prompts.signal); + process.stdout.write('Setup cancellation recorded. The local machine key and attempt were preserved.\n'); + return; + } + let workspaceDirectory = options.get('--worker-dir') as string | undefined; + if (saved) { + if (workspaceDirectory && (saved.profile.workspace.type !== 'directory' || await setupWorkspaceDirectory(workspaceDirectory) !== saved.profile.workspace.path)) + throw new Error('The workspace differs from the saved setup attempt'); + if (options.has('--default-workspace') && saved.profile.workspace.type !== 'private') throw new Error('The workspace differs from the saved setup attempt'); + if ((options.has('--allow-workspace-writes') && !saved.profile.allowWorkspaceWrites) || + (options.has('--allow-workspace-commands') && !saved.profile.allowWorkspaceCommands)) throw new Error('Permissions differ from the saved setup attempt'); + workspaceDirectory = saved.profile.workspace.type === 'directory' ? saved.profile.workspace.path : undefined; + process.stdout.write('Resuming the saved setup attempt with its original machine key and access choices.\n'); + } else if (prompts.interactive && !workspaceDirectory && !options.has('--default-workspace')) { + workspaceDirectory = (await prompts.ask('Project directory (Enter for a new private workspace): ')) || undefined; + } + if (workspaceDirectory !== undefined) workspaceDirectory = await setupWorkspaceDirectory(workspaceDirectory); + const machineName = String(options.get('--machine-name') ?? saved?.request.machineName ?? hostname().replace(/[^A-Za-z0-9 ._()-]/g, '-').slice(0, 80)); + const allowWorkspaceWrites = saved?.profile.allowWorkspaceWrites ?? (options.has('--allow-workspace-writes') || + (prompts.interactive && await prompts.yesNo('Allow file writes within this workspace?'))); + const allowWorkspaceCommands = saved?.profile.allowWorkspaceCommands ?? (options.has('--allow-workspace-commands') || + (prompts.interactive && await prompts.yesNo('Allow sandboxed commands? Commands can modify workspace files independently of file-write tooling'))); + process.stdout.write(`\nLibreChat: ${instance}\nMachine: ${machineName}\nWorkspace: ${workspaceDirectory ?? 'new private workspace'}\nFile writes: ${allowWorkspaceWrites ? 'enabled' : 'disabled'}\nCommands: ${allowWorkspaceCommands ? 'enabled, restricted sandbox policy' : 'disabled'}\nProfile: ${profilePath}\nPairing does not attest this host or replace tool approvals.\n`); + if (!saved && prompts.interactive && !await prompts.yesNo('Request browser approval with these choices?')) { + process.stdout.write('Setup cancelled before enrollment.\n'); + return; + } + await setupBrowserWorker({ + instance, machineName, profilePath, + identityPath: options.get('--identity') as string | undefined ?? saved?.profile.identityPath, + workspaceDirectory, allowWorkspaceWrites, allowWorkspaceCommands, + signal: prompts.signal, + }, { + onVerification: async status => { + const attempt = (await loadBrowserSetupAttempt(profilePath))!; + const fingerprint = createHash('sha256').update(createPublicKey(attempt.request.publicKey).export({ type: 'spki', format: 'der' })).digest('hex'); + process.stdout.write(`Machine key: SHA256:${fingerprint}\n`); + // Always show both, even when opening a browser succeeds. + process.stdout.write(`\nApprove this machine in LibreChat:\n ${status.verificationUrl}\nVerification code: ${status.verificationCode}\nExpires: ${status.expiresAt}\nCheck the matching code and key identity before approving. Waiting for your explicit decision.\n`); + if (!options.has('--no-browser') && prompts.interactive && !await openSetupBrowser(status.verificationUrl)) + process.stdout.write('Could not open a browser here. Open the URL on another device and compare the code.\n'); + }, + }); + process.stdout.write('\nMachine enrolled. Profile saved. It is not connected or ready until the worker starts and readiness is confirmed.\n'); + prompts.signal.throwIfAborted(); + const start = options.has('--start') || (prompts.interactive && await prompts.yesNo('Start the worker in the foreground now?')); + if (start) { + prompts.signal.throwIfAborted(); + process.stdout.write('Starting the saved profile in the foreground. Stop with Ctrl-C.\n'); + await new Promise((resolve, reject) => { + const child = spawn(process.execPath, [fileURLToPath(new URL('./cli.js', import.meta.url)), 'run', '--profile', profilePath], { stdio: 'inherit' }); + const abort = () => child.kill('SIGINT'); + prompts.signal.addEventListener('abort', abort, { once: true }); + child.once('error', reject); + child.once('exit', code => { + prompts.signal.removeEventListener('abort', abort); + if (code === 0 || prompts.signal.aborted) resolve(); + else reject(new Error('Worker stopped before a successful foreground session; enrollment and profile remain saved')); + }); + }); + } else { + const quoted = `'${profilePath.replace(/'/g, "'\\''")}'`; + process.stdout.write(`Start it with:\n librechat-code run${profilePath === resolve(defaultWorkerProfilePath()) ? '' : ` --profile ${quoted}`}\n`); + } +} diff --git a/packages/code/src/browser-setup.test.ts b/packages/code/src/browser-setup.test.ts new file mode 100644 index 00000000..077db673 --- /dev/null +++ b/packages/code/src/browser-setup.test.ts @@ -0,0 +1,279 @@ +import assert from 'node:assert/strict'; +import { mkdtemp, readFile, readdir, rm, stat, writeFile } from 'node:fs/promises'; +import { spawn } from 'node:child_process'; +import { once } from 'node:events'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import test from 'node:test'; + +import { createBridgeIdentity } from './identity.js'; +import { browserSetupRequestHash, verifyBrowserSetupProof, verifyEnrollmentRedemption } from './browser-enrollment-protocol.js'; +import type { BrowserSetupRequest, BrowserSetupProof } from './browser-enrollment-protocol.js'; +import { browserSetupAttemptPath, browserSetupProtectedPaths, loadBrowserSetupAttempt, setupBrowserWorker, setupInstanceUrl, cancelBrowserSetup } from './browser-setup.js'; +import type { BrowserSetupOptions } from './browser-setup.js'; +import { loadBridgeIdentity, saveBridgeIdentity, savePrivateJson } from './storage.js'; +import { loadWorkerProfile } from './worker-profile.js'; +import { parseSetupArguments } from './setup-cli.js'; +import { runBrowserSetupCommand } from './browser-setup-cli.js'; + +async function fixture(t: test.TestContext) { + const root = await mkdtemp(join(tmpdir(), 'browser-setup-')); + t.after(() => rm(root, { recursive: true, force: true })); + const options: BrowserSetupOptions = { + instance: 'https://chat.example', machineName: 'headless-machine', + profilePath: join(root, 'profile.json'), + allowWorkspaceWrites: false, allowWorkspaceCommands: false, + }; + let completeCalls = 0; + let startCalls = 0; + let initialRequest: BrowserSetupRequest | undefined; + let lostStart = false; + let lostComplete = false; + let status: 'approved' | 'denied' | 'expired' | 'cancelled' = 'approved'; + const credential = { protocolVersion: 1 as const, workerId: 'approved-machine', credential: 'approved-credential-that-is-long-enough', expiresAt: '2099-01-01T00:00:00.000Z' }; + const fetchImpl = (async (url: string | URL | Request, init?: RequestInit) => { + if (String(url).endsWith('/capabilities')) return Response.json({ browserEnrollmentVersion: 1 }); + const body = JSON.parse(String(init?.body)) as { request: BrowserSetupRequest; proof: BrowserSetupProof; signature: string; redemptionSignature?: string }; + assert.equal((body.request as unknown as Record).privateKey, undefined); + assert.equal(verifyBrowserSetupProof(body.request.publicKey, body.proof, body.signature), true); + assert.equal(body.proof.requestHash, browserSetupRequestHash(body.request)); + const saved = await loadBrowserSetupAttempt(options.profilePath!); + assert.equal(saved?.request.publicKey, body.request.publicKey, 'key saved before remote effect'); + assert.equal(saved?.request.attemptId, body.request.attemptId); + initialRequest ??= body.request; + assert.deepEqual(body.request, initialRequest, 'retries retain immutable request and key'); + const expiresAt = new Date(Date.parse(body.request.createdAt) + 600_000).toISOString(); + const approval = { operation: 'enrollment.redeem' as const, serverId: 'https://code.example', instance: body.request.instance, + attemptId: body.request.attemptId, requestHash: browserSetupRequestHash(body.request), workerId: credential.workerId, + expiresAt, codeApiUrl: 'https://code.example/v1' }; + const base = { version: 1, expiresAt, verificationCode: 'ABCD-EFGH', verificationUrl: `https://chat.example/code/setup/${body.request.attemptId}` }; + if (String(url).endsWith('/start')) { + startCalls++; + if (lostStart) { lostStart = false; throw new Error('lost issuance response'); } + return Response.json({ ...base, status: 'pending' }); + } + if (String(url).endsWith('/cancel')) return Response.json({ ...base, status: 'cancelled' }); + if (String(url).endsWith('/complete')) { + completeCalls++; + assert.equal(body.proof.operation, 'setup.complete'); + assert.equal(verifyEnrollmentRedemption(body.request.publicKey, approval, body.redemptionSignature!), true); + if (lostComplete) { lostComplete = false; throw new Error('lost completion response'); } + return Response.json({ ...base, status: 'completed', approval, credential }); + } + return Response.json({ ...base, status, ...(status === 'approved' ? { approval } : {}) }); + }) as typeof fetch; + return { root, options, fetchImpl, credential, counts: () => ({ completeCalls, startCalls }), + loseStart: () => { lostStart = true; }, loseComplete: () => { lostComplete = true; }, setStatus: (value: typeof status) => { status = value; } }; +} + +test('headless setup displays URL and code and saves the existing least-privilege profile', async t => { + const f = await fixture(t); + let displayed = false; + const saved = await setupBrowserWorker(f.options, { fetchImpl: f.fetchImpl, pollIntervalMs: 0, onVerification: async value => { + assert.match(value.verificationUrl, /^https:\/\/chat.example\/code\/setup\//); + assert.equal(value.verificationCode, 'ABCD-EFGH'); + assert.ok(await loadBrowserSetupAttempt(f.options.profilePath!)); + displayed = true; + } }); + assert.equal(displayed, true); + assert.deepEqual(await loadWorkerProfile(saved.profilePath), saved.profile); + assert.equal(saved.profile.allowWorkspaceCommands, false); + assert.equal(saved.profile.allowWorkspaceWrites, false); + assert.deepEqual(saved.profile.workspace, { type: 'private' }); + const identity = await loadBridgeIdentity(saved.profile.identityPath); + assert.equal(identity.credential, f.credential.credential); + assert.equal((await stat(browserSetupAttemptPath(saved.profilePath))).mode & 0o777, 0o600); + assert.ok(!(await readFile(saved.profilePath, 'utf8')).includes('PRIVATE KEY')); + const receipt = JSON.parse(await readFile(browserSetupAttemptPath(saved.profilePath), 'utf8')); + assert.equal(receipt.privateKey, undefined); + assert.equal(receipt.identity, undefined); + assert.equal(receipt.completed, true); + assert.equal(identity.setupAttemptPath, browserSetupAttemptPath(saved.profilePath)); +}); + +for (const boundary of ['start', 'complete'] as const) test(`lost ${boundary} response resumes the identical key and attempt`, async t => { + const f = await fixture(t); + if (boundary === 'start') f.loseStart(); else f.loseComplete(); + await assert.rejects(setupBrowserWorker(f.options, { fetchImpl: f.fetchImpl, pollIntervalMs: 0 }), /lost/); + const before = await loadBrowserSetupAttempt(f.options.profilePath!); + assert.ok(before); + await setupBrowserWorker(f.options, { fetchImpl: f.fetchImpl, pollIntervalMs: 0 }); + const after = await loadBrowserSetupAttempt(f.options.profilePath!); + assert.deepEqual(after?.request, before.request); + assert.equal(after?.privateKey, before.privateKey); + assert.equal(f.counts().startCalls, 2); +}); + +test('SIGKILL during journal publish preserves the attempt and contains the secret temporary after completion', { timeout: 10_000 }, async t => { + const f = await fixture(t); + f.loseComplete(); + await assert.rejects(setupBrowserWorker(f.options, { fetchImpl: f.fetchImpl, pollIntervalMs: 0 }), /lost/); + const before = (await loadBrowserSetupAttempt(f.options.profilePath!))!; + const attempt = browserSetupAttemptPath(f.options.profilePath!); + const child = spawn(process.execPath, ['--input-type=module', '--eval', ` + import fs from 'node:fs/promises'; + import { syncBuiltinESMExports } from 'node:module'; + const rename = fs.rename; + fs.rename = async (from, to) => { + if (to === ${JSON.stringify(attempt)}) { + process.send('journal-written'); + await new Promise(resolve => setTimeout(resolve, 60_000)); + } + return rename(from, to); + }; + syncBuiltinESMExports(); + const { savePrivateJson } = await import(${JSON.stringify(new URL('./storage.js', import.meta.url).href)}); + await savePrivateJson(${JSON.stringify(attempt)}, JSON.parse(await fs.readFile(${JSON.stringify(attempt)}, 'utf8'))); + `], { stdio: ['ignore', 'ignore', 'pipe', 'ipc'] }); + let stderr = ''; + child.stderr!.on('data', chunk => { stderr = (stderr + String(chunk)).slice(-4096); }); + const closed = once(child, 'close'); + t.after(async () => { child.kill('SIGKILL'); await closed; }); + await Promise.race([ + once(child, 'message', { signal: t.signal }).then(([message]) => assert.equal(message, 'journal-written')), + closed.then(() => { throw new Error(`Journal writer exited before the crash boundary: ${stderr}`); }), + ]); + child.kill('SIGKILL'); + assert.deepEqual(await closed, [null, 'SIGKILL']); + const directory = dirname(attempt); + const orphanName = (await readdir(directory)).find(name => /^attempt\.json\.[a-f0-9]{16}\.tmp$/.test(name)); + assert.ok(orphanName, 'real atomic writer left its secret generation when killed before rename'); + const orphan = join(directory, orphanName); + const crashCopy = JSON.parse(await readFile(orphan, 'utf8')); + assert.equal(crashCopy.privateKey, before.privateKey); + assert.deepEqual(browserSetupProtectedPaths(attempt), [directory]); + const saved = await setupBrowserWorker(f.options, { fetchImpl: f.fetchImpl, pollIntervalMs: 0 }); + const identity = await loadBridgeIdentity(saved.profile.identityPath); + assert.equal(identity.privateKey, before.privateKey); + assert.equal(identity.setupAttemptPath, attempt); + assert.deepEqual((await loadBrowserSetupAttempt(saved.profilePath))?.request, before.request); + assert.equal(JSON.parse(await readFile(attempt, 'utf8')).privateKey, undefined); + assert.ok((await stat(orphan)).isFile(), 'crash leftovers stay inside the denied namespace after final-receipt scrubbing'); +}); + +test('an older draft journal is never silently replaced by a different key or attempt', async t => { + const f = await fixture(t); + const oldPath = `${f.options.profilePath}.setup.json`; + await writeFile(oldPath, 'preserve the existing draft key', { mode: 0o600 }); + await assert.rejects(setupBrowserWorker(f.options, { fetchImpl: async () => { assert.fail('no remote allocation'); } }), /older draft setup journal/); + assert.equal(await readFile(oldPath, 'utf8'), 'preserve the existing draft key'); +}); + +test('local profile failure after enrollment resumes without remote enrollment effects', async t => { + const f = await fixture(t); + await assert.rejects(setupBrowserWorker(f.options, { fetchImpl: f.fetchImpl, pollIntervalMs: 0, saveProfile: async () => { throw new Error('disk full'); } }), /enrolled.*profile persistence failed/); + assert.ok((await loadBrowserSetupAttempt(f.options.profilePath!))?.identity); + assert.equal(f.counts().completeCalls, 1); + const saved = await setupBrowserWorker(f.options, { fetchImpl: async () => { throw new Error('must not enroll again'); } }); + assert.ok(await loadWorkerProfile(saved.profilePath)); + assert.equal(f.counts().completeCalls, 1); +}); + +test('rerunning completed setup preserves a newer startup credential without network or redundant writes', async t => { + const f = await fixture(t); + const saved = await setupBrowserWorker(f.options, { fetchImpl: f.fetchImpl, pollIntervalMs: 0 }); + const identity = { ...await loadBridgeIdentity(saved.profile.identityPath), credential: 'newer-credential-recovered-after-startup' }; + await saveBridgeIdentity(saved.profile.identityPath, identity); + await setupBrowserWorker(f.options, { + fetchImpl: async () => { assert.fail('completed setup does not enroll again'); }, + saveIdentity: async () => { assert.fail('do not replace a newer credential'); }, + saveProfile: async () => { assert.fail('do not rewrite an unchanged profile'); }, + }); + assert.deepEqual(await loadBridgeIdentity(saved.profile.identityPath), identity); +}); + +test('identity persistence failure after enrollment preserves the completed local receipt', async t => { + const f = await fixture(t); + await assert.rejects(setupBrowserWorker(f.options, { fetchImpl: f.fetchImpl, pollIntervalMs: 0, saveIdentity: async () => { throw new Error('identity disk full'); } }), /disk full/); + assert.ok((await loadBrowserSetupAttempt(f.options.profilePath!))?.identity); + await setupBrowserWorker(f.options, { fetchImpl: async () => { throw new Error('must not enroll again'); } }); + assert.equal(f.counts().completeCalls, 1); +}); + +test('expired setup with a lost completion response recovers only existing key-bound enrollment', async t => { + const f = await fixture(t); + f.loseComplete(); + await assert.rejects(setupBrowserWorker(f.options, { fetchImpl: f.fetchImpl, pollIntervalMs: 0 }), /lost/); + const attempt = (await loadBrowserSetupAttempt(f.options.profilePath!))!; + const now = Date.now(); + t.mock.method(Date, 'now', () => now + 11 * 60_000); + const saved = await setupBrowserWorker(f.options, { + fetchImpl: async () => { assert.fail('an expired attempt must not allocate another session'); }, + recoverCredential: async options => { + assert.equal(options.privateKey, attempt.privateKey); + assert.equal(options.workerId, attempt.approval?.workerId); + assert.equal(options.codeApiUrl, attempt.approval?.codeApiUrl); + return f.credential; + }, + }); + assert.equal((await loadBridgeIdentity(saved.profile.identityPath)).publicKey, attempt.request.publicKey); + assert.equal(f.counts().completeCalls, 1); +}); + +test('unsupported servers fail closed and never invoke manual redemption', async t => { + const f = await fixture(t); + let calls = 0; + await assert.rejects(setupBrowserWorker(f.options, { fetchImpl: async url => { + calls++; + assert.ok(String(url).endsWith('/capabilities')); + return Response.json({ protocolVersion: 1 }); + } }), /will not downgrade/); + assert.equal(calls, 1); + assert.ok(await loadBrowserSetupAttempt(f.options.profilePath!)); +}); + +for (const status of ['denied', 'expired', 'cancelled'] as const) test(`${status} setup preserves the saved identity and does not complete`, async t => { + const f = await fixture(t); + f.setStatus(status); + await assert.rejects(setupBrowserWorker(f.options, { fetchImpl: f.fetchImpl, pollIntervalMs: 0 }), new RegExp(status)); + assert.equal(f.counts().completeCalls, 0); + assert.ok(await loadBrowserSetupAttempt(f.options.profilePath!)); +}); + +test('retry cannot widen permissions, rename a machine, switch instances, or replace a key', async t => { + const f = await fixture(t); + f.loseStart(); + await assert.rejects(setupBrowserWorker(f.options, { fetchImpl: f.fetchImpl }), /lost/); + for (const change of [{ allowWorkspaceWrites: true }, { machineName: 'other-machine' }, { instance: 'https://other.example' }]) { + await assert.rejects(setupBrowserWorker({ ...f.options, ...change }, { fetchImpl: async () => { assert.fail('no remote effects'); } }), /choices differ/); + } + const attempt = (await loadBrowserSetupAttempt(f.options.profilePath!))!; + await savePrivateJson(browserSetupAttemptPath(f.options.profilePath!), { ...attempt, privateKey: createBridgeIdentity().privateKey }); + await assert.rejects(loadBrowserSetupAttempt(f.options.profilePath!), /key does not match/); +}); + +test('cancellation is signed by the persisted machine key', async t => { + const f = await fixture(t); + f.loseStart(); + await assert.rejects(setupBrowserWorker(f.options, { fetchImpl: f.fetchImpl }), /lost/); + await cancelBrowserSetup(f.options.profilePath!, undefined, f.fetchImpl); +}); + +test('cancellation after enrollment saves the profile but prevents requested foreground startup', { timeout: 5000 }, async t => { + const f = await fixture(t); + const controller = new AbortController(); + t.mock.method(globalThis, 'fetch', async (input: Parameters[0], init?: RequestInit) => { + const response = await f.fetchImpl(input, init); + if (String(input).endsWith('/complete')) controller.abort(new Error('owner cancelled startup')); + return response; + }); + await assert.rejects(runBrowserSetupCommand(new Map([ + ['--instance', f.options.instance], ['--machine-name', f.options.machineName], + ['--profile', f.options.profilePath!], ['--default-workspace', true], ['--start', true], + ]), { + interactive: false, signal: controller.signal, + ask: async () => { assert.fail('no prompt expected'); }, + yesNo: async () => { assert.fail('no prompt expected'); }, + }), /owner cancelled startup/); + assert.ok(await loadWorkerProfile(f.options.profilePath!)); +}); + +test('browser flags preserve manual automation without accepting mixed credentials', () => { + const flags = parseSetupArguments(['--instance', 'https://chat.example', '--non-interactive', '--default-workspace', '--no-browser']); + assert.equal(flags.get('--instance'), 'https://chat.example'); + for (const manual of ['--url=https://code.example/v1', '--worker-id=worker', '--code-stdin']) + assert.throws(() => parseSetupArguments(['--instance=https://chat.example', manual]), /manual pairing/); + assert.throws(() => parseSetupArguments(['--no-browser']), /require --instance/); + assert.equal(setupInstanceUrl('https://chat.example/'), 'https://chat.example'); + assert.throws(() => setupInstanceUrl('https://user:secret@chat.example'), /credentials/); +}); diff --git a/packages/code/src/browser-setup.ts b/packages/code/src/browser-setup.ts new file mode 100644 index 00000000..7bbbe493 --- /dev/null +++ b/packages/code/src/browser-setup.ts @@ -0,0 +1,352 @@ +import { createPrivateKey, createPublicKey, randomBytes } from 'node:crypto'; +import { lstat } from 'node:fs/promises'; +import { basename, dirname, join, resolve } from 'node:path'; +import { setTimeout as delay } from 'node:timers/promises'; + +import { createBridgeIdentity } from './identity.js'; +import { recoverWorkerCredential } from './credential-recovery.js'; +import { + browserSetupRequestHash, + signBrowserSetupProof, + signEnrollmentRedemption, + isBrowserEnrollmentRedemptionContract, +} from './browser-enrollment-protocol.js'; +import type { + BrowserSetupRequest, + BrowserSetupProof, + EnrollmentRedemption, +} from './browser-enrollment-protocol.js'; +import { assertPrivateStorageSupported } from './private-storage.js'; +import { withProcessLock } from './process-lock.js'; +import { + assertIdentityPathIsPrivate, + loadBridgeIdentity, + ensurePrivateWorkspaceDirectory, + readPrivateJson, + saveBridgeIdentity, + savePrivateJson, +} from './storage.js'; +import { checkSetupCommands, setupCodeApiUrl, setupWorkspaceDirectory } from './setup.js'; +import { + assertWorkerProfileIsolation, + defaultWorkerProfilePath, + loadWorkerProfile, + saveWorkerProfile, + workerProfileArguments, +} from './worker-profile.js'; +import type { WorkerProfile } from './worker-profile.js'; +import type { PairedBridgeWorkerIdentity } from './pairing.js'; + +export interface BrowserSetupAttempt { + version: 1; + request: BrowserSetupRequest; + privateKey: string; + profile: WorkerProfile; + approval?: BrowserSetupApproval; + identity?: PairedBridgeWorkerIdentity; + completed?: true; +} + +export type BrowserSetupApproval = EnrollmentRedemption & { codeApiUrl: string }; + +export interface BrowserSetupOptions { + instance: string; + machineName: string; + profilePath?: string; + identityPath?: string; + workspaceDirectory?: string; + allowWorkspaceWrites: boolean; + allowWorkspaceCommands: boolean; + signal?: AbortSignal; +} + +interface SetupStatus { + version: 1; + status: 'pending' | 'approved' | 'completed' | 'denied' | 'cancelled' | 'expired'; + expiresAt: string; + verificationUrl: string; + verificationCode: string; + approval?: BrowserSetupApproval; + credential?: { protocolVersion: 1; workerId: string; credential: string; expiresAt: string }; +} + +interface BrowserSetupDependencies { + fetchImpl?: typeof fetch; + checkCommands?: typeof checkSetupCommands; + onVerification?: (status: Pick) => void | Promise; + pollIntervalMs?: number; + saveProfile?: typeof saveWorkerProfile; + saveIdentity?: typeof saveBridgeIdentity; + recoverCredential?: typeof recoverWorkerCredential; +} + +function record(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +export function setupInstanceUrl(value: string): string { + return setupCodeApiUrl(value); +} + +export function browserSetupAttemptPath(profilePath: string): string { + return join(`${resolve(profilePath)}.setup`, 'attempt.json'); +} + +export function browserSetupProtectedPaths(attemptPath: string | undefined): string[] { + if (attemptPath === undefined) return []; + const directory = dirname(attemptPath); + if (basename(attemptPath) !== 'attempt.json' || !directory.endsWith('.setup')) + throw new Error('Unsupported setup journal location; preserve the original identity and journal'); + // Directory protection includes atomic-write temporaries, even after SIGKILL. + return [directory]; +} + +export async function loadBrowserSetupAttempt(profilePath: string): Promise { + // The earlier, unreleased draft stored journals beside profiles. Never rekey them silently. + const previous = await lstat(`${resolve(profilePath)}.setup.json`).catch((error: NodeJS.ErrnoException) => { + if (error.code === 'ENOENT') return undefined; + throw error; + }); + if (previous) throw new Error('An older draft setup journal exists; preserve its key and attempt instead of starting a new enrollment'); + let value: unknown; + try { value = await readPrivateJson(browserSetupAttemptPath(profilePath), 32 * 1024); } + catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return undefined; + throw error; + } + if (!record(value) || value.version !== 1 || !record(value.request) || !record(value.profile)) + throw new Error('Invalid saved setup attempt; preserve it and choose another profile, do not replace its machine key'); + const attempt = value as unknown as BrowserSetupAttempt; + workerProfileArguments(attempt.profile); + if (attempt.completed === true) { + attempt.identity = await loadBridgeIdentity(attempt.profile.identityPath); + attempt.privateKey = attempt.identity.privateKey; + } + if (typeof attempt.privateKey !== 'string') throw new Error('Saved setup key is unavailable; preserve the original identity'); + if (setupInstanceUrl(attempt.request.instance) !== attempt.request.instance || + createPublicKey(createPrivateKey(attempt.privateKey)).export({ type: 'spki', format: 'pem' }) !== attempt.request.publicKey) + throw new Error('Saved setup key does not match its request'); + // The shared canonicalizer validates the immutable request before it is signed. + browserSetupRequestHash(attempt.request); + if (attempt.approval && (!isBrowserEnrollmentRedemptionContract(attempt.approval) || + attempt.approval.instance !== attempt.request.instance || attempt.approval.attemptId !== attempt.request.attemptId || + attempt.approval.requestHash !== browserSetupRequestHash(attempt.request) || + setupCodeApiUrl(attempt.approval.codeApiUrl) !== attempt.approval.codeApiUrl || + new URL(attempt.approval.codeApiUrl).origin !== attempt.approval.serverId)) + throw new Error('Saved approval does not match its setup request'); + if (attempt.identity && (!attempt.approval || attempt.identity.protocolVersion !== 1 || + attempt.identity.publicKey !== attempt.request.publicKey || attempt.identity.privateKey !== attempt.privateKey || + attempt.identity.workerId !== attempt.approval.workerId || attempt.identity.codeApiUrl !== attempt.approval.codeApiUrl || + typeof attempt.identity.credential !== 'string' || attempt.identity.credential.length < 32 || + attempt.identity.credential.length > 4096 || !Number.isFinite(Date.parse(attempt.identity.expiresAt)))) + throw new Error('Saved completion does not match its approved machine key'); + return attempt; +} + +async function boundedJson(response: Response): Promise { + const reader = response.body?.getReader(); + if (!reader) throw new Error('LibreChat returned an empty setup response'); + let size = 0; + const chunks: Uint8Array[] = []; + try { + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + size += value.byteLength; + if (size > 32 * 1024) throw new Error('LibreChat setup response exceeds 32 KiB'); + chunks.push(value); + } + try { return JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(Buffer.concat(chunks))) as unknown; } + catch { throw new Error('LibreChat returned an invalid setup response'); } + } finally { await reader.cancel().catch(() => undefined); reader.releaseLock(); } +} + +async function requestJson(instance: string, path: string, body: unknown, signal: AbortSignal | undefined, fetchImpl: typeof fetch): Promise { + const response = await fetchImpl(`${instance}/api/code-environments/setup/${path}`, { + method: body === undefined ? 'GET' : 'POST', + redirect: 'error', + headers: { 'Content-Type': 'application/json' }, + body: body === undefined ? undefined : JSON.stringify(body), + signal: signal ? AbortSignal.any([signal, AbortSignal.timeout(30_000)]) : AbortSignal.timeout(30_000), + }); + if (!response.ok) { + await response.body?.cancel(); + if ([404, 405, 501].includes(response.status)) + throw new Error('This LibreChat instance does not support browser-approved machine setup. Update compatible servers first, or explicitly use the existing manual pairing flow'); + throw new Error(`LibreChat setup request failed (HTTP ${response.status}). Your saved key and attempt are unchanged; rerun the same setup command to resume`); + } + return boundedJson(response); +} + +function machineBody(attempt: BrowserSetupAttempt, operation: BrowserSetupProof['operation']): object { + const proof: BrowserSetupProof = { + operation, + instance: attempt.request.instance, + attemptId: attempt.request.attemptId, + requestHash: browserSetupRequestHash(attempt.request), + timestamp: new Date().toISOString(), + nonce: randomBytes(32).toString('base64url'), + }; + return { + request: attempt.request, + proof, + signature: signBrowserSetupProof(attempt.privateKey, proof), + ...(operation === 'setup.complete' && attempt.approval ? { + redemptionSignature: signEnrollmentRedemption(attempt.privateKey, attempt.approval), + } : {}), + }; +} + +function parseStatus(value: unknown, attempt: BrowserSetupAttempt): SetupStatus { + if (!record(value) || value.version !== 1 || + !['pending', 'approved', 'completed', 'denied', 'cancelled', 'expired'].includes(String(value.status)) || + typeof value.expiresAt !== 'string' || !Number.isFinite(Date.parse(value.expiresAt)) || + Date.parse(value.expiresAt) > Date.parse(attempt.request.createdAt) + 10 * 60_000 || + typeof value.verificationUrl !== 'string' || typeof value.verificationCode !== 'string' || + !/^[A-Z0-9-]{6,20}$/.test(value.verificationCode)) + throw new Error('LibreChat returned an invalid setup session'); + let url: URL; + try { url = new URL(value.verificationUrl); } + catch { throw new Error('LibreChat returned an invalid approval destination'); } + if (url.href !== `${attempt.request.instance}/code/setup/${attempt.request.attemptId}` || + url.username || url.password || url.search || url.hash) + throw new Error('LibreChat returned an unexpected approval destination'); + if (value.approval !== undefined) { + const approval = value.approval as BrowserSetupApproval; + if (!isBrowserEnrollmentRedemptionContract(approval) || approval.operation !== 'enrollment.redeem' || + approval.instance !== attempt.request.instance || approval.attemptId !== attempt.request.attemptId || + approval.requestHash !== browserSetupRequestHash(attempt.request) || + approval.expiresAt !== value.expiresAt || typeof approval.workerId !== 'string' || + typeof approval.codeApiUrl !== 'string' || setupCodeApiUrl(approval.codeApiUrl) !== approval.codeApiUrl || + new URL(approval.codeApiUrl).origin !== approval.serverId) + throw new Error('LibreChat approval does not match this setup attempt'); + if (attempt.approval && JSON.stringify(attempt.approval) !== JSON.stringify(approval)) + throw new Error('LibreChat changed the saved approval; refusing to replace the machine binding'); + } + return value as unknown as SetupStatus; +} + +async function assertEmptyOrMatching(path: string, expected: unknown, load: (path: string) => Promise, same = (left: unknown, right: unknown) => JSON.stringify(left) === JSON.stringify(right)): Promise { + const metadata = await lstat(path); + if (metadata.isFile() && metadata.size === 0) return false; + if (!same(await load(path), expected)) + throw new Error('Existing profile or identity differs from the saved setup attempt; refusing to replace it'); + return true; +} + +function sameEnrolledKey(left: unknown, right: unknown): boolean { + return record(left) && record(right) && ['protocolVersion', 'workerId', 'codeApiUrl', 'publicKey', 'privateKey', 'setupAttemptPath'] + .every(key => left[key] === right[key]); +} + +/** Every remote mutation follows a durable, private key and immutable local request. */ +export async function setupBrowserWorker(options: BrowserSetupOptions, dependencies: BrowserSetupDependencies = {}): Promise<{ profilePath: string; profile: WorkerProfile }> { + options.signal?.throwIfAborted(); + assertPrivateStorageSupported(); + const instance = setupInstanceUrl(options.instance); + const profilePath = resolve(options.profilePath ?? defaultWorkerProfilePath()); + const attemptPath = browserSetupAttemptPath(profilePath); + await ensurePrivateWorkspaceDirectory(dirname(attemptPath)); + // This stable inode is deliberately retained. Removing an advisory lock can split its holders. + await assertIdentityPathIsPrivate(`${attemptPath}.lock`); + return withProcessLock(`${attemptPath}.lock`, async () => { + let attempt = await loadBrowserSetupAttempt(profilePath); + const workspaceDirectory = options.workspaceDirectory === undefined ? undefined : await setupWorkspaceDirectory(options.workspaceDirectory); + const profile: WorkerProfile = { + version: 1, + identityPath: resolve(options.identityPath ?? `${profilePath}.identity.json`), + workspace: workspaceDirectory ? { type: 'directory', path: workspaceDirectory } : { type: 'private' }, + allowWorkspaceWrites: options.allowWorkspaceWrites, + allowWorkspaceCommands: options.allowWorkspaceCommands, + }; + workerProfileArguments(profile); + if (attempt && (attempt.request.instance !== instance || attempt.request.machineName !== options.machineName || JSON.stringify(attempt.profile) !== JSON.stringify(profile))) + throw new Error('Setup choices differ from the saved attempt. Resume its original instance and permissions, or explicitly select another profile'); + if (profile.allowWorkspaceCommands) await (dependencies.checkCommands ?? checkSetupCommands)(); + const reservations = []; + try { + reservations.push(await assertIdentityPathIsPrivate(profilePath, !attempt)); + reservations.push(await assertIdentityPathIsPrivate(profile.identityPath, !attempt)); + reservations.push(await assertIdentityPathIsPrivate(attemptPath, !attempt)); + await assertWorkerProfileIsolation(profilePath, profile.identityPath, workspaceDirectory); + await assertWorkerProfileIsolation(attemptPath, profilePath, workspaceDirectory); + await assertWorkerProfileIsolation(attemptPath, profile.identityPath, workspaceDirectory); + if (!attempt) { + const key = createBridgeIdentity(); + attempt = { + version: 1, + request: { + version: 1, instance, attemptId: randomBytes(32).toString('base64url'), + createdAt: new Date().toISOString(), publicKey: key.publicKey, + machineName: options.machineName, workspace: { type: profile.workspace.type }, + allowWorkspaceWrites: profile.allowWorkspaceWrites, allowWorkspaceCommands: profile.allowWorkspaceCommands, + }, + privateKey: key.privateKey, profile, + }; + browserSetupRequestHash(attempt.request); + await savePrivateJson(attemptPath, attempt); + } + const persist = () => savePrivateJson(attemptPath, attempt); + const publish = async () => { + if (!await assertEmptyOrMatching(profile.identityPath, attempt!.identity, loadBridgeIdentity, sameEnrolledKey)) + await (dependencies.saveIdentity ?? saveBridgeIdentity)(profile.identityPath, attempt!.identity!); + if (!attempt!.completed) { + await savePrivateJson(attemptPath, { version: 1, request: attempt!.request, profile, approval: attempt!.approval, completed: true }); + attempt!.completed = true; + } + const profileSaved = await assertEmptyOrMatching(profilePath, profile, loadWorkerProfile); + try { if (!profileSaved) await (dependencies.saveProfile ?? saveWorkerProfile)(profilePath, profile); } + catch { throw new Error(`Machine enrolled and identity saved, but profile persistence failed. Rerun setup with this same profile to resume: ${profilePath}`); } + return { profilePath, profile }; + }; + if (attempt.identity) return await publish(); + const fetchImpl = dependencies.fetchImpl ?? fetch; + if (attempt.approval && Date.now() >= Date.parse(attempt.approval.expiresAt)) { + // Session expiry cannot undo enrollment. Recovery proves only existing trust. + const credential = await (dependencies.recoverCredential ?? recoverWorkerCredential)({ + codeApiUrl: attempt.approval.codeApiUrl, workerId: attempt.approval.workerId, + privateKey: attempt.privateKey, fetchImpl, validThroughMs: Date.now() + 60_000, + signal: options.signal ? AbortSignal.any([options.signal, AbortSignal.timeout(30_000)]) : AbortSignal.timeout(30_000), + }); + attempt.identity = { ...credential, codeApiUrl: attempt.approval.codeApiUrl, + publicKey: attempt.request.publicKey, privateKey: attempt.privateKey, setupAttemptPath: attemptPath }; + await persist(); + return await publish(); + } + const capabilities = await requestJson(instance, 'capabilities', undefined, options.signal, fetchImpl); + if (!record(capabilities) || capabilities.browserEnrollmentVersion !== 1) + throw new Error('This instance lacks compatible key-bound browser enrollment. Update compatible servers first; setup will not downgrade to manual pairing'); + let status = parseStatus(await requestJson(instance, 'start', machineBody(attempt, 'setup.start'), options.signal, fetchImpl), attempt); + await dependencies.onVerification?.(status); + for (;;) { + options.signal?.throwIfAborted(); + if (['denied', 'cancelled', 'expired'].includes(status.status)) throw new Error(`Machine setup ${status.status}. The original key and attempt were preserved; choose another profile only for an intentional new enrollment`); + if (status.approval) { attempt.approval = status.approval; await persist(); } + if (status.status === 'approved' || status.status === 'completed') { + if (!attempt.approval) throw new Error('LibreChat did not supply the key-bound approval contract'); + const completed = parseStatus(await requestJson(instance, 'complete', machineBody(attempt, 'setup.complete'), options.signal, fetchImpl), attempt); + const credential = completed.credential; + if (completed.status !== 'completed' || !credential || credential.protocolVersion !== 1 || + credential.workerId !== attempt.approval.workerId || typeof credential.credential !== 'string' || + credential.credential.length < 32 || credential.credential.length > 4096 || + !Number.isFinite(Date.parse(credential.expiresAt))) throw new Error('LibreChat returned an invalid enrollment credential'); + attempt.identity = { protocolVersion: 1, workerId: credential.workerId, credential: credential.credential, expiresAt: credential.expiresAt, + codeApiUrl: attempt.approval.codeApiUrl, publicKey: attempt.request.publicKey, privateKey: attempt.privateKey, setupAttemptPath: attemptPath }; + // Keep replayable local state even if either final file cannot be published. + await persist(); + return await publish(); + } + if (Date.now() >= Date.parse(status.expiresAt)) throw new Error('Machine setup expired; the saved key and attempt were preserved'); + await delay(dependencies.pollIntervalMs ?? 2000, undefined, { signal: options.signal }); + status = parseStatus(await requestJson(instance, 'poll', machineBody(attempt, 'setup.poll'), options.signal, fetchImpl), attempt); + } + } finally { for (const reservation of reservations.reverse()) await reservation.release(); } + }, options.signal, false); +} + +export async function cancelBrowserSetup(profilePath: string, signal?: AbortSignal, fetchImpl: typeof fetch = fetch): Promise { + const attempt = await loadBrowserSetupAttempt(profilePath); + if (!attempt) throw new Error('No saved setup attempt exists for this profile'); + if (attempt.identity) throw new Error('This machine is already enrolled; revoke it through LibreChat instead'); + await requestJson(attempt.request.instance, 'cancel', machineBody(attempt, 'setup.cancel'), signal, fetchImpl); +} diff --git a/packages/code/src/cli.ts b/packages/code/src/cli.ts index 85ce947e..a0352d34 100644 --- a/packages/code/src/cli.ts +++ b/packages/code/src/cli.ts @@ -1,10 +1,17 @@ #!/usr/bin/env node import { createHash, createHmac, randomBytes } from 'node:crypto'; import { readFileSync } from 'node:fs'; -import { readdir, realpath, stat } from 'node:fs/promises'; +import { lstat, readdir, realpath, stat } from 'node:fs/promises'; import { basename, dirname, join, resolve, relative, isAbsolute, sep } from 'node:path'; import { pairBridgeWorker } from './pairing.js'; +import { commandFailureLogLine } from './command-diagnostics.js'; +import { runSetupCommand } from './setup-cli.js'; +import { browserSetupProtectedPaths } from './browser-setup.js'; +import { assertPrivateStorageAncestors } from './private-storage.js'; +import { profileRunConfiguration } from './setup.js'; +import { assertWorkerProfileIsolation } from './worker-profile.js'; +import type { WorkerProfile } from './worker-profile.js'; import { discoverProjects } from './projects.js'; import { loadProjectRoots, projectRootArguments } from './project-roots.js'; import { @@ -33,6 +40,7 @@ import { } from './storage.js'; import { BridgeWorker } from './worker.js'; import { LocalWorkspaceTools, SandboxWorkspaceTools } from './workspace.js'; +import { createWorkspaceFileImporter } from './workspace-import.js'; import { DockerRuntimeSupervisor, EndpointRuntimeSupervisor, @@ -72,6 +80,7 @@ import type { RuntimeSupervisor } from './runtime.js'; import type { GitHubCredentialProvider } from './github.js'; import type { WorkspaceToolExecutor } from './workspace.js'; import { + BRIDGE_WORKSPACE_IMPORT_FILE_DEFAULT_MAX_BYTES, BRIDGE_WORKSPACE_NAME_MAX_LENGTH, BridgeProtocolError, isValidBridgeWorkerCapabilities, @@ -358,6 +367,7 @@ async function relay(): Promise { async function run( runtimeSessionId?: string, args: string[] = [], + savedProfile?: { path: string; config: WorkerProfile }, ): Promise { const projectArgs = projectRootArguments(args); if ( @@ -422,7 +432,7 @@ async function run( } const configuredWorkerId = process.env.LIBRECHAT_CODE_WORKER_ID?.trim(); const configuredIdentityPath = - process.env.LIBRECHAT_CODE_IDENTITY_FILE?.trim(); + savedProfile?.config.identityPath ?? process.env.LIBRECHAT_CODE_IDENTITY_FILE?.trim(); const configuredToken = process.env.LIBRECHAT_CODE_WORKER_TOKEN?.trim(); const identityPath = configuredIdentityPath ?? @@ -432,6 +442,7 @@ async function run( const pairedIdentity = identityPath ? await loadBridgeIdentity(identityPath) : undefined; + const setupProtectedPaths = browserSetupProtectedPaths(pairedIdentity?.setupAttemptPath); const workerId = required( 'LIBRECHAT_CODE_WORKER_ID', configuredWorkerId ?? pairedIdentity?.workerId, @@ -645,6 +656,9 @@ async function run( throw new Error('Invalid workspace registration'); } } + if (savedProfile) { + await assertWorkerProfileIsolation(savedProfile.path, identityPath!, canonicalWorkerDirectory); + } const mutationQuarantinePath = (allowWorkspaceWrites || allowWorkspaceCommands) && canonicalWorkerDirectory @@ -842,6 +856,19 @@ async function run( }), ]), ); + for (const path of setupProtectedPaths) { + const namespace = await lstat(path).catch((error: NodeJS.ErrnoException) => { + if (error.code === 'ENOENT') return undefined; + throw error; + }); + if (!namespace) continue; + if (!namespace.isDirectory() || namespace.isSymbolicLink()) + throw new Error('Setup journal namespace must be a private directory, not a symlink'); + const sourceParents = await assertPrivateStorageAncestors(path); + await assertEnvironmentDefinitionsOutsideRoots([{ + path, sourceParents, definition: { name: 'setup-state', root: path }, fingerprint: '', + }], roots); + } const preparationDirectory = join(dirname(identityPath ?? defaultBridgeIdentityPath(workerId)), 'environment-preparation'); if (environments.some(environment => environment.definition.setup?.reuse)) { const sourceParents = await prepareEnvironmentPreparationDirectory(preparationDirectory); @@ -857,13 +884,13 @@ async function run( definition: { name: 'shared-resource', root: resource.path }, fingerprint: '', }))); await assertEnvironmentResourceIsolation(environments.flatMap(environment => environment.resources ?? []), roots.map(root => root.root), [ - identityPath, preparationDirectory, github.privateKeyPath, + identityPath, ...setupProtectedPaths, preparationDirectory, github.privateKeyPath, ...environments.map(environment => environment.path), ...rootQuarantinePaths.values(), ].filter((path): path is string => path != null)); await assertEnvironmentDefinitionsOutsideRoots(resourceRoots, roots); const snapshotStores = environments.flatMap(environment => environment.snapshotStore ? [environment.snapshotStore] : []); await assertEnvironmentResourceIsolation(snapshotStores.map(store => ({ kind: 'npm-cache' as const, path: store.store, access: 'read-only' as const })), roots.map(root => root.root), [ - identityPath, preparationDirectory, github.privateKeyPath, ...environments.map(environment => environment.path), + identityPath, ...setupProtectedPaths, preparationDirectory, github.privateKeyPath, ...environments.map(environment => environment.path), ...rootQuarantinePaths.values(), ...environments.flatMap(environment => (environment.resources ?? []).map(resource => resource.path)), ].filter((path): path is string => path != null)); await assertEnvironmentDefinitionsOutsideRoots(snapshotStores.map(store => ({ path: store.store, sourceParents: store.controlPaths, @@ -890,10 +917,25 @@ async function run( ]), ) : undefined; + // A chat upload reaches the workspace only through the same relay upstream + // programmatic inputs use; without one the worker never advertises import_file. + const workspaceImporter = createWorkspaceFileImporter({ + optOut: args.includes('--no-workspace-imports'), + enabled: process.env.LIBRECHAT_CODE_ALLOW_WORKSPACE_IMPORTS, + writable: allowWorkspaceWrites, + upstreamUrl: fileRelayUpstream, + maxBytes: positiveInteger( + 'LIBRECHAT_CODE_IMPORT_FILE_MAX_BYTES', + option(args, '--import-file-max-bytes') ?? + process.env.LIBRECHAT_CODE_IMPORT_FILE_MAX_BYTES, + BRIDGE_WORKSPACE_IMPORT_FILE_DEFAULT_MAX_BYTES, + ), + }); const localWorkspaceTools = workerDirectory ? await LocalWorkspaceTools.create({ workspaces: roots, repositoryInstructions: args.includes('--repository-instructions'), + importer: workspaceImporter, }) : undefined; let workspaceTools: WorkspaceToolExecutor | undefined = localWorkspaceTools; @@ -1092,6 +1134,8 @@ async function run( commandPolicy, protectedPaths: [ identityPath, + savedProfile?.path, + ...setupProtectedPaths, ...(environments.some(environment => environment.definition.setup?.reuse) ? [preparationDirectory] : []), ...snapshotStores.map(store => store.store), ...environments.map(environment => environment.path), @@ -1272,6 +1316,7 @@ async function run( }, repositoryInstructions: args.includes('--repository-instructions'), writable: root.writable ?? false, + importer: workspaceImporter, }, ]), ), @@ -1307,6 +1352,7 @@ async function run( command: nativeOptionsForWorkspace(root.id), repositoryInstructions: args.includes('--repository-instructions'), writable: root.writable ?? false, + importer: workspaceImporter, }, ]), ), @@ -1482,8 +1528,17 @@ async function run( throw error; } worktreeRetirementScheduler?.start(); + let readinessReported = false; try { const worker = new BridgeWorker({ + onCommandFailure: event => { + process.stderr.write(commandFailureLogLine(event)); + }, + onReady: () => { + if (readinessReported) return; + readinessReported = true; + process.stdout.write('librechat-code: connected and ready\n'); + }, instructionDescriptors: () => localWorkspaceTools?.instructionDescriptors() ?? Promise.resolve(undefined), codeApiUrl, token: configuredToken, @@ -1766,6 +1821,26 @@ async function clearMutationQuarantine(args: string[]): Promise { async function main(): Promise { const args = process.argv.slice(2); + if (args.length === 1 && (args[0] === '--help' || args[0] === 'help')) { + process.stdout.write(`Usage: librechat-code + + setup --instance Browser-approved enrollment and private profile setup + run [--profile ] Start a saved profile or existing worker configuration + pair --worker-id Existing one-time pairing flow + projects --root Inspect local Git projects + reset-workspace Existing runtime maintenance + clear-workspace-quarantine Existing workspace maintenance + +Run librechat-code setup --help for setup options. +Legacy LIBRECHAT_CODE_* configuration takes precedence over the default profile. +Explicit profiles cannot be mixed with legacy settings or run options. +`); + return; + } + if (args[0] === 'setup') { + await runSetupCommand(args.slice(1)); + return; + } if (args[0] === 'prune-environment-storage') { const paths: string[] = []; for (let i = 1; i < args.length; i++) { @@ -1823,7 +1898,10 @@ async function main(): Promise { if (args[0] && args[0] !== 'run') { throw new Error(`Unknown command: ${args[0]}`); } - await run(undefined, args.slice(1)); + const configuration = await profileRunConfiguration(args.slice(1)); + await run(undefined, configuration.args, configuration.profile && configuration.profilePath + ? { path: configuration.profilePath, config: configuration.profile } + : undefined); } main().catch((error: Error) => { process.stderr.write(`librechat-code: ${error.message}\n`); diff --git a/packages/code/src/command-diagnostics.test.ts b/packages/code/src/command-diagnostics.test.ts new file mode 100644 index 00000000..76e48f30 --- /dev/null +++ b/packages/code/src/command-diagnostics.test.ts @@ -0,0 +1,165 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + commandFailureDiagnostic, + commandFailureEvent, + commandFailureLogLine, + readCommandFailureDiagnostic, +} from './command-diagnostics.js'; + +test('command diagnostics retain only fixed classifications and allowlisted errno', () => { + const error = Object.assign( + new Error('token secret /private/path echo password'), + { + code: 'ENOSPC', + command: 'echo password', + token: 'secret', + }, + ); + assert.deepEqual( + commandFailureDiagnostic('sandbox_startup', 'startup_failed', error), + { + stage: 'sandbox_startup', + reason: 'startup_failed', + osCode: 'ENOSPC', + }, + ); + assert.deepEqual( + commandFailureDiagnostic('credential_resolution', 'credential_failed', { + code: 'private-provider-value', + }), + { stage: 'credential_resolution', reason: 'credential_failed' }, + ); +}); + +test('IPC diagnostics are reconstructed rather than forwarding arbitrary metadata', () => { + const diagnostic = { + stage: 'sandbox_startup', + reason: 'startup_failed', + osCode: 'EPERM', + message: 'secret', + command: 'echo password', + path: '/private/path', + }; + assert.deepEqual(readCommandFailureDiagnostic(diagnostic), { + stage: 'sandbox_startup', + reason: 'startup_failed', + osCode: 'EPERM', + }); + for (const value of [ + null, + 'secret', + {}, + { stage: 'secret', reason: 'startup_failed' }, + { stage: 'sandbox_startup', reason: 'secret' }, + ]) { + assert.equal(readCommandFailureDiagnostic(value), undefined); + } + assert.deepEqual( + readCommandFailureDiagnostic({ ...diagnostic, osCode: 'secret' }), + { + stage: 'sandbox_startup', + reason: 'startup_failed', + }, + ); +}); + +test('failure events bound correlation IDs and omit free-form error data', () => { + const event = commandFailureEvent(undefined, { + assignmentId: 'assignment-1', + workerId: 'worker-1', + incarnationId: 'incarnation-1', + workspaceId: '/private/path\nsecret', + operation: 'execute_command', + mutationMayHaveCommitted: false, + requiresQuarantine: false, + }); + assert.deepEqual(JSON.parse(JSON.stringify(event)), { + event: 'workspace_command_unavailable', + stage: 'dispatch', + reason: 'executor_rejected', + assignmentId: 'assignment-1', + workerId: 'worker-1', + incarnationId: 'incarnation-1', + operation: 'execute_command', + mutationMayHaveCommitted: false, + requiresQuarantine: false, + }); + assert.equal( + commandFailureEvent(undefined, { + ...event, + assignmentId: 'a'.repeat(129), + }).assignmentId, + undefined, + ); +}); + +test('diagnostic correlation retains every valid worker/workspace identifier character', () => { + const event = commandFailureEvent(undefined, { + assignmentId: '_assignment-1', + workerId: 'vm.example', + workspaceId: 'repo:api', + incarnationId: 'worker_v1-2', + operation: 'execute_command', + mutationMayHaveCommitted: false, + requiresQuarantine: false, + }); + assert.equal(event.workerId, 'vm.example'); + assert.equal(event.workspaceId, 'repo:api'); + assert.equal(event.incarnationId, 'worker_v1-2'); + assert.equal(event.assignmentId, '_assignment-1'); + assert.equal( + commandFailureEvent(undefined, { ...event, workerId: 'vm\n' }).workerId, + undefined, + ); +}); + +test('diagnostic extraction cannot invoke exception getters or mask a failure', () => { + const error = { + get code() { + throw new Error('private getter'); + }, + }; + assert.deepEqual( + commandFailureDiagnostic( + 'credential_resolution', + 'credential_failed', + error, + ), + { + stage: 'credential_resolution', + reason: 'credential_failed', + }, + ); + assert.deepEqual( + commandFailureDiagnostic( + 'credential_resolution', + 'credential_failed', + new Proxy( + {}, + { + getOwnPropertyDescriptor() { + throw new Error('private proxy'); + }, + }, + ), + ), + { + stage: 'credential_resolution', + reason: 'credential_failed', + }, + ); +}); + +test('CLI diagnostic lines are complete JSON records without a text prefix', () => { + const event = commandFailureEvent(undefined, { + assignmentId: 'assignment-1', + operation: 'execute_command', + mutationMayHaveCommitted: false, + requiresQuarantine: false, + }); + const line = commandFailureLogLine(event); + assert.equal(line.endsWith('\n'), true); + assert.equal(line.split('\n').length, 2); + assert.deepEqual(JSON.parse(line), JSON.parse(JSON.stringify(event))); +}); diff --git a/packages/code/src/command-diagnostics.ts b/packages/code/src/command-diagnostics.ts new file mode 100644 index 00000000..86b42c5b --- /dev/null +++ b/packages/code/src/command-diagnostics.ts @@ -0,0 +1,155 @@ +import { BRIDGE_WORKER_ID_PATTERN } from './protocol.js'; + +/** Worker-local diagnostics: never add these fields to bridge settlements. */ +export type CommandFailureStage = + | 'allocation' + | 'sandbox_startup' + | 'programmatic_preflight' + | 'credential_resolution' + | 'command_wrapping' + | 'dispatch' + | 'executor_transport' + | 'cleanup'; + +const reasons = [ + 'workspace_busy', + 'capacity_exhausted', + 'allocation_failed', + 'startup_failed', + 'prerequisite_failed', + 'credential_failed', + 'wrapping_failed', + 'executor_rejected', + 'executor_unavailable', + 'executor_lost', + 'send_failed', + 'rpc_timeout', + 'cleanup_failed', +] as const; +export type CommandFailureReason = (typeof reasons)[number]; + +const setupReasons = { + sandbox_startup: 'startup_failed', + programmatic_preflight: 'prerequisite_failed', + credential_resolution: 'credential_failed', + command_wrapping: 'wrapping_failed', +} as const; +export type CommandSetupStage = keyof typeof setupReasons; + +export function commandSetupFailureDiagnostic( + stage: CommandSetupStage, + error: unknown, +): CommandFailureDiagnostic { + return commandFailureDiagnostic(stage, setupReasons[stage], error); +} +export interface CommandFailureDiagnostic { + stage: CommandFailureStage; + reason: CommandFailureReason; + osCode?: string; +} +const stages = new Set([ + 'allocation', + 'sandbox_startup', + 'programmatic_preflight', + 'credential_resolution', + 'command_wrapping', + 'dispatch', + 'executor_transport', + 'cleanup', +]); +const osCodes = new Set([ + 'ENOSPC', + 'EDQUOT', + 'EACCES', + 'EPERM', + 'ENOMEM', + 'EMFILE', + 'ENFILE', + 'EIO', + 'ENOENT', + 'EPIPE', + 'ECONNRESET', + 'ETIMEDOUT', +]); + +export function commandFailureDiagnostic( + stage: CommandFailureStage, + reason: CommandFailureReason, + error?: unknown, +): CommandFailureDiagnostic { + let code: unknown; + try { + // Diagnostic extraction must not execute an exception object's getters. + if (error != null && typeof error === 'object') { + code = Object.getOwnPropertyDescriptor(error, 'code')?.value; + } + } catch { + // Even an unusual provider error/proxy cannot replace the original failure. + } + return { + stage, + reason, + ...(typeof code === 'string' && osCodes.has(code) + ? { osCode: code } + : {}), + }; +} + +/** Reconstruct only allowlisted fields, even across the trusted executor IPC. */ +export function readCommandFailureDiagnostic( + value: unknown, +): CommandFailureDiagnostic | undefined { + if (value == null || typeof value !== 'object') return; + const v = value as Partial; + if (!stages.has(v.stage ?? '') || !reasons.includes(v.reason!)) return; + return commandFailureDiagnostic(v.stage!, v.reason!, { code: v.osCode }); +} + +export interface CommandFailureEvent extends CommandFailureDiagnostic { + event: 'workspace_command_unavailable'; + assignmentId?: string; + workspaceId?: string; + workerId?: string; + incarnationId?: string; + operation: 'execute_command' | 'workspace_programmatic'; + mutationMayHaveCommitted: boolean; + requiresQuarantine: boolean; +} + +/** One complete JSON record, suitable for a JSON-lines log collector. */ +export function commandFailureLogLine(event: CommandFailureEvent): string { + return `${JSON.stringify(event)}\n`; +} + +export function commandFailureEvent( + diagnostic: CommandFailureDiagnostic | undefined, + context: Omit< + CommandFailureEvent, + keyof CommandFailureDiagnostic | 'event' + >, +): CommandFailureEvent { + const id = ( + value: string | undefined, + pattern: RegExp, + ): string | undefined => + value && + value.length <= 128 && + !/[\r\n]/.test(value) && + pattern.test(value) + ? value + : undefined; + // Assignment/incarnation IDs are opaque and may begin with '-' or '_'. + const opaqueIdPattern = /^[A-Za-z0-9._:-]{1,128}$/; + return { + event: 'workspace_command_unavailable', + ...(readCommandFailureDiagnostic(diagnostic) ?? + commandFailureDiagnostic('dispatch', 'executor_rejected')), + assignmentId: id(context.assignmentId, opaqueIdPattern), + workspaceId: id(context.workspaceId, BRIDGE_WORKER_ID_PATTERN), + workerId: id(context.workerId, BRIDGE_WORKER_ID_PATTERN), + incarnationId: id(context.incarnationId, opaqueIdPattern), + operation: context.operation, + mutationMayHaveCommitted: context.mutationMayHaveCommitted, + requiresQuarantine: context.requiresQuarantine, + }; +} diff --git a/packages/code/src/credential-recovery.test.ts b/packages/code/src/credential-recovery.test.ts new file mode 100644 index 00000000..1cce473e --- /dev/null +++ b/packages/code/src/credential-recovery.test.ts @@ -0,0 +1,701 @@ +import assert from 'node:assert/strict'; +import { randomBytes } from 'node:crypto'; +import test from 'node:test'; + +import { recoverWorkerCredential } from './credential-recovery.js'; +import { + createBridgeIdentity, + verifyBridgeRecovery, + verifyBridgeRecoveryStart, + verifyBridgeRequest, +} from './identity.js'; +import { BridgeProtocolError } from './protocol.js'; +import { BridgeWorker } from './worker.js'; +import type { BridgeWorkerOptions } from './worker.js'; + +const workerId = 'recovery:vm'; +const codeApiUrl = 'https://code.example/proxy/v1'; +const incarnationId = 'worker-recovery-incarnation'; +const oldCredential = 'expired-short-lived-credential-value'; +const newCredential = 'recovered-short-lived-credential-value'; +const capabilities = { + statefulWorkspace: false, + sandboxProfile: 'nsjail', + runtimes: ['bash'], +}; + +function challenge() { + return { + protocolVersion: 1 as const, + operation: 'credential.recover' as const, + serverId: 'https://code.example', + workerId, + enrollmentGeneration: randomBytes(18).toString('base64url'), + challenge: randomBytes(32).toString('base64url'), + expiresAt: new Date(Date.now() + 60_000).toISOString(), + }; +} + +function credential() { + return { + protocolVersion: 1 as const, + workerId, + credential: newCredential, + expiresAt: new Date(Date.now() + 900_000).toISOString(), + }; +} + +function identity() { + return { + ...createBridgeIdentity(), + credential: oldCredential, + expiresAt: new Date(Date.now() - 60_000).toISOString(), + }; +} + +function workerOptions( + overrides: Partial = {}, +): BridgeWorkerOptions { + return { + codeApiUrl, + workerId, + incarnationId, + sandboxEndpoint: 'http://127.0.0.1:2000/api/v2', + identity: identity(), + capabilities, + ...overrides, + }; +} + +function expired() { + return Response.json( + { + code: 'CREDENTIAL_INVALID', + error: 'Worker credential is invalid or expired', + }, + { status: 401 }, + ); +} + +test('startup recovers the enrolled key and persists before registering with the new credential', async () => { + const key = identity(); + const recovered = credential(); + const issued = challenge(); + const requests: string[] = []; + let persisted = false; + const controller = new AbortController(); + const worker = new BridgeWorker( + workerOptions({ + identity: key, + onIdentityChange: next => { + assert.equal(key.credential, oldCredential); + assert.equal(next.privateKey, key.privateKey); + assert.equal(next.credential, newCredential); + persisted = true; + }, + fetchImpl: async (input, init) => { + const url = String(input); + const body = JSON.parse(String(init?.body)); + requests.push(url); + if (url.endsWith('/refresh')) return expired(); + if (url.endsWith('/challenge') || url.endsWith('/recover')) { + assert.equal( + (init?.headers as Record).Authorization, + undefined, + ); + assert.equal(init?.redirect, 'error'); + assert.ok(!String(init?.body).includes(key.privateKey)); + assert.ok(!String(init?.body).includes(oldCredential)); + assert.match( + url, + /\/proxy\/v1\/bridge\/workers\/recovery%3Avm\/credentials\//, + ); + } + if (url.endsWith('/challenge')) { + assert.equal(body.serverId, 'https://code.example'); + assert.equal(body.operation, 'credential.challenge'); + assert.equal(body.workerId, workerId); + assert.match(body.nonce, /^[A-Za-z0-9_-]{43}$/); + assert.equal( + verifyBridgeRecoveryStart(key.publicKey, body, body.signature), + true, + ); + return Response.json(issued); + } + if (url.endsWith('/recover')) { + assert.equal( + verifyBridgeRecovery(key.publicKey, body, body.signature), + true, + ); + assert.deepEqual( + { ...body, signature: undefined }, + { ...issued, signature: undefined }, + ); + return Response.json(recovered); + } + assert.equal(persisted, true); + const headers = init!.headers as Record; + assert.equal(headers.Authorization, `Bridge ${newCredential}`); + assert.equal( + verifyBridgeRequest( + key.publicKey, + { + credential: newCredential, + method: 'POST', + path: new URL(url).pathname, + timestamp: headers['X-LibreChat-Code-Timestamp'], + nonce: headers['X-LibreChat-Code-Nonce'], + body: String(init!.body), + }, + headers['X-LibreChat-Code-Signature'], + ), + true, + ); + if (url.endsWith('/register')) + return Response.json({ + protocolVersion: 1, + workerId, + incarnationId, + registeredAt: new Date().toISOString(), + leaseTtlMs: 60_000, + }); + controller.abort(); + return Response.json({ protocolVersion: 1, serverElapsedMs: 0 }); + }, + }), + ); + await worker.run(controller.signal); + assert.equal(key.credential, newCredential); + assert.equal(requests.filter(url => url.endsWith('/challenge')).length, 1); + assert.equal(requests.filter(url => url.endsWith('/recover')).length, 1); + assert.ok( + !requests.some( + url => + url.includes('pairing') || + url.includes('reset') || + url.includes('revoke'), + ), + ); +}); + +test('ordinary successful refresh does not probe recovery and static workers do not recover', async () => { + let calls = 0; + const worker = new BridgeWorker( + workerOptions({ + fetchImpl: async input => { + assert.ok(String(input).endsWith('/refresh')); + calls++; + return Response.json(credential()); + }, + }), + ); + await worker.refreshCredential(); + assert.equal(calls, 1); + const staticWorker = new BridgeWorker( + workerOptions({ + identity: undefined, + token: 'legacy', + fetchImpl: async () => { + assert.fail('static worker has no credential refresh'); + }, + }), + ); + await staticWorker.refreshCredential(); +}); + +for (const [status, code] of [ + [401, undefined], + [401, 'PROOF_INVALID'], + [401, 'PROOF_REPLAYED'], + [403, 'CREDENTIAL_INVALID'], + [409, 'WORKER_FENCED'], + [429, 'RECOVERY_RATE_LIMITED'], + [503, undefined], +] as const) { + test(`refresh failure ${status}/${code} never triggers recovery`, async () => { + let calls = 0; + const worker = new BridgeWorker( + workerOptions({ + fetchImpl: async input => { + calls++; + assert.ok(String(input).endsWith('/refresh')); + return Response.json({ code }, { status }); + }, + }), + ); + await assert.rejects( + worker.refreshCredential(), + error => error instanceof BridgeProtocolError && error.status === status, + ); + assert.equal(calls, 1); + }); +} + +test('active workers never bootstrap recovery after registration', async () => { + const key = identity(); + let recoveries = 0; + const worker = new BridgeWorker( + workerOptions({ + identity: key, + fetchImpl: async input => { + const url = String(input); + if (url.endsWith('/register')) + return Response.json({ + protocolVersion: 1, + workerId, + incarnationId, + registeredAt: new Date().toISOString(), + leaseTtlMs: 60_000, + }); + if (!url.endsWith('/refresh')) recoveries++; + return expired(); + }, + }), + ); + await worker.register(); + await assert.rejects(worker.refreshCredential(), { + status: 401, + code: 'CREDENTIAL_INVALID', + }); + assert.equal(recoveries, 0); +}); + +for (const status of [404, 405, 401, 403, 429, 503]) { + test(`recovery failure ${status} preserves enrollment, key and old local credential`, async () => { + const key = identity(); + const original = { ...key }; + let persisted = false; + const worker = new BridgeWorker( + workerOptions({ + identity: key, + onIdentityChange: () => { + persisted = true; + }, + fetchImpl: async input => { + if (String(input).endsWith('/refresh')) return expired(); + assert.ok(String(input).endsWith('/challenge')); + return status === 404 + ? new Response('upstream secret', { status }) + : Response.json( + { code: status === 401 ? 'ENROLLMENT_INVALID' : undefined }, + { status }, + ); + }, + }), + ); + await assert.rejects(worker.refreshCredential(), error => { + assert.ok(error instanceof BridgeProtocolError); + assert.equal( + error.status, + status === 404 || status === 405 ? 401 : status, + ); + assert.ok(!error.message.includes('upstream secret')); + if (status === 401) + assert.match(error.message, /revoked.*pairing|pairing.*revoked/i); + if (status === 404) + assert.match( + error.message, + /enable durable enrollment or approve a new pairing/, + ); + return true; + }); + assert.deepEqual(key, original); + assert.equal(persisted, false); + }); +} + +for (const codeApiUrl of [ + 'http://code.example/v1', + 'https://user:password@code.example/v1', + 'https://code.example/v1?token=secret', + 'https://code.example/v1#fragment', +]) { + test('recovery refuses unsafe server context without disclosing identity', async () => { + await assert.rejects( + recoverWorkerCredential({ + codeApiUrl, + workerId, + privateKey: identity().privateKey, + validThroughMs: Date.now(), + signal: AbortSignal.timeout(1000), + fetchImpl: async () => { + assert.fail('must not fetch'); + }, + }), + { code: 'RECOVERY_UNAVAILABLE', status: 401 }, + ); + }); +} + +const badChallenges: Array< + [string, (value: ReturnType) => unknown] +> = [ + ['null', () => null], + ['array', () => []], + ['wrong version', value => ({ ...value, protocolVersion: 2 })], + [ + 'wrong operation', + value => ({ ...value, operation: 'credential.challenge' }), + ], + ['other server', value => ({ ...value, serverId: 'https://other.example' })], + ['other machine', value => ({ ...value, workerId: 'other-worker' })], + ['bad generation', value => ({ ...value, enrollmentGeneration: 'short' })], + ['bad challenge', value => ({ ...value, challenge: 'short' })], + [ + 'expired', + value => ({ ...value, expiresAt: new Date(Date.now() - 1).toISOString() }), + ], + [ + 'unbounded lifetime', + value => ({ ...value, expiresAt: '2099-01-01T00:00:00Z' }), + ], + ['invalid date', value => ({ ...value, expiresAt: 'not a date' })], +]; +for (const [name, alter] of badChallenges) { + test(`recovery refuses ${name} challenge before signing completion`, async () => { + let calls = 0; + await assert.rejects( + recoverWorkerCredential({ + codeApiUrl, + workerId, + privateKey: identity().privateKey, + validThroughMs: Date.now(), + signal: AbortSignal.timeout(1000), + fetchImpl: async () => { + calls++; + return Response.json(alter(challenge())); + }, + }), + /invalid machine recovery challenge/, + ); + assert.equal(calls, 1); + }); +} + +for (const value of [ + null, + [], + { ...credential(), workerId: 'another-worker' }, + { ...credential(), credential: 'short' }, + { ...credential(), credential: 'x'.repeat(4097) }, + { ...credential(), expiresAt: 'bad' }, + { ...credential(), expiresAt: new Date(0).toISOString() }, +]) { + test('invalid recovered credentials are never adopted or persisted', async () => { + const key = identity(); + const worker = new BridgeWorker( + workerOptions({ + identity: key, + onIdentityChange: () => { + assert.fail('must not persist'); + }, + fetchImpl: async input => { + if (String(input).endsWith('/refresh')) return expired(); + if (String(input).endsWith('/challenge')) + return Response.json(challenge()); + return Response.json(value); + }, + }), + ); + await assert.rejects( + worker.refreshCredential(), + /invalid recovered worker credential/, + ); + assert.equal(key.credential, oldCredential); + }); +} + +test('oversized and non-JSON recovery responses have bounded, sanitized failures', async () => { + for (const source of ['sensitive upstream contents', 'x'.repeat(8193)]) { + await assert.rejects( + recoverWorkerCredential({ + codeApiUrl, + workerId, + privateKey: identity().privateKey, + validThroughMs: Date.now(), + signal: AbortSignal.timeout(1000), + fetchImpl: async () => new Response(source), + }), + error => { + assert.ok(!String(error).includes('sensitive upstream contents')); + assert.match( + String(error), + /invalid machine recovery response|exceeds 8 KiB/, + ); + return true; + }, + ); + } +}); + +test('failed persistence never adopts a recovered credential; retry proves the same key with a new nonce', async () => { + const key = identity(); + const nonces = new Set(); + let attempts = 0; + const worker = new BridgeWorker( + workerOptions({ + identity: key, + onIdentityChange: () => { + if (++attempts === 1) throw new Error('disk unavailable'); + }, + fetchImpl: async (input, init) => { + if (String(input).endsWith('/refresh')) return expired(); + if (String(input).endsWith('/challenge')) { + const body = JSON.parse(String(init?.body)); + assert.equal(nonces.has(body.nonce), false); + nonces.add(body.nonce); + assert.equal( + verifyBridgeRecoveryStart(key.publicKey, body, body.signature), + true, + ); + return Response.json(challenge()); + } + return Response.json(credential()); + }, + }), + ); + await assert.rejects(worker.refreshCredential(), /disk unavailable/); + assert.equal(key.credential, oldCredential); + await worker.refreshCredential(); + assert.equal(key.credential, newCredential); + assert.equal(attempts, 2); + assert.equal(nonces.size, 2); +}); + +test('concurrent callers share recovery and one cancellation cannot abort another waiter', async () => { + let release!: () => void; + let notify!: () => void; + const started = new Promise(resolve => { + notify = resolve; + }); + const ready = new Promise(resolve => { + release = resolve; + }); + const first = new AbortController(); + let challenges = 0; + let aborted = false; + const worker = new BridgeWorker( + workerOptions({ + fetchImpl: async (input, init) => { + if (String(input).endsWith('/refresh')) return expired(); + if (String(input).endsWith('/challenge')) { + challenges++; + return Response.json(challenge()); + } + init!.signal!.addEventListener('abort', () => { + aborted = true; + }); + notify(); + await ready; + return Response.json(credential()); + }, + }), + ); + const firstRefresh = worker.refreshCredential(first.signal); + await started; + const secondRefresh = worker.refreshCredential(); + first.abort(); + await assert.rejects(firstRefresh, { name: 'AbortError' }); + assert.equal(aborted, false); + release(); + await secondRefresh; + assert.equal(challenges, 1); + assert.equal(aborted, false); +}); + +test('last waiter cancellation aborts recovery without persisting or registering', async () => { + const controller = new AbortController(); + let notify!: () => void; + const started = new Promise(resolve => { + notify = resolve; + }); + let transportAborted = false; + const worker = new BridgeWorker( + workerOptions({ + onIdentityChange: () => { + assert.fail('must not persist'); + }, + fetchImpl: async (input, init) => { + if (String(input).endsWith('/refresh')) return expired(); + assert.ok(String(input).endsWith('/challenge')); + notify(); + return new Promise((_resolve, reject) => + init!.signal!.addEventListener( + 'abort', + () => { + transportAborted = true; + reject(new DOMException('aborted', 'AbortError')); + }, + { once: true }, + ), + ); + }, + }), + ); + const pending = worker.refreshCredential(controller.signal); + await started; + controller.abort(); + await assert.rejects(pending, { name: 'AbortError' }); + assert.equal(transportAborted, true); +}); + +test('challenge and recovery share the remaining credential-refresh deadline', async () => { + let challengeSignal: AbortSignal | null | undefined; + const worker = new BridgeWorker( + workerOptions({ + credentialRefreshTransportTimeoutMs: 100, + fetchImpl: async (input, init) => { + if (String(input).endsWith('/refresh')) return expired(); + if (String(input).endsWith('/challenge')) { + challengeSignal = init?.signal; + return Response.json(challenge()); + } + assert.equal(init?.signal, challengeSignal); + return new Promise((_resolve, reject) => + init!.signal!.addEventListener( + 'abort', + () => reject(new DOMException('aborted', 'AbortError')), + { once: true }, + ), + ); + }, + }), + ); + // Keep the event loop alive because AbortSignal.timeout timers are unref'ed. + const timer = setTimeout(() => {}, 2000); + try { + await assert.rejects(worker.refreshCredential(), { name: 'AbortError' }); + } finally { + clearTimeout(timer); + } + assert.equal(challengeSignal?.aborted, true); +}); + +test('revoked enrollment terminates startup instead of retrying or issuing a pairing', async () => { + let calls = 0; + const worker = new BridgeWorker( + workerOptions({ + onError: () => { + assert.fail('terminal auth failure must not reconnect'); + }, + fetchImpl: async input => { + calls++; + if (String(input).endsWith('/refresh')) return expired(); + assert.ok(String(input).endsWith('/challenge')); + return Response.json({ code: 'ENROLLMENT_INVALID' }, { status: 401 }); + }, + }), + ); + await assert.rejects(worker.run(), { + status: 401, + code: 'ENROLLMENT_INVALID', + }); + assert.equal(calls, 2); +}); + +test('recovery retries transient startup failure without replacing the enrolled key', async () => { + let challenges = 0; + let failures = 0; + const key = identity(); + const controller = new AbortController(); + const worker = new BridgeWorker( + workerOptions({ + identity: key, + reconnectDelayMs: 1, + reconnectMaxDelayMs: 1, + onError: error => { + assert.ok(error instanceof BridgeProtocolError); + assert.equal(error.status, 503); + failures++; + }, + fetchImpl: async (input, init) => { + const url = String(input); + if (url.endsWith('/refresh')) return expired(); + if (url.endsWith('/challenge')) { + const body = JSON.parse(String(init?.body)); + assert.equal( + verifyBridgeRecoveryStart(key.publicKey, body, body.signature), + true, + ); + return ++challenges === 1 + ? Response.json({}, { status: 503 }) + : Response.json(challenge()); + } + if (url.endsWith('/recover')) return Response.json(credential()); + if (url.endsWith('/register')) + return Response.json({ + protocolVersion: 1, + workerId, + incarnationId, + registeredAt: new Date().toISOString(), + leaseTtlMs: 60_000, + }); + controller.abort(); + return Response.json({ protocolVersion: 1, serverElapsedMs: 0 }); + }, + }), + ); + await worker.run(controller.signal); + assert.equal(challenges, 2); + assert.equal(failures, 1); + assert.equal(key.credential, newCredential); +}); + +test('revocation between challenge and completion cannot restore access', async () => { + let persisted = false; + const worker = new BridgeWorker( + workerOptions({ + onIdentityChange: () => { + persisted = true; + }, + fetchImpl: async input => { + if (String(input).endsWith('/refresh')) return expired(); + if (String(input).endsWith('/challenge')) + return Response.json(challenge()); + assert.ok(String(input).endsWith('/recover')); + return Response.json({ code: 'ENROLLMENT_INVALID' }, { status: 401 }); + }, + }), + ); + await assert.rejects(worker.run(), { + code: 'ENROLLMENT_INVALID', + status: 401, + }); + assert.equal(persisted, false); +}); + +test('successful credential recovery never clears an existing workspace quarantine', async () => { + let checks = 0; + const worker = new BridgeWorker( + workerOptions({ + workspaceMutationQuarantine: { + assertAvailable: async () => { + checks++; + throw new BridgeProtocolError( + 'Workspace requires inspection', + 409, + 'WORKER_QUARANTINED', + ); + }, + arm: async () => { + assert.fail('must not arm'); + }, + clear: async () => { + assert.fail('must not clear'); + }, + quarantine: async () => { + assert.fail('must not change quarantine'); + }, + }, + fetchImpl: async input => { + if (String(input).endsWith('/refresh')) return expired(); + if (String(input).endsWith('/challenge')) + return Response.json(challenge()); + assert.ok(String(input).endsWith('/recover')); + return Response.json(credential()); + }, + }), + ); + await assert.rejects(worker.run(), { code: 'WORKER_QUARANTINED' }); + assert.equal(checks, 1); +}); diff --git a/packages/code/src/credential-recovery.ts b/packages/code/src/credential-recovery.ts new file mode 100644 index 00000000..01aae3fd --- /dev/null +++ b/packages/code/src/credential-recovery.ts @@ -0,0 +1,223 @@ +import { randomBytes } from 'node:crypto'; + +import { signBridgeRecovery, signBridgeRecoveryStart } from './identity.js'; +import { + BRIDGE_PROTOCOL_VERSION, + BridgeProtocolError, + bridgeWorkerPath, +} from './protocol.js'; +import type { + BridgeRecoveryChallengeResponse, + BridgeWorkerCredentialResponse, +} from './protocol.js'; + +interface RecoverWorkerCredentialOptions { + codeApiUrl: string; + workerId: string; + privateKey: string; + validThroughMs: number; + fetchImpl: typeof fetch; + signal: AbortSignal; +} + +const MAX_RESPONSE_BYTES = 8192; +const MAX_CHALLENGE_LIFETIME_MS = 6 * 60_000; + +async function responsePayload(response: Response): Promise { + const reader = response.body?.getReader(); + if (!reader) + throw new BridgeProtocolError( + 'Code API returned an empty machine recovery response', + 502, + ); + const chunks: Uint8Array[] = []; + let size = 0; + try { + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + size += value.byteLength; + if (size > MAX_RESPONSE_BYTES) { + throw new BridgeProtocolError( + 'Code API machine recovery response exceeds 8 KiB', + 502, + ); + } + chunks.push(value); + } + try { + return JSON.parse( + new TextDecoder('utf-8', { fatal: true }).decode(Buffer.concat(chunks)), + ) as unknown; + } catch { + throw new BridgeProtocolError( + 'Code API returned an invalid machine recovery response', + 502, + ); + } + } finally { + await reader.cancel().catch(() => undefined); + reader.releaseLock(); + } +} + +function record(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +async function recoveryRequest( + url: string, + body: object, + options: RecoverWorkerCredentialOptions, +): Promise { + options.signal.throwIfAborted(); + const response = await options.fetchImpl(url, { + method: 'POST', + redirect: 'error', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + signal: options.signal, + }); + if (!response.ok) { + // Older/disabled servers may return HTML. Do not echo untrusted response text. + let payload: unknown; + try { + payload = await responsePayload(response); + } catch { + /* Status is sufficient. */ + } + options.signal.throwIfAborted(); + if (response.status === 404 || response.status === 405) { + throw new BridgeProtocolError( + 'Machine credential expired or is unavailable. This Code API does not support enabled machine recovery; enable durable enrollment or approve a new pairing in LibreChat.', + 401, + 'RECOVERY_UNAVAILABLE', + ); + } + const code = + record(payload) && + typeof payload.code === 'string' && + [ + 'ENROLLMENT_INVALID', + 'PROOF_INVALID', + 'PROOF_REPLAYED', + 'CHALLENGE_INVALID', + 'RECOVERY_RATE_LIMITED', + ].includes(payload.code) + ? payload.code + : undefined; + const retryable = + response.status === 408 || + response.status === 429 || + response.status >= 500; + throw new BridgeProtocolError( + code === 'ENROLLMENT_INVALID' + ? 'Machine enrollment is unavailable or revoked. Approve a new pairing in LibreChat; recovery cannot restore revoked access.' + : `Machine credential recovery failed with HTTP ${response.status}${retryable ? '; reconnect will retry' : '; check enrollment and approve a new pairing in LibreChat if needed'}`, + retryable ? response.status : response.status === 403 ? 403 : 401, + code, + ); + } + const payload = await responsePayload(response); + options.signal.throwIfAborted(); + return payload; +} + +/** Recover only existing enrollment. Never pair, replace a key, or reset workspace state. */ +export async function recoverWorkerCredential( + options: RecoverWorkerCredentialOptions, +): Promise { + options.signal.throwIfAborted(); + const server = new URL(options.codeApiUrl); + if ( + server.protocol !== 'https:' || + server.username || + server.password || + server.search || + server.hash || + server.origin.length > 256 + ) { + throw new BridgeProtocolError( + 'Automatic machine recovery requires the enrolled Code API HTTPS origin; use the existing pairing flow for this endpoint.', + 401, + 'RECOVERY_UNAVAILABLE', + ); + } + const url = `${options.codeApiUrl.replace(/\/+$/, '')}${bridgeWorkerPath(options.workerId)}/credentials`; + const start = { + operation: 'credential.challenge' as const, + serverId: server.origin, + workerId: options.workerId, + timestamp: new Date().toISOString(), + nonce: randomBytes(32).toString('base64url'), + }; + const payload = await recoveryRequest( + `${url}/challenge`, + { + protocolVersion: BRIDGE_PROTOCOL_VERSION, + ...start, + signature: signBridgeRecoveryStart(options.privateKey, start), + }, + options, + ); + if ( + !record(payload) || + payload.protocolVersion !== BRIDGE_PROTOCOL_VERSION || + payload.operation !== 'credential.recover' || + payload.serverId !== server.origin || + payload.workerId !== options.workerId || + typeof payload.enrollmentGeneration !== 'string' || + !/^[A-Za-z0-9_-]{24}$/.test(payload.enrollmentGeneration) || + typeof payload.challenge !== 'string' || + !/^[A-Za-z0-9_-]{43}$/.test(payload.challenge) || + typeof payload.expiresAt !== 'string' || + !Number.isFinite(Date.parse(payload.expiresAt)) || + Date.parse(payload.expiresAt) <= Date.now() || + Date.parse(payload.expiresAt) > Date.now() + MAX_CHALLENGE_LIFETIME_MS + ) { + throw new BridgeProtocolError( + 'Code API returned an invalid machine recovery challenge', + 502, + ); + } + // Sign only the validated recovery operation, never arbitrary server-supplied fields. + const challenge: BridgeRecoveryChallengeResponse = { + protocolVersion: BRIDGE_PROTOCOL_VERSION, + operation: 'credential.recover', + serverId: server.origin, + workerId: options.workerId, + enrollmentGeneration: payload.enrollmentGeneration, + challenge: payload.challenge, + expiresAt: payload.expiresAt, + }; + const credential = await recoveryRequest( + `${url}/recover`, + { + ...challenge, + signature: signBridgeRecovery(options.privateKey, challenge), + }, + options, + ); + if ( + !record(credential) || + credential.protocolVersion !== BRIDGE_PROTOCOL_VERSION || + credential.workerId !== options.workerId || + typeof credential.credential !== 'string' || + credential.credential.length < 32 || + credential.credential.length > 4096 || + typeof credential.expiresAt !== 'string' || + !Number.isFinite(Date.parse(credential.expiresAt)) || + Date.parse(credential.expiresAt) <= options.validThroughMs + ) { + throw new BridgeProtocolError( + 'Code API returned an invalid recovered worker credential', + 502, + ); + } + return { + protocolVersion: BRIDGE_PROTOCOL_VERSION, + workerId: options.workerId, + credential: credential.credential, + expiresAt: credential.expiresAt, + }; +} diff --git a/packages/code/src/index.ts b/packages/code/src/index.ts index 6f94b190..d8c53f41 100644 --- a/packages/code/src/index.ts +++ b/packages/code/src/index.ts @@ -1,5 +1,6 @@ export * from './protocol.js'; export * from './identity.js'; +export * from './browser-enrollment-protocol.js'; export * from './pairing.js'; export * from './storage.js'; export * from './runtime.js'; diff --git a/packages/code/src/linked-worktrees.ts b/packages/code/src/linked-worktrees.ts index 225abebd..609bfbd8 100644 --- a/packages/code/src/linked-worktrees.ts +++ b/packages/code/src/linked-worktrees.ts @@ -18,7 +18,7 @@ import type { WorkspaceToolRequest, WorkspaceToolResult, } from './protocol.js'; -import type { WorkspaceToolExecutor } from './workspace.js'; +import type { WorkspaceFileImporter, WorkspaceToolExecutor } from './workspace.js'; /** Linked worktrees are only admitted from this directory beneath a checkout. */ export const LINKED_WORKTREE_DIRECTORY = '.worktrees'; @@ -60,6 +60,7 @@ export interface LinkedWorktreeSource { command?: NativeProcessSandboxOptions; repositoryInstructions: boolean; writable: boolean; + importer?: WorkspaceFileImporter; } export interface VerifiedLinkedWorktree { @@ -324,6 +325,7 @@ export class LinkedWorktreeWorkspaceTools implements WorkspaceToolExecutor, Link fingerprint, value: LocalWorkspaceTools.create({ repositoryInstructions: source.repositoryInstructions, + importer: source.importer, workspaces: [ { id: internalId, identity: lane.identity, root: lane.root, writable: source.writable }, ], diff --git a/packages/code/src/native-pool.test.ts b/packages/code/src/native-pool.test.ts index ce25caa4..2a339d59 100644 --- a/packages/code/src/native-pool.test.ts +++ b/packages/code/src/native-pool.test.ts @@ -14,6 +14,37 @@ const request = (workspaceId: string): WorkspaceExecuteCommandRequest => ({ command: 'fixture', }); +test('pool distinguishes same-root contention from exhausted machine capacity', async () => { + let release!: () => void; + let entered!: () => void; + const running = new Promise(resolve => { entered = resolve; }); + const finish = new Promise(resolve => { release = resolve; }); + const pool = new NativeWorkspaceCommandPool(roots, 1, () => ({ + async prepare() {}, async close() {}, + async execute(req) { + entered(); + await finish; + return { ...req, stdout: '', stderr: '', exitCode: 0, timedOut: false, truncated: false }; + }, + })); + const first = pool.execute(request('a')); + await running; + try { + for (const [root, reason] of [['a', 'workspace_busy'], ['b', 'capacity_exhausted']]) { + await assert.rejects(pool.execute(request(root!)), error => { + assert.ok(error instanceof WorkspaceToolError); + assert.deepEqual(error.commandDiagnostic, { stage: 'allocation', reason }); + assert.equal(error.mutationMayHaveCommitted, false); + return true; + }); + } + } finally { + release(); + await first; + await pool.close(); + } +}); + test('failed provisioning roots are removed only after confirmed executor cleanup', async () => { let failClose = true; const pool = new NativeWorkspaceCommandPool(roots, 2, () => ({ diff --git a/packages/code/src/native-pool.ts b/packages/code/src/native-pool.ts index 59db3e0a..60ba4b43 100644 --- a/packages/code/src/native-pool.ts +++ b/packages/code/src/native-pool.ts @@ -1,5 +1,6 @@ import { NativeProcessWorkspaceCommandSandbox } from './native-process.js'; import { WorkspaceToolError } from './workspace.js'; +import { commandFailureDiagnostic } from './command-diagnostics.js'; import type { NativeProcessSandboxOptions } from './native-process.js'; import type { BridgeWorkspaceProgrammaticRequest, @@ -122,7 +123,8 @@ export class NativeWorkspaceCommandPool { if (entry?.busy) throw new WorkspaceToolError( 'Native workspace already executing', - 'COMMAND_UNAVAILABLE' + 'COMMAND_UNAVAILABLE', false, false, + commandFailureDiagnostic('allocation', 'workspace_busy'), ); if (!entry) { if (this.entries.size >= this.capacity) { @@ -132,9 +134,18 @@ export class NativeWorkspaceCommandPool { if (!idle) throw new WorkspaceToolError( 'Native executor capacity reached', - 'COMMAND_UNAVAILABLE' + 'COMMAND_UNAVAILABLE', false, false, + commandFailureDiagnostic('allocation', 'capacity_exhausted'), ); - await idle[1].sandbox.close(); + try { + await idle[1].sandbox.close(); + } catch (error) { + const diagnostic = commandFailureDiagnostic('cleanup', 'cleanup_failed', error); + throw error instanceof WorkspaceToolError + ? new WorkspaceToolError(error.message, error.code, error.mutationMayHaveCommitted, + error.requiresQuarantine, error.commandDiagnostic ?? diagnostic) + : new WorkspaceToolError('Native executor allocation failed', 'COMMAND_UNAVAILABLE', false, false, diagnostic); + } this.entries.delete(idle[0]); } entry = { @@ -151,10 +162,15 @@ export class NativeWorkspaceCommandPool { const checked = pending.catch((error) => { // Allocation/idle eviction precedes dispatch into the requested root. // Do not turn a pool resource failure into an uncertain mutation there. - if (error instanceof WorkspaceToolError) throw error; + if (error instanceof WorkspaceToolError) { + if (error.commandDiagnostic) throw error; + throw new WorkspaceToolError(error.message, error.code, error.mutationMayHaveCommitted, + error.requiresQuarantine, commandFailureDiagnostic('allocation', 'allocation_failed', error)); + } throw new WorkspaceToolError( 'Native executor allocation failed', - 'COMMAND_UNAVAILABLE' + 'COMMAND_UNAVAILABLE', false, false, + commandFailureDiagnostic('allocation', 'allocation_failed', error), ); }); this.allocation = checked.catch(() => undefined); @@ -236,7 +252,8 @@ export class NativeWorkspaceCommandPool { if (!entry.sandbox.executeProgrammatic) { throw new WorkspaceToolError( 'Native programmatic executor is unavailable', - 'COMMAND_UNAVAILABLE' + 'COMMAND_UNAVAILABLE', false, false, + commandFailureDiagnostic('programmatic_preflight', 'prerequisite_failed'), ); } return await entry.sandbox.executeProgrammatic( diff --git a/packages/code/src/native-process-child.ts b/packages/code/src/native-process-child.ts index c3b9614b..e0e0d322 100644 --- a/packages/code/src/native-process-child.ts +++ b/packages/code/src/native-process-child.ts @@ -1,6 +1,7 @@ import { NativeSrtWorkspaceCommandSandbox } from './native-sandbox.js'; import { NativeWorkspaceProgrammaticExecutor } from './native-programmatic.js'; import { WorkspaceToolError } from './workspace.js'; +import { commandFailureDiagnostic } from './command-diagnostics.js'; import type { NativeSrtWorkspaceCommandSandboxOptions } from './native-sandbox.js'; import type { BridgeWorkspaceProgrammaticRequest, @@ -189,6 +190,14 @@ process.on('message', async (raw: unknown) => { reply({ id: message.id, ok: false, + diagnostic: + error instanceof WorkspaceToolError && error.commandDiagnostic + ? error.commandDiagnostic + : commandFailureDiagnostic( + message.type === 'prepare' ? 'sandbox_startup' : message.type === 'close' ? 'cleanup' : 'dispatch', + message.type === 'prepare' ? 'startup_failed' : message.type === 'close' ? 'cleanup_failed' : 'executor_rejected', + error, + ), code: error instanceof WorkspaceToolError ? error.code diff --git a/packages/code/src/native-process.test.ts b/packages/code/src/native-process.test.ts index 8abf263c..afdad6f8 100644 --- a/packages/code/src/native-process.test.ts +++ b/packages/code/src/native-process.test.ts @@ -484,6 +484,7 @@ test('programmatic staging watchdog expires without claiming a workspace mutatio execution, (error: unknown) => error instanceof WorkspaceToolError && + error.commandDiagnostic?.reason === 'rpc_timeout' && !error.mutationMayHaveCommitted && !error.requiresQuarantine, ); @@ -876,3 +877,92 @@ test('executor classifies every pre-dispatch setup failure as mutation-atomic', await sandbox.close(); } }); + +test('executor diagnoses setup stages without dispatching or retaining private exception values', async () => { + for (const failure of ['fork', 'credential', 'wrapper'] as const) { + const fake = fixture(); + const privateError = Object.assign(new Error('secret /private/path command'), { code: 'ENOSPC' }); + const sandbox = new NativeProcessWorkspaceCommandSandbox({ + workspaceRoot: '/workspace', + maskedEnvironment: { + variables: [], + async resolve() { + if (failure === 'credential') throw privateError; + return {}; + }, + wrapCommand(command) { + if (failure === 'wrapper') throw privateError; + return command; + }, + }, + }, failure === 'fork' ? () => { throw privateError; } : fake.fork); + await assert.rejects(sandbox.execute(request), error => { + assert.ok(error instanceof WorkspaceToolError); + assert.equal(error.code, 'COMMAND_UNAVAILABLE'); + assert.equal(error.mutationMayHaveCommitted, false); + assert.equal(error.requiresQuarantine, false); + assert.deepEqual(error.commandDiagnostic, { + stage: failure === 'fork' ? 'sandbox_startup' : failure === 'credential' ? 'credential_resolution' : 'command_wrapping', + reason: failure === 'fork' ? 'startup_failed' : failure === 'credential' ? 'credential_failed' : 'wrapping_failed', + osCode: 'ENOSPC', + }); + assert.equal(JSON.stringify(error.commandDiagnostic).includes('secret'), false); + return true; + }); + assert.equal(fake.messages.some(m => m.type === 'execute'), false); + await sandbox.close(); + } +}); + +test('executor preserves sanitized child diagnostics and mutation uncertainty', async () => { + const fake = fixture((child, message) => child.emit('message', { + id: message.id, ok: false, mutation: true, + diagnostic: { stage: 'dispatch', reason: 'executor_rejected', osCode: 'EIO', secret: 'token' }, + })); + const sandbox = new NativeProcessWorkspaceCommandSandbox({ workspaceRoot: '/workspace' }, fake.fork); + await assert.rejects(sandbox.execute(request), error => { + assert.ok(error instanceof WorkspaceToolError); + assert.deepEqual(error.commandDiagnostic, { stage: 'dispatch', reason: 'executor_rejected', osCode: 'EIO' }); + assert.equal(error.mutationMayHaveCommitted, true); + assert.equal(error.requiresQuarantine, true); + return true; + }); + await sandbox.close(); +}); + +test('programmatic startup diagnoses child preparation failures before mutation', async () => { + const fake = fixture(undefined, (child, message) => child.emit('message', { + id: message.id, ok: false, + diagnostic: { stage: 'sandbox_startup', reason: 'startup_failed', osCode: 'ENOSPC' }, + })); + const sandbox = new NativeProcessWorkspaceCommandSandbox({ + workspaceRoot: '/workspace', programmaticFileUpstream: 'http://127.0.0.1:3190', + }, fake.fork); + await assert.rejects(sandbox.executeProgrammatic('primary', { + body: { language: 'bash', version: '5.2', session_id: 'session-1', files: [{ name: 'main.sh', content: 'echo ok' }] }, headers: {}, + }), error => { + assert.ok(error instanceof WorkspaceToolError); + assert.deepEqual(error.commandDiagnostic, { stage: 'sandbox_startup', reason: 'startup_failed', osCode: 'ENOSPC' }); + assert.equal(error.mutationMayHaveCommitted, false); + return true; + }); + assert.equal(fake.messages.some(m => m.type === 'programmatic'), false); + await sandbox.close(); +}); + +test('real executor IPC carries a startup errno without including the workspace path in diagnostics', async t => { + const directory = await mkdtemp(join(tmpdir(), 'command-diagnostic-')); + const sandbox = new NativeProcessWorkspaceCommandSandbox({ workspaceRoot: join(directory, 'missing-private-root') }); + t.after(async () => { + await sandbox.close(); + await rm(directory, { recursive: true, force: true }); + }); + await assert.rejects(sandbox.execute(request), error => { + assert.ok(error instanceof WorkspaceToolError); + assert.equal(error.code, 'COMMAND_UNAVAILABLE'); + assert.equal(error.mutationMayHaveCommitted, false); + assert.deepEqual(error.commandDiagnostic, { stage: 'sandbox_startup', reason: 'startup_failed', osCode: 'ENOENT' }); + assert.equal(JSON.stringify(error.commandDiagnostic).includes(directory), false); + return true; + }); +}); diff --git a/packages/code/src/native-process.ts b/packages/code/src/native-process.ts index 588f4c33..acb1c043 100644 --- a/packages/code/src/native-process.ts +++ b/packages/code/src/native-process.ts @@ -5,6 +5,12 @@ import { access, realpath } from 'node:fs/promises'; import { isAbsolute, join, relative, resolve, sep } from 'node:path'; import { promisify } from 'node:util'; import { WorkspaceToolError } from './workspace.js'; +import { + commandFailureDiagnostic, + commandSetupFailureDiagnostic, + readCommandFailureDiagnostic, +} from './command-diagnostics.js'; +import type { CommandFailureReason, CommandSetupStage } from './command-diagnostics.js'; import { NATIVE_PROGRAMMATIC_COMMAND } from './native-programmatic.js'; import { BRIDGE_WORKSPACE_COMMAND_MAX_TIMEOUT_MS, @@ -171,11 +177,13 @@ export function nativeExecutorEnvironment( /** The executor process was lost, refused a send, or stalled past its * deadline, as opposed to a failure the executor reported explicitly. */ class NativeExecutorUnavailableError extends WorkspaceToolError { - constructor(mutation: boolean) { + constructor(mutation: boolean, reason: CommandFailureReason = 'executor_unavailable') { super( 'Native executor is unavailable', 'COMMAND_UNAVAILABLE', mutation, + mutation, + commandFailureDiagnostic('executor_transport', reason), ); this.name = 'NativeExecutorUnavailableError'; } @@ -227,8 +235,8 @@ export class NativeProcessWorkspaceCommandSandbox implements WorkspaceCommandSan return this.ready; } - private unavailable(mutation: boolean): WorkspaceToolError { - return new NativeExecutorUnavailableError(mutation); + private unavailable(mutation: boolean, reason?: CommandFailureReason): WorkspaceToolError { + return new NativeExecutorUnavailableError(mutation, reason); } private async resolveProgrammaticExecutables(): Promise<{ @@ -271,6 +279,7 @@ export class NativeProcessWorkspaceCommandSandbox implements WorkspaceCommandSan errorMessage?: unknown; fatal?: unknown; phase?: unknown; + diagnostic?: unknown; }; if ( !message || @@ -323,13 +332,14 @@ export class NativeProcessWorkspaceCommandSandbox implements WorkspaceCommandSan code, mutationMayHaveCommitted, mutationMayHaveCommitted && !processTerminationConfirmed, + readCommandFailureDiagnostic(message.diagnostic) ?? commandFailureDiagnostic('dispatch', 'executor_rejected'), ), ); } }); const lost = () => { this.failed = true; - this.pending?.reject(this.unavailable(this.pending.mutation)); + this.pending?.reject(this.unavailable(this.pending.mutation, 'executor_lost')); }; child.on('error', lost); child.on('exit', lost); @@ -407,6 +417,8 @@ export class NativeProcessWorkspaceCommandSandbox implements WorkspaceCommandSan throw new WorkspaceToolError( 'Native programmatic file transport is unavailable', 'COMMAND_UNAVAILABLE', + false, false, + commandFailureDiagnostic('programmatic_preflight', 'prerequisite_failed'), ); } const active = this.executeProgrammaticOnce( @@ -435,16 +447,20 @@ export class NativeProcessWorkspaceCommandSandbox implements WorkspaceCommandSan let credentials: Record | undefined; let wrappedCommand: string | undefined; let programmaticExecutables: { shellPath: string; jqPath: string }; + let setupStage: CommandSetupStage = 'sandbox_startup'; try { await this.prepare(); if (signal?.aborted) throw new Error('aborted'); + setupStage = 'programmatic_preflight'; programmaticExecutables = await this.resolveProgrammaticExecutables(); if (signal?.aborted) throw new Error('aborted'); + setupStage = 'credential_resolution'; credentials = await this.options.maskedEnvironment?.resolve( signal, this.options.workspaceRoot, ); if (signal?.aborted) throw new Error('aborted'); + setupStage = 'command_wrapping'; wrappedCommand = this.options.maskedEnvironment?.wrapCommand?.( NATIVE_PROGRAMMATIC_COMMAND, process.platform, @@ -458,11 +474,13 @@ export class NativeProcessWorkspaceCommandSandbox implements WorkspaceCommandSan 'EXECUTION_ABORTED', ); } + const diagnostic = commandSetupFailureDiagnostic(setupStage, error); throw error instanceof WorkspaceToolError - ? new WorkspaceToolError(error.message, error.code, false) + ? new WorkspaceToolError(error.message, error.code, false, false, error.commandDiagnostic ?? diagnostic) : new WorkspaceToolError( 'Native programmatic executor setup failed before dispatch', 'COMMAND_UNAVAILABLE', + false, false, diagnostic, ); } const result = await this.rpc( @@ -534,12 +552,15 @@ export class NativeProcessWorkspaceCommandSandbox implements WorkspaceCommandSan ); let credentials: Record | undefined; let wrappedCommand: string | undefined; + let setupStage: CommandSetupStage = 'sandbox_startup'; try { await this.prepare(); if (signal?.aborted) throw new Error('aborted'); const cwd = resolve(this.options.workspaceRoot, request.cwd ?? '.'); + setupStage = 'credential_resolution'; credentials = await this.options.maskedEnvironment?.resolve(signal, cwd); if (signal?.aborted) throw new Error('aborted'); + setupStage = 'command_wrapping'; wrappedCommand = this.options.maskedEnvironment?.wrapCommand?.( request.command, process.platform, @@ -554,11 +575,13 @@ export class NativeProcessWorkspaceCommandSandbox implements WorkspaceCommandSan 'Command aborted', 'EXECUTION_ABORTED', ); + const diagnostic = commandSetupFailureDiagnostic(setupStage, error); throw error instanceof WorkspaceToolError - ? new WorkspaceToolError(error.message, error.code, false) + ? new WorkspaceToolError(error.message, error.code, false, false, error.commandDiagnostic ?? diagnostic) : new WorkspaceToolError( 'Native executor setup failed before dispatch', 'COMMAND_UNAVAILABLE', + false, false, diagnostic, ); } const result = await this.rpc( @@ -610,8 +633,8 @@ export class NativeProcessWorkspaceCommandSandbox implements WorkspaceCommandSan if (timer) clearTimeout(timer); timer = this.scheduleRpcTimeout(() => { this.failed = true; + reject(this.unavailable(this.pending?.mutation ?? mutation, 'rpc_timeout')); this.terminate(); - reject(this.unavailable(this.pending?.mutation ?? mutation)); }, timeoutMs); }; this.pending = { @@ -627,8 +650,8 @@ export class NativeProcessWorkspaceCommandSandbox implements WorkspaceCommandSan signal?.addEventListener('abort', abort, { once: true }); const sendFailed = () => { this.failed = true; + reject(this.unavailable(mutation, 'send_failed')); this.terminate(); - reject(this.unavailable(mutation)); }; try { child.send({ type, id, ...payload }, error => { diff --git a/packages/code/src/native-programmatic.test.ts b/packages/code/src/native-programmatic.test.ts index 902c707a..076f141c 100644 --- a/packages/code/src/native-programmatic.test.ts +++ b/packages/code/src/native-programmatic.test.ts @@ -1,7 +1,7 @@ import assert from 'node:assert/strict'; import { createHash } from 'node:crypto'; import { createServer } from 'node:http'; -import { mkdir, mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises'; +import { chmod, mkdir, mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import test from 'node:test'; @@ -389,6 +389,112 @@ test('preflights copy-on-write isolation and removes its private snapshot', asyn } }); +test('programmatic prerequisite failures retain their stage before executor dispatch', async t => { + const scratch = await mkdtemp(join(tmpdir(), 'native-ptc-diagnostic-')); + t.after(() => rm(scratch, { recursive: true, force: true })); + for (const failure of ['missing_probe', 'private_storage', 'isolation_probe'] as const) { + let executions = 0; + const executor = new NativeWorkspaceProgrammaticExecutor({ + upstreamUrl: 'http://127.0.0.1:1', + sandbox: { + async createExecutionDirectory() { + if (failure === 'private_storage') throw Object.assign(new Error('secret path'), { code: 'ENOSPC' }); + return await mkdtemp(join(scratch, 'execution-')); + }, + ...(failure === 'missing_probe' ? {} : { + async createProgrammaticProbeWorkspace() { + throw new WorkspaceToolError('Probe isolation unavailable', 'COMMAND_UNAVAILABLE'); + }, + }), + async executeProgrammatic() { executions += 1; throw new Error('must not run'); }, + }, + }); + await assert.rejects(executor.prepare(), error => { + assert.ok(error instanceof WorkspaceToolError); + assert.equal(error.code, 'COMMAND_UNAVAILABLE'); + assert.equal(error.mutationMayHaveCommitted, false); + assert.equal(error.requiresQuarantine, false); + assert.deepEqual(error.commandDiagnostic, { + stage: 'programmatic_preflight', reason: 'prerequisite_failed', + ...(failure === 'private_storage' ? { osCode: 'ENOSPC' } : {}), + }); + assert.equal(JSON.stringify(error.commandDiagnostic).includes('secret'), false); + return true; + }); + assert.equal(executions, 0); + assert.deepEqual(await readdir(scratch), []); + } +}); + +for (const phase of ['preflight', 'probe', 'commit'] as const) { + test(`programmatic ${phase} cleanup failures retain cleanup diagnostics and mutation flags`, { + skip: process.platform === 'win32' || process.getuid?.() === 0, + }, async t => { + const scratch = await mkdtemp(join(tmpdir(), 'native-ptc-cleanup-diagnostic-')); + t.after(async () => { + await chmod(scratch, 0o700); + await rm(scratch, { recursive: true, force: true }); + }); + const executor = new NativeWorkspaceProgrammaticExecutor({ + upstreamUrl: 'http://127.0.0.1:1', + sandbox: { + async createExecutionDirectory() { return await mkdtemp(join(scratch, 'execution-')); }, + async createProgrammaticProbeWorkspace(directory) { + const workspace = join(directory, 'workspace'); + await mkdir(workspace); + if (phase === 'preflight') await chmod(scratch, 0o500); + return workspace; + }, + async executeProgrammatic(request) { + await chmod(scratch, 0o500); + if (phase === 'probe') throw new WorkspaceToolError('Probe failed', 'COMMAND_UNAVAILABLE'); + return { protocolVersion: 1, operation: 'execute_command', workspaceId: request.workspaceId, + stdout: '', stderr: '', exitCode: 0, timedOut: false, truncated: false }; + }, + }, + }); + const execution = phase === 'preflight' ? executor.prepare() : executor.execute({ + headers: {}, body: { language: 'bash', version: '5.2.0', session_id: 'session', + replay_tool_count: phase === 'probe' ? 1 : 0, files: [{ name: 'main.sh', content: 'true' }] }, + }, 'primary'); + await assert.rejects(execution, error => { + assert.ok(error instanceof WorkspaceToolError); + assert.equal(error.code, 'COMMAND_UNAVAILABLE'); + assert.equal(error.commandDiagnostic?.stage, 'cleanup'); + assert.equal(error.commandDiagnostic?.reason, 'cleanup_failed'); + assert.ok(['EACCES', 'EPERM'].includes(error.commandDiagnostic?.osCode ?? '')); + assert.equal(error.mutationMayHaveCommitted, phase === 'commit'); + assert.equal(error.requiresQuarantine, phase === 'commit'); + return true; + }); + }); +} + +test('programmatic commit normalization preserves the executor diagnostic', async t => { + const scratch = await mkdtemp(join(tmpdir(), 'native-ptc-commit-diagnostic-')); + t.after(() => rm(scratch, { recursive: true, force: true })); + const diagnostic = { stage: 'executor_transport' as const, reason: 'executor_lost' as const }; + const executor = new NativeWorkspaceProgrammaticExecutor({ + upstreamUrl: 'http://127.0.0.1:1', + sandbox: { + async createExecutionDirectory() { return await mkdtemp(join(scratch, 'execution-')); }, + async executeProgrammatic() { + throw new WorkspaceToolError('Executor unavailable', 'COMMAND_UNAVAILABLE', false, false, diagnostic); + }, + }, + }); + await assert.rejects(executor.execute({ + headers: {}, body: { language: 'bash', version: '5.2.0', session_id: 'session', + files: [{ name: 'main.sh', content: 'true' }] }, + }, 'primary'), error => { + assert.ok(error instanceof WorkspaceToolError); + assert.equal(error.mutationMayHaveCommitted, true); + assert.equal(error.requiresQuarantine, true); + assert.deepEqual(error.commandDiagnostic, diagnostic); + return true; + }); +}); + test('keeps replay probes read-only and commits the script exactly once', async () => { const scratch = await mkdtemp(join(tmpdir(), 'native-ptc-probe-test-')); const phases: boolean[] = []; @@ -622,7 +728,8 @@ for (const failure of ['truncated', 'process-error']) test(`a failed speculative async createProgrammaticProbeWorkspace(directory) { const root = join(directory, 'workspace'); await mkdir(root); return root; }, async executeProgrammatic(request, _directory, _signal, options) { assert.equal(options?.probe, true); - if (failure === 'process-error') throw new WorkspaceToolError('probe output exceeded its limit', 'COMMAND_UNAVAILABLE', true, true); + if (failure === 'process-error') throw new WorkspaceToolError('probe output exceeded its limit', 'COMMAND_UNAVAILABLE', true, true, + { stage: 'executor_transport', reason: 'executor_lost' }); return { protocolVersion: 1, operation: 'execute_command', workspaceId: request.workspaceId, exitCode: 0, stdout: '', stderr: '', truncated: true, timedOut: false }; }, @@ -635,6 +742,8 @@ for (const failure of ['truncated', 'process-error']) test(`a failed speculative assert.match(String(error), /probe output exceeded/); assert.equal((error as { requiresQuarantine: boolean }).requiresQuarantine, false); assert.equal((error as { mutationMayHaveCommitted: boolean }).mutationMayHaveCommitted, false); + if (failure === 'process-error') assert.deepEqual((error as WorkspaceToolError).commandDiagnostic, + { stage: 'executor_transport', reason: 'executor_lost' }); return true; }); }); diff --git a/packages/code/src/native-programmatic.ts b/packages/code/src/native-programmatic.ts index 9b6eea9e..eca7316d 100644 --- a/packages/code/src/native-programmatic.ts +++ b/packages/code/src/native-programmatic.ts @@ -15,8 +15,13 @@ import { isSafePortableRelativePath, isSupportedBridgeArtifactName, } from './protocol.js'; -import { validateFileRelayUpstream } from './relay.js'; +import { + FILE_RELAY_GRANT_HEADER, + fileRelayObjectUrl, + validateFileRelayUpstream, +} from './relay.js'; import { WorkspaceToolError } from './workspace.js'; +import { commandFailureDiagnostic, commandSetupFailureDiagnostic } from './command-diagnostics.js'; import type { BridgeProgrammaticPayloadFile, @@ -25,7 +30,6 @@ import type { } from './protocol.js'; import type { NativeSrtWorkspaceCommandSandbox } from './native-sandbox.js'; -const EGRESS_GRANT_HEADER = 'X-CodeAPI-Egress-Grant'; const EXECUTION_MAIN_FILE = 'main.sh'; const EXECUTION_HISTORY_FILE = '_ptc_history.json'; const EXECUTION_CONTROL_FILE = '_ptc_pending_result.json'; @@ -248,6 +252,25 @@ export class NativeWorkspaceProgrammaticExecutor { * real replay will use, then removes the snapshot before registration. */ async prepare(signal?: AbortSignal): Promise { + try { + await this.prepareOnce(signal); + } catch (error) { + const diagnostic = commandSetupFailureDiagnostic( + 'programmatic_preflight', error, + ); + throw error instanceof WorkspaceToolError + ? new WorkspaceToolError( + error.message, error.code, error.mutationMayHaveCommitted, + error.requiresQuarantine, error.commandDiagnostic ?? diagnostic, + ) + : new WorkspaceToolError( + 'Native executor request failed', 'COMMAND_UNAVAILABLE', + false, false, diagnostic, + ); + } + } + + private async prepareOnce(signal?: AbortSignal): Promise { const createProbeWorkspace = this.options.sandbox.createProgrammaticProbeWorkspace; if (createProbeWorkspace == null) { @@ -265,7 +288,14 @@ export class NativeWorkspaceProgrammaticExecutor { signal, ); } finally { - await rm(executionDirectory, { recursive: true, force: true }); + try { + await rm(executionDirectory, { recursive: true, force: true }); + } catch (error) { + throw new WorkspaceToolError( + 'Native executor request failed', 'COMMAND_UNAVAILABLE', false, false, + commandFailureDiagnostic('cleanup', 'cleanup_failed', error), + ); + } } } @@ -327,12 +357,9 @@ export class NativeWorkspaceProgrammaticExecutor { const timer = setTimeout(() => controller.abort(), transferTimeoutMs); try { const response = await this.fetchImpl( - new URL( - `sessions/${encodeURIComponent(file.storage_session_id)}/objects/${encodeURIComponent(file.id)}`, - `${this.upstream.toString().replace(/\/+$/, '')}/`, - ), + fileRelayObjectUrl(this.upstream, file.storage_session_id, file.id), { - headers: { [EGRESS_GRANT_HEADER]: grant }, + headers: { [FILE_RELAY_GRANT_HEADER]: grant }, redirect: 'error', signal: controller.signal, }, @@ -704,14 +731,11 @@ export class NativeWorkspaceProgrammaticExecutor { let response: Response; try { response = await this.fetchImpl( - new URL( - `sessions/${encodeURIComponent(outputSessionId!)}/objects/${id}`, - `${this.upstream.toString().replace(/\/+$/, '')}/`, - ), + fileRelayObjectUrl(this.upstream, outputSessionId!, id), { method: 'PUT', headers: { - [EGRESS_GRANT_HEADER]: grant!, + [FILE_RELAY_GRANT_HEADER]: grant!, 'Content-Type': bridgeArtifactMediaType(name), 'Content-Length': String(bytes.byteLength), 'X-Original-Filename': encodeURIComponent(name), @@ -773,6 +797,9 @@ export class NativeWorkspaceProgrammaticExecutor { error instanceof WorkspaceToolError ? error.code : 'COMMAND_UNAVAILABLE', false, false, + error instanceof WorkspaceToolError && error.commandDiagnostic + ? error.commandDiagnostic + : commandSetupFailureDiagnostic('programmatic_preflight', error), ); } if (error instanceof WorkspaceToolError) { @@ -787,6 +814,7 @@ export class NativeWorkspaceProgrammaticExecutor { error.code, true, true, + error.commandDiagnostic, ); } throw new WorkspaceToolError( @@ -794,16 +822,18 @@ export class NativeWorkspaceProgrammaticExecutor { 'COMMAND_UNAVAILABLE', true, true, + commandFailureDiagnostic('dispatch', 'executor_rejected', error), ); } finally { try { await rm(executionDirectory, { recursive: true, force: true }); - } catch { + } catch (error) { throw new WorkspaceToolError( 'Native programmatic execution cleanup failed', 'COMMAND_UNAVAILABLE', commandDispatched, commandDispatched, + commandFailureDiagnostic('cleanup', 'cleanup_failed', error), ); } } diff --git a/packages/code/src/native-sandbox.test.ts b/packages/code/src/native-sandbox.test.ts index 90215156..4b31658d 100644 --- a/packages/code/src/native-sandbox.test.ts +++ b/packages/code/src/native-sandbox.test.ts @@ -32,6 +32,7 @@ import { NativeSrtWorkspaceCommandSandbox, } from './native-sandbox.js'; import { restoreScratchTraversal } from './native-scratch.js'; +import { browserSetupAttemptPath, browserSetupProtectedPaths } from './browser-setup.js'; import { WorkspaceToolError } from './workspace.js'; import { isWorkspaceToolResult } from './protocol.js'; @@ -1309,6 +1310,30 @@ test('fails closed when SRT dependencies are unavailable', async t => { ); }); +test('protects retained browser setup keys outside home from command reads and writes', async t => { + const root = await mkdtemp(join(tmpdir(), 'librechat-enrollment-protection-')); + t.after(() => rm(root, { recursive: true, force: true })); + const workspace = join(root, 'workspace'); + const state = join(root, 'state'); + await mkdir(workspace); + await mkdir(state); + const attempt = browserSetupAttemptPath(join(state, 'profile.json')); + const paths = browserSetupProtectedPaths(attempt); + await mkdir(paths[0], { mode: 0o700 }); + for (const path of [attempt, `${attempt}.lock`, `${attempt}.0123456789abcdef.tmp`]) + await writeFile(path, 'private setup state', { mode: 0o600 }); + const fake = fakeManager(); + const sandbox = new NativeSrtWorkspaceCommandSandbox({ workspaceRoot: workspace, protectedPaths: paths, manager: fake.manager }); + t.after(() => sandbox.close()); + await sandbox.prepare(); + for (const path of paths) { + const canonical = await realpath(path); + assert.ok(fake.config?.credentials?.files?.some(file => file.path === canonical && file.mode === 'deny'), + 'setup keys must be denied through the sandbox credential-file policy'); + assert.ok(fake.config?.filesystem.denyWrite.includes(canonical), 'commands must not replace setup state'); + } +}); + test('refuses workspace roots that expose worker home or control files', async t => { const root = await mkdtemp(join(tmpdir(), 'librechat-code-native-')); const controlDirectory = join(root, '.control'); @@ -1714,7 +1739,9 @@ test('cleans allocated command state exactly once on every execution exit', asyn (outcome === 'abort-after-spawn') && error.requiresQuarantine === (outcome === 'abort-after-spawn' && - process.platform === 'win32'), + process.platform === 'win32') && + (outcome.startsWith('abort') || (error.commandDiagnostic?.stage === + (outcome === 'wrap-throw' ? 'command_wrapping' : 'dispatch'))), ); } assert.equal( diff --git a/packages/code/src/native-sandbox.ts b/packages/code/src/native-sandbox.ts index 2701fe44..d994b1f9 100644 --- a/packages/code/src/native-sandbox.ts +++ b/packages/code/src/native-sandbox.ts @@ -15,6 +15,8 @@ import type { Dirent, Stats } from 'node:fs'; import { access, mkdtemp, open, readdir, realpath, rm, stat } from 'node:fs/promises'; import type { FileHandle } from 'node:fs/promises'; import { matchesWorkspaceRoot } from './root-identity.js'; +import { commandFailureDiagnostic, commandSetupFailureDiagnostic } from './command-diagnostics.js'; +import type { CommandSetupStage } from './command-diagnostics.js'; import { assertEnvironmentResourcesStable, environmentResourceVariables } from './environment-resources.js'; import type { WorkspaceRootIdentity } from './root-identity.js'; import { withWorkspaceRoot, WorkspaceRootAccessError, spawnWithinWorkspace, realpath as rootedRealpath, stat as rootedStat } from './root-access.js'; @@ -1071,9 +1073,10 @@ export class NativeSrtWorkspaceCommandSandbox implements WorkspaceCommandSandbox // permissive proxy session through a live updateConfig. await this.manager.reset(); await this.manager.initialize(config, config.network.strictAllowlist ? undefined : async () => true); - } catch { + } catch (error) { this.resetFailed = true; - throw new WorkspaceToolError('Native probe network cleanup failed', 'COMMAND_UNAVAILABLE'); + throw new WorkspaceToolError('Native probe network cleanup failed', 'COMMAND_UNAVAILABLE', false, false, + commandFailureDiagnostic('cleanup', 'cleanup_failed', error)); } } } @@ -1175,10 +1178,13 @@ export class NativeSrtWorkspaceCommandSandbox implements WorkspaceCommandSandbox let wrapped: Awaited< ReturnType >; + let setupStage: CommandSetupStage = 'command_wrapping'; try { const commandConfig = customConfig ?? (await this.rootCommandConfig()); + setupStage = 'credential_resolution'; const credentialEnvironment = await this.options.maskedEnvironment?.resolve(signal, cwd); + setupStage = 'command_wrapping'; const sandboxedCommand = this.options.maskedEnvironment?.wrapCommand ? this.options.maskedEnvironment.wrapCommand( request.command, @@ -1215,6 +1221,10 @@ export class NativeSrtWorkspaceCommandSandbox implements WorkspaceCommandSandbox throw new WorkspaceToolError( 'Native sandbox command could not start', 'COMMAND_UNAVAILABLE', + false, false, + error instanceof WorkspaceToolError && error.commandDiagnostic + ? error.commandDiagnostic + : commandSetupFailureDiagnostic(setupStage, error), ); } try { @@ -1314,11 +1324,13 @@ export class NativeSrtWorkspaceCommandSandbox implements WorkspaceCommandSandbox }, ); child.stdin.end(); - } catch { + } catch (error) { reject( new WorkspaceToolError( 'Native sandbox command could not start', 'COMMAND_UNAVAILABLE', + false, false, + commandFailureDiagnostic('dispatch', 'executor_rejected', error), ), ); return; @@ -1344,7 +1356,7 @@ export class NativeSrtWorkspaceCommandSandbox implements WorkspaceCommandSandbox clearTimeout(timer); signal?.removeEventListener('abort', abort); }; - child.once('error', () => { + child.once('error', error => { if (settled) return; settled = true; const mayHaveStarted = child.pid != null; @@ -1355,6 +1367,8 @@ export class NativeSrtWorkspaceCommandSandbox implements WorkspaceCommandSandbox 'Native sandbox command could not start', 'COMMAND_UNAVAILABLE', mayHaveStarted, + mayHaveStarted, + commandFailureDiagnostic('dispatch', 'executor_rejected', error), ), ); }); diff --git a/packages/code/src/pairing.test.ts b/packages/code/src/pairing.test.ts index 2b564394..a7dd6bd0 100644 --- a/packages/code/src/pairing.test.ts +++ b/packages/code/src/pairing.test.ts @@ -34,3 +34,61 @@ test('pairing binds a generated worker key to a single-use code', async () => { assert.match(paired.publicKey, /BEGIN PUBLIC KEY/); assert.match(paired.privateKey, /BEGIN PRIVATE KEY/); }); + +test('pairing propagates cancellation and never follows a code-bearing redirect', async () => { + const controller = new AbortController(); + const paired = await pairBridgeWorker({ + codeApiUrl: 'https://code.example/v1', + workerId: 'vm-1', + code: 'one-time-code', + signal: controller.signal, + fetchImpl: async (_input, init) => { + assert.equal(init?.redirect, 'error'); + assert.equal(init?.signal, controller.signal); + return Response.json({ + protocolVersion: 1, + workerId: 'vm-1', + credential: 'issued-short-lived-credential-value', + expiresAt: new Date(Date.now() + 300_000).toISOString(), + }); + }, + }); + assert.equal(paired.workerId, 'vm-1'); +}); + +for (const payload of [ + null, + [], + 'not a response', + { protocolVersion: 2 }, + { protocolVersion: 1, workerId: 'another-worker' }, +]) { + test(`pairing rejects malformed response ${JSON.stringify(payload)}`, async () => { + await assert.rejects( + pairBridgeWorker({ + codeApiUrl: 'https://code.example/v1', + workerId: 'vm-1', + code: 'one-time-code', + fetchImpl: async () => Response.json(payload), + }), + /invalid/, + ); + }); +} + +test('non-JSON pairing failures have a bounded diagnosis without echoing response contents', async () => { + await assert.rejects( + pairBridgeWorker({ + codeApiUrl: 'https://code.example/v1', + workerId: 'vm-1', + code: 'one-time-code', + fetchImpl: async () => + new Response('private upstream response', { status: 502 }), + }), + error => { + assert.match(String(error), /invalid pairing response/); + assert.ok(!String(error).includes('private upstream response')); + return true; + }, + ); +}); diff --git a/packages/code/src/pairing.ts b/packages/code/src/pairing.ts index ed33fb18..cef571eb 100644 --- a/packages/code/src/pairing.ts +++ b/packages/code/src/pairing.ts @@ -1,8 +1,5 @@ import { createBridgeIdentity } from './identity.js'; -import { - BRIDGE_PROTOCOL_VERSION, - BridgeProtocolError, -} from './protocol.js'; +import { BRIDGE_PROTOCOL_VERSION, BridgeProtocolError } from './protocol.js'; import type { BridgeWorkerCredentialResponse } from './protocol.js'; @@ -11,6 +8,7 @@ export interface PairBridgeWorkerOptions { workerId: string; code: string; fetchImpl?: typeof fetch; + signal?: AbortSignal; } export interface PairedBridgeWorkerIdentity @@ -18,14 +16,16 @@ export interface PairedBridgeWorkerIdentity codeApiUrl: string; publicKey: string; privateKey: string; + /** Private setup journal to protect until completion removes its secret copies. */ + setupAttemptPath?: string; } function normalizedBaseUrl(value: string): string { return value.replace(/\/+$/, ''); } -function errorMessage(value: object): string | undefined { - if ('error' in value && typeof value.error === 'string') return value.error; +function errorMessage(value: Record): string | undefined { + if (typeof value.error === 'string') return value.error; return undefined; } @@ -38,6 +38,8 @@ export async function pairBridgeWorker( `${codeApiUrl}/bridge/pairings/redeem`, { method: 'POST', + redirect: 'error', + signal: options.signal, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ protocolVersion: BRIDGE_PROTOCOL_VERSION, @@ -47,10 +49,29 @@ export async function pairBridgeWorker( }), }, ); - const payload = (await response.json()) as object; + let payload: unknown; + try { + payload = await response.json(); + } catch { + throw new BridgeProtocolError( + 'Code API returned an invalid pairing response', + response.status, + ); + } + if ( + typeof payload !== 'object' || + payload == null || + Array.isArray(payload) + ) { + throw new BridgeProtocolError( + 'Code API returned an invalid pairing response', + response.status, + ); + } if (!response.ok) { throw new BridgeProtocolError( - errorMessage(payload) ?? `Bridge pairing failed with HTTP ${response.status}`, + errorMessage(payload as Record) ?? + `Bridge pairing failed with HTTP ${response.status}`, response.status, ); } @@ -62,7 +83,9 @@ export async function pairBridgeWorker( credential.credential.length < 32 || !Number.isFinite(Date.parse(credential.expiresAt)) ) { - throw new BridgeProtocolError('Code API returned an invalid worker credential'); + throw new BridgeProtocolError( + 'Code API returned an invalid worker credential', + ); } return { ...credential, diff --git a/packages/code/src/protocol.ts b/packages/code/src/protocol.ts index 1451986d..0fcb6090 100644 --- a/packages/code/src/protocol.ts +++ b/packages/code/src/protocol.ts @@ -35,6 +35,12 @@ export function programmaticTransferReserveMs(jobTimeoutMs: number): number { export const BRIDGE_WORKSPACE_PROGRAMMATIC_MAX_FILE_BYTES = 10 * 1024 * 1024; export const BRIDGE_WORKSPACE_PROGRAMMATIC_MAX_HISTORY_BYTES = 40_000_000; export const BRIDGE_WORKSPACE_PROGRAMMATIC_MAX_TOTAL_BYTES = 100 * 1024 * 1024; +/** Default per-file `import_file` ceiling; Code API and each worker may configure their own. */ +export const BRIDGE_WORKSPACE_IMPORT_FILE_DEFAULT_MAX_BYTES = 100 * 1024 * 1024; +/** Longest execution budget Code API grants one `import_file` transfer. */ +export const BRIDGE_WORKSPACE_IMPORT_FILE_TIMEOUT_MS = 5 * 60_000; +const BRIDGE_WORKSPACE_IMPORT_FILE_HANDLE_MAX_LENGTH = 32_768; +const BRIDGE_WORKSPACE_IMPORT_FILE_GRANT_MAX_LENGTH = 256 * 1024; /** How long Code API drains a clean rejection after Stop cancels a workspace mutation. */ export const BRIDGE_CANCELLED_WORKSPACE_SETTLEMENT_GRACE_MS = 5_000; @@ -313,7 +319,20 @@ export type BridgeWorkspaceToolOperation = | 'write_file' | 'preview_edit' | 'edit_file' - | 'execute_command'; + | 'execute_command' + | 'import_file'; + +/** Operations that may change workspace contents and therefore arm the durable mutation guard. */ +export function isWorkspaceMutationOperation( + operation: BridgeWorkspaceToolOperation, +): boolean { + return ( + operation === 'write_file' || + operation === 'edit_file' || + operation === 'execute_command' || + operation === 'import_file' + ); +} export type WorkspaceWriteFileMode = 'replace' | 'create'; export type WorkspaceEditFileMode = 'single' | 'batch'; @@ -674,6 +693,54 @@ export interface WorkspaceExecuteCommandResult { laneGit?: WorkspaceLaneGit; } +/** A stored object the caller owns, named exactly as Code API file storage names it. */ +export interface WorkspaceImportFileSource { + storage_session_id: string; + id: string; +} + +/** + * Copies one stored object into the workspace as a binary-safe file. The + * worker streams it through its file relay upstream, refuses `.git` paths and + * symlinked parents, verifies `bytes` and `sha256`, and installs it atomically + * with mode 0644. + */ +export interface WorkspaceImportFileRequest { + protocolVersion: BridgeProtocolVersion; + operation: 'import_file'; + workspaceId: string; + workspaceInstanceId?: string; + /** Linked worktree lane at `.worktrees/`; paths and cwd are relative to it. */ + worktree?: string; + path: string; + source: WorkspaceImportFileSource; + /** Exact object size, enforced as a hard cap while streaming. */ + bytes: number; + /** Lowercase hex SHA-256 of the object's bytes. */ + sha256: string; + /** False or omitted refuses to replace a file whose content differs. */ + overwrite?: boolean; + /** + * Set only by Code API when it relays the request to a worker: a short-lived + * read grant scoped to `source`, whose ids are then opaque grant handles. + * Callers never send it. + */ + egressGrant?: string; +} + +export interface WorkspaceImportFileResult { + protocolVersion: BridgeProtocolVersion; + operation: 'import_file'; + workspaceId: string; + path: string; + created: boolean; + /** True when the target already held these exact bytes; nothing was written. */ + unchanged?: boolean; + /** Bytes installed, or 0 when `unchanged`. */ + bytesWritten: number; + sha256: string; +} + export type WorkspaceToolRequest = | WorkspaceReadFileRequest | WorkspaceSearchTextRequest @@ -681,7 +748,8 @@ export type WorkspaceToolRequest = | WorkspaceWriteFileRequest | WorkspacePreviewEditRequest | WorkspaceEditFileRequest - | WorkspaceExecuteCommandRequest; + | WorkspaceExecuteCommandRequest + | WorkspaceImportFileRequest; export type WorkspaceToolResult = | WorkspaceReadFileResult | WorkspaceSearchTextResult @@ -689,7 +757,8 @@ export type WorkspaceToolResult = | WorkspaceWriteFileResult | WorkspacePreviewEditResult | WorkspaceEditFileResult - | WorkspaceExecuteCommandResult; + | WorkspaceExecuteCommandResult + | WorkspaceImportFileResult; const WORKSPACE_READ_REQUEST_KEYS = new Set([ 'instructionSha256', @@ -771,6 +840,30 @@ const WORKSPACE_COMMAND_REQUEST_KEYS = new Set([ 'timeoutMs', 'maxOutputBytes', ]); +const WORKSPACE_IMPORT_REQUEST_KEYS = new Set([ + 'protocolVersion', + 'operation', + 'workspaceId', + 'workspaceInstanceId', + 'worktree', + 'path', + 'source', + 'bytes', + 'sha256', + 'overwrite', + 'egressGrant', +]); +const WORKSPACE_IMPORT_SOURCE_KEYS = new Set(['storage_session_id', 'id']); +const WORKSPACE_IMPORT_RESULT_KEYS = new Set([ + 'protocolVersion', + 'operation', + 'workspaceId', + 'path', + 'created', + 'unchanged', + 'bytesWritten', + 'sha256', +]); const WORKSPACE_READ_RESULT_KEYS = new Set([ 'protocolVersion', 'operation', @@ -1275,6 +1368,94 @@ export function isSafePortableRelativePath(value: unknown): value is string { return value.split('/').every(segment => segment !== '..'); } +/** + * Whether a path segment names Git metadata on any host filesystem: case + * folding (macOS, Windows), ignorable code points (HFS+), trailing dots and + * spaces (Windows), NTFS stream syntax such as `.git::$INDEX_ALLOCATION`, and + * the NTFS short name all alias `.git`. + */ +function isDotGitPathSegment(segment: string): boolean { + const folded = segment + .split(':', 1)[0]! + .toLowerCase() + .replace(/\p{Default_Ignorable_Code_Point}/gu, '') + .replace(/[. ]+$/, ''); + return folded === '.git' || folded === 'git~1'; +} + +/** `write_file` path rules, plus no `.git` segment and a file-like final segment. */ +export function isWorkspaceImportFilePath(value: unknown): value is string { + if (!isSafePortableRelativePath(value)) return false; + const segments = value.split('/'); + const last = segments[segments.length - 1]; + return ( + last !== undefined && + last !== '' && + last !== '.' && + !segments.some(isDotGitPathSegment) + ); +} + +function isWorkspaceImportFileHandle(value: unknown): value is string { + return ( + typeof value === 'string' && + value.length > 0 && + value.length <= BRIDGE_WORKSPACE_IMPORT_FILE_HANDLE_MAX_LENGTH && + !/[\0\r\n]/.test(value) + ); +} + +function isWorkspaceImportFileRequestShape( + request: Record, +): boolean { + const source = request.source; + return ( + hasOnlyKeys(request, WORKSPACE_IMPORT_REQUEST_KEYS) && + isWorkspaceImportFilePath(request.path) && + typeof source === 'object' && + source !== null && + !Array.isArray(source) && + hasOnlyKeys(source as Record, WORKSPACE_IMPORT_SOURCE_KEYS) && + isWorkspaceImportFileHandle((source as Record).storage_session_id) && + isWorkspaceImportFileHandle((source as Record).id) && + Number.isSafeInteger(request.bytes) && + Number(request.bytes) >= 0 && + typeof request.sha256 === 'string' && + /^[a-f0-9]{64}$/.test(request.sha256) && + (request.overwrite === undefined || typeof request.overwrite === 'boolean') && + (request.egressGrant === undefined || + (typeof request.egressGrant === 'string' && + request.egressGrant.length > 0 && + request.egressGrant.length <= BRIDGE_WORKSPACE_IMPORT_FILE_GRANT_MAX_LENGTH && + !/[\0\r\n]/.test(request.egressGrant))) + ); +} + +/** + * The caller-facing `import_file` request LibreChat sends to Code API. A + * caller may never supply `egressGrant`; Code API mints it when relaying. + */ +export function isBridgeWorkspaceImportFileRequest( + value: unknown, +): value is WorkspaceImportFileRequest & { egressGrant?: never } { + return ( + isWorkspaceToolRequest(value) && + value.operation === 'import_file' && + value.egressGrant === undefined + ); +} + +/** The relayed `import_file` request a worker executes: it must carry Code API's grant. */ +export function isRelayedWorkspaceImportFileRequest( + value: unknown, +): value is WorkspaceImportFileRequest & { egressGrant: string } { + return ( + isWorkspaceToolRequest(value) && + value.operation === 'import_file' && + value.egressGrant !== undefined + ); +} + function normalizePortableRelativePath(value: string): string { return ( value @@ -1573,6 +1754,9 @@ export function isWorkspaceToolRequest( BRIDGE_WORKSPACE_COMMAND_MAX_OUTPUT_BYTES)) ); } + if (request.operation === 'import_file') { + return isWorkspaceImportFileRequestShape(request); + } return false; } @@ -1706,6 +1890,26 @@ export function isWorkspaceToolResult( ); } + if (request.operation === 'import_file') { + if ( + !hasOnlyKeys(result, WORKSPACE_IMPORT_RESULT_KEYS) || + result.path !== request.path || + result.sha256 !== request.sha256 || + typeof result.created !== 'boolean' || + (result.unchanged !== undefined && typeof result.unchanged !== 'boolean') || + !Number.isSafeInteger(result.bytesWritten) + ) { + return false; + } + if (result.unchanged === true) { + return result.created === false && result.bytesWritten === 0; + } + return ( + result.bytesWritten === request.bytes && + (request.overwrite === true || result.created === true) + ); + } + if (request.operation === 'edit_file') { const replacements = request.edits?.length ?? 1; return ( @@ -1808,7 +2012,7 @@ export function isValidBridgeWorkspaceToolCapabilities( capabilities.protocolVersion !== BRIDGE_PROTOCOL_VERSION || !Array.isArray(capabilities.operations) || capabilities.operations.length < 1 || - capabilities.operations.length > 7 || + capabilities.operations.length > 8 || !capabilities.operations.every( operation => operation === 'read_file' || @@ -1817,7 +2021,8 @@ export function isValidBridgeWorkspaceToolCapabilities( operation === 'write_file' || operation === 'preview_edit' || operation === 'edit_file' || - operation === 'execute_command', + operation === 'execute_command' || + operation === 'import_file', ) || new Set(capabilities.operations).size !== capabilities.operations.length || diff --git a/packages/code/src/relay.ts b/packages/code/src/relay.ts index 07aa8714..1cab6e65 100644 --- a/packages/code/src/relay.ts +++ b/packages/code/src/relay.ts @@ -34,6 +34,21 @@ const LOCAL_HTTP_HOSTS = new Set([ class RelayPayloadTooLargeError extends Error {} class UpstreamPayloadTooLargeError extends Error {} +/** Header carrying Code API's egress grant on every file relay object request. */ +export const FILE_RELAY_GRANT_HEADER = 'X-CodeAPI-Egress-Grant'; + +/** `/sessions//objects/`, the egress gateway's object route. */ +export function fileRelayObjectUrl( + upstream: URL, + storageSessionId: string, + objectId: string, +): URL { + return new URL( + `sessions/${encodeURIComponent(storageSessionId)}/objects/${encodeURIComponent(objectId)}`, + `${upstream.toString().replace(/\/+$/, '')}/`, + ); +} + export function validateFileRelayUpstream(value: string): URL { const upstream = new URL(value); if ( diff --git a/packages/code/src/setup-cli.test.ts b/packages/code/src/setup-cli.test.ts new file mode 100644 index 00000000..1bef6e14 --- /dev/null +++ b/packages/code/src/setup-cli.test.ts @@ -0,0 +1,266 @@ +import assert from 'node:assert/strict'; +import { spawn, spawnSync } from 'node:child_process'; +import { once } from 'node:events'; +import { mkdtemp, readFile, rm } from 'node:fs/promises'; +import { createServer } from 'node:http'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import test from 'node:test'; +import type { TestContext } from 'node:test'; + +const cli = fileURLToPath(new URL('./cli.js', import.meta.url)); +const code = 'secret-one-time-code-123456789'; + +async function fixture(t: TestContext) { + const home = await mkdtemp(join(tmpdir(), 'setup-cli-home-')); + t.after(() => rm(home, { recursive: true, force: true })); + const env = { + ...Object.fromEntries( + Object.entries(process.env).filter( + ([key]) => !key.startsWith('LIBRECHAT_CODE_'), + ), + ), + HOME: home, + }; + const profilePath = join( + home, + '.config', + 'librechat', + 'code', + 'profiles', + 'default.json', + ); + return { home, env, profilePath }; +} + +async function execute(args: string[], env: NodeJS.ProcessEnv, stdin = '') { + const child = spawn(process.execPath, [cli, ...args], { env, stdio: 'pipe' }); + let stdout = ''; + let stderr = ''; + child.stdout.on('data', chunk => { + stdout += String(chunk); + }); + child.stderr.on('data', chunk => { + stderr += String(chunk); + }); + child.stdin.end(stdin); + const [status] = await once(child, 'exit', { + signal: AbortSignal.timeout(10_000), + }); + return { status, stdout, stderr }; +} + +test('CLI help explains the pairing prerequisite and setup does not silently read piped prompts', async t => { + const { env } = await fixture(t); + const help = spawnSync(process.execPath, [cli, 'setup', '--help'], { + env, + encoding: 'utf8', + }); + assert.equal(help.status, 0, help.stderr); + assert.match(help.stdout, /Settings > Code Environments/); + assert.match(help.stdout, /does not\nsign in to LibreChat/); + const guided = await execute(['setup'], env, code); + assert.notEqual(guided.status, 0); + assert.match(guided.stderr, /requires a terminal/); + assert.ok(!guided.stdout.includes(code)); + assert.ok(!guided.stderr.includes(code)); +}); + +test( + 'CLI setup followed by plain run registers the saved identity with read-only access', + { timeout: 15_000 }, + async t => { + const { env, profilePath } = await fixture(t); + let paired = false; + let registered!: (body: Record) => void; + const registration = new Promise>(resolve => { + registered = resolve; + }); + const server = createServer(async (request, response) => { + const chunks: Buffer[] = []; + for await (const chunk of request) chunks.push(Buffer.from(chunk)); + const body = JSON.parse(Buffer.concat(chunks).toString('utf8') || '{}'); + response.setHeader('Content-Type', 'application/json'); + if (request.url === '/v1/bridge/pairings/redeem') { + assert.equal(body.workerId, 'guided-vm'); + assert.equal(body.code, code); + assert.match(body.publicKey, /BEGIN PUBLIC KEY/); + assert.equal(body.privateKey, undefined); + paired = true; + response.end( + JSON.stringify({ + protocolVersion: 1, + workerId: body.workerId, + credential: 'short-lived-worker-credential-value', + expiresAt: '2099-01-01T00:00:00Z', + }), + ); + return; + } + if (request.url === '/v1/bridge/workers/register') { + assert.equal(paired, true); + assert.match(request.headers.authorization ?? '', /^Bridge /); + assert.ok(request.headers['x-librechat-code-signature']); + registered(body); + response.end( + JSON.stringify({ + protocolVersion: 1, + workerId: body.workerId, + incarnationId: body.incarnationId, + registeredAt: new Date().toISOString(), + leaseTtlMs: 60_000, + }), + ); + return; + } + response.end(JSON.stringify({ protocolVersion: 1, serverElapsedMs: 0 })); + }); + await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); + t.after(() => { + server.closeAllConnections(); + server.close(); + }); + const address = server.address(); + assert.ok(address && typeof address !== 'string'); + const result = await execute( + [ + 'setup', + '--non-interactive', + '--code-stdin', + '--url', + `http://127.0.0.1:${address.port}/v1`, + '--worker-id', + 'guided-vm', + '--default-workspace', + ], + env, + `${code}\n`, + ); + assert.equal(result.status, 0, result.stderr); + assert.match(result.stdout, /librechat-code run\n/); + assert.match(result.stdout, /still needs to start/); + assert.ok(!result.stdout.includes(code)); + assert.ok(!result.stderr.includes(code)); + const profile = JSON.parse(await readFile(profilePath, 'utf8')); + assert.equal(profile.allowWorkspaceWrites, false); + assert.equal(profile.allowWorkspaceCommands, false); + const worker = spawn(process.execPath, [cli, 'run'], { + env, + stdio: ['ignore', 'pipe', 'pipe'], + }); + let stderr = ''; + worker.stderr.on('data', chunk => { + stderr += String(chunk); + }); + const exited = once(worker, 'exit'); + t.after(() => worker.kill('SIGKILL')); + const body = await Promise.race([ + registration, + exited.then(() => { + throw new Error(`Worker exited before registration: ${stderr}`); + }), + ]); + worker.kill('SIGTERM'); + await exited; + assert.equal(body.workerId, 'guided-vm'); + const operations = body.capabilities.workspaceTools.operations as string[]; + assert.ok(operations.includes('read_file')); + assert.ok(!operations.includes('write_file')); + assert.ok(!operations.includes('execute_command')); + assert.ok(!JSON.stringify(body).includes(profile.identityPath)); + assert.ok(!JSON.stringify(body).includes(profilePath)); + }, +); + +test('CLI rejects missing automation inputs without consuming stdin or pairing', async t => { + const { env } = await fixture(t); + const result = await execute(['setup', '--non-interactive'], env, code); + assert.notEqual(result.status, 0); + assert.match(result.stderr, /requires --url, --worker-id, --code-stdin/); + assert.ok(!result.stderr.includes(code)); +}); + +test('CLI refuses unsafe URLs before disclosing the code', async t => { + const { env } = await fixture(t); + const result = await execute( + [ + 'setup', + '--non-interactive', + '--code-stdin', + '--url', + 'http://external.example/v1', + '--worker-id', + 'vm', + '--default-workspace', + ], + env, + code, + ); + assert.notEqual(result.status, 0); + assert.match(result.stderr, /requires HTTPS/); + assert.ok(!result.stdout.includes(code)); + assert.ok(!result.stderr.includes(code)); +}); + +for (const [name, secretEditing] of [ + ['history', ''], + ['kill beginning', '\x15\x19'], + ['kill end', '\x01\x0b\x19'], + ['kill word', '\x17\x19'], +]) + test( + `terminal ${name} never recalls a hidden pairing code at confirmation`, + { timeout: 10_000 }, + async t => { + const { env } = await fixture(t); + const secret = 'SyntheticPairingSecret0123456789'; + // Real readline terminal editing over pipes avoids an OS-specific PTY dependency. + const bootstrap = ` + Object.defineProperty(process.stdin, 'isTTY', { value: true }); + Object.defineProperty(process.stdout, 'isTTY', { value: true }); + process.argv = [process.execPath, ${JSON.stringify(cli)}, 'setup', '--url', + 'https://code.example/v1', '--worker-id', 'history-vm', '--default-workspace']; + await import(${JSON.stringify(new URL('./cli.js', import.meta.url).href)}); + `; + const child = spawn( + process.execPath, + ['--input-type=module', '--eval', bootstrap], + { env, stdio: 'pipe' }, + ); + t.after(() => child.kill('SIGKILL')); + let transcript = ''; + let stderr = ''; + let step = 0; + const inputs = [ + { prompt: 'Allow file writes', input: '\n' }, + { prompt: 'Allow sandboxed commands', input: '\n' }, + { + prompt: 'One-time pairing code (hidden): ', + input: `${secret}${secretEditing}\n`, + }, + // History and yank must not reveal secret editing state before redemption. + { + prompt: 'Pair this machine and save these choices?', + input: '\x1b[A\x10\x19\x15n\n', + }, + ]; + child.stderr.on('data', chunk => { + stderr += String(chunk); + }); + child.stdout.on('data', chunk => { + transcript += String(chunk); + if (step < inputs.length && transcript.includes(inputs[step].prompt)) { + child.stdin.write(inputs[step++].input); + } + }); + const [status] = await once(child, 'exit'); + assert.equal(step, inputs.length, stderr); + assert.equal(status, 0, stderr); + assert.ok( + !transcript.includes(secret), + 'hidden pairing code was recalled visibly from history', + ); + assert.match(transcript, /No pairing was redeemed/); + }, + ); diff --git a/packages/code/src/setup-cli.ts b/packages/code/src/setup-cli.ts new file mode 100644 index 00000000..08262153 --- /dev/null +++ b/packages/code/src/setup-cli.ts @@ -0,0 +1,284 @@ +import { createInterface } from 'node:readline/promises'; +import { Writable } from 'node:stream'; +import { resolve } from 'node:path'; + +import { + setupCodeApiUrl, + setupWorker, + setupWorkspaceDirectory, +} from './setup.js'; +import { defaultWorkerProfilePath } from './worker-profile.js'; +import { isValidBridgeWorkerId } from './protocol.js'; +import { runBrowserSetupCommand } from './browser-setup-cli.js'; + +export const SETUP_HELP = `Usage: librechat-code setup [options] + +Enroll with browser approval: + librechat-code setup --instance https://chat.example.com + +The browser uses LibreChat's normal login, including configured OAuth/OIDC. +SSH/headless machines display a URL and matching verification code. No inbound +listener or CLI identity-provider integration is needed. Interrupted setup +resumes the same saved key and attempt. Never delete it to retry an enrollment. + +Existing manual pairing: +Create a private worker profile using a one-time pairing from LibreChat. +Open Settings > Code Environments in LibreChat to add the machine first. +Use its Code API URL, worker ID, and pairing code. This command does not +sign in to LibreChat or bypass its enrollment policy. + + --instance LibreChat instance URL for browser approval + --machine-name Display name (default: host name) + --no-browser Display approval instructions without opening a browser + --start Start the saved worker in the foreground after setup + --cancel Cancel this profile's pending browser setup + --url Manual Code API URL, including its /v1 prefix + --worker-id Worker ID supplied by LibreChat + --worker-dir Expose one existing project directory + --default-workspace Create a private workspace when the worker starts + --allow-workspace-writes Explicitly enable file writes (default: off) + --allow-workspace-commands Explicitly enable sandboxed commands (default: off) + --profile Profile destination (default: profiles/default.json) + --identity Separate private identity destination + --non-interactive Require explicit inputs instead of prompting + --code-stdin Read the pairing code from stdin, never an argument + --help Show this help + +Existing profiles and identities are never replaced. Choose another profile +and identity for a second machine. Setup does not install OS dependencies, +start a background service, or change the restricted command policy. +`; + +const VALUE_FLAGS = new Set([ + '--instance', + '--machine-name', + '--url', + '--worker-id', + '--worker-dir', + '--profile', + '--identity', +]); +const BOOLEAN_FLAGS = new Set([ + '--no-browser', + '--start', + '--cancel', + '--default-workspace', + '--allow-workspace-writes', + '--allow-workspace-commands', + '--non-interactive', + '--code-stdin', +]); + +export function parseSetupArguments( + args: string[], +): Map { + const options = new Map(); + for (let i = 0; i < args.length; i++) { + const [flag, ...inline] = args[i].split('='); + if ( + (!VALUE_FLAGS.has(flag) && !BOOLEAN_FLAGS.has(flag)) || + options.has(flag) + ) { + throw new Error( + 'Unknown or repeated setup option; use librechat-code setup --help', + ); + } + if (BOOLEAN_FLAGS.has(flag)) { + if (inline.length) + throw new Error( + 'Setup switches do not accept a value; use setup --help', + ); + options.set(flag, true); + } else { + const value = inline.length ? inline.join('=') : args[++i]; + if ( + !value?.trim() || + value.startsWith('--') || + /[\x00-\x1f\x7f]/.test(value) + ) { + throw new Error( + 'A setup option is missing its value; use setup --help', + ); + } + options.set(flag, value); + } + } + if (options.has('--worker-dir') && options.has('--default-workspace')) { + throw new Error('Choose --worker-dir or --default-workspace, not both'); + } + if (options.has('--instance') && ['--url', '--worker-id', '--code-stdin'].some(flag => options.has(flag))) + throw new Error('Browser approval cannot be combined with manual pairing options'); + if (!options.has('--instance') && ['--machine-name', '--no-browser', '--start', '--cancel'].some(flag => options.has(flag))) + throw new Error('Browser setup options require --instance'); + if (options.has('--non-interactive') && options.has('--instance') && !options.has('--cancel') && + !options.has('--worker-dir') && !options.has('--default-workspace')) + throw new Error('Non-interactive browser setup requires --worker-dir or --default-workspace'); + if ( + options.has('--non-interactive') && !options.has('--instance') && + (!options.has('--url') || + !options.has('--worker-id') || + !options.has('--code-stdin') || + (!options.has('--worker-dir') && !options.has('--default-workspace'))) + ) { + throw new Error( + 'Non-interactive setup requires --url, --worker-id, --code-stdin, and --worker-dir or --default-workspace', + ); + } + if (options.has('--code-stdin') && !options.has('--non-interactive')) { + throw new Error('--code-stdin requires --non-interactive'); + } + return options; +} + +async function pairingCodeFromStdin(signal: AbortSignal): Promise { + let code = ''; + const abort = () => process.stdin.destroy(new Error('Setup cancelled')); + signal.addEventListener('abort', abort, { once: true }); + try { + signal.throwIfAborted(); + for await (const chunk of process.stdin) { + code += String(chunk); + if (Buffer.byteLength(code) > 1024) + throw new Error('Pairing code input is too large'); + signal.throwIfAborted(); + } + return code.trim(); + } finally { + signal.removeEventListener('abort', abort); + } +} + +export async function runSetupCommand(args: string[]): Promise { + if (args.length === 1 && args[0] === '--help') { + process.stdout.write(SETUP_HELP); + return; + } + const options = parseSetupArguments(args); + const interactive = !options.has('--non-interactive'); + if (interactive && (!process.stdin.isTTY || !process.stdout.isTTY)) { + throw new Error( + 'Guided setup requires a terminal. For automation use --non-interactive and --code-stdin; see setup --help', + ); + } + const controller = new AbortController(); + const abort = () => controller.abort(new Error('Setup cancelled')); + process.once('SIGINT', abort); + let muted = false; + const output = new Writable({ + write(chunk, _encoding, callback) { + if (!muted) process.stdout.write(chunk); + callback(); + }, + }); + const ask = async (message: string, secret = false): Promise => { + controller.signal.throwIfAborted(); + muted = secret; + // Readline retains more than history (including a kill ring). Never reuse prompt state. + const terminal = createInterface({ + input: process.stdin, + output, + terminal: true, + historySize: 0, + }); + terminal.on('SIGINT', abort); + terminal.once('close', abort); + process.stdout.write(message); + try { + return ( + await terminal.question('', { signal: controller.signal }) + ).trim(); + } finally { + terminal.off('close', abort); + terminal.off('SIGINT', abort); + terminal.close(); + muted = false; + if (secret) process.stdout.write('\n'); + } + }; + const yesNo = async (message: string): Promise => { + for (;;) { + const value = (await ask(`${message} [y/N]: `)).toLowerCase(); + if (value === '' || value === 'n' || value === 'no') return false; + if (value === 'y' || value === 'yes') return true; + process.stdout.write('Enter yes or no.\n'); + } + }; + try { + if (options.has('--instance')) { + await runBrowserSetupCommand(options, { interactive, ask, yesNo, signal: controller.signal }); + return; + } + if (interactive) + process.stdout.write( + 'Connect a machine using the pairing supplied by LibreChat.\nOnly selected folders and permissions will be exposed.\n\n', + ); + const codeApiUrl = setupCodeApiUrl( + String( + options.get('--url') ?? (await ask('Code API URL (including /v1): ')), + ), + ); + const workerId = String( + options.get('--worker-id') ?? (await ask('Worker ID from LibreChat: ')), + ); + if (!isValidBridgeWorkerId(workerId)) + throw new Error('Enter the worker ID supplied by LibreChat'); + let workspaceDirectory = options.get('--worker-dir') as string | undefined; + if ( + interactive && + !workspaceDirectory && + !options.has('--default-workspace') + ) { + workspaceDirectory = + (await ask( + 'Project directory (Enter for a new private workspace): ', + )) || undefined; + } + if (workspaceDirectory != null) + workspaceDirectory = await setupWorkspaceDirectory(workspaceDirectory); + const allowWorkspaceWrites = + options.has('--allow-workspace-writes') || + (interactive && + (await yesNo('Allow file writes within this workspace?'))); + const allowWorkspaceCommands = + options.has('--allow-workspace-commands') || + (interactive && + (await yesNo( + 'Allow sandboxed commands within this workspace? Commands can modify workspace files', + ))); + const profilePath = resolve( + String(options.get('--profile') ?? defaultWorkerProfilePath()), + ); + const code = interactive + ? await ask('One-time pairing code (hidden): ', true) + : await pairingCodeFromStdin(controller.signal); + process.stdout.write( + `\nCode API: ${codeApiUrl}\nWorker: ${workerId}\nWorkspace: ${workspaceDirectory ?? 'new private workspace'}\nFile writes: ${allowWorkspaceWrites ? 'enabled' : 'disabled'}\nCommands: ${allowWorkspaceCommands ? 'enabled, restricted sandbox policy' : 'disabled'}\nProfile: ${profilePath}\n`, + ); + if ( + interactive && + !(await yesNo('Pair this machine and save these choices?')) + ) { + process.stdout.write('Setup cancelled. No pairing was redeemed.\n'); + return; + } + await setupWorker({ + codeApiUrl, + workerId, + code, + profilePath, + identityPath: options.get('--identity') as string | undefined, + workspaceDirectory, + allowWorkspaceWrites, + allowWorkspaceCommands, + signal: controller.signal, + }); + const defaultProfile = profilePath === resolve(defaultWorkerProfilePath()); + const quotedProfile = `'${profilePath.replace(/'/g, "'\\''")}'`; + process.stdout.write( + `\nSetup saved. Start the worker with:\n librechat-code run${defaultProfile ? '' : ` --profile ${quotedProfile}`}\n\nPairing is complete. The worker still needs to start, prepare its sandbox,\nand register before LibreChat can report it ready.\n`, + ); + } finally { + process.off('SIGINT', abort); + output.destroy(); + } +} diff --git a/packages/code/src/setup.test.ts b/packages/code/src/setup.test.ts new file mode 100644 index 00000000..0241f083 --- /dev/null +++ b/packages/code/src/setup.test.ts @@ -0,0 +1,334 @@ +import assert from 'node:assert/strict'; +import { + mkdir, + mkdtemp, + readFile, + readdir, + rm, + writeFile, +} from 'node:fs/promises'; +import { homedir, tmpdir } from 'node:os'; +import { join } from 'node:path'; +import test from 'node:test'; + +import { createBridgeIdentity } from './identity.js'; +import { loadBridgeIdentity } from './storage.js'; +import { loadWorkerProfile, saveWorkerProfile } from './worker-profile.js'; +import { + profileRunConfiguration, + setupCodeApiUrl, + setupWorker, +} from './setup.js'; +import { parseSetupArguments } from './setup-cli.js'; +import type { SetupWorkerOptions } from './setup.js'; +import type { PairBridgeWorkerOptions } from './pairing.js'; + +const code = 'one-time-code-value-123456789'; + +async function fixture(t: test.TestContext) { + const root = await mkdtemp(join(tmpdir(), 'worker-setup-')); + t.after(() => rm(root, { recursive: true, force: true })); + const options: SetupWorkerOptions = { + codeApiUrl: 'https://code.example/v1/', + workerId: 'setup-worker', + code, + profilePath: join(root, 'profiles', 'default.json'), + identityPath: join(root, 'identity.json'), + allowWorkspaceWrites: false, + allowWorkspaceCommands: false, + }; + const pair = async (input: PairBridgeWorkerOptions) => ({ + protocolVersion: 1 as const, + workerId: input.workerId, + codeApiUrl: input.codeApiUrl, + ...createBridgeIdentity(), + credential: 'issued-short-lived-credential-value', + expiresAt: '2099-01-01T00:00:00Z', + }); + return { root, options, pair }; +} + +test('setup saves separate identity and least-privilege profile after pairing', async t => { + const { options, pair } = await fixture(t); + let calls = 0; + const saved = await setupWorker(options, { + pair: async input => { + calls++; + assert.equal(input.codeApiUrl, 'https://code.example/v1'); + assert.equal(input.code, code); + assert.ok(input.signal); + return pair(input); + }, + }); + assert.equal(calls, 1); + assert.deepEqual(await loadWorkerProfile(saved.profilePath), saved.profile); + assert.deepEqual(saved.profile.workspace, { type: 'private' }); + assert.equal(saved.profile.allowWorkspaceCommands, false); + assert.equal(saved.profile.allowWorkspaceWrites, false); + assert.equal( + (await loadBridgeIdentity(saved.profile.identityPath)).workerId, + 'setup-worker', + ); + const profileSource = await readFile(saved.profilePath, 'utf8'); + assert.ok(!profileSource.includes(code)); + assert.ok(!profileSource.includes('PRIVATE KEY')); + assert.ok(!profileSource.includes('issued-short-lived')); +}); + +test('setup canonicalizes an explicitly selected project and preflights command dependencies', async t => { + const { root, options, pair } = await fixture(t); + const project = join(root, 'project with spaces'); + await mkdir(project); + let checked = false; + const saved = await setupWorker( + { ...options, workspaceDirectory: project, allowWorkspaceCommands: true }, + { + checkCommands: async () => { + checked = true; + }, + pair: async input => { + assert.equal(checked, true); + return pair(input); + }, + }, + ); + assert.deepEqual(saved.profile.workspace, { + type: 'directory', + path: project, + }); + assert.equal(saved.profile.allowWorkspaceCommands, true); + assert.equal(saved.profile.allowWorkspaceWrites, false); +}); + +test('missing command prerequisites do not spend pairing code or create storage', async t => { + const { root, options, pair } = await fixture(t); + await assert.rejects( + setupWorker( + { ...options, allowWorkspaceCommands: true }, + { + checkCommands: async () => { + throw new Error('missing sandbox dependency'); + }, + pair: async input => { + assert.fail('must not pair'); + return pair(input); + }, + }, + ), + /missing sandbox dependency/, + ); + assert.deepEqual(await readdir(root), []); +}); + +test('denied or expired pairing releases only the new empty reservations', async t => { + const { root, options } = await fixture(t); + await assert.rejects( + setupWorker(options, { + pair: async () => { + throw new Error('Pairing code is invalid or expired'); + }, + }), + /invalid or expired/, + ); + assert.deepEqual(await readdir(root), ['profiles']); + assert.deepEqual(await readdir(join(root, 'profiles')), []); +}); + +test('existing identity is never replaced and code is not redeemed', async t => { + const { options } = await fixture(t); + await writeFile(options.identityPath!, 'keep this identity', { mode: 0o600 }); + await assert.rejects( + setupWorker(options, { + pair: async () => { + assert.fail('must not pair'); + }, + }), + /already exists/, + ); + assert.equal( + await readFile(options.identityPath!, 'utf8'), + 'keep this identity', + ); + assert.equal(await loadWorkerProfile(options.profilePath!), undefined); +}); + +test('profile write failure preserves the paired identity for manual recovery', async t => { + const { options, pair } = await fixture(t); + await assert.rejects( + setupWorker(options, { + pair: async input => { + await rm(options.profilePath!); + await mkdir(options.profilePath!); + return pair(input); + }, + }), + /Machine paired and identity saved.*profile could not be saved/, + ); + assert.equal( + (await loadBridgeIdentity(options.identityPath!)).workerId, + options.workerId, + ); +}); + +test('setup rejects exposed control files even with read-only permissions', async t => { + const { root, options } = await fixture(t); + await assert.rejects( + setupWorker( + { ...options, workspaceDirectory: root }, + { + pair: async () => { + assert.fail('must not pair'); + }, + }, + ), + /outside the workspace/, + ); +}); + +test('setup never admits home or a parent as a project', async t => { + const { options } = await fixture(t); + for (const workspaceDirectory of [homedir(), '/']) { + await assert.rejects( + setupWorker({ ...options, workspaceDirectory }), + /Do not expose your home directory/, + ); + } +}); + +test('setup stops before storage or pairing when cancelled', async t => { + const { root, options } = await fixture(t); + const controller = new AbortController(); + controller.abort(new Error('cancelled by owner')); + await assert.rejects( + setupWorker({ ...options, signal: controller.signal }), + /cancelled by owner/, + ); + assert.deepEqual(await readdir(root), []); +}); + +test('setup accepts HTTPS prefixes and only permits insecure HTTP on loopback', () => { + assert.equal( + setupCodeApiUrl('https://code.example/proxy/v1/'), + 'https://code.example/proxy/v1', + ); + for (const host of ['localhost', '127.0.0.1', '[::1]']) { + assert.equal( + setupCodeApiUrl(`http://${host}:3000/v1`), + `http://${host}:3000/v1`, + ); + } + for (const value of [ + 'http://code.example/v1', + 'ftp://code.example', + 'https://user:secret@code.example/v1', + 'https://code.example/v1?token=secret', + 'https://code.example/v1#fragment', + 'https://code.example/\n', + ]) { + assert.throws(() => setupCodeApiUrl(value), /HTTPS|credentials/); + } +}); + +test('saved profile startup never merges legacy identity or permission settings', async t => { + const { options, pair } = await fixture(t); + const saved = await setupWorker(options, { pair }); + const selected = await profileRunConfiguration( + ['--profile', saved.profilePath], + {}, + ); + assert.equal(selected.profilePath, saved.profilePath); + assert.deepEqual(selected.args, ['--default-workspace']); + assert.equal(selected.profile?.identityPath, options.identityPath); + for (const key of [ + 'LIBRECHAT_CODE_WORKER_TOKEN', + 'LIBRECHAT_CODE_IDENTITY_FILE', + 'LIBRECHAT_CODE_ALLOW_WORKSPACE_COMMANDS', + ]) { + await assert.rejects( + profileRunConfiguration(['--profile', saved.profilePath], { + [key]: 'configured', + }), + /cannot be combined/, + ); + assert.deepEqual( + await profileRunConfiguration(['--worker-dir', '/legacy'], { + [key]: 'configured', + }), + { args: ['--worker-dir', '/legacy'] }, + ); + } + await assert.rejects( + profileRunConfiguration( + ['--profile', saved.profilePath, '--allow-workspace-commands'], + {}, + ), + /cannot be combined with run options/, + ); +}); + +test('explicit profile parse rejects missing, repeated, absent, or corrupt profiles', async t => { + const { options } = await fixture(t); + await assert.rejects( + profileRunConfiguration(['--profile'], {}), + /requires a file/, + ); + await assert.rejects( + profileRunConfiguration(['--profile=a', '--profile=b'], {}), + /only once/, + ); + await assert.rejects( + profileRunConfiguration(['--profile', options.profilePath!], {}), + /not found/, + ); + await mkdir(join(options.profilePath!, '..'), { recursive: true }); + await writeFile(options.profilePath!, '{}', { mode: 0o600 }); + await assert.rejects( + profileRunConfiguration(['--profile', options.profilePath!], {}), + /Invalid worker profile/, + ); +}); + +test('profile file environment variable selects the same profile without changing process environment', async t => { + const { options } = await fixture(t); + const profile = { + version: 1 as const, + identityPath: options.identityPath!, + workspace: { type: 'private' as const }, + allowWorkspaceWrites: true, + allowWorkspaceCommands: false, + }; + await saveWorkerProfile(options.profilePath!, profile); + const environment = { LIBRECHAT_CODE_PROFILE_FILE: options.profilePath! }; + const result = await profileRunConfiguration([], environment); + assert.deepEqual(result.args, [ + '--default-workspace', + '--allow-workspace-writes', + ]); + assert.deepEqual(environment, { + LIBRECHAT_CODE_PROFILE_FILE: options.profilePath!, + }); +}); + +test('setup CLI parsing rejects ambiguity and requires explicit non-interactive access', () => { + for (const args of [ + ['--worker-id'], + ['--url', '--worker-id'], + ['--unknown'], + ['--url=a', '--url=b'], + ['--default-workspace=true'], + ['--worker-dir', '/project', '--default-workspace'], + ['--non-interactive'], + ['--code-stdin'], + ]) { + assert.throws(() => parseSetupArguments(args)); + } + const parsed = parseSetupArguments([ + '--non-interactive', + '--code-stdin', + '--url=https://code.example/v1', + '--worker-id=vm1', + '--default-workspace', + ]); + assert.equal(parsed.get('--allow-workspace-commands'), undefined); + assert.equal(parsed.get('--allow-workspace-writes'), undefined); +}); diff --git a/packages/code/src/setup.ts b/packages/code/src/setup.ts new file mode 100644 index 00000000..46879207 --- /dev/null +++ b/packages/code/src/setup.ts @@ -0,0 +1,254 @@ +import { access, realpath, stat } from 'node:fs/promises'; +import { constants } from 'node:fs'; +import { homedir } from 'node:os'; +import { isAbsolute, relative, resolve, sep } from 'node:path'; + +import { pairBridgeWorker } from './pairing.js'; +import { isValidBridgeWorkerId } from './protocol.js'; +import { assertPrivateStorageSupported } from './private-storage.js'; +import { + assertIdentityPathIsPrivate, + defaultBridgeIdentityPath, + saveBridgeIdentity, +} from './storage.js'; +import { + assertWorkerProfileIsolation, + defaultWorkerProfilePath, + loadWorkerProfile, + saveWorkerProfile, + workerProfileArguments, +} from './worker-profile.js'; +import type { WorkerProfile } from './worker-profile.js'; + +export interface SetupWorkerOptions { + codeApiUrl: string; + workerId: string; + code: string; + profilePath?: string; + identityPath?: string; + workspaceDirectory?: string; + allowWorkspaceWrites: boolean; + allowWorkspaceCommands: boolean; + signal?: AbortSignal; +} + +export function setupCodeApiUrl(value: string): string { + if (value.length > 2048 || /[\s\x00-\x1f\x7f]/.test(value)) { + throw new Error( + 'Enter a Code API HTTPS URL without credentials, query, or fragment', + ); + } + let url: URL; + try { + url = new URL(value); + } catch { + throw new Error( + 'Enter a valid Code API HTTPS URL, including its /v1 prefix', + ); + } + const loopback = ['localhost', '127.0.0.1', '[::1]'].includes(url.hostname); + if ( + (url.protocol !== 'https:' && !(url.protocol === 'http:' && loopback)) || + url.username || + url.password || + url.search || + url.hash + ) { + throw new Error( + 'Code API requires HTTPS; HTTP is allowed only on loopback, without credentials, query, or fragment', + ); + } + return url.href.replace(/\/+$/, ''); +} + +export async function checkSetupCommands(): Promise { + const { SandboxManager } = await import('@anthropic-ai/sandbox-runtime'); + if (!SandboxManager.isSupportedPlatform()) { + throw new Error( + 'Native sandbox commands require a supported macOS or Linux host', + ); + } + const dependencies = await SandboxManager.checkDependenciesAsync(); + if (dependencies.errors.length) { + throw new Error( + `Install the native sandbox prerequisites before pairing: ${dependencies.errors.join('; ')}`, + ); + } + await access('/bin/bash', constants.X_OK); +} + +export async function setupWorkspaceDirectory(value: string): Promise { + if (!value.trim() || /[\x00-\x1f\x7f]/.test(value)) { + throw new Error('Choose an existing project directory'); + } + const path = await realpath(resolve(value)); + if (!(await stat(path)).isDirectory()) + throw new Error('Workspace must be a directory'); + const home = await realpath(homedir()); + const homeRelative = relative(path, home); + if ( + homeRelative === '' || + (!isAbsolute(homeRelative) && + homeRelative !== '..' && + !homeRelative.startsWith(`..${sep}`)) + ) { + throw new Error( + 'Do not expose your home directory or a directory containing it; choose a project or a private workspace', + ); + } + return path; +} + +export async function setupWorker( + options: SetupWorkerOptions, + dependencies: { + pair?: typeof pairBridgeWorker; + checkCommands?: typeof checkSetupCommands; + } = {}, +): Promise<{ profilePath: string; profile: WorkerProfile }> { + options.signal?.throwIfAborted(); + assertPrivateStorageSupported(); + const codeApiUrl = setupCodeApiUrl(options.codeApiUrl); + if (!isValidBridgeWorkerId(options.workerId)) + throw new Error('Enter the worker ID supplied by LibreChat'); + if (!/^[A-Za-z0-9_-]{16,256}$/.test(options.code)) + throw new Error('Enter a valid one-time pairing code from LibreChat'); + const profilePath = resolve( + options.profilePath ?? defaultWorkerProfilePath(), + ); + const identityPath = resolve( + options.identityPath ?? defaultBridgeIdentityPath(options.workerId), + ); + if ( + [profilePath, identityPath].some( + path => Buffer.byteLength(path) > 4096 || /[\x00-\x1f\x7f]/.test(path), + ) + ) { + throw new Error( + 'Profile and identity paths must contain no control characters and be at most 4096 bytes', + ); + } + if ( + typeof options.allowWorkspaceWrites !== 'boolean' || + typeof options.allowWorkspaceCommands !== 'boolean' + ) { + throw new Error( + 'Workspace permissions must be explicitly enabled or disabled', + ); + } + const workspaceDirectory = + options.workspaceDirectory == null + ? undefined + : await setupWorkspaceDirectory(options.workspaceDirectory); + const profile: WorkerProfile = { + version: 1, + identityPath, + workspace: + workspaceDirectory == null + ? { type: 'private' } + : { type: 'directory', path: workspaceDirectory }, + allowWorkspaceWrites: options.allowWorkspaceWrites, + allowWorkspaceCommands: options.allowWorkspaceCommands, + }; + if (profile.allowWorkspaceCommands) + await (dependencies.checkCommands ?? checkSetupCommands)(); + options.signal?.throwIfAborted(); + const profileReservation = await assertIdentityPathIsPrivate( + profilePath, + true, + ); + try { + const identityReservation = await assertIdentityPathIsPrivate( + identityPath, + true, + ); + try { + await assertWorkerProfileIsolation( + profilePath, + identityPath, + workspaceDirectory, + ); + const identity = await (dependencies.pair ?? pairBridgeWorker)({ + codeApiUrl, + workerId: options.workerId, + code: options.code, + signal: + options.signal == null + ? AbortSignal.timeout(30_000) + : AbortSignal.any([options.signal, AbortSignal.timeout(30_000)]), + }); + await saveBridgeIdentity(identityPath, identity); + try { + await saveWorkerProfile(profilePath, profile); + } catch { + throw new Error( + `Machine paired and identity saved to ${identityPath}, but the profile could not be saved. Keep this identity and use LIBRECHAT_CODE_IDENTITY_FILE with the existing run command; do not pair again automatically.`, + ); + } + return { profilePath, profile }; + } finally { + await identityReservation.release(); + } + } finally { + await profileReservation.release(); + } +} + +export interface ProfileRunConfiguration { + args: string[]; + profilePath?: string; + profile?: WorkerProfile; +} + +export async function profileRunConfiguration( + args: string[], + environment: NodeJS.ProcessEnv = process.env, +): Promise { + const profileArgs = args.filter( + arg => arg === '--profile' || arg.startsWith('--profile='), + ); + if (profileArgs.length > 1) throw new Error('Supply --profile only once'); + let profilePath = environment.LIBRECHAT_CODE_PROFILE_FILE?.trim(); + if (profileArgs.length) { + const flag = profileArgs[0]; + const value = + flag === '--profile' + ? args[args.indexOf(flag) + 1] + : flag.slice('--profile='.length); + if (!value || value.startsWith('--')) + throw new Error('--profile requires a file'); + profilePath = value; + args = args.filter( + (_, index) => + index !== args.indexOf(flag) && + (flag !== '--profile' || index !== args.indexOf(flag) + 1), + ); + } + const legacyConfiguration = Object.entries(environment).some( + ([key, value]) => + key.startsWith('LIBRECHAT_CODE_') && + key !== 'LIBRECHAT_CODE_PROFILE_FILE' && + value?.trim(), + ); + if (legacyConfiguration) { + if (profilePath) + throw new Error( + 'Saved profiles cannot be combined with LIBRECHAT_CODE_* settings; unset them or use the existing run configuration', + ); + return { args }; + } + const path = resolve(profilePath ?? defaultWorkerProfilePath()); + const profile = await loadWorkerProfile(path); + if (!profile) { + if (profilePath) + throw new Error( + 'Saved profile not found; run librechat-code setup first', + ); + return { args }; + } + if (args.length) + throw new Error( + 'Saved profiles cannot be combined with run options; use a separate profile or the existing environment-variable configuration', + ); + return { profilePath: path, profile, args: workerProfileArguments(profile) }; +} diff --git a/packages/code/src/storage.ts b/packages/code/src/storage.ts index 904a8562..578f9fb6 100644 --- a/packages/code/src/storage.ts +++ b/packages/code/src/storage.ts @@ -11,7 +11,7 @@ import { } from 'node:fs/promises'; import type { FileHandle } from 'node:fs/promises'; import { homedir } from 'node:os'; -import { dirname, join, resolve } from 'node:path'; +import { dirname, isAbsolute, join, resolve } from 'node:path'; import { BRIDGE_PROTOCOL_VERSION, BridgeProtocolError } from './protocol.js'; import { assertPrivateStorageAcl, removePrivateStorageAcl, assertPrivateStorageAncestors, assertPrivateStorageSupported } from './private-storage.js'; @@ -34,7 +34,10 @@ function isPairedIdentity(value: unknown): value is PairedBridgeWorkerIdentity { typeof value.expiresAt === 'string' && Number.isFinite(Date.parse(value.expiresAt)) && typeof value.publicKey === 'string' && - typeof value.privateKey === 'string' + typeof value.privateKey === 'string' && + (value.setupAttemptPath === undefined || + (typeof value.setupAttemptPath === 'string' && isAbsolute(value.setupAttemptPath) && + Buffer.byteLength(value.setupAttemptPath) <= 4096 && !/[\x00-\x1f\x7f]/.test(value.setupAttemptPath))) ); } @@ -223,7 +226,18 @@ async function readGuardedFile( if ((mode & 0o077) !== 0) throw new BridgeProtocolError(exposed(mode.toString(8))); } await assertPrivateStorageAcl(handle, path); - return await handle.readFile('utf8'); + if (!Number.isFinite(maxBytes)) return await handle.readFile('utf8'); + const buffer = Buffer.alloc(maxBytes + 1); + let length = 0; + while (length < buffer.length) { + const { bytesRead } = await handle.read(buffer, length, buffer.length - length, null); + if (bytesRead === 0) break; + length += bytesRead; + } + if (length > maxBytes) { + throw new BridgeProtocolError('Invalid private storage file size or type'); + } + return buffer.toString('utf8', 0, length); } finally { await handle.close(); } @@ -360,6 +374,7 @@ async function assertSiblingPublishable(path: string): Promise { export async function assertIdentityPathIsPrivate( path: string, + createOnly = false, ): Promise { assertPrivateStorageSupported(); await assertWriteContainerPrivate(path); @@ -389,6 +404,11 @@ export async function assertIdentityPathIsPrivate( throw error; } if (!isRecord(error) || error.code !== 'EEXIST') throw error; + if (createOnly) { + throw new BridgeProtocolError( + `${path} already exists; choose a different identity/profile path.`, + ); + } /* Already present: judge what is there instead of the placeholder, and * prove the publish itself is possible - an unwritable parent would * otherwise surface as EACCES only after the code was spent. */ @@ -465,7 +485,25 @@ export async function saveEnvironmentPreparationKey(path: string, key: string): await savePrivateJson(path, { version: 1, key }); } -async function savePrivateJson(path: string, value: unknown): Promise { +export async function readPrivateJson(path: string, maxBytes: number): Promise { + assertPrivateStorageSupported(); + if (!Number.isSafeInteger(maxBytes) || maxBytes < 1) { + throw new BridgeProtocolError('Private JSON file limit must be a positive integer'); + } + const content = await readGuardedFile( + path, + (mode) => `Private JSON file ${path} must be owner-only (mode ${mode})`, + maxBytes, + ); + await assertReadPathPrivate(path); + try { + return JSON.parse(content) as unknown; + } catch { + throw new BridgeProtocolError(`Invalid private JSON file: ${path}`); + } +} + +export async function savePrivateJson(path: string, value: unknown): Promise { assertPrivateStorageSupported(); await assertWriteContainerPrivate(path); await mkdir(dirname(path), { recursive: true, mode: 0o700 }); diff --git a/packages/code/src/worker-profile.test.ts b/packages/code/src/worker-profile.test.ts new file mode 100644 index 00000000..585144c0 --- /dev/null +++ b/packages/code/src/worker-profile.test.ts @@ -0,0 +1,529 @@ +import assert from 'node:assert/strict'; +import { + chmod, + link, + mkdir, + mkdtemp, + open, + readFile, + readdir, + rm, + stat, + symlink, + writeFile, +} from 'node:fs/promises'; +import type { FileHandle } from 'node:fs/promises'; +import { homedir, tmpdir } from 'node:os'; +import { join } from 'node:path'; +import test from 'node:test'; + +import { assertIdentityPathIsPrivate } from './storage.js'; +import { + assertWorkerProfileIsolation, + defaultWorkerProfilePath, + loadWorkerProfile, + saveWorkerProfile, + workerProfileArguments, +} from './worker-profile.js'; +import type { WorkerProfile } from './worker-profile.js'; + +function profile(directory: string): WorkerProfile { + return { + version: 1, + identityPath: join(directory, 'identity.json'), + workspace: { type: 'private' }, + allowWorkspaceWrites: false, + allowWorkspaceCommands: false, + }; +} + +test('the default worker profile is separate from identity storage', () => { + assert.equal( + defaultWorkerProfilePath(), + join(homedir(), '.config', 'librechat', 'code', 'profiles', 'default.json'), + ); +}); + +test('worker profiles roundtrip atomically with owner-only file and directory permissions', async t => { + const directory = await mkdtemp(join(tmpdir(), 'worker-profile-')); + t.after(() => rm(directory, { recursive: true, force: true })); + const path = join(directory, 'profiles', 'default.json'); + const value = profile(directory); + await saveWorkerProfile(path, value); + assert.deepEqual(await loadWorkerProfile(path), value); + assert.deepEqual(JSON.parse(await readFile(path, 'utf8')), value); + assert.equal((await stat(path)).mode & 0o777, 0o600); + assert.equal((await stat(join(directory, 'profiles'))).mode & 0o777, 0o700); + + const updated: WorkerProfile = { + ...value, + workspace: { + type: 'directory', + path: join(directory, 'project with spaces'), + }, + allowWorkspaceWrites: true, + allowWorkspaceCommands: true, + }; + await saveWorkerProfile(path, updated); + assert.deepEqual(await loadWorkerProfile(path), updated); + assert.equal((await stat(path)).mode & 0o777, 0o600); + assert.deepEqual(await readdir(join(directory, 'profiles')), [ + 'default.json', + ]); +}); + +test('worker profile arguments enable only explicitly granted permissions', () => { + for (const workspace of [ + { type: 'private' as const }, + { type: 'directory' as const, path: '/srv/project with spaces' }, + ]) { + for (const allowWorkspaceWrites of [false, true]) { + for (const allowWorkspaceCommands of [false, true]) { + const value = { + ...profile('/srv'), + workspace, + allowWorkspaceWrites, + allowWorkspaceCommands, + }; + assert.deepEqual(workerProfileArguments(value), [ + ...(workspace.type === 'private' + ? ['--default-workspace'] + : ['--worker-dir', workspace.path]), + ...(allowWorkspaceWrites ? ['--allow-workspace-writes'] : []), + ...(allowWorkspaceCommands ? ['--allow-workspace-commands'] : []), + ]); + } + } + } +}); + +test('only an absent worker profile returns undefined', async t => { + const directory = await mkdtemp(join(tmpdir(), 'worker-profile-missing-')); + t.after(() => rm(directory, { recursive: true, force: true })); + assert.equal( + await loadWorkerProfile(join(directory, 'absent.json')), + undefined, + ); + assert.equal( + await loadWorkerProfile(join(directory, 'missing', 'absent.json')), + undefined, + ); + await assert.rejects(loadWorkerProfile(directory), /size or type/); + + const dangling = join(directory, 'dangling.json'); + await symlink(join(directory, 'absent.json'), dangling); + await assert.rejects(loadWorkerProfile(dangling), { code: 'ENOENT' }); + const loop = join(directory, 'loop.json'); + await symlink(loop, loop); + await assert.rejects(loadWorkerProfile(loop), /Too many storage symlinks/); +}); + +test('malformed, empty, and oversized profiles fail without disclosing their contents', async t => { + const directory = await mkdtemp( + join(tmpdir(), 'worker-profile-invalid-json-'), + ); + t.after(() => rm(directory, { recursive: true, force: true })); + const path = join(directory, 'profile.json'); + for (const content of ['', ' ', '{"credential":"do-not-disclose",']) { + await writeFile(path, content, { mode: 0o600 }); + await assert.rejects(loadWorkerProfile(path), (error: Error) => { + assert.match(error.message, /Invalid private JSON file/); + assert.doesNotMatch(error.message, /do-not-disclose/); + return true; + }); + } + await writeFile( + path, + `${JSON.stringify(profile(directory))}${' '.repeat(32 * 1024)}`, + { mode: 0o600 }, + ); + await assert.rejects(loadWorkerProfile(path), /size or type/); +}); + +test('profiles reject unknown fields, unsupported versions, invalid paths, and implicit grants', async t => { + const directory = await mkdtemp(join(tmpdir(), 'worker-profile-fields-')); + t.after(() => rm(directory, { recursive: true, force: true })); + const path = join(directory, 'profile.json'); + const value = profile(directory); + const cases: [string, unknown][] = [ + ['null', null], + ['array', []], + ['unsupported version', { ...value, version: 2 }], + ['unknown root field', { ...value, credential: 'not-allowed' }], + [ + 'unknown private workspace field', + { ...value, workspace: { type: 'private', path: '/srv' } }, + ], + [ + 'unknown directory workspace field', + { ...value, workspace: { type: 'directory', path: '/srv', extra: true } }, + ], + ['unknown workspace type', { ...value, workspace: { type: 'shared' } }], + ['missing workspace path', { ...value, workspace: { type: 'directory' } }], + ['relative identity path', { ...value, identityPath: 'identity.json' }], + [ + 'relative workspace path', + { ...value, workspace: { type: 'directory', path: './project' } }, + ], + ['empty identity path', { ...value, identityPath: '' }], + ['NUL identity path', { ...value, identityPath: '/srv/identity\0.json' }], + [ + 'NUL workspace path', + { ...value, workspace: { type: 'directory', path: '/srv/project\0' } }, + ], + [ + 'oversized identity path', + { ...value, identityPath: `/${'x'.repeat(4096)}` }, + ], + [ + 'oversized workspace path', + { + ...value, + workspace: { type: 'directory', path: `/${'é'.repeat(2048)}` }, + }, + ], + [ + 'missing write permission', + { + version: 1, + identityPath: value.identityPath, + workspace: value.workspace, + allowWorkspaceCommands: false, + }, + ], + ['string write permission', { ...value, allowWorkspaceWrites: 'true' }], + ['numeric command permission', { ...value, allowWorkspaceCommands: 1 }], + ]; + for (const [name, invalid] of cases) { + await t.test(name, async () => { + const serialized = JSON.stringify(invalid); + await writeFile(path, serialized, { mode: 0o600 }); + await assert.rejects(loadWorkerProfile(path), /worker profile/i); + await assert.rejects( + saveWorkerProfile(path, invalid as WorkerProfile), + /worker profile/i, + ); + assert.equal(await readFile(path, 'utf8'), serialized); + assert.throws( + () => workerProfileArguments(invalid as WorkerProfile), + /worker profile/i, + ); + }); + } + await assert.rejects( + loadWorkerProfile('relative.json'), + /paths must be absolute/, + ); + await assert.rejects( + saveWorkerProfile('relative.json', value), + /paths must be absolute/, + ); +}); + +test('save rejects non-JSON fields and bounds the serialized profile size', async t => { + const directory = await mkdtemp( + join(tmpdir(), 'worker-profile-serialization-'), + ); + t.after(() => rm(directory, { recursive: true, force: true })); + const path = join(directory, 'profile.json'); + const value = profile(directory); + await assert.rejects( + saveWorkerProfile(path, { ...value, ignored: undefined } as WorkerProfile), + /fields/, + ); + await assert.rejects( + saveWorkerProfile(path, { ...value, [Symbol('ignored')]: true }), + /fields/, + ); + await assert.rejects( + saveWorkerProfile(path, { ...value, toJSON: () => value } as WorkerProfile), + /fields/, + ); + await assert.rejects( + saveWorkerProfile(path, { + ...value, + identityPath: `/${'"'.repeat(4095)}`, + workspace: { type: 'directory', path: `/${'"'.repeat(4095)}` }, + }), + /16 KiB limit/, + ); + await assert.rejects(stat(path), { code: 'ENOENT' }); +}); + +test('private profile reads remain bounded if the file grows after its size check', async t => { + const directory = await mkdtemp(join(tmpdir(), 'worker-profile-growth-')); + t.after(() => rm(directory, { recursive: true, force: true })); + const path = join(directory, 'profile.json'); + await saveWorkerProfile(path, profile(directory)); + const probe = await open(path, 'r'); + const prototype = Object.getPrototypeOf(probe) as FileHandle; + await probe.close(); + const originalRead = prototype.read; + let grown = false; + t.mock.method( + prototype, + 'read', + async function (this: FileHandle, ...args: unknown[]) { + if (!grown) { + grown = true; + await writeFile( + path, + `${JSON.stringify(profile(directory))}${' '.repeat(32 * 1024)}`, + ); + } + return Reflect.apply(originalRead, this, args); + }, + ); + await assert.rejects(loadWorkerProfile(path), /size or type/); + assert.equal(grown, true); +}); + +test('permission errors are not mistaken for an absent profile', async t => { + const directory = await mkdtemp(join(tmpdir(), 'worker-profile-permission-')); + t.after(() => rm(directory, { recursive: true, force: true })); + const path = join(directory, 'profile.json'); + await saveWorkerProfile(path, profile(directory)); + const probe = await open(path, 'r'); + const prototype = Object.getPrototypeOf(probe) as FileHandle; + await probe.close(); + t.mock.method(prototype, 'read', async () => { + throw Object.assign(new Error('Permission denied'), { code: 'EACCES' }); + }); + await assert.rejects(loadWorkerProfile(path), { code: 'EACCES' }); +}); + +test('exposed profiles and unsafe storage ancestry are rejected', async t => { + const directory = await mkdtemp(join(tmpdir(), 'worker-profile-modes-')); + t.after(() => rm(directory, { recursive: true, force: true })); + const path = join(directory, 'profile.json'); + const value = profile(directory); + await saveWorkerProfile(path, value); + await chmod(path, 0o644); + await assert.rejects(loadWorkerProfile(path), /owner-only.*644/); + await chmod(path, 0o600); + await chmod(directory, 0o777); + await assert.rejects(loadWorkerProfile(path), /writable by other accounts/); + await assert.rejects( + loadWorkerProfile(join(directory, 'missing.json')), + /writable by other accounts/, + ); + await assert.rejects( + saveWorkerProfile(path, value), + /writable by other accounts/, + ); +}); + +test('worker profile storage fails closed on unsupported platforms', async t => { + const descriptor = Object.getOwnPropertyDescriptor(process, 'platform')!; + t.after(() => Object.defineProperty(process, 'platform', descriptor)); + Object.defineProperty(process, 'platform', { + value: 'freebsd', + configurable: true, + }); + const value = profile('/srv'); + await assert.rejects( + loadWorkerProfile('/srv/missing.json'), + /unavailable on this platform/, + ); + await assert.rejects( + saveWorkerProfile('/srv/profile.json', value), + /unavailable on this platform/, + ); + await assert.rejects( + assertWorkerProfileIsolation('/srv/profile.json', value.identityPath), + /unavailable on this platform/, + ); +}); + +test('isolation accepts separate reserved files outside the workspace', async t => { + const directory = await mkdtemp(join(tmpdir(), 'worker-profile-isolation-')); + t.after(() => rm(directory, { recursive: true, force: true })); + const path = join(directory, 'profile.json'); + const identity = join(directory, 'identity.json'); + const workspace = join(directory, 'workspace'); + await mkdir(workspace); + const reservedProfile = await assertIdentityPathIsPrivate(path, true); + const reservedIdentity = await assertIdentityPathIsPrivate(identity, true); + await assertWorkerProfileIsolation(path, identity); + await assertWorkerProfileIsolation(path, identity, workspace); + await reservedIdentity.release(); + await reservedProfile.release(); + assert.deepEqual(await readdir(directory), ['workspace']); +}); + +test('isolation rejects lexical, symlink, and hardlink aliases of the identity', async t => { + const directory = await mkdtemp(join(tmpdir(), 'worker-profile-alias-')); + t.after(() => rm(directory, { recursive: true, force: true })); + const identity = join(directory, 'identity.json'); + await writeFile(identity, '', { mode: 0o600 }); + const symlinkPath = join(directory, 'symlink.json'); + const hardlinkPath = join(directory, 'hardlink.json'); + await symlink(identity, symlinkPath); + await link(identity, hardlinkPath); + for (const path of [ + identity, + `${directory}/./identity.json`, + symlinkPath, + hardlinkPath, + ]) { + await assert.rejects( + assertWorkerProfileIsolation(path, identity), + /separate files, not aliases/, + ); + } +}); + +test('isolation rejects either private file inside a read-only workspace', async t => { + const directory = await mkdtemp( + join(tmpdir(), 'worker-profile-containment-'), + ); + t.after(() => rm(directory, { recursive: true, force: true })); + const workspace = join(directory, 'workspace'); + await mkdir(workspace); + const profilePath = join(directory, 'profile.json'); + const identity = join(directory, 'identity.json'); + const inside = join(workspace, 'storage.json'); + for (const path of [profilePath, identity, inside]) + await writeFile(path, '', { mode: 0o600 }); + await assert.rejects( + assertWorkerProfileIsolation(inside, identity, workspace), + /outside the workspace/, + ); + await assert.rejects( + assertWorkerProfileIsolation(profilePath, inside, workspace), + /outside the workspace/, + ); + const workspaceAlias = join(directory, 'workspace-alias'); + await symlink(workspace, workspaceAlias); + await assert.rejects( + assertWorkerProfileIsolation(profilePath, inside, workspaceAlias), + /outside the workspace/, + ); +}); + +test('isolation checks every intermediate symlink and traversed storage ancestor', async t => { + const directory = await mkdtemp( + join(tmpdir(), 'worker-profile-link-ancestry-'), + ); + t.after(() => rm(directory, { recursive: true, force: true })); + const workspace = join(directory, 'workspace'); + const outside = join(directory, 'workspace-private'); + await mkdir(workspace); + await mkdir(outside); + const profilePath = join(outside, 'profile.json'); + const identity = join(outside, 'identity.json'); + await writeFile(profilePath, '', { mode: 0o600 }); + await writeFile(identity, '', { mode: 0o600 }); + await assertWorkerProfileIsolation(profilePath, identity, workspace); + + const linkInWorkspace = join(workspace, 'storage'); + const indirect = join(directory, 'indirect'); + await symlink(outside, linkInWorkspace); + await symlink(linkInWorkspace, indirect); + const cases = [ + join(linkInWorkspace, 'profile.json'), + join(indirect, 'profile.json'), + `${workspace}/../workspace-private/profile.json`, + `${indirect}/../workspace-private/profile.json`, + ]; + for (const path of cases) { + await assert.rejects( + assertWorkerProfileIsolation(path, identity, workspace), + /outside the workspace/, + ); + } + await assert.rejects( + assertWorkerProfileIsolation( + profilePath, + join(indirect, 'identity.json'), + workspace, + ), + /outside the workspace/, + ); + const fileLink = join(workspace, 'identity-link.json'); + await symlink(identity, fileLink); + await assert.rejects( + assertWorkerProfileIsolation(profilePath, fileLink, workspace), + /outside the workspace/, + ); +}); + +test('isolation fails on missing or non-file storage and missing or non-directory workspaces', async t => { + const directory = await mkdtemp( + join(tmpdir(), 'worker-profile-isolation-invalid-'), + ); + t.after(() => rm(directory, { recursive: true, force: true })); + const path = join(directory, 'profile.json'); + const identity = join(directory, 'identity.json'); + await writeFile(path, '', { mode: 0o600 }); + await writeFile(identity, '', { mode: 0o600 }); + await assert.rejects( + assertWorkerProfileIsolation(join(directory, 'missing.json'), identity), + { code: 'ENOENT' }, + ); + await assert.rejects( + assertWorkerProfileIsolation(directory, identity), + /regular files/, + ); + await assert.rejects( + assertWorkerProfileIsolation(path, identity, join(directory, 'missing')), + { code: 'ENOENT' }, + ); + await assert.rejects( + assertWorkerProfileIsolation(path, identity, path), + /must be a directory/, + ); +}); + +test('create-only reservations never overwrite existing profiles or identities', async t => { + const directory = await mkdtemp( + join(tmpdir(), 'worker-profile-create-only-'), + ); + t.after(() => rm(directory, { recursive: true, force: true })); + const path = join(directory, 'profile.json'); + const value = profile(directory); + await saveWorkerProfile(path, value); + const before = await stat(path, { bigint: true }); + await assert.rejects( + assertIdentityPathIsPrivate(path, true), + /already exists; choose a different identity\/profile path/, + ); + assert.equal((await stat(path, { bigint: true })).ino, before.ino); + assert.deepEqual(await loadWorkerProfile(path), value); + const existing = await assertIdentityPathIsPrivate(path); + await existing.release(); + assert.deepEqual(await loadWorkerProfile(path), value); +}); + +test('only one concurrent create-only reservation can claim a path', async t => { + const directory = await mkdtemp( + join(tmpdir(), 'worker-profile-reservations-'), + ); + t.after(() => rm(directory, { recursive: true, force: true })); + const path = join(directory, 'profile.json'); + const results = await Promise.allSettled([ + assertIdentityPathIsPrivate(path, true), + assertIdentityPathIsPrivate(path, true), + ]); + const successes = results.filter(result => result.status === 'fulfilled'); + const failures = results.filter(result => result.status === 'rejected'); + assert.equal(successes.length, 1); + assert.equal(failures.length, 1); + assert.match(String(failures[0].reason), /already exists/); + assert.equal((await stat(path)).size, 0); + await successes[0].value.release(); + await assert.rejects(stat(path), { code: 'ENOENT' }); +}); + +test('reservation cleanup preserves a profile published over its placeholder', async t => { + const directory = await mkdtemp( + join(tmpdir(), 'worker-profile-reservation-release-'), + ); + t.after(() => rm(directory, { recursive: true, force: true })); + const path = join(directory, 'profile.json'); + const reservation = await assertIdentityPathIsPrivate(path, true); + const value = profile(directory); + await saveWorkerProfile(path, value); + await reservation.release(); + assert.deepEqual(await loadWorkerProfile(path), value); +}); diff --git a/packages/code/src/worker-profile.ts b/packages/code/src/worker-profile.ts new file mode 100644 index 00000000..a466a5d1 --- /dev/null +++ b/packages/code/src/worker-profile.ts @@ -0,0 +1,200 @@ +import { lstat, realpath, stat } from 'node:fs/promises'; +import { homedir } from 'node:os'; +import { isAbsolute, join, relative, sep } from 'node:path'; + +import { assertPrivateStorageAncestors } from './private-storage.js'; +import { BridgeProtocolError } from './protocol.js'; +import { readPrivateJson, savePrivateJson } from './storage.js'; + +const MAX_PROFILE_BYTES = 16 * 1024; +const MAX_PATH_BYTES = 4096; + +export interface WorkerProfile { + version: 1; + identityPath: string; + workspace: { type: 'private' } | { type: 'directory'; path: string }; + allowWorkspaceWrites: boolean; + allowWorkspaceCommands: boolean; +} + +function hasExactKeys( + value: unknown, + keys: string[], +): value is Record { + return ( + typeof value === 'object' && + value !== null && + !Array.isArray(value) && + Reflect.ownKeys(value).length === keys.length && + keys.every(key => Object.hasOwn(value, key)) + ); +} + +function assertProfilePath(value: unknown): asserts value is string { + if ( + typeof value !== 'string' || + !isAbsolute(value) || + /[\x00-\x1f\x7f]/.test(value) || + Buffer.byteLength(value, 'utf8') > MAX_PATH_BYTES + ) { + throw new BridgeProtocolError( + 'Worker profile paths must be absolute, contain no control characters, and be at most 4096 bytes', + ); + } +} + +function parseWorkerProfile(value: unknown): WorkerProfile { + if ( + !hasExactKeys(value, [ + 'version', + 'identityPath', + 'workspace', + 'allowWorkspaceWrites', + 'allowWorkspaceCommands', + ]) || + value.version !== 1 || + typeof value.allowWorkspaceWrites !== 'boolean' || + typeof value.allowWorkspaceCommands !== 'boolean' + ) { + throw new BridgeProtocolError( + 'Invalid worker profile fields or unsupported version', + ); + } + assertProfilePath(value.identityPath); + let workspace: WorkerProfile['workspace']; + if ( + hasExactKeys(value.workspace, ['type']) && + value.workspace.type === 'private' + ) { + workspace = { type: 'private' }; + } else if ( + hasExactKeys(value.workspace, ['type', 'path']) && + value.workspace.type === 'directory' + ) { + assertProfilePath(value.workspace.path); + workspace = { type: 'directory', path: value.workspace.path }; + } else { + throw new BridgeProtocolError('Invalid worker profile workspace'); + } + return { + version: 1, + identityPath: value.identityPath, + workspace, + allowWorkspaceWrites: value.allowWorkspaceWrites, + allowWorkspaceCommands: value.allowWorkspaceCommands, + }; +} + +export function defaultWorkerProfilePath(): string { + return join( + homedir(), + '.config', + 'librechat', + 'code', + 'profiles', + 'default.json', + ); +} + +export async function loadWorkerProfile( + path: string, +): Promise { + assertProfilePath(path); + let value: unknown; + try { + value = await readPrivateJson(path, MAX_PROFILE_BYTES); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; + // A dangling leaf symlink is not an absent profile. + const entry = await lstat(path).catch((failure: NodeJS.ErrnoException) => { + if (failure.code === 'ENOENT') return undefined; + throw failure; + }); + if (entry !== undefined) throw error; + return undefined; + } + return parseWorkerProfile(value); +} + +export async function saveWorkerProfile( + path: string, + profile: WorkerProfile, +): Promise { + assertProfilePath(path); + const value = parseWorkerProfile(profile); + if ( + Buffer.byteLength(`${JSON.stringify(value, null, 2)}\n`, 'utf8') > + MAX_PROFILE_BYTES + ) { + throw new BridgeProtocolError('Worker profile exceeds the 16 KiB limit'); + } + await savePrivateJson(path, value); +} + +export function workerProfileArguments(profile: WorkerProfile): string[] { + const value = parseWorkerProfile(profile); + const args = + value.workspace.type === 'private' + ? ['--default-workspace'] + : ['--worker-dir', value.workspace.path]; + if (value.allowWorkspaceWrites) args.push('--allow-workspace-writes'); + if (value.allowWorkspaceCommands) args.push('--allow-workspace-commands'); + return args; +} + +function isWithin(root: string, path: string): boolean { + const suffix = relative(root, path); + return ( + suffix === '' || + (suffix !== '..' && !suffix.startsWith(`..${sep}`) && !isAbsolute(suffix)) + ); +} + +export async function assertWorkerProfileIsolation( + profilePath: string, + identityPath: string, + workspaceRoot?: string, +): Promise { + assertProfilePath(profilePath); + assertProfilePath(identityPath); + if (workspaceRoot !== undefined) assertProfilePath(workspaceRoot); + const [profileAncestors, identityAncestors] = await Promise.all([ + assertPrivateStorageAncestors(profilePath), + assertPrivateStorageAncestors(identityPath), + ]); + const [profile, identity, profileMetadata, identityMetadata] = + await Promise.all([ + realpath(profilePath), + realpath(identityPath), + stat(profilePath, { bigint: true }), + stat(identityPath, { bigint: true }), + ]); + if (!profileMetadata.isFile() || !identityMetadata.isFile()) { + throw new BridgeProtocolError( + 'Worker profile and identity paths must be regular files', + ); + } + if ( + profile === identity || + (profileMetadata.dev === identityMetadata.dev && + profileMetadata.ino === identityMetadata.ino) + ) { + throw new BridgeProtocolError( + 'Worker profile and identity must use separate files, not aliases', + ); + } + if (workspaceRoot === undefined) return; + const root = await realpath(workspaceRoot); + if (!(await stat(root)).isDirectory()) { + throw new BridgeProtocolError('Worker workspace root must be a directory'); + } + if ( + [profile, identity, ...profileAncestors, ...identityAncestors].some(path => + isWithin(root, path), + ) + ) { + throw new BridgeProtocolError( + 'Worker profile, identity, and their storage ancestry must stay outside the workspace', + ); + } +} diff --git a/packages/code/src/worker.test.ts b/packages/code/src/worker.test.ts index e26f212a..b555c1cd 100644 --- a/packages/code/src/worker.test.ts +++ b/packages/code/src/worker.test.ts @@ -75,11 +75,12 @@ test('worker confirms readiness only after local registration activation succeed onRegistered: async () => { events.push('activated'); }, + onReady: () => { events.push('observed-ready'); }, }); await worker.register(); - assert.deepEqual(events, ['registered', 'activated', 'ready']); + assert.deepEqual(events, ['registered', 'activated', 'ready', 'observed-ready']); }); test('worker does not confirm readiness when local activation fails', async () => { @@ -110,12 +111,29 @@ test('worker does not confirm readiness when local activation fails', async () = onRegistered: async () => { throw new Error('relay activation failed'); }, + onReady: () => { assert.fail('failed activation is not readiness'); }, }); await assert.rejects(worker.register(), /relay activation failed/); assert.equal(readinessRequests, 0); }); +test('worker never reports ready for an absent or negative readiness acknowledgement', async () => { + for (const ready of [undefined, false]) { + const worker = new BridgeWorker({ + codeApiUrl: 'https://code.example/v1', token: 'worker-secret', workerId: 'vm-1', incarnationId, + sandboxEndpoint: 'http://127.0.0.1:2000/api/v2', + capabilities: { statefulWorkspace: false, sandboxProfile: 'nsjail', runtimes: ['bash'], requiresReadyConfirmation: true }, + fetchImpl: async input => Response.json(String(input).endsWith('/ready') + ? { protocolVersion: 1, ready } + : { protocolVersion: 1, workerId: 'vm-1', incarnationId, registrationGeneration: 1, + registeredAt: new Date().toISOString(), leaseTtlMs: 60_000 }), + onReady: () => { assert.fail('readiness was not confirmed'); }, + }); + await assert.rejects(worker.register(), /did not confirm worker readiness/); + } +}); + test('worker forwards a fenced assignment to the sandbox and settles the result', async () => { const requests: Array<{ url: string; init?: RequestInit }> = []; const fetchImpl: typeof fetch = async (input, init) => { diff --git a/packages/code/src/worker.ts b/packages/code/src/worker.ts index 49b96eb1..c2ae9924 100644 --- a/packages/code/src/worker.ts +++ b/packages/code/src/worker.ts @@ -7,6 +7,7 @@ import { bridgeWorkerPath, isBridgeWorkspaceProgrammaticRequest, isValidLinkedWorktreeName, + isWorkspaceMutationOperation, isWorkspaceToolResult, workspaceIsolationKey, workspaceIsolationKeysConflict, @@ -15,7 +16,10 @@ import { } from './protocol.js'; import { EndpointRuntimeSupervisor } from './runtime.js'; import { signBridgeRequest } from './identity.js'; +import { recoverWorkerCredential } from './credential-recovery.js'; import { isWorkspaceToolRequest, WorkspaceToolError } from './workspace.js'; +import { commandFailureEvent } from './command-diagnostics.js'; +import type { CommandFailureEvent } from './command-diagnostics.js'; import type { WorkspaceCommandResultFeature, @@ -93,10 +97,14 @@ export interface BridgeWorkerOptions { credentialRefreshTransportTimeoutMs?: number; fetchImpl?: typeof fetch; onError?: (error: unknown) => void; + /** Local-only, allowlisted command failure metadata; never contains request data. */ + onCommandFailure?: (event: CommandFailureEvent) => void | Promise; onIdentityChange?: (identity: BridgeWorkerIdentity) => void | Promise; onRegistered?: ( registration: BridgeWorkerRegistrationResponse, ) => void | Promise; + /** Called only after registration and required readiness confirmation succeed. */ + onReady?: () => void | Promise; incarnationId?: string; } @@ -561,10 +569,7 @@ export class BridgeWorker { } if ( options.capabilities.workspaceTools?.operations.some( - (operation) => - operation === 'write_file' || - operation === 'edit_file' || - operation === 'execute_command', + isWorkspaceMutationOperation, ) === true && options.workspaceMutationQuarantine == null && options.workspaceQuarantines == null && @@ -839,6 +844,7 @@ export class BridgeWorker { await this.confirmReady(registration, signal); } this.lastRegisteredAtMs = registrationStartedAtMs; + if (!this.maintenanceOnly) await this.options.onReady?.(); return registration; } @@ -855,7 +861,7 @@ export class BridgeWorker { 'Code API does not support explicit worker readiness confirmation', ); } - await this.timedRequest( + const ready = await this.timedRequest<{ ready?: boolean }>( `${this.codeApiUrl}${bridgeWorkerPath(this.options.workerId)}/ready`, { protocolVersion: BRIDGE_PROTOCOL_VERSION, @@ -869,6 +875,9 @@ export class BridgeWorker { ), signal, ); + if (ready.ready !== true) { + throw new BridgeProtocolError('Code API did not confirm worker readiness'); + } } async resetWorkspace( @@ -1317,20 +1326,49 @@ export class BridgeWorker { if (Date.parse(identity.expiresAt) > validThroughMs) { return; } - const credential = await this.timedRequest( - `${this.codeApiUrl}${bridgeWorkerPath(this.options.workerId)}` + - '/credentials/refresh', - { protocolVersion: BRIDGE_PROTOCOL_VERSION }, - Math.max( - 1, - Math.min( - transportTimeoutMs, - this.options.credentialRefreshTransportTimeoutMs ?? - DEFAULT_CONTROL_TRANSPORT_TIMEOUT_MS, - ), + const timeoutMs = Math.max( + 1, + Math.min( + transportTimeoutMs, + this.options.credentialRefreshTransportTimeoutMs ?? + DEFAULT_CONTROL_TRANSPORT_TIMEOUT_MS, ), - signal, ); + const deadline = performance.now() + timeoutMs; + let credential: BridgeWorkerCredentialResponse; + try { + credential = await this.timedRequest( + `${this.codeApiUrl}${bridgeWorkerPath(this.options.workerId)}/credentials/refresh`, + { protocolVersion: BRIDGE_PROTOCOL_VERSION }, + timeoutMs, + signal, + ); + } catch (error) { + // Only bootstrap recovery. Auth failures during active work retain existing fencing/settlement rules. + if ( + this.lastRegisteredAtMs !== 0 || + !(error instanceof BridgeProtocolError) || + error.status !== 401 || + error.code !== 'CREDENTIAL_INVALID' + ) throw error; + signal?.throwIfAborted(); + const remainingMs = Math.floor(deadline - performance.now()); + if (remainingMs <= 0) { + throw new DOMException('Credential recovery timed out', 'TimeoutError'); + } + const recoverySignal = AbortSignal.timeout(remainingMs); + credential = await recoverWorkerCredential({ + codeApiUrl: this.codeApiUrl, + workerId: this.options.workerId, + privateKey: identity.privateKey, + validThroughMs, + fetchImpl: this.fetchImpl, + signal: signal == null + ? recoverySignal + : AbortSignal.any([signal, recoverySignal]), + }); + } + signal?.throwIfAborted(); if ( credential.protocolVersion !== BRIDGE_PROTOCOL_VERSION || credential.workerId !== this.options.workerId || @@ -1849,10 +1887,9 @@ export class BridgeWorker { 'Workspace listing feature is not advertised', ); } - const isMutation = - workspaceRequest.operation === 'write_file' || - workspaceRequest.operation === 'edit_file' || - workspaceRequest.operation === 'execute_command'; + const isMutation = isWorkspaceMutationOperation( + workspaceRequest.operation, + ); if (isMutation) { this.mutationGuardArmed = true; try { @@ -2081,6 +2118,35 @@ export class BridgeWorker { result: payload, }; } catch (error) { + if ( + error instanceof WorkspaceToolError && + error.code === 'COMMAND_UNAVAILABLE' && + (assignment.executionKind === 'workspace_programmatic' || + (assignment.executionKind === 'workspace_tool' && + isWorkspaceToolRequest(assignment.request) && + assignment.request.operation === 'execute_command')) + ) { + try { + const event = commandFailureEvent(error.commandDiagnostic, { + assignmentId: assignment.assignmentId, + workerId: this.options.workerId, + incarnationId: this.incarnationId, + workspaceId: assignment.executionKind === 'workspace_programmatic' + ? assignment.workspaceId + : (isWorkspaceToolRequest(assignment.request) ? assignment.request.workspaceId : undefined), + operation: assignment.executionKind === 'workspace_programmatic' + ? 'workspace_programmatic' + : 'execute_command', + mutationMayHaveCommitted: error.mutationMayHaveCommitted, + requiresQuarantine: error.requiresQuarantine, + }); + void Promise.resolve(this.options.onCommandFailure?.(event)).catch(() => { + // A rejected asynchronous log sink must not interrupt settlement. + }); + } catch { + // Observability must never change settlement or quarantine decisions. + } + } if ( error instanceof BridgeWorkspaceQuarantinedError && !workspaceMutationArmed @@ -2530,9 +2596,7 @@ export class BridgeWorker { (assignment.executionKind === 'workspace_programmatic' || (assignment.executionKind === 'workspace_tool' && isWorkspaceToolRequest(assignment.request) && - (assignment.request.operation === 'write_file' || - assignment.request.operation === 'edit_file' || - assignment.request.operation === 'execute_command'))); + isWorkspaceMutationOperation(assignment.request.operation))); if (signal?.aborted === true) { if (assignment.runtimeSessionId != null || fulfilledWorkspaceMutation) { throw await this.quarantineWorkspace( diff --git a/packages/code/src/workspace-import.test.ts b/packages/code/src/workspace-import.test.ts new file mode 100644 index 00000000..18d37b86 --- /dev/null +++ b/packages/code/src/workspace-import.test.ts @@ -0,0 +1,565 @@ +import assert from 'node:assert/strict'; +import { createHash, randomBytes } from 'node:crypto'; +import { createServer } from 'node:http'; +import { chmod, lstat, mkdir, mkdtemp, readdir, readFile, realpath, rename, rm, stat, symlink, writeFile } from 'node:fs/promises'; +import { setTimeout as delay } from 'node:timers/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import test from 'node:test'; + +import type { IncomingMessage, ServerResponse } from 'node:http'; +import type { AddressInfo } from 'node:net'; +import type { TestContext } from 'node:test'; + +import { + isBridgeWorkspaceImportFileRequest, + isRelayedWorkspaceImportFileRequest, + isValidBridgeWorkspaceToolCapabilities, + isWorkspaceImportFilePath, + isWorkspaceToolRequest, + isWorkspaceToolResult, +} from './protocol.js'; +import { captureWorkspaceRootIdentity } from './root-identity.js'; +import { BridgeWorker } from './worker.js'; +import { createWorkspaceFileImporter, HttpWorkspaceFileImporter } from './workspace-import.js'; +import { LocalWorkspaceTools, WorkspaceToolError } from './workspace.js'; + +import type { WorkspaceImportFileRequest, WorkspaceToolRequest } from './protocol.js'; +import type { HttpWorkspaceFileImporterOptions } from './workspace-import.js'; + +const GRANT = 'ceg1.grant-for-one-object'; +const SESSION = 'session-handle'; +const OBJECT = 'object-handle'; +const incarnationId = 'incarnation-00000001'; + +const sha256 = (bytes: Uint8Array): string => createHash('sha256').update(bytes).digest('hex'); + +function importRequest( + bytes: Buffer, + overrides: Partial = {}, +): WorkspaceImportFileRequest { + return { + protocolVersion: 1, + operation: 'import_file', + workspaceId: 'primary', + path: 'uploads/archive.zip', + source: { storage_session_id: SESSION, id: OBJECT }, + bytes: bytes.byteLength, + sha256: sha256(bytes), + egressGrant: GRANT, + ...overrides, + }; +} + +interface EgressStub { + url: string; + hits: number; + body: Buffer; + /** Replace the default response (exact body, declared length, valid grant). */ + respond?: (req: IncomingMessage, res: ServerResponse) => void; +} + +/** A local egress gateway: serves one object to one grant, like the real object route. */ +async function egressStub(t: TestContext, body: Buffer): Promise { + const stub: EgressStub = { url: '', hits: 0, body }; + const server = createServer((req, res) => { + stub.hits += 1; + if (stub.respond) return stub.respond(req, res); + if ( + req.method !== 'GET' || + req.url !== `/v1/egress/sessions/${SESSION}/objects/${OBJECT}` || + req.headers['x-codeapi-egress-grant'] !== GRANT + ) { + res.writeHead(403).end(); + return; + } + res.writeHead(200, { 'content-length': String(stub.body.byteLength) }).end(stub.body); + }); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + t.after(() => new Promise((resolve) => { + server.closeAllConnections(); + server.close(() => resolve()); + })); + stub.url = `http://127.0.0.1:${(server.address() as AddressInfo).port}/v1/egress`; + return stub; +} + +async function workspace( + t: TestContext, + stub: EgressStub, + options: { writable?: boolean; held?: boolean; importer?: Partial } = {}, +) { + const root = await realpath(await mkdtemp(join(tmpdir(), 'librechat-code-import-'))); + t.after(() => rm(root, { recursive: true, force: true })); + const tools = await LocalWorkspaceTools.create({ + importer: new HttpWorkspaceFileImporter({ upstreamUrl: stub.url, ...options.importer }), + workspaces: [{ + id: 'primary', + root, + writable: options.writable ?? true, + ...(options.held === false ? {} : { identity: await captureWorkspaceRootIdentity(root) }), + }], + }); + return { root, tools }; +} + +async function rejection(promise: Promise): Promise { + try { + await promise; + } catch (error) { + assert.ok(error instanceof WorkspaceToolError, String(error)); + return error; + } + assert.fail('expected a WorkspaceToolError'); +} + +async function temporaryFiles(directory: string): Promise { + return (await readdir(directory).catch(() => [] as string[])).filter((name) => name.endsWith('.tmp')); +} + +test('import requests follow write_file path rules and refuse Git metadata', () => { + const bytes = Buffer.from('payload'); + assert.equal(isWorkspaceToolRequest(importRequest(bytes)), true); + for (const path of [ + '../escape.bin', '/etc/passwd', 'C:/x.bin', 'a\\b.bin', 'dir/', 'dir/.', '.git', '.git/config', + 'src/.git/hooks/pre-commit', '.GIT/config', '.Git./x', 'a/.git /x', 'GIT~1/config', '.g\u200cit/config', + '.git::$INDEX_ALLOCATION/config', '.GIT:stream', '.git. ::$INDEX_ALLOCATION/hooks/x', 'git~1::$INDEX_ALLOCATION/x', + ]) { + assert.equal(isWorkspaceImportFilePath(path), false, path); + assert.equal(isWorkspaceToolRequest(importRequest(bytes, { path })), false, path); + } + for (const path of ['archive.zip', 'uploads/photo.png', '.gitignore', '.github/workflow.yml', 'docs/.gitkeep', 'notes/10:30 standup.md']) { + assert.equal(isWorkspaceImportFilePath(path), true, path); + } + for (const invalid of [ + { sha256: 'A'.repeat(64) }, + { sha256: 'a'.repeat(63) }, + { bytes: -1 }, + { bytes: 1.5 }, + { source: { storage_session_id: SESSION } }, + { source: { storage_session_id: SESSION, id: OBJECT, name: 'x' } }, + { source: { storage_session_id: 'a\nb', id: OBJECT } }, + { overwrite: 'yes' }, + { egressGrant: '' }, + ] as Array>) { + assert.equal(isWorkspaceToolRequest({ ...importRequest(bytes), ...invalid }), false, JSON.stringify(invalid)); + } + assert.equal(isWorkspaceToolRequest({ ...importRequest(bytes), content: 'x' }), false); + + const { egressGrant: _grant, ...callerRequest } = importRequest(bytes); + assert.equal(isBridgeWorkspaceImportFileRequest(callerRequest), true); + assert.equal(isBridgeWorkspaceImportFileRequest(importRequest(bytes)), false, 'callers never supply the grant'); + assert.equal(isRelayedWorkspaceImportFileRequest(importRequest(bytes)), true); + assert.equal(isRelayedWorkspaceImportFileRequest(callerRequest), false); +}); + +test('import results must echo the request and account for every byte', () => { + const bytes = Buffer.from('payload'); + const request = importRequest(bytes); + const result = { + protocolVersion: 1, + operation: 'import_file', + workspaceId: 'primary', + path: request.path, + created: true, + bytesWritten: bytes.byteLength, + sha256: request.sha256, + }; + assert.equal(isWorkspaceToolResult(request, result), true); + assert.equal(isWorkspaceToolResult(request, { ...result, created: false, unchanged: true, bytesWritten: 0 }), true); + assert.equal(isWorkspaceToolResult(request, { ...result, created: false, unchanged: true }), false); + assert.equal(isWorkspaceToolResult(request, { ...result, unchanged: true, bytesWritten: 0 }), false); + assert.equal(isWorkspaceToolResult(request, { ...result, created: false }), false, 'a create cannot replace'); + assert.equal(isWorkspaceToolResult({ ...request, overwrite: true }, { ...result, created: false }), true); + assert.equal(isWorkspaceToolResult(request, { ...result, bytesWritten: 3 }), false); + assert.equal(isWorkspaceToolResult(request, { ...result, sha256: 'b'.repeat(64) }), false); + assert.equal(isWorkspaceToolResult(request, { ...result, path: 'other.zip' }), false); + assert.equal(isWorkspaceToolResult(request, { ...result, content: 'x' }), false); +}); + +test('a worker imports by default whenever it can, and an explicit opt-out removes it', async (t) => { + const upstreamUrl = 'https://code.example.test/egress'; + const enabled = createWorkspaceFileImporter({ optOut: false, writable: true, upstreamUrl }); + assert.ok(enabled instanceof HttpWorkspaceFileImporter, 'on by default'); + for (const value of ['true', ' TRUE ', '']) { + assert.ok(createWorkspaceFileImporter({ optOut: false, enabled: value, writable: true, upstreamUrl })); + } + assert.equal(createWorkspaceFileImporter({ optOut: true, writable: true, upstreamUrl }), undefined, '--no-workspace-imports'); + assert.equal(createWorkspaceFileImporter({ optOut: false, enabled: 'false', writable: true, upstreamUrl }), undefined); + assert.equal(createWorkspaceFileImporter({ optOut: false, enabled: 'False', writable: true, upstreamUrl }), undefined); + assert.equal(createWorkspaceFileImporter({ optOut: false, writable: true }), undefined, 'no relay upstream'); + assert.equal(createWorkspaceFileImporter({ optOut: false, writable: true, upstreamUrl: ' ' }), undefined); + assert.equal(createWorkspaceFileImporter({ optOut: false, writable: false, upstreamUrl }), undefined, 'writes disabled'); + assert.throws(() => createWorkspaceFileImporter({ optOut: false, enabled: 'yes', writable: true, upstreamUrl }), /must be true or false/); + assert.equal(createWorkspaceFileImporter({ optOut: false, writable: true, upstreamUrl, maxBytes: 4096 })?.maxBytes, 4096); + + const root = await realpath(await mkdtemp(join(tmpdir(), 'librechat-code-import-'))); + t.after(() => rm(root, { recursive: true, force: true })); + const advertised = await LocalWorkspaceTools.create({ importer: enabled, workspaces: [{ id: 'primary', root, writable: true }] }); + assert.ok(advertised.capabilities.operations.includes('import_file')); + const optedOut = await LocalWorkspaceTools.create({ + importer: createWorkspaceFileImporter({ optOut: true, writable: true, upstreamUrl }), + workspaces: [{ id: 'primary', root, writable: true }], + }); + assert.equal(optedOut.capabilities.operations.includes('import_file'), false); +}); + +test('import_file is advertised only beside writes, on writable roots, with an importer', async (t) => { + const stub = await egressStub(t, Buffer.from('x')); + const writable = await realpath(await mkdtemp(join(tmpdir(), 'librechat-code-import-'))); + const readOnly = await realpath(await mkdtemp(join(tmpdir(), 'librechat-code-import-'))); + t.after(() => Promise.all([rm(writable, { recursive: true }), rm(readOnly, { recursive: true })])); + const importer = new HttpWorkspaceFileImporter({ upstreamUrl: stub.url }); + + const mixed = await LocalWorkspaceTools.create({ + importer, + workspaces: [{ id: 'primary', root: writable, writable: true }, { id: 'docs', root: readOnly }], + }); + assert.ok(mixed.capabilities.operations.includes('import_file')); + assert.ok(mixed.capabilities.workspaces[0]?.operations?.includes('import_file')); + assert.equal(mixed.capabilities.workspaces[1]?.operations?.includes('import_file'), false); + assert.equal(isValidBridgeWorkspaceToolCapabilities(mixed.capabilities), true); + const denied = await rejection(mixed.execute({ ...importRequest(Buffer.from('x')), workspaceId: 'docs' })); + assert.equal(denied.code, 'WRITE_DISABLED'); + assert.equal(stub.hits, 0); + + const allReadOnly = await LocalWorkspaceTools.create({ importer, workspaces: [{ id: 'docs', root: readOnly }] }); + assert.equal(allReadOnly.capabilities.operations.includes('import_file'), false); + const withoutRelay = await LocalWorkspaceTools.create({ workspaces: [{ id: 'primary', root: writable, writable: true }] }); + assert.equal(withoutRelay.capabilities.operations.includes('import_file'), false); + assert.equal((await rejection(withoutRelay.execute(importRequest(Buffer.from('x'))))).code, 'WRITE_UNAVAILABLE'); +}); + +test('imports binary bytes atomically into new 0755 parents as a 0644 file, whatever the umask', async (t) => { + const bytes = Buffer.concat([Buffer.from([0, 0xff, 0x50, 0x4b, 3, 4, 0]), randomBytes(256 * 1024)]); + const stub = await egressStub(t, bytes); + const { root, tools } = await workspace(t, stub); + const request = importRequest(bytes, { path: 'uploads/2026/archive.zip' }); + const previousUmask = process.umask(0o077); + t.after(() => process.umask(previousUmask)); + + const result = await tools.execute(request); + assert.deepEqual(result, { + protocolVersion: 1, + operation: 'import_file', + workspaceId: 'primary', + path: request.path, + created: true, + bytesWritten: bytes.byteLength, + sha256: request.sha256, + }); + assert.equal(isWorkspaceToolResult(request, result), true); + const target = join(root, 'uploads', '2026', 'archive.zip'); + assert.deepEqual(await readFile(target), bytes); + assert.equal((await stat(target)).mode & 0o777, 0o644); + assert.equal((await stat(join(root, 'uploads'))).mode & 0o777, 0o755); + assert.equal((await stat(join(root, 'uploads', '2026'))).mode & 0o777, 0o755); + assert.deepEqual(await temporaryFiles(join(root, 'uploads', '2026')), []); + assert.equal(stub.hits, 1); +}); + +test('re-importing identical bytes is idempotent and never downloads again', async (t) => { + const bytes = randomBytes(4096); + const stub = await egressStub(t, bytes); + const { root, tools } = await workspace(t, stub); + const request = importRequest(bytes, { path: 'photo.png' }); + await tools.execute(request); + const installed = await stat(join(root, 'photo.png')); + + const again = await tools.execute(request); + assert.deepEqual(again, { + protocolVersion: 1, + operation: 'import_file', + workspaceId: 'primary', + path: 'photo.png', + created: false, + unchanged: true, + bytesWritten: 0, + sha256: request.sha256, + }); + assert.equal(isWorkspaceToolResult(request, again), true); + assert.equal(stub.hits, 1); + assert.equal((await stat(join(root, 'photo.png'))).ino, installed.ino); +}); + +test('different existing content conflicts unless overwrite is explicit', async (t) => { + const bytes = Buffer.from('new report'); + const stub = await egressStub(t, bytes); + const { root, tools } = await workspace(t, stub); + const target = join(root, 'report.pdf'); + await writeFile(target, 'old report'); + await chmod(target, 0o755); + + const conflict = await rejection(tools.execute(importRequest(bytes, { path: 'report.pdf' }))); + assert.equal(conflict.code, 'EDIT_CONFLICT'); + assert.equal(conflict.mutationMayHaveCommitted, false); + assert.equal(await readFile(target, 'utf8'), 'old report'); + assert.equal(stub.hits, 0, 'a conflict is decided before any transfer'); + + const replaced = await tools.execute(importRequest(bytes, { path: 'report.pdf', overwrite: true })); + assert.equal((replaced as { created: boolean }).created, false); + assert.equal(await readFile(target, 'utf8'), 'new report'); + assert.equal((await stat(target)).mode & 0o777, 0o644, 'an import is never executable'); +}); + +test('a hash or size mismatch installs nothing and removes the parents it created', async (t) => { + const bytes = Buffer.from('expected bytes'); + const stub = await egressStub(t, Buffer.from('tampered bytes')); + const { root, tools } = await workspace(t, stub); + + const mismatch = await rejection(tools.execute(importRequest(bytes, { path: 'fresh/dir/file.bin' }))); + assert.equal(mismatch.code, 'INVALID_REQUEST'); + assert.equal(mismatch.mutationMayHaveCommitted, false); + assert.match(mismatch.message, /SHA-256/); + assert.deepEqual(await readdir(root), [], 'created parents are rolled back'); + + stub.respond = (_req, res) => { + res.writeHead(200); + res.write(bytes); + res.end(Buffer.from('and then some')); + }; + const oversize = await rejection(tools.execute(importRequest(bytes, { path: 'file.bin' }))); + assert.equal(oversize.code, 'INVALID_REQUEST'); + assert.match(oversize.message, /larger than its declared size/); + + stub.respond = (_req, res) => res.writeHead(200, { 'content-length': '3' }).end('abc'); + const declared = await rejection(tools.execute(importRequest(bytes, { path: 'file.bin' }))); + assert.equal(declared.code, 'INVALID_REQUEST'); + + stub.respond = (_req, res) => res.writeHead(200).end(bytes.subarray(0, 4)); + const short = await rejection(tools.execute(importRequest(bytes, { path: 'file.bin' }))); + assert.match(short.message, /smaller than its declared size/); + assert.deepEqual(await readdir(root), []); +}); + +test('the staged file is verified after it is synced, not only the bytes that arrived', async (t) => { + const bytes = randomBytes(64 * 1024); + const stub = await egressStub(t, bytes); + const { root } = await workspace(t, stub); + const relay = new HttpWorkspaceFileImporter({ upstreamUrl: stub.url }); + const tools = await LocalWorkspaceTools.create({ + importer: { + maxBytes: relay.maxBytes, + availableBytes: (directory) => relay.availableBytes(directory), + async open(request, signal) { + const chunks = await relay.open(request, signal); + return (async function* tamper() { + let first = true; + for await (const chunk of chunks) { + yield chunk; + if (!first) continue; + first = false; + // Another local writer rewrites already-staged bytes before the transfer finishes. + const [staged] = await temporaryFiles(root); + assert.ok(staged); + await writeFile(join(root, staged), Buffer.alloc(chunk.byteLength, 0x41), { flag: 'r+' }); + } + })(); + }, + }, + workspaces: [{ id: 'primary', root, writable: true, identity: await captureWorkspaceRootIdentity(root) }], + }); + const tampered = await rejection(tools.execute(importRequest(bytes, { path: 'payload.bin' }))); + assert.equal(tampered.code, 'INVALID_REQUEST'); + assert.match(tampered.message, /SHA-256/); + assert.deepEqual(await readdir(root), []); +}); + +test('rollback never removes a directory that replaced one the import created', async (t) => { + const bytes = randomBytes(32 * 1024); + const stub = await egressStub(t, Buffer.concat([bytes.subarray(0, 1024), Buffer.alloc(bytes.byteLength - 1024)])); + const { root } = await workspace(t, stub); + const relay = new HttpWorkspaceFileImporter({ upstreamUrl: stub.url }); + const tools = await LocalWorkspaceTools.create({ + importer: { + maxBytes: relay.maxBytes, + availableBytes: (directory) => relay.availableBytes(directory), + async open(request, signal) { + const chunks = await relay.open(request, signal); + return (async function* swap() { + for await (const chunk of chunks) yield chunk; + // Another local process moves the new parent aside and makes its own at that path. + await rename(join(root, 'incoming'), join(root, 'moved')); + await mkdir(join(root, 'incoming')); + })(); + }, + }, + workspaces: [{ id: 'primary', root, writable: true, identity: await captureWorkspaceRootIdentity(root) }], + }); + const failed = await rejection(tools.execute(importRequest(bytes, { path: 'incoming/file.bin' }))); + assert.equal(failed.code, 'INVALID_REQUEST'); + assert.equal(failed.mutationMayHaveCommitted, true, 'an unremovable created parent is reported, not hidden'); + assert.equal((await stat(join(root, 'incoming'))).isDirectory(), true); +}); + +test('the stall timeout covers waiting on the source, not slow consumer writes', async (t) => { + const bytes = randomBytes(8 * 1024); + const stub = await egressStub(t, bytes); + stub.respond = (_req, res) => { + res.writeHead(200, { 'content-length': String(bytes.byteLength) }); + res.write(bytes.subarray(0, 4096)); + setTimeout(() => res.end(bytes.subarray(4096)), 10); + }; + const importer = new HttpWorkspaceFileImporter({ upstreamUrl: stub.url, stallTimeoutMs: 50 }); + const received: Buffer[] = []; + for await (const chunk of await importer.open(importRequest(bytes) as WorkspaceImportFileRequest & { egressGrant: string })) { + received.push(Buffer.from(chunk)); + await delay(150); + } + assert.deepEqual(Buffer.concat(received), bytes); +}); + +test('file and free-space limits fail before anything is written', async (t) => { + const bytes = randomBytes(2048); + const stub = await egressStub(t, bytes); + const small = await workspace(t, stub, { importer: { maxBytes: 1024 } }); + const tooLarge = await rejection(small.tools.execute(importRequest(bytes, { path: 'big.bin' }))); + assert.equal(tooLarge.code, 'WRITE_LIMIT_EXCEEDED'); + assert.equal(stub.hits, 0); + + const full = await workspace(t, stub, { importer: { availableBytes: async () => 4096 } }); + const noSpace = await rejection(full.tools.execute(importRequest(bytes, { path: 'big.bin' }))); + assert.equal(noSpace.code, 'WRITE_LIMIT_EXCEEDED'); + assert.match(noSpace.message, /free space/); + assert.equal(stub.hits, 0); + assert.deepEqual(await readdir(full.root), []); +}); + +test('imports refuse symlinked parents, Git metadata, and requests without a grant', async (t) => { + const bytes = Buffer.from('payload'); + const stub = await egressStub(t, bytes); + const { root, tools } = await workspace(t, stub); + const outside = await realpath(await mkdtemp(join(tmpdir(), 'librechat-code-import-outside-'))); + t.after(() => rm(outside, { recursive: true, force: true })); + await mkdir(join(root, 'real')); + await symlink(join(root, 'real'), join(root, 'alias')); + await symlink(outside, join(root, 'escape')); + await mkdir(join(root, '.git')); + + for (const path of ['alias/file.bin', 'escape/file.bin']) { + const refused = await rejection(tools.execute(importRequest(bytes, { path }))); + assert.equal(refused.code, 'INVALID_PATH', path); + } + assert.deepEqual(await readdir(join(root, 'real')), []); + assert.deepEqual(await readdir(outside), []); + + const git = await rejection(tools.execute(importRequest(bytes, { path: '.git/hooks/post-checkout' }))); + assert.equal(git.code, 'INVALID_REQUEST'); + assert.deepEqual(await readdir(join(root, '.git')), []); + + await symlink(join(root, 'real', 'target.bin'), join(root, 'link.bin')); + const linkTarget = await rejection(tools.execute(importRequest(bytes, { path: 'link.bin', overwrite: true }))); + assert.equal(linkTarget.code, 'INVALID_PATH'); + assert.equal((await lstat(join(root, 'link.bin'))).isSymbolicLink(), true); + + const { egressGrant: _grant, ...ungranted } = importRequest(bytes, { path: 'file.bin' }); + assert.equal((await rejection(tools.execute(ungranted))).code, 'INVALID_REQUEST'); + assert.equal(stub.hits, 0); +}); + +test('an unavailable or refusing relay fails cleanly as WRITE_UNAVAILABLE', async (t) => { + const bytes = Buffer.from('payload'); + const stub = await egressStub(t, bytes); + const { root, tools } = await workspace(t, stub); + const refused = await rejection(tools.execute(importRequest(bytes, { egressGrant: 'ceg1.another-object' }))); + assert.equal(refused.code, 'WRITE_UNAVAILABLE'); + assert.equal(refused.mutationMayHaveCommitted, false); + assert.deepEqual(await readdir(root), []); + + stub.respond = (_req, res) => { + res.writeHead(200, { 'content-length': String(bytes.byteLength) }); + res.write(bytes.subarray(0, 2)); + }; + const stalled = await workspace(t, stub, { importer: { stallTimeoutMs: 50 } }); + const timedOut = await rejection(stalled.tools.execute(importRequest(bytes))); + assert.equal(timedOut.code, 'WRITE_UNAVAILABLE'); + assert.match(timedOut.message, /stalled/); + assert.deepEqual(await readdir(stalled.root), []); +}); + +test('a worker negotiates import_file and executes it as a guarded mutation', async (t) => { + const bytes = randomBytes(8192); + const stub = await egressStub(t, bytes); + const { root, tools } = await workspace(t, stub); + const lifecycle: string[] = []; + const registrations: string[][] = []; + const settlements: Array> = []; + const worker = new BridgeWorker({ + codeApiUrl: 'https://code.example/v1', + token: 'worker-secret', + workerId: 'vm-1', + incarnationId, + sandboxEndpoint: 'http://127.0.0.1:2000/api/v2', + capabilities: { statefulWorkspace: false, sandboxProfile: 'anthropic-srt', runtimes: [], workspaceTools: tools.capabilities }, + workspaceTools: tools, + workspaceMutationQuarantine: { + async assertAvailable() {}, + async arm() { lifecycle.push('arm'); }, + async clear() { lifecycle.push('clear'); }, + async quarantine() { lifecycle.push('quarantine'); }, + }, + fetchImpl: async (input, init) => { + const url = String(input); + const body = (init?.body == null ? {} : JSON.parse(String(init.body))) as Record; + if (url.endsWith('/register')) { + registrations.push((body.capabilities as { workspaceTools: { operations: string[] } }).workspaceTools.operations); + return Response.json({ + protocolVersion: 1, workerId: 'vm-1', incarnationId, registeredAt: new Date().toISOString(), leaseTtlMs: 60_000, + supportedWorkspaceToolOperations: ['read_file', 'search_text', 'list_files', 'write_file', 'preview_edit', 'edit_file', 'import_file'], + supportedWorkspaceWriteFileModes: ['replace', 'create'], + }); + } + if (!url.endsWith('/settle')) return Response.json({ protocolVersion: 1, cancelled: false }); + lifecycle.push('settle'); + settlements.push(body); + return Response.json({ protocolVersion: 1, accepted: true }); + }, + }); + await worker.register(); + assert.ok(registrations.at(-1)?.includes('import_file')); + + const request: WorkspaceToolRequest = importRequest(bytes, { path: 'chat/upload.bin' }); + await worker.executeAndSettle({ + protocolVersion: 1, + assignmentId: 'assignment-import', + workerId: 'vm-1', + incarnationId, + generation: 1, + leaseToken: 'lease-token-that-is-long-enough-for-testing', + expiresAt: new Date(Date.now() + 10_000).toISOString(), + remainingMs: 10_000, + executionKind: 'workspace_tool', + request, + }); + assert.deepEqual(lifecycle, ['arm', 'settle', 'clear']); + assert.equal(settlements[0]?.status, 'fulfilled'); + assert.deepEqual(await readFile(join(root, 'chat', 'upload.bin')), bytes); +}); + +test('a worker never advertises import_file to a Code API that cannot relay it', async (t) => { + const stub = await egressStub(t, Buffer.from('x')); + const { tools } = await workspace(t, stub); + const registrations: string[][] = []; + const worker = new BridgeWorker({ + codeApiUrl: 'https://code.example/v1', + token: 'worker-secret', + workerId: 'vm-1', + incarnationId, + sandboxEndpoint: 'http://127.0.0.1:2000/api/v2', + capabilities: { statefulWorkspace: false, sandboxProfile: 'anthropic-srt', runtimes: [], workspaceTools: tools.capabilities }, + workspaceTools: tools, + workspaceMutationQuarantine: { async assertAvailable() {}, async arm() {}, async clear() {}, async quarantine() {} }, + fetchImpl: async (_input, init) => { + const body = JSON.parse(String(init?.body)) as { capabilities: { workspaceTools: { operations: string[] } } }; + registrations.push(body.capabilities.workspaceTools.operations); + return Response.json({ + protocolVersion: 1, workerId: 'vm-1', incarnationId, registeredAt: new Date().toISOString(), leaseTtlMs: 60_000, + supportedWorkspaceToolOperations: ['read_file', 'search_text', 'list_files', 'write_file', 'preview_edit', 'edit_file'], + supportedWorkspaceWriteFileModes: ['replace', 'create'], + }); + }, + }); + await worker.register(); + assert.ok(registrations.length > 0); + for (const operations of registrations) assert.equal(operations.includes('import_file'), false); +}); diff --git a/packages/code/src/workspace-import.ts b/packages/code/src/workspace-import.ts new file mode 100644 index 00000000..e4c927d3 --- /dev/null +++ b/packages/code/src/workspace-import.ts @@ -0,0 +1,188 @@ +import { statfs } from 'node:fs/promises'; + +import { + BRIDGE_WORKSPACE_IMPORT_FILE_DEFAULT_MAX_BYTES, + BRIDGE_WORKSPACE_PROGRAMMATIC_TRANSFER_TIMEOUT_MS, +} from './protocol.js'; +import { + FILE_RELAY_GRANT_HEADER, + fileRelayObjectUrl, + validateFileRelayUpstream, +} from './relay.js'; +import { WorkspaceToolError } from './workspace.js'; + +import type { WorkspaceImportFileRequest } from './protocol.js'; +import type { WorkspaceFileImporter } from './workspace.js'; + +export interface HttpWorkspaceFileImporterOptions { + /** The egress gateway this worker already uses for programmatic inputs. */ + upstreamUrl: string; + maxBytes?: number; + /** Longest wait for response headers or for the next body chunk. */ + stallTimeoutMs?: number; + fetchImpl?: typeof fetch; + availableBytes?: (directory: string) => Promise; +} + +/** + * The importer a worker runs with, if any. `import_file` is on by default + * whenever the worker can perform it: writes enabled and a relay upstream to + * reach stored objects. `--no-workspace-imports` or + * `LIBRECHAT_CODE_ALLOW_WORKSPACE_IMPORTS=false` opts out; any other value of + * that variable fails startup rather than silently choosing either way. + */ +export function createWorkspaceFileImporter(input: { + optOut: boolean; + enabled?: string; + writable: boolean; + upstreamUrl?: string; + maxBytes?: number; +}): HttpWorkspaceFileImporter | undefined { + const enabled = input.enabled?.trim().toLowerCase() ?? ''; + if (enabled !== '' && enabled !== 'true' && enabled !== 'false') { + throw new Error('LIBRECHAT_CODE_ALLOW_WORKSPACE_IMPORTS must be true or false'); + } + const upstreamUrl = input.upstreamUrl?.trim() ?? ''; + if (input.optOut || enabled === 'false' || !input.writable || upstreamUrl === '') { + return undefined; + } + return new HttpWorkspaceFileImporter({ upstreamUrl, maxBytes: input.maxBytes }); +} + +async function filesystemAvailableBytes(directory: string): Promise { + const stats = await statfs(directory); + return Number(stats.bavail) * Number(stats.bsize); +} + +function importAborted(): WorkspaceToolError { + return new WorkspaceToolError('Workspace import aborted', 'EXECUTION_ABORTED'); +} + +function importUnavailable(message: string): WorkspaceToolError { + return new WorkspaceToolError(message, 'WRITE_UNAVAILABLE'); +} + +/** + * Streams one granted object from the file relay upstream. The worker holds no + * storage credential: Code API's grant authorizes exactly this object, briefly. + */ +export class HttpWorkspaceFileImporter implements WorkspaceFileImporter { + readonly maxBytes: number; + private readonly upstream: URL; + private readonly stallTimeoutMs: number; + private readonly fetchImpl: typeof fetch; + private readonly freeBytes: (directory: string) => Promise; + + constructor(options: HttpWorkspaceFileImporterOptions) { + this.upstream = validateFileRelayUpstream(options.upstreamUrl); + this.maxBytes = options.maxBytes ?? BRIDGE_WORKSPACE_IMPORT_FILE_DEFAULT_MAX_BYTES; + this.stallTimeoutMs = options.stallTimeoutMs ?? BRIDGE_WORKSPACE_PROGRAMMATIC_TRANSFER_TIMEOUT_MS; + if (!Number.isSafeInteger(this.maxBytes) || this.maxBytes < 1) { + throw new RangeError('Workspace import file limit must be a positive safe integer'); + } + if (!Number.isSafeInteger(this.stallTimeoutMs) || this.stallTimeoutMs < 1) { + throw new RangeError('Workspace import stall timeout must be a positive safe integer'); + } + this.fetchImpl = options.fetchImpl ?? fetch; + this.freeBytes = options.availableBytes ?? filesystemAvailableBytes; + } + + availableBytes(directory: string): Promise { + return this.freeBytes(directory); + } + + async open( + request: WorkspaceImportFileRequest & { egressGrant: string }, + signal?: AbortSignal, + ): Promise> { + if (signal?.aborted) throw importAborted(); + const controller = new AbortController(); + const abort = (): void => controller.abort(signal?.reason); + signal?.addEventListener('abort', abort, { once: true }); + let stalled = false; + let timer: ReturnType | undefined; + const arm = (): void => { + clearTimeout(timer); + timer = setTimeout(() => { + stalled = true; + controller.abort(); + }, this.stallTimeoutMs); + }; + const release = (): void => { + clearTimeout(timer); + signal?.removeEventListener('abort', abort); + }; + const failure = (): WorkspaceToolError => + signal?.aborted + ? importAborted() + : importUnavailable(stalled ? 'Import source transfer stalled' : 'Import source download failed'); + + arm(); + let response: Response; + try { + response = await this.fetchImpl( + fileRelayObjectUrl(this.upstream, request.source.storage_session_id, request.source.id), + { + headers: { + [FILE_RELAY_GRANT_HEADER]: request.egressGrant, + 'Accept-Encoding': 'identity', + }, + redirect: 'error', + signal: controller.signal, + }, + ); + } catch { + release(); + throw failure(); + } + // A compressed transfer declares its encoded length; only the decoded bytes are checked then. + const encoding = response.headers.get('content-encoding')?.trim().toLowerCase(); + const declaredLength = encoding == null || encoding === 'identity' + ? response.headers.get('content-length') + : null; + let rejection: WorkspaceToolError | undefined; + if (!response.ok) { + rejection = importUnavailable(`Import source download failed with HTTP ${response.status}`); + } else if (declaredLength != null && declaredLength !== String(request.bytes)) { + rejection = new WorkspaceToolError( + 'Import source size does not match the declared size', + 'INVALID_REQUEST', + ); + } else if (response.body == null && request.bytes > 0) { + rejection = importUnavailable('Import source returned no content'); + } + if (rejection != null) { + release(); + await response.body?.cancel().catch(() => undefined); + throw rejection; + } + const body = response.body; + async function* chunks(): AsyncGenerator { + if (body == null) { + release(); + return; + } + const reader = body.getReader(); + try { + for (;;) { + let next: Awaited>; + // The stall timer covers only waiting on the source, never the consumer's writes. + arm(); + try { + next = await reader.read(); + } catch { + throw failure(); + } finally { + clearTimeout(timer); + } + if (next.done) return; + yield next.value; + } + } finally { + release(); + await reader.cancel().catch(() => undefined); + } + } + return chunks(); + } +} diff --git a/packages/code/src/workspace-instances.ts b/packages/code/src/workspace-instances.ts index 68e76a10..7927152b 100644 --- a/packages/code/src/workspace-instances.ts +++ b/packages/code/src/workspace-instances.ts @@ -12,12 +12,13 @@ import type { WorkspaceToolRequest, WorkspaceToolResult, } from './protocol.js'; -import type { WorkspaceToolExecutor } from './workspace.js'; +import type { WorkspaceFileImporter, WorkspaceToolExecutor } from './workspace.js'; interface WorkspaceInstanceSource { command?: NativeProcessSandboxOptions; repositoryInstructions: boolean; writable: boolean; + importer?: WorkspaceFileImporter; } export interface GitWorktreeWorkspaceToolsOptions { @@ -119,6 +120,7 @@ export class GitWorktreeWorkspaceTools implements WorkspaceToolExecutor { identity: instance.identity, value: LocalWorkspaceTools.create({ repositoryInstructions: source.repositoryInstructions, + importer: source.importer, workspaces: [ { id: internalId, diff --git a/packages/code/src/workspace-worker.test.ts b/packages/code/src/workspace-worker.test.ts index 99fcdd02..46d117bd 100644 --- a/packages/code/src/workspace-worker.test.ts +++ b/packages/code/src/workspace-worker.test.ts @@ -8,6 +8,61 @@ import { SandboxWorkspaceTools, WorkspaceToolError } from './workspace.js'; const incarnationId = 'incarnation-00000001'; +test('worker logs command failures locally without changing bridge settlements', async () => { + for (const sink of ['sync', 'async', 'pending'] as const) { + for (const operation of ['execute_command', 'list_files'] as const) { + const events: unknown[] = []; + let settlement: Record | undefined; + const capabilities = { + protocolVersion: 1 as const, + operations: ['execute_command' as const, 'list_files' as const], + workspaces: [{ id: 'primary' }], + }; + const worker = new BridgeWorker({ + codeApiUrl: 'https://code.example/v1', token: 'worker-secret', workerId: 'vm-1', incarnationId, + sandboxEndpoint: 'http://127.0.0.1:2000/api/v2', + capabilities: { statefulWorkspace: false, sandboxProfile: 'anthropic-srt', runtimes: [], workspaceTools: capabilities }, + workspaceTools: { + capabilities, mutationFailuresAreAtomic: true, + async execute() { + throw new WorkspaceToolError('private error', 'COMMAND_UNAVAILABLE', false, false, + { stage: 'credential_resolution', reason: 'credential_failed' }); + }, + }, + workspaceQuarantines: new Map([['primary', mutationQuarantine()]]), + onCommandFailure(event) { + events.push(event); + if (sink === 'sync') throw new Error('private log sink error'); + if (sink === 'async') return Promise.reject(new Error('private log sink error')); + return new Promise(() => {}); + }, + fetchImpl: async (_input, init) => { + settlement = JSON.parse(String(init?.body)); + return Response.json({ protocolVersion: 1, accepted: true }); + }, + }); + await worker.executeAndSettle({ + protocolVersion: 1, assignmentId: 'assignment-1', workerId: 'vm-1', incarnationId, generation: 4, + leaseToken: 'lease-token-that-is-long-enough-for-testing', expiresAt: new Date(Date.now() + 5000).toISOString(), + executionKind: 'workspace_tool', + request: operation === 'execute_command' + ? { protocolVersion: 1, operation, workspaceId: 'primary', command: 'echo secret' } + : { protocolVersion: 1, operation, workspaceId: 'primary', path: '.' }, + }); + assert.equal(events.length, operation === 'execute_command' ? 1 : 0); + if (events.length) assert.deepEqual(events[0], { + event: 'workspace_command_unavailable', stage: 'credential_resolution', reason: 'credential_failed', + assignmentId: 'assignment-1', workerId: 'vm-1', incarnationId, workspaceId: 'primary', + operation: 'execute_command', mutationMayHaveCommitted: false, requiresQuarantine: false, + }); + assert.equal(settlement?.status, 'rejected'); + assert.equal(settlement?.errorCode, 'COMMAND_UNAVAILABLE'); + assert.equal('diagnostic' in settlement!, false); + assert.equal(JSON.stringify(events).includes('secret'), false); + } + } +}); + test('instance advertisement requires a guard resolver even for reads and with base guards', () => { for (const operation of ['read_file', 'write_file'] as const) { const workspaceTools = { protocolVersion: 1 as const, operations: [operation], workspaces: [{ id: 'primary', workspaceInstances: ['git_worktree'] as ['git_worktree'] }] }; @@ -1308,6 +1363,7 @@ test('worker executes programmatic Bash in the selected workspace and preserves test('worker keeps a selected workspace usable after an atomic programmatic setup failure', async () => { const lifecycle: string[] = []; + const diagnostics: unknown[] = []; let settlement: Record | undefined; const workspaceCapabilities = { protocolVersion: 1 as const, @@ -1343,6 +1399,10 @@ test('worker keeps a selected workspace usable after an atomic programmatic setu ); }, }, + onCommandFailure(event) { + diagnostics.push(event); + throw new Error('logging sink failed'); + }, workspaceQuarantines: new Map([ [ 'primary', @@ -1383,6 +1443,14 @@ test('worker keeps a selected workspace usable after an atomic programmatic setu assert.deepEqual(lifecycle, ['arm', 'clear']); assert.equal(settlement?.status, 'rejected'); assert.equal(settlement?.errorCode, 'COMMAND_UNAVAILABLE'); + assert.deepEqual(diagnostics, [{ + event: 'workspace_command_unavailable', stage: 'dispatch', reason: 'executor_rejected', + assignmentId: 'assignment-programmatic-setup-failure', workerId: 'vm-1', incarnationId, + workspaceId: 'primary', operation: 'workspace_programmatic', + mutationMayHaveCommitted: false, requiresQuarantine: false, + }]); + assert.equal('commandDiagnostic' in settlement!, false); + assert.equal('diagnostic' in settlement!, false); }); test('worker keeps a selected workspace usable after confirmed programmatic cancellation cleanup', async () => { diff --git a/packages/code/src/workspace.test.ts b/packages/code/src/workspace.test.ts index 787fbe7d..16e2ad13 100644 --- a/packages/code/src/workspace.test.ts +++ b/packages/code/src/workspace.test.ts @@ -2603,7 +2603,8 @@ test('fails closed on invalid or failed sandbox command results', async (t) => { for (const execute of [ async () => ({ ...request, exitCode: 0, stdout: 'ok', stderr: '' }), async () => { throw new Error('container details'); }, - async () => { throw new WorkspaceToolError('untrusted clean claim', 'COMMAND_UNAVAILABLE'); }, + async () => { throw new WorkspaceToolError('untrusted clean claim', 'COMMAND_UNAVAILABLE', false, false, + { stage: 'command_wrapping', reason: 'wrapping_failed' }); }, ]) { const tools = new SandboxWorkspaceTools({ workspaceTools: local, @@ -2616,6 +2617,7 @@ test('fails closed on invalid or failed sandbox command results', async (t) => { error instanceof WorkspaceToolError && error.code === 'COMMAND_UNAVAILABLE' && error.mutationMayHaveCommitted === true && + (error.message !== 'untrusted clean claim' || error.commandDiagnostic?.stage === 'command_wrapping') && !error.message.includes('container details'), ); assert.equal(tools.mutationFailuresAreAtomic, undefined); diff --git a/packages/code/src/workspace.ts b/packages/code/src/workspace.ts index d740d48a..5aecbfd9 100644 --- a/packages/code/src/workspace.ts +++ b/packages/code/src/workspace.ts @@ -1,9 +1,10 @@ import { createHash, randomBytes } from 'node:crypto'; import { constants } from 'node:fs'; -import { deferWorkspaceRootCleanup, holdsWorkspaceRoot, link, lstat, mkdir, open, realpath, rename, stat, unlink, spawn, withWorkspaceRoot, WorkspaceRootAccessError } from './root-access.js'; +import { deferWorkspaceRootCleanup, holdsWorkspaceRoot, link, lstat, mkdir, open, realpath, rename, rmdir, stat, unlink, spawn, withWorkspaceRoot, WorkspaceRootAccessError } from './root-access.js'; import { basename, dirname, isAbsolute, relative, resolve, sep } from 'node:path'; import type { FileHandle } from 'node:fs/promises'; +import type { CommandFailureDiagnostic } from './command-diagnostics.js'; import { matchesWorkspaceRoot } from './root-identity.js'; import type { WorkspaceRootIdentity } from './root-identity.js'; @@ -17,6 +18,7 @@ import { BRIDGE_WORKSPACE_SEARCH_TEXT_MAX_LENGTH, comparePortableRelativePaths, isSafePortableRelativePath, + isWorkspaceImportFilePath, isValidBridgeWorkspaceToolCapabilities, isWorkspaceToolRequest, isWorkspaceToolResult, @@ -42,6 +44,8 @@ import type { WorkspacePreviewEditResult, WorkspaceExecuteCommandRequest, WorkspaceExecuteCommandResult, + WorkspaceImportFileRequest, + WorkspaceImportFileResult, WorkspaceListFilesRequest, WorkspaceListFilesResult, WorkspaceSearchMatch, @@ -65,6 +69,8 @@ export type { WorkspacePreviewEditResult, WorkspaceExecuteCommandRequest, WorkspaceExecuteCommandResult, + WorkspaceImportFileRequest, + WorkspaceImportFileResult, WorkspaceListFilesRequest, WorkspaceListFilesResult, WorkspaceSearchMatch, @@ -85,9 +91,27 @@ export interface LocalWorkspaceConfig { writable?: boolean; } +/** + * Fetches a stored object for `import_file`. Only a worker with a configured + * file relay upstream has one, and `import_file` is advertised only then. + */ +export interface WorkspaceFileImporter { + /** Largest file this worker imports; larger requests fail before any transfer. */ + readonly maxBytes: number; + /** Streams the granted object. Failures are WorkspaceToolErrors. */ + open( + request: WorkspaceImportFileRequest & { egressGrant: string }, + signal?: AbortSignal, + ): Promise>; + /** Bytes a new file in `directory` may still use on its filesystem. */ + availableBytes(directory: string): Promise; +} + export interface LocalWorkspaceToolsOptions { workspaces: LocalWorkspaceConfig[]; repositoryInstructions?: boolean; + /** Enables `import_file` on writable workspaces. */ + importer?: WorkspaceFileImporter; } export interface WorkspaceToolExecutor { @@ -144,6 +168,12 @@ const READ_OPERATIONS = [ 'list_files', ] as const; const WRITE_OPERATIONS = ['write_file', 'preview_edit', 'edit_file'] as const; +const IMPORT_OPERATIONS = ['import_file'] as const; +const IMPORT_DIRECTORY_MODE = 0o755; +const IMPORT_FILE_MODE = 0o644; +const IMPORT_HASH_CHUNK_BYTES = 1024 * 1024; +/** Headroom left on the target filesystem after an import lands. */ +export const WORKSPACE_IMPORT_FREE_SPACE_RESERVE_BYTES = 64 * 1024 * 1024; function isUtf8ScalarString(value: string): boolean { return Buffer.from(value).toString('utf8') === value; @@ -197,6 +227,8 @@ export class WorkspaceToolError extends Error { public readonly mutationMayHaveCommitted = false, /** Retain the durable mutation guard when process or write settlement is uncertain. */ public readonly requiresQuarantine = mutationMayHaveCommitted, + /** Worker-local diagnostic metadata; never serialized into bridge settlements. */ + public readonly commandDiagnostic?: CommandFailureDiagnostic, ) { super(message); this.name = 'WorkspaceToolError'; @@ -682,6 +714,7 @@ async function classifyMissingWorkspacePath( async function createMissingParentDirectories( root: string, candidate: string, + mode = 0o777, ): Promise { const created: string[] = []; if (!holdsWorkspaceRoot()) return created; @@ -699,7 +732,7 @@ async function createMissingParentDirectories( let createdHere = false; if (entry == null) { try { - await mkdir(current, 0o777); + await mkdir(current, mode); created.push(current); createdHere = true; } catch (error) { @@ -1070,6 +1103,373 @@ async function writeWorkspaceFile( }; } +interface ImportTargetIdentity { + dev: bigint | number; + ino: bigint | number; +} + +type ImportTarget = + | { state: 'missing' } + | { state: 'unchanged' } + | ({ state: 'different' } & ImportTargetIdentity); + +function importIntegrityError(message: string): WorkspaceToolError { + return new WorkspaceToolError(message, 'INVALID_REQUEST'); +} + +/** Size and SHA-256 of everything an open file holds now, read through its descriptor. */ +async function hashOpenFile( + handle: FileHandle, + signal?: AbortSignal, +): Promise<{ bytes: number; sha256: string }> { + const hash = createHash('sha256'); + const chunk = Buffer.allocUnsafe(IMPORT_HASH_CHUNK_BYTES); + let position = 0; + for (;;) { + throwIfAborted(signal); + const { bytesRead } = await handle.read(chunk, 0, chunk.byteLength, position); + if (bytesRead === 0) break; + hash.update(chunk.subarray(0, bytesRead)); + position += bytesRead; + } + return { bytes: position, sha256: hash.digest('hex') }; +} + +/** Hash an existing target only when its size already matches the import. */ +async function inspectImportTarget( + root: string, + request: WorkspaceImportFileRequest, + signal?: AbortSignal, +): Promise { + try { + return await withConfinedFile(root, request.path, async (handle, size) => { + const { dev, ino } = await handle.stat(); + if (size !== request.bytes) return { state: 'different', dev, ino }; + const existing = await hashOpenFile(handle, signal); + return existing.bytes === request.bytes && existing.sha256 === request.sha256 + ? { state: 'unchanged' } + : { state: 'different', dev, ino }; + }); + } catch (error) { + if (error instanceof WorkspaceToolError && error.code === 'NOT_FOUND') { + return { state: 'missing' }; + } + throw error; + } +} + +/** + * Undo parent directories this import created; true when every one is gone + * again. A directory is removed only while it is still the inode this import + * created, so a replacement made by another local process is never deleted. + */ +async function removeCreatedDirectories( + created: readonly string[], + identities: readonly ImportTargetIdentity[], +): Promise { + if (identities.length !== created.length) return false; + for (let index = created.length - 1; index >= 0; index -= 1) { + const directory = created[index]!; + try { + const current = await lstat(directory); + if ( + current.isSymbolicLink() || + !current.isDirectory() || + current.dev !== identities[index]!.dev || + current.ino !== identities[index]!.ino + ) { + return false; + } + await rmdir(directory); + await syncWorkspaceDirectory(dirname(directory)); + } catch { + return false; + } + } + return true; +} + +/** + * Record each directory this import created, then give it the documented mode + * regardless of the worker's umask. `identities` fills as each is opened. + */ +async function setImportDirectoryModes( + created: readonly string[], + identities: ImportTargetIdentity[], +): Promise { + for (const directory of created) { + const handle = await open( + directory, + constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW, + ); + try { + const { dev, ino } = await handle.stat(); + identities.push({ dev, ino }); + await handle.chmod(IMPORT_DIRECTORY_MODE); + } finally { + await handle.close(); + } + } +} + +async function writeImportChunk(handle: FileHandle, chunk: Uint8Array, position: number): Promise { + let offset = 0; + while (offset < chunk.byteLength) { + const { bytesWritten } = await handle.write(chunk, offset, chunk.byteLength - offset, position + offset); + if (bytesWritten < 1) throw new Error('short workspace write'); + offset += bytesWritten; + } +} + +/** Confirm the installed entry is the staged inode, inside the root, after syncing its directory. */ +async function confirmInstalledImport( + root: string, + installTarget: string, + staged: ImportTargetIdentity, +): Promise { + let handle: FileHandle | undefined; + try { + await syncWorkspaceDirectory(dirname(installTarget)); + handle = await open( + installTarget, + constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK, + ); + const [installed, canonicalPath] = await Promise.all([handle.stat(), realpath(installTarget)]); + if ( + !installed.isFile() || + !isWithinRoot(root, canonicalPath) || + installed.dev !== staged.dev || + installed.ino !== staged.ino + ) { + throw new Error('installed workspace import changed'); + } + } catch { + throw new WorkspaceToolError( + 'Workspace import durability could not be confirmed', + 'WRITE_UNAVAILABLE', + true, + ); + } finally { + await handle?.close().catch(() => undefined); + } +} + +/** + * Stream one object into a same-directory temporary file, verify the synced + * file's size and SHA-256 through its own descriptor, then install it + * atomically. A create links exclusively so a racing file is never replaced. + * An overwrite rechecks the inspected inode immediately before its rename, the + * same check-then-rename `write_file` uses; bridge mutations of one root are + * serialized, so only an out-of-band local writer can race that last step. + */ +async function installImportedFile( + root: string, + candidate: string, + request: WorkspaceImportFileRequest & { egressGrant: string }, + importer: WorkspaceFileImporter, + target: Exclude, + signal?: AbortSignal, +): Promise<{ created: boolean }> { + const parent = dirname(candidate); + let canonicalParent: string; + let parentIdentity: Awaited>; + try { + canonicalParent = await realpath(parent); + parentIdentity = await verifyDirectoryPathHasNoSymlinks(root, parent); + if (!isWithinRoot(root, canonicalParent) || !parentIdentity.isDirectory()) { + throw new WorkspaceToolError('Invalid workspace path', 'INVALID_PATH'); + } + } catch (error) { + if (isMissingEntry(error)) throw await classifyMissingWorkspacePath(root, parent); + throw classifyWritePathValidationError(error); + } + let available: number; + try { + available = await importer.availableBytes(canonicalParent); + } catch { + throw new WorkspaceToolError('Workspace free space could not be determined', 'WRITE_UNAVAILABLE'); + } + if (available < request.bytes + WORKSPACE_IMPORT_FREE_SPACE_RESERVE_BYTES) { + throw new WorkspaceToolError( + 'Workspace filesystem does not have enough free space for this file', + 'WRITE_LIMIT_EXCEEDED', + ); + } + + const temporary = resolve(canonicalParent, `.librechat-code-${randomBytes(18).toString('hex')}.tmp`); + const installTarget = resolve(canonicalParent, basename(candidate)); + let handle: FileHandle | undefined; + let temporaryNeedsCleanup = false; + try { + handle = await open( + temporary, + constants.O_RDWR | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, + 0o600, + ); + temporaryNeedsCleanup = true; + let written = 0; + for await (const chunk of await importer.open(request, signal)) { + throwIfAborted(signal); + if (written + chunk.byteLength > request.bytes) { + throw importIntegrityError('Imported file is larger than its declared size'); + } + await writeImportChunk(handle, chunk, written); + written += chunk.byteLength; + } + throwIfAborted(signal); + if (written !== request.bytes) { + throw importIntegrityError('Imported file is smaller than its declared size'); + } + await handle.chmod(IMPORT_FILE_MODE); + await handle.sync(); + // Verify what will be installed, not what arrived: the staged file is a workspace entry. + const staged = await hashOpenFile(handle, signal); + if (staged.bytes !== request.bytes) { + throw importIntegrityError('Imported file does not match its declared size'); + } + if (staged.sha256 !== request.sha256) { + throw importIntegrityError('Imported file does not match its declared SHA-256'); + } + const stagedStat = await handle.stat(); + const stagedIdentity = { dev: stagedStat.dev, ino: stagedStat.ino }; + if (process.platform === 'win32') { + await handle.close(); + handle = undefined; + } + + const [currentParent, currentParentIdentity] = await Promise.all([ + realpath(parent), + verifyDirectoryPathHasNoSymlinks(root, parent), + ]); + if ( + currentParent !== canonicalParent || + currentParentIdentity.dev !== parentIdentity.dev || + currentParentIdentity.ino !== parentIdentity.ino + ) { + throw new WorkspaceToolError('Invalid workspace path', 'INVALID_PATH'); + } + let current: Awaited> | undefined; + try { + current = await lstat(candidate); + } catch (error) { + if (!isMissingEntry(error)) throw error; + } + if ( + target.state === 'missing' + ? current != null + : current == null || current.isSymbolicLink() || current.dev !== target.dev || current.ino !== target.ino + ) { + throw new WorkspaceToolError( + 'Workspace file changed before import could be committed', + 'EDIT_CONFLICT', + ); + } + throwIfAborted(signal); + if (target.state === 'different') { + await rename(temporary, installTarget); + temporaryNeedsCleanup = false; + } else { + try { + await link(temporary, installTarget); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'EEXIST') { + throw new WorkspaceToolError('Workspace file already exists', 'EDIT_CONFLICT'); + } + throw error; + } + try { + await unlink(temporary); + temporaryNeedsCleanup = false; + } catch { + throw new WorkspaceToolError( + 'Workspace import cleanup could not be confirmed', + 'WRITE_UNAVAILABLE', + true, + ); + } + } + await confirmInstalledImport(root, installTarget, stagedIdentity); + return { created: target.state === 'missing' }; + } catch (error) { + if (error instanceof WorkspaceToolError) throw error; + throw new WorkspaceToolError('Workspace storage is unavailable', 'WRITE_UNAVAILABLE'); + } finally { + await handle?.close().catch(() => undefined); + if (temporaryNeedsCleanup) await removeImportStaging(temporary); + } +} + +/** A staging file that cannot be removed is upload bytes left in the workspace: never a clean failure. */ +async function removeImportStaging(temporary: string): Promise { + try { + await unlink(temporary); + } catch (error) { + if (isMissingEntry(error)) return; + throw new WorkspaceToolError( + 'Workspace import staging file could not be removed', + 'WRITE_UNAVAILABLE', + true, + ); + } +} + +async function importWorkspaceFile( + root: string, + request: WorkspaceImportFileRequest, + importer: WorkspaceFileImporter, + signal?: AbortSignal, +): Promise { + throwIfAborted(signal); + if (!isWorkspaceImportFilePath(request.path)) { + throw new WorkspaceToolError('Invalid workspace path', 'INVALID_PATH'); + } + const { egressGrant } = request; + if (egressGrant === undefined) { + throw new WorkspaceToolError('Import source grant is unavailable', 'INVALID_REQUEST'); + } + if (request.bytes > importer.maxBytes) { + throw new WorkspaceToolError('Imported file exceeds the worker file limit', 'WRITE_LIMIT_EXCEEDED'); + } + const result = (created: boolean, bytesWritten: number, unchanged = false): WorkspaceImportFileResult => ({ + protocolVersion: BRIDGE_PROTOCOL_VERSION, + operation: 'import_file', + workspaceId: request.workspaceId, + path: request.path, + created, + ...(unchanged ? { unchanged: true } : {}), + bytesWritten, + sha256: request.sha256, + }); + const candidate = resolveWorkspacePath(root, request.path); + const target = await inspectImportTarget(root, request, signal); + if (target.state === 'unchanged') return result(false, 0, true); + if (target.state === 'different' && request.overwrite !== true) { + throw new WorkspaceToolError( + 'Workspace file already exists with different content', + 'EDIT_CONFLICT', + ); + } + const directories = await createMissingParentDirectories(root, candidate, IMPORT_DIRECTORY_MODE); + const identities: ImportTargetIdentity[] = []; + try { + await setImportDirectoryModes(directories, identities); + const { created } = await installImportedFile( + root, + candidate, + { ...request, egressGrant }, + importer, + target, + signal, + ); + return result(created, request.bytes); + } catch (error) { + const failure = classifyWritePathValidationError(error); + if (directories.length === 0 || failure.mutationMayHaveCommitted) throw failure; + if (await removeCreatedDirectories(directories, identities)) throw failure; + throw afterCreatedDirectories(directories, failure); + } +} + async function editWorkspaceFile( root: string, request: WorkspaceEditFileRequest, @@ -1878,6 +2278,7 @@ export class LocalWorkspaceTools implements WorkspaceToolExecutor { editFileModes?: BridgeWorkspaceToolCapabilities['editFileModes'], editFileFeatures?: BridgeWorkspaceToolCapabilities['editFileFeatures'], listFileFeatures?: BridgeWorkspaceToolCapabilities['listFileFeatures'], + private readonly importer?: WorkspaceFileImporter, ) { this.capabilities = { protocolVersion: BRIDGE_PROTOCOL_VERSION, @@ -1897,9 +2298,13 @@ export class LocalWorkspaceTools implements WorkspaceToolExecutor { const anyWritable = options.workspaces.some( (workspace) => workspace.writable === true, ); + const mutations = [ + ...WRITE_OPERATIONS, + ...(options.importer != null ? IMPORT_OPERATIONS : []), + ]; const operations: BridgeWorkspaceToolCapabilities['operations'] = [ ...READ_OPERATIONS, - ...(anyWritable ? WRITE_OPERATIONS : []), + ...(anyWritable ? mutations : []), ]; const workspaces: BridgeWorkspaceDescriptor[] = options.workspaces.map( (workspace) => ({ @@ -1909,7 +2314,7 @@ export class LocalWorkspaceTools implements WorkspaceToolExecutor { ? { operations: [ ...READ_OPERATIONS, - ...(workspace.writable === true ? WRITE_OPERATIONS : []), + ...(workspace.writable === true ? mutations : []), ], } : {}), @@ -1959,6 +2364,7 @@ export class LocalWorkspaceTools implements WorkspaceToolExecutor { capabilities.editFileModes, capabilities.editFileFeatures, capabilities.listFileFeatures, + anyWritable ? options.importer : undefined, ); tools.repositoryInstructions = options.repositoryInstructions === true; return tools; @@ -2000,6 +2406,22 @@ export class LocalWorkspaceTools implements WorkspaceToolExecutor { } const { root } = workspace; + if (request.operation === 'import_file') { + if (!workspace.writable) { + throw new WorkspaceToolError( + 'Workspace mutations are disabled by the worker', + 'WRITE_DISABLED', + ); + } + if (this.importer == null) { + throw new WorkspaceToolError( + 'Workspace file import is not configured on this worker', + 'WRITE_UNAVAILABLE', + ); + } + return importWorkspaceFile(root, request, this.importer, signal); + } + if ( request.operation === 'write_file' || request.operation === 'preview_edit' || @@ -2163,7 +2585,7 @@ export class SandboxWorkspaceTools implements WorkspaceToolExecutor { ) { throw error; } - throw new WorkspaceToolError(error.message, error.code, true); + throw new WorkspaceToolError(error.message, error.code, true, true, error.commandDiagnostic); } throw new WorkspaceToolError( 'Sandboxed command execution unavailable', diff --git a/packages/extraction/.gitattributes b/packages/extraction/.gitattributes new file mode 100644 index 00000000..67da8786 --- /dev/null +++ b/packages/extraction/.gitattributes @@ -0,0 +1,2 @@ +tests/fixtures/*.pdf binary +tests/fixtures/*.docx binary diff --git a/packages/extraction/.gitignore b/packages/extraction/.gitignore new file mode 100644 index 00000000..2e3a563c --- /dev/null +++ b/packages/extraction/.gitignore @@ -0,0 +1 @@ +native/guard diff --git a/packages/extraction/Dockerfile b/packages/extraction/Dockerfile new file mode 100644 index 00000000..cfc48c14 --- /dev/null +++ b/packages/extraction/Dockerfile @@ -0,0 +1,32 @@ +FROM node:24.22.1-bookworm-slim AS build +WORKDIR /build +COPY packages/extraction/package*.json ./ +RUN npm ci --ignore-scripts --no-audit --no-fund +COPY packages/extraction/tsconfig.json ./ +COPY packages/extraction/src ./src +RUN npm run build +RUN apt-get update && apt-get install -y --no-install-recommends gcc libc6-dev linux-libc-dev && rm -rf /var/lib/apt/lists/* +COPY packages/extraction/native/guard.c ./guard.c +RUN gcc -O2 -Wall -Wextra -Werror -o guard guard.c +COPY packages/extraction/tests/block-landlock.c ./block-landlock.c +RUN gcc -O2 -Wall -Wextra -Werror -o block-landlock block-landlock.c + +FROM python:3.13-slim-bookworm@sha256:5024f48ba9441d4b13a95d3945abc6365538e3a31109833367a1923523c6efed AS runtime +RUN apt-get update && apt-get install -y --no-install-recommends libstdc++6 libatomic1 && rm -rf /var/lib/apt/lists/* +COPY packages/extraction/worker/requirements.txt /opt/extraction/requirements.txt +RUN pip install --no-cache-dir -r /opt/extraction/requirements.txt +COPY --from=build /usr/local/bin/node /usr/local/bin/node +COPY --from=build --chmod=0555 /build/guard /opt/extraction/guard +COPY --from=build --chmod=0555 /build/dist /opt/extraction/dist +COPY --chmod=0444 packages/extraction/package.json /opt/extraction/package.json +COPY --chmod=0444 packages/extraction/worker/extract.py /opt/extraction/extract.py +RUN groupadd -g 10001 extraction && useradd -u 10001 -g extraction -M extraction && \ + mkdir /socket /jobs && chown extraction:extraction /socket /jobs && chmod 0700 /jobs && chmod 0770 /socket +USER 10001:10001 +WORKDIR /opt/extraction +HEALTHCHECK --interval=10s --timeout=2s --start-period=10s --retries=3 CMD ["node", "dist/health.js"] +CMD ["node", "dist/main.js"] + +FROM runtime AS qualification +COPY --chown=10001:10001 packages/extraction/tests /tests +COPY --from=build --chmod=0555 /build/block-landlock /tests/block-landlock diff --git a/packages/extraction/compose.yaml b/packages/extraction/compose.yaml new file mode 100644 index 00000000..f6bbf9f4 --- /dev/null +++ b/packages/extraction/compose.yaml @@ -0,0 +1,22 @@ +services: + extraction: + image: ${EXTRACTION_IMAGE:-librechat-extraction:local} + build: + context: ../.. + dockerfile: packages/extraction/Dockerfile + target: runtime + init: true + user: '10001:10001' + read_only: true + cap_drop: [ALL] + security_opt: [no-new-privileges:true] + network_mode: none + mem_limit: 768m + cpus: 2 + pids_limit: 32 + tmpfs: + - /jobs:rw,noexec,nosuid,nodev,size=96m,nr_inodes=1024,uid=10001,gid=10001,mode=0700 + volumes: + - extraction-socket:/socket +volumes: + extraction-socket: diff --git a/packages/extraction/native/guard.c b/packages/extraction/native/guard.c new file mode 100644 index 00000000..ee1d2cbc --- /dev/null +++ b/packages/extraction/native/guard.c @@ -0,0 +1,156 @@ +#define _GNU_SOURCE +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +/* Stable UAPI values missing from Debian 12's older build headers. */ +#ifndef LANDLOCK_ACCESS_FS_TRUNCATE +#define LANDLOCK_ACCESS_FS_TRUNCATE (1ULL << 14) +#endif +#ifndef __NR_fchmodat2 +#define __NR_fchmodat2 452 +#endif + +#if defined(__x86_64__) +#define ARCH AUDIT_ARCH_X86_64 +#define LOADER "/lib64/ld-linux-x86-64.so.2" +#elif defined(__aarch64__) +#define ARCH AUDIT_ARCH_AARCH64 +#define LOADER "/lib/ld-linux-aarch64.so.1" +#else +#error Unsupported architecture +#endif + +static void fail_at(int line) { fprintf(stderr, "ISOLATION_UNAVAILABLE:%d:%d\n", line, errno); exit(125); } +#define fail() fail_at(__LINE__) +static void limit(int resource, rlim_t value) { + struct rlimit r = {value, value}; + if (setrlimit(resource, &r)) fail(); +} +static void allow(int rules, const char *path, __u64 access) { + int fd = open(path, O_PATH | O_CLOEXEC); + if (fd < 0) fail(); + struct landlock_path_beneath_attr rule = {.allowed_access = access, .parent_fd = fd}; + if (syscall(SYS_landlock_add_rule, rules, LANDLOCK_RULE_PATH_BENEATH, &rule, 0)) fail(); + close(fd); +} +#define DENY(nr) BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, nr, 0, 1), BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_ERRNO | EPERM) +static void restrict_syscalls(void) { + struct sock_filter filter[] = { + BPF_STMT(BPF_LD | BPF_W | BPF_ABS, offsetof(struct seccomp_data, arch)), + BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, ARCH, 1, 0), + BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_KILL_PROCESS), + BPF_STMT(BPF_LD | BPF_W | BPF_ABS, offsetof(struct seccomp_data, nr)), +#ifdef __x86_64__ + BPF_JUMP(BPF_JMP | BPF_JGE | BPF_K, 0x40000000, 0, 1), + BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_KILL_PROCESS), +#endif + DENY(__NR_socket), DENY(__NR_socketpair), DENY(__NR_connect), + DENY(__NR_bind), DENY(__NR_listen), DENY(__NR_accept), DENY(__NR_accept4), + DENY(__NR_clone), DENY(__NR_clone3), +#ifdef __NR_fork + DENY(__NR_fork), DENY(__NR_vfork), +#endif + DENY(__NR_ptrace), DENY(__NR_process_vm_readv), DENY(__NR_process_vm_writev), + DENY(__NR_kill), DENY(__NR_tkill), DENY(__NR_tgkill), DENY(__NR_pidfd_send_signal), + DENY(__NR_rt_sigqueueinfo), DENY(__NR_rt_tgsigqueueinfo), + /* CPython marks opened files close-on-exec through FIOCLEX. */ + BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, __NR_ioctl, 0, 3), + BPF_STMT(BPF_LD | BPF_W | BPF_ABS, offsetof(struct seccomp_data, args[1])), + BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, FIOCLEX, 1, 0), + BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_ERRNO | EPERM), + BPF_STMT(BPF_LD | BPF_W | BPF_ABS, offsetof(struct seccomp_data, nr)), + /* Async descriptor ownership can cause kernel-delivered signals. */ + BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, __NR_fcntl, 0, 7), + BPF_STMT(BPF_LD | BPF_W | BPF_ABS, offsetof(struct seccomp_data, args[1])), + DENY(F_SETOWN), DENY(F_SETOWN_EX), DENY(F_SETSIG), + BPF_STMT(BPF_LD | BPF_W | BPF_ABS, offsetof(struct seccomp_data, nr)), + DENY(__NR_mq_open), DENY(__NR_mq_unlink), DENY(__NR_mq_timedsend), + DENY(__NR_mq_timedreceive), DENY(__NR_mq_notify), DENY(__NR_mq_getsetattr), + DENY(__NR_mount), DENY(__NR_umount2), DENY(__NR_pivot_root), DENY(__NR_chroot), + DENY(__NR_unshare), DENY(__NR_setns), DENY(__NR_bpf), DENY(__NR_userfaultfd), + DENY(__NR_io_uring_setup), DENY(__NR_perf_event_open), + DENY(__NR_setpriority), DENY(__NR_sched_setparam), DENY(__NR_sched_setscheduler), + DENY(__NR_sched_setaffinity), DENY(__NR_sched_setattr), + DENY(__NR_pidfd_open), DENY(__NR_pidfd_getfd), + DENY(__NR_keyctl), DENY(__NR_add_key), DENY(__NR_request_key), + DENY(__NR_shmget), DENY(__NR_shmat), DENY(__NR_shmctl), + DENY(__NR_semget), DENY(__NR_semop), DENY(__NR_semctl), + DENY(__NR_msgget), DENY(__NR_msgsnd), DENY(__NR_msgrcv), DENY(__NR_msgctl), + BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, __NR_prlimit64, 0, 3), + BPF_STMT(BPF_LD | BPF_W | BPF_ABS, offsetof(struct seccomp_data, args[0])), + BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, 0, 1, 0), + BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_ERRNO | EPERM), + BPF_STMT(BPF_LD | BPF_W | BPF_ABS, offsetof(struct seccomp_data, nr)), + DENY(__NR_utimensat), +#ifdef __NR_utime + DENY(__NR_utime), DENY(__NR_utimes), DENY(__NR_futimesat), +#endif +#ifdef __NR_fchmodat2 + DENY(__NR_fchmodat2), +#endif + DENY(__NR_open_by_handle_at), DENY(__NR_name_to_handle_at), + DENY(__NR_fchmod), DENY(__NR_fchmodat), DENY(__NR_fchown), DENY(__NR_fchownat), +#ifdef __NR_chmod + DENY(__NR_chmod), DENY(__NR_chown), DENY(__NR_lchown), +#endif + DENY(__NR_mknodat), DENY(__NR_setuid), DENY(__NR_setgid), + DENY(__NR_setreuid), DENY(__NR_setregid), DENY(__NR_setresuid), DENY(__NR_setresgid), + DENY(__NR_setgroups), DENY(__NR_setxattr), DENY(__NR_lsetxattr), DENY(__NR_fsetxattr), + DENY(__NR_removexattr), DENY(__NR_lremovexattr), DENY(__NR_fremovexattr), + BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_ALLOW) + }; + struct sock_fprog program = {.len = sizeof(filter) / sizeof(filter[0]), .filter = filter}; + if (prctl(PR_SET_SECCOMP, SECCOMP_MODE_FILTER, &program)) fail(); +} +int main(int argc, char **argv) { + if (argc != 5 || geteuid() == 0) fail(); + /* Paths are supervisor-owned, never supplied in the HTTP protocol. */ + if (chdir(argv[1])) fail(); + pid_t parent = getppid(); + if (parent == 1 || prctl(PR_SET_PDEATHSIG, SIGKILL) || getppid() != parent) fail(); + limit(RLIMIT_AS, 512ULL * 1024 * 1024); + limit(RLIMIT_CPU, 8); + limit(RLIMIT_FSIZE, 4ULL * 1024 * 1024); + limit(RLIMIT_NOFILE, 64); + limit(RLIMIT_CORE, 0); + if (prctl(PR_SET_DUMPABLE, 0) || prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0)) fail(); + if (syscall(SYS_close_range, 3, ~0U, 0)) fail(); + int abi = syscall(SYS_landlock_create_ruleset, NULL, 0, LANDLOCK_CREATE_RULESET_VERSION); + if (abi < 3) fail(); + __u64 read = LANDLOCK_ACCESS_FS_READ_FILE | LANDLOCK_ACCESS_FS_READ_DIR; + __u64 write = LANDLOCK_ACCESS_FS_WRITE_FILE | LANDLOCK_ACCESS_FS_TRUNCATE | LANDLOCK_ACCESS_FS_MAKE_REG; + struct landlock_ruleset_attr attr = { + .handled_access_fs = (1ULL << 15) - 1 /* ABI 3, including REFER and TRUNCATE */ + }; + int rules = syscall(SYS_landlock_create_ruleset, &attr, sizeof(attr), 0); + if (rules < 0) fail(); + allow(rules, "/usr", read); + allow(rules, "/lib", read); + if (access("/lib64", F_OK) == 0) allow(rules, "/lib64", read); + allow(rules, argv[2], LANDLOCK_ACCESS_FS_READ_FILE | LANDLOCK_ACCESS_FS_EXECUTE); + allow(rules, LOADER, LANDLOCK_ACCESS_FS_READ_FILE | LANDLOCK_ACCESS_FS_EXECUTE); + allow(rules, argv[3], LANDLOCK_ACCESS_FS_READ_FILE); + allow(rules, argv[1], read | write); + allow(rules, "/dev/null", LANDLOCK_ACCESS_FS_READ_FILE | LANDLOCK_ACCESS_FS_WRITE_FILE); + allow(rules, "/dev/urandom", LANDLOCK_ACCESS_FS_READ_FILE); + if (syscall(SYS_landlock_restrict_self, rules, 0)) fail(); + close(rules); + restrict_syscalls(); + if (clearenv() || setenv("LANG", "C.UTF-8", 1)) fail(); + execl(argv[2], argv[2], "-I", "-B", argv[3], argv[4], (char *)NULL); + fail(); +} diff --git a/packages/extraction/package-lock.json b/packages/extraction/package-lock.json new file mode 100644 index 00000000..86e21c9f --- /dev/null +++ b/packages/extraction/package-lock.json @@ -0,0 +1,64 @@ +{ + "name": "@librechat/extraction-service", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@librechat/extraction-service", + "version": "0.1.0", + "devDependencies": { + "@types/node": "24.10.1", + "prettier": "2.4.1", + "typescript": "5.9.3" + }, + "engines": { + "node": ">=24 <25" + } + }, + "node_modules/@types/node": { + "version": "24.10.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.1.tgz", + "integrity": "sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.16.0" + } + }, + "node_modules/prettier": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-2.4.1.tgz", + "integrity": "sha512-9fbDAXSBcc6Bs1mZrDYb3XKzDLm4EXXL9sC1LqKP5rZkT6KRr/rf9amVUcODVXgguK/isJz0d0hP72WeaKWsvA==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin-prettier.js" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", + "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/packages/extraction/package.json b/packages/extraction/package.json new file mode 100644 index 00000000..5934c126 --- /dev/null +++ b/packages/extraction/package.json @@ -0,0 +1,22 @@ +{ + "name": "@librechat/extraction-service", + "version": "0.1.0", + "private": true, + "type": "module", + "engines": { + "node": ">=24 <25" + }, + "scripts": { + "build": "tsc", + "typecheck": "tsc --noEmit", + "test": "npm run build && node --test dist/*.test.js", + "start": "node dist/main.js", + "test:container": "bash tests/container.sh", + "format:check": "prettier --check src tests/*.mjs *.json compose.yaml ../../.github/workflows/extraction.yml" + }, + "devDependencies": { + "@types/node": "24.10.1", + "prettier": "2.4.1", + "typescript": "5.9.3" + } +} diff --git a/packages/extraction/src/backend.ts b/packages/extraction/src/backend.ts new file mode 100644 index 00000000..a3831c65 --- /dev/null +++ b/packages/extraction/src/backend.ts @@ -0,0 +1,166 @@ +import { spawn } from 'node:child_process'; +import { constants } from 'node:fs'; +import { mkdtemp, open, rm, writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { ExtractionError, policy, validateResult } from './protocol.js'; +import type { Format } from './protocol.js'; + +export interface Backend { + healthy: boolean; + create(): Promise; + run( + job: string, + format: Format, + digest: string, + signal: AbortSignal + ): Promise; + cleanup(job: string): Promise; +} +export interface Runtime { + jobs: string; + guard: string; + python: string; + worker: string; +} +const parserErrors = new Set([ + 'INVALID_DOCUMENT', + 'EMPTY_OUTPUT', + 'OUTPUT_LIMIT', + 'DECOMPRESSION_LIMIT', + 'STRUCTURE_LIMIT', + 'ENCRYPTED_DOCUMENT', + 'UNSUPPORTED_ENCODING', + 'UNSUPPORTED_CONTENT', + 'RESOURCE_LIMIT', +]); +export class RestrictedBackend implements Backend { + healthy = true; + constructor(private readonly runtime: Runtime) {} + async create(): Promise { + if (!this.healthy) throw new ExtractionError('UNAVAILABLE'); + return mkdtemp(join(this.runtime.jobs, 'job-')); + } + private async execute( + job: string, + mode: string, + signal: AbortSignal + ): Promise { + signal.throwIfAborted(); + const child = spawn( + this.runtime.guard, + [job, this.runtime.python, this.runtime.worker, mode], + { + detached: true, + stdio: ['ignore', 'pipe', 'pipe'], + env: {}, + } + ); + let output = ''; + let count = 0; + let overflow = false; + const kill = () => { + if ( + child.pid && + child.exitCode === null && + child.signalCode === null + ) { + try { + process.kill(-child.pid, 'SIGKILL'); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ESRCH') + this.healthy = false; + } + } + }; + signal.addEventListener('abort', kill, { once: true }); + if (signal.aborted) kill(); + let spawnError = false; + child.on('error', () => { + spawnError = true; + }); + child.stdout.on('data', (chunk: Buffer) => { + count += chunk.length; + if (count > 1024) { + overflow = true; + kill(); + } else output += chunk.toString('ascii'); + }); + child.stderr.on('data', (chunk: Buffer) => { + count += chunk.length; + if (count > 2048) { + overflow = true; + kill(); + } + }); + const code = await new Promise(resolve => + child.once('close', resolve) + ); + signal.removeEventListener('abort', kill); + signal.throwIfAborted(); + if (spawnError || overflow) throw new ExtractionError('UNAVAILABLE'); + if (code !== 0) { + const value = output.trim(); + if (parserErrors.has(value)) + throw new ExtractionError(value as 'INVALID_DOCUMENT'); + throw new ExtractionError( + code === null ? 'RESOURCE_LIMIT' : 'UNAVAILABLE' + ); + } + return output; + } + async probe(): Promise { + const job = await this.create(); + try { + const output = await this.execute( + job, + 'probe', + AbortSignal.timeout(policy.deadlineMs) + ); + if (output.trim() !== 'READY') + throw new ExtractionError('UNAVAILABLE'); + } finally { + await this.cleanup(job); + } + } + async run( + job: string, + format: Format, + digest: string, + signal: AbortSignal + ): Promise { + await this.execute(job, format, signal); + const file = await open( + join(job, 'result'), + constants.O_RDONLY | constants.O_NOFOLLOW + ); + try { + const stat = await file.stat(); + if ( + !stat.isFile() || + stat.nlink !== 1 || + stat.size > policy.resultBytes + ) { + throw new ExtractionError('OUTPUT_LIMIT'); + } + const bytes = await file.readFile(); + signal.throwIfAborted(); + validateResult(bytes, format, digest); + return bytes; + } finally { + await file.close(); + } + } + async cleanup(job: string): Promise { + try { + await rm(job, { recursive: true, force: true, maxRetries: 0 }); + } catch { + this.healthy = false; + throw new ExtractionError('UNAVAILABLE'); + } + } +} + +/** Used by focused worker tests, not by HTTP callers. */ +export async function writeInput(job: string, input: Buffer): Promise { + await writeFile(join(job, 'input'), input, { flag: 'wx', mode: 0o600 }); +} diff --git a/packages/extraction/src/boundary.test.ts b/packages/extraction/src/boundary.test.ts new file mode 100644 index 00000000..da54749e --- /dev/null +++ b/packages/extraction/src/boundary.test.ts @@ -0,0 +1,22 @@ +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +import { test } from 'node:test'; + +test('supervisor has no parser or application stack dependencies', async () => { + const manifest = JSON.parse( + await readFile(new URL('../package.json', import.meta.url), 'utf8') + ); + assert.equal(manifest.dependencies, undefined); + for (const name of ['server.ts', 'backend.ts', 'protocol.ts', 'main.ts']) { + const source = await readFile( + new URL(`../src/${name}`, import.meta.url), + 'utf8' + ); + for (const match of source.matchAll(/from ['"]([^'"]+)['"]/g)) { + assert.ok( + match[1]?.startsWith('node:') || match[1]?.startsWith('./'), + match[1] + ); + } + } +}); diff --git a/packages/extraction/src/health.ts b/packages/extraction/src/health.ts new file mode 100644 index 00000000..641575c4 --- /dev/null +++ b/packages/extraction/src/health.ts @@ -0,0 +1,15 @@ +import { request } from 'node:http'; +const req = request( + { + socketPath: '/socket/extraction.sock', + path: '/v1/capabilities', + timeout: 1000, + }, + res => { + res.resume(); + res.once('end', () => process.exit(res.statusCode === 200 ? 0 : 1)); + } +); +req.on('timeout', () => req.destroy()); +req.on('error', () => process.exit(1)); +req.end(); diff --git a/packages/extraction/src/main.ts b/packages/extraction/src/main.ts new file mode 100644 index 00000000..424a6ed6 --- /dev/null +++ b/packages/extraction/src/main.ts @@ -0,0 +1,64 @@ +import { chmod, lstat, readdir, unlink } from 'node:fs/promises'; +import { connect } from 'node:net'; +import { RestrictedBackend } from './backend.js'; +import { extractionServer } from './server.js'; + +// Deployment-owned locations, not caller-selected paths or runtimes. +const backend = new RestrictedBackend({ + jobs: '/jobs', + guard: '/opt/extraction/guard', + python: '/usr/local/bin/python3.13', + worker: '/opt/extraction/extract.py', +}); +try { + if ((await readdir('/jobs')).length) throw new Error('DIRTY_SCRATCH'); + try { + const stat = await lstat('/socket/extraction.sock'); + if (!stat.isSocket()) throw new Error('INVALID_SOCKET'); + await new Promise((resolve, reject) => { + const client = connect('/socket/extraction.sock'); + client.setTimeout(1000, () => { + client.destroy(); + reject(new Error('SOCKET_EXISTS')); + }); + client.once('connect', () => { + client.destroy(); + reject(new Error('SOCKET_EXISTS')); + }); + client.once('error', error => { + if ((error as NodeJS.ErrnoException).code === 'ECONNREFUSED') + resolve(); + else reject(error); + }); + }); + await unlink('/socket/extraction.sock'); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; + } + await backend.probe(); + const server = extractionServer(backend); + server.on('error', () => { + console.error('EXTRACTION_UNAVAILABLE'); + process.exit(1); + }); + process.umask(0o007); + server.listen('/socket/extraction.sock', async () => { + try { + await chmod('/socket/extraction.sock', 0o660); + } catch { + console.error('EXTRACTION_UNAVAILABLE'); + process.exit(1); + } + console.log('Extraction service ready'); + }); + const shutdown = () => { + server.close(); + server.closeAllConnections(); + setTimeout(() => process.exit(0), 12_000).unref(); + }; + process.once('SIGTERM', shutdown); + process.once('SIGINT', shutdown); +} catch { + console.error('EXTRACTION_UNAVAILABLE'); + process.exit(1); +} diff --git a/packages/extraction/src/protocol.test.ts b/packages/extraction/src/protocol.test.ts new file mode 100644 index 00000000..6688fa49 --- /dev/null +++ b/packages/extraction/src/protocol.test.ts @@ -0,0 +1,68 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { policy, validateResult } from './protocol.js'; +const digest = 'a'.repeat(64); +const value = (text = 'hello') => ({ + version: 1, + operation: 'document.extract-text', + format: 'pdf', + inputSha256: digest, + textBytes: Buffer.byteLength(text), + segments: [{ kind: 'page', index: 1, text }], +}); +const encode = (result: unknown) => Buffer.from(JSON.stringify(result)); +test('preserves ordered page segments and validates digest and byte count', () => { + const result = value(); + assert.deepEqual(validateResult(encode(result), 'pdf', digest), result); + for (const changed of [ + { ...result, inputSha256: 'b'.repeat(64) }, + { ...result, textBytes: 4 }, + { ...result, extra: true }, + { ...result, format: 'docx' }, + { ...result, segments: [{ kind: 'page', index: 0, text: 'hello' }] }, + { ...result, segments: [{ kind: 'sheet', index: 1, text: 'hello' }] }, + ]) + assert.throws(() => validateResult(encode(changed), 'pdf', digest)); +}); +test('rejects malformed UTF-8, lone surrogates, emptiness and invalid JSON', () => { + for (const bytes of [ + Buffer.from([0xff]), + Buffer.from('{'), + encode(value('\ud800')), + encode(value(' ')), + encode(null), + ]) { + assert.throws(() => validateResult(bytes, 'pdf', digest)); + } +}); +test('accepts exact text limit and rejects one byte more or oversized wire result', () => { + assert.equal( + validateResult( + encode(value('x'.repeat(policy.textBytes))), + 'pdf', + digest + ).textBytes, + policy.textBytes + ); + assert.throws(() => + validateResult( + encode(value('x'.repeat(policy.textBytes + 1))), + 'pdf', + digest + ) + ); + assert.throws(() => + validateResult(Buffer.alloc(policy.resultBytes + 1), 'pdf', digest) + ); +}); +test('rejects excess pages and does not accept PDF pages as DOCX structure', () => { + const result = value(); + result.segments = Array.from( + { length: policy.maxSegments + 1 }, + (_, index) => ({ kind: 'page', index: index + 1, text: 'hello' }) + ); + assert.throws(() => validateResult(encode(result), 'pdf', digest)); + assert.throws(() => + validateResult(encode({ ...value(), format: 'docx' }), 'docx', digest) + ); +}); diff --git a/packages/extraction/src/protocol.ts b/packages/extraction/src/protocol.ts new file mode 100644 index 00000000..935c902c --- /dev/null +++ b/packages/extraction/src/protocol.ts @@ -0,0 +1,107 @@ +import { createHash } from 'node:crypto'; + +export const policy = Object.freeze({ + inputBytes: 10 * 1024 * 1024, + textBytes: 1024 * 1024, + resultBytes: 3 * 1024 * 1024, + deadlineMs: 10_000, + maxSegments: 128, +}); +export type Format = 'pdf' | 'docx'; +export type ErrorCode = + | 'INVALID_REQUEST' + | 'INPUT_LIMIT' + | 'BUSY' + | 'DEADLINE' + | 'CANCELLED' + | 'UNAVAILABLE' + | 'INVALID_DOCUMENT' + | 'EMPTY_OUTPUT' + | 'OUTPUT_LIMIT' + | 'DECOMPRESSION_LIMIT' + | 'STRUCTURE_LIMIT' + | 'ENCRYPTED_DOCUMENT' + | 'UNSUPPORTED_ENCODING' + | 'UNSUPPORTED_CONTENT' + | 'RESOURCE_LIMIT'; +export class ExtractionError extends Error { + constructor(readonly code: ErrorCode) { + super(code); + } +} +export interface Segment { + kind: 'page' | 'document'; + index: number; + text: string; +} +export interface Result { + version: 1; + operation: 'document.extract-text'; + format: Format; + inputSha256: string; + textBytes: number; + segments: Segment[]; +} +const keys = (value: object, expected: string[]) => + Object.keys(value).length === expected.length && + expected.every(key => Object.hasOwn(value, key)); +export function validateResult( + bytes: Buffer, + format: Format, + digest: string +): Result { + if (bytes.length > policy.resultBytes) + throw new ExtractionError('OUTPUT_LIMIT'); + let result: Result; + try { + result = JSON.parse( + new TextDecoder('utf-8', { fatal: true }).decode(bytes) + ) as Result; + } catch { + throw new ExtractionError('INVALID_DOCUMENT'); + } + if ( + !result || + typeof result !== 'object' || + !keys(result, [ + 'version', + 'operation', + 'format', + 'inputSha256', + 'textBytes', + 'segments', + ]) || + result.version !== 1 || + result.operation !== 'document.extract-text' || + result.format !== format || + result.inputSha256 !== digest || + !Array.isArray(result.segments) || + !result.segments.length || + result.segments.length > (format === 'pdf' ? policy.maxSegments : 1) + ) { + throw new ExtractionError('INVALID_DOCUMENT'); + } + let size = 0; + let nonempty = false; + for (const [index, segment] of result.segments.entries()) { + if ( + !segment || + typeof segment !== 'object' || + !keys(segment, ['kind', 'index', 'text']) || + segment.kind !== (format === 'pdf' ? 'page' : 'document') || + segment.index !== index + 1 || + typeof segment.text !== 'string' || + !segment.text.isWellFormed() + ) + throw new ExtractionError('INVALID_DOCUMENT'); + size += Buffer.byteLength(segment.text); + nonempty ||= Boolean(segment.text.trim()); + } + if (size > policy.textBytes) throw new ExtractionError('OUTPUT_LIMIT'); + if (size !== result.textBytes || !nonempty) + throw new ExtractionError('INVALID_DOCUMENT'); + return result; +} +export function sha256(bytes: Buffer): string { + return createHash('sha256').update(bytes).digest('hex'); +} diff --git a/packages/extraction/src/server.test.ts b/packages/extraction/src/server.test.ts new file mode 100644 index 00000000..60f9bc63 --- /dev/null +++ b/packages/extraction/src/server.test.ts @@ -0,0 +1,267 @@ +import assert from 'node:assert/strict'; +import { mkdtemp, rm, readdir } from 'node:fs/promises'; +import { request } from 'node:http'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { once } from 'node:events'; +import { test } from 'node:test'; +import { extractionServer } from './server.js'; +import { ExtractionError, policy } from './protocol.js'; +import type { Backend } from './backend.js'; + +async function fixture(deadline = 500) { + const root = await mkdtemp(join(tmpdir(), 'extraction-test-')); + const events: string[] = []; + const backend: Backend = { + healthy: true, + create: async () => { + events.push('create'); + return mkdtemp(join(root, 'job-')); + }, + run: async () => { + events.push('run'); + return Buffer.from('{"segments":[]}'); + }, + cleanup: async job => { + events.push('cleanup'); + await rm(job, { recursive: true }); + }, + }; + const server = extractionServer(backend, deadline); + const socketPath = join(root, 'service.sock'); + server.listen(socketPath); + await once(server, 'listening'); + return { + backend, + events, + root, + socketPath, + server, + close: async () => { + server.closeAllConnections(); + await new Promise(resolve => server.close(() => resolve())); + await rm(root, { recursive: true, force: true }); + }, + }; +} +const headers = { + 'Content-Type': 'application/octet-stream', + 'Content-Length': '4', + 'X-Extraction-Version': '1', + 'X-Extraction-Format': 'pdf', +}; +async function send( + socketPath: string, + overrides = {}, + path = '/v1/extract-text' +) { + return new Promise<{ status: number; body: string }>((resolve, reject) => { + const req = request( + { + socketPath, + path, + method: 'POST', + headers: { ...headers, ...overrides }, + }, + res => { + let body = ''; + res.on('data', bytes => { + body += bytes; + }); + res.on('end', () => resolve({ status: res.statusCode!, body })); + } + ); + req.on('error', reject); + req.end('test'); + }); +} +test('binary upload executes once and cleanup completes before success', async () => { + const f = await fixture(); + try { + assert.equal((await send(f.socketPath)).status, 200); + assert.deepEqual(f.events, ['create', 'run', 'cleanup']); + assert.deepEqual(await readdir(f.root), ['service.sock']); + } finally { + await f.close(); + } +}); +test('closed parameters and declared input limit are rejected before admission', async () => { + const f = await fixture(); + try { + for (const changes of [ + { 'X-Extraction-Format': 'xls' }, + { 'X-Extraction-Version': '2' }, + { 'X-Extraction-Url': 'https://example.com' }, + { 'Content-Type': 'text/plain' }, + ]) { + assert.equal((await send(f.socketPath, changes)).status, 400); + } + assert.equal( + (await send(f.socketPath, {}, '/v1/extract-text?path=x')).status, + 404 + ); + assert.equal( + ( + await send(f.socketPath, { + 'Content-Length': String(policy.inputBytes + 1), + }) + ).status, + 413 + ); + assert.equal(f.events.length, 0); + } finally { + await f.close(); + } +}); +test('cleanup failure suppresses success and quarantines backend', async () => { + const f = await fixture(); + f.backend.cleanup = async () => { + f.backend.healthy = false; + throw new ExtractionError('UNAVAILABLE'); + }; + try { + assert.equal((await send(f.socketPath)).status, 503); + assert.equal((await send(f.socketPath)).status, 503); + assert.equal(f.events.filter(e => e === 'run').length, 1); + } finally { + await f.close(); + } +}); +test('deadline aborts execution and waits for its close before cleanup', async () => { + const f = await fixture(50); + f.backend.run = async (_job, _format, _digest, signal) => { + await new Promise(resolve => + signal.addEventListener( + 'abort', + () => { + setTimeout(() => { + f.events.push('closed'); + resolve(); + }, 15); + }, + { once: true } + ) + ); + signal.throwIfAborted(); + return Buffer.alloc(0); + }; + try { + assert.equal((await send(f.socketPath)).status, 504); + assert.deepEqual(f.events, ['create', 'closed', 'cleanup']); + } finally { + await f.close(); + } +}); +test('upload disconnect and stalled upload both release scratch and admission', async () => { + const f = await fixture(60); + try { + for (const disconnect of [true, false]) { + const req = request({ + socketPath: f.socketPath, + path: '/v1/extract-text', + method: 'POST', + headers, + }); + req.on('error', () => {}); + req.flushHeaders(); + req.write('t'); + await new Promise(resolve => setTimeout(resolve, 20)); + if (disconnect) req.destroy(); + await new Promise(resolve => setTimeout(resolve, 80)); + assert.deepEqual(await readdir(f.root), ['service.sock']); + } + assert.equal(f.events.includes('run'), false); + assert.equal((await send(f.socketPath)).status, 200); + } finally { + await f.close(); + } +}); +test('two active jobs bound concurrency without a queue', async () => { + const f = await fixture(200); + let active = 0; + f.backend.run = async (_job, _format, _digest, signal) => { + active++; + await new Promise(resolve => + signal.addEventListener('abort', () => resolve(), { once: true }) + ); + signal.throwIfAborted(); + return Buffer.alloc(0); + }; + try { + const a = send(f.socketPath), + b = send(f.socketPath); + while (active !== 2) + await new Promise(resolve => setTimeout(resolve, 5)); + assert.equal((await send(f.socketPath)).status, 429); + assert.equal((await a).status, 504); + assert.equal((await b).status, 504); + } finally { + await f.close(); + } +}); +test('deadline closes stalled output readers while keeping their slots admitted', async () => { + const f = await fixture(250); + f.backend.run = async () => Buffer.alloc(policy.resultBytes, 'a'); + const clients = [0, 1].map(() => { + const req = request( + { + socketPath: f.socketPath, + path: '/v1/extract-text', + method: 'POST', + headers, + }, + res => { + res.pause(); + res.on('error', () => {}); + } + ); + req.on('error', () => {}); + req.end('test'); + return req; + }); + try { + while (f.events.filter(e => e === 'cleanup').length !== 2) + await new Promise(resolve => setTimeout(resolve, 5)); + assert.equal((await send(f.socketPath)).status, 429); + await new Promise(resolve => setTimeout(resolve, 300)); + f.backend.run = async () => Buffer.from('{}'); + assert.equal((await send(f.socketPath)).status, 200); + } finally { + clients.forEach(client => client.destroy()); + await f.close(); + } +}); +test('duplicate extraction control headers are rejected before worker creation', async () => { + const f = await fixture(); + try { + assert.equal( + ( + await send(f.socketPath, { + 'X-Extraction-Format': ['pdf', 'pdf'], + }) + ).status, + 400 + ); + assert.equal(f.events.length, 0); + } finally { + await f.close(); + } +}); + +test('unsupported document content returns a safe failure after cleanup', async () => { + const f = await fixture(); + f.backend.run = async () => { + throw new ExtractionError('UNSUPPORTED_CONTENT'); + }; + try { + const response = await send(f.socketPath); + assert.equal(response.status, 422); + assert.deepEqual(JSON.parse(response.body), { + error: { code: 'UNSUPPORTED_CONTENT' }, + }); + assert.deepEqual(f.events, ['create', 'cleanup']); + assert.deepEqual(await readdir(f.root), ['service.sock']); + } finally { + await f.close(); + } +}); diff --git a/packages/extraction/src/server.ts b/packages/extraction/src/server.ts new file mode 100644 index 00000000..b3bd37c8 --- /dev/null +++ b/packages/extraction/src/server.ts @@ -0,0 +1,260 @@ +import { createHash } from 'node:crypto'; +import { createServer } from 'node:http'; +import { open } from 'node:fs/promises'; +import { join } from 'node:path'; +import { ExtractionError, policy } from './protocol.js'; +import type { IncomingMessage, ServerResponse } from 'node:http'; +import type { Backend } from './backend.js'; +import type { Format } from './protocol.js'; + +export const capabilities = Object.freeze({ + version: 1, + operation: 'document.extract-text', + formats: ['pdf', 'docx'], + structure: { + pdf: 'ordered pages, one-based index', + docx: 'document body, paragraphs and tables', + }, + limitations: { + pdf: 'Text only. Simple fonts need interpretable standard/Unicode mappings. Type3/composite fonts need complete Unicode maps. Unmapped glyphs, codec recovery and graphics-state font selection are rejected.', + docx: 'Body paragraphs/tables and direct runs/hyperlinks only. Other content structures, fields, images and wrappers are rejected; formatting is ignored.', + }, + limits: { + ...policy, + concurrency: 2, + expandedBytes: 32 * 1024 * 1024, + archiveEntries: 512, + tableColumns: 512, + }, +}); +const status = (code: string): number => { + if (code === 'BUSY') return 429; + if (code === 'UNAVAILABLE') return 503; + if (code === 'DEADLINE') return 504; + if ( + code === 'INPUT_LIMIT' || + code === 'OUTPUT_LIMIT' || + code === 'DECOMPRESSION_LIMIT' || + code === 'STRUCTURE_LIMIT' + ) + return 413; + if (code === 'INVALID_REQUEST') return 400; + return 422; +}; +function reply(res: ServerResponse, code: number, bytes: Buffer): void { + res.writeHead(code, { + 'Content-Type': 'application/json; charset=utf-8', + 'Content-Length': bytes.length, + Connection: 'close', + 'Cache-Control': 'no-store', + 'X-Content-Type-Options': 'nosniff', + }); + res.end(bytes); +} +function requestFormat(req: IncomingMessage): Format { + const seen = new Set(); + for (let i = 0; i < req.rawHeaders.length; i += 2) { + const name = req.rawHeaders[i]!.toLowerCase(); + if ( + name.startsWith('x-extraction-') && + !['x-extraction-version', 'x-extraction-format'].includes(name) + ) { + throw new ExtractionError('INVALID_REQUEST'); + } + if ( + [ + 'content-length', + 'content-type', + 'x-extraction-version', + 'x-extraction-format', + ].includes(name) + ) { + if (seen.has(name)) throw new ExtractionError('INVALID_REQUEST'); + seen.add(name); + } + } + if ( + req.headers['content-type'] !== 'application/octet-stream' || + req.headers['x-extraction-version'] !== '1' || + req.headers['transfer-encoding'] + ) { + throw new ExtractionError('INVALID_REQUEST'); + } + const format = req.headers['x-extraction-format']; + if (format !== 'pdf' && format !== 'docx') + throw new ExtractionError('INVALID_REQUEST'); + const length = req.headers['content-length']; + if (typeof length !== 'string' || !/^[1-9]\d*$/.test(length)) + throw new ExtractionError('INVALID_REQUEST'); + if ( + !Number.isSafeInteger(Number(length)) || + Number(length) > policy.inputBytes + ) + throw new ExtractionError('INPUT_LIMIT'); + return format; +} +export function extractionServer( + backend: Backend, + deadlineMs: number = policy.deadlineMs +) { + if ( + !Number.isInteger(deadlineMs) || + deadlineMs <= 0 || + deadlineMs > policy.deadlineMs + ) + throw new Error('INVALID_DEADLINE'); + let admitted = 0; + const server = createServer( + { + maxHeaderSize: 4096, + requireHostHeader: true, + connectionsCheckingInterval: 1000, + }, + (req, res) => { + void handle(req, res); + } + ); + server.maxConnections = 16; + server.maxRequestsPerSocket = 1; + server.headersTimeout = 5000; + server.requestTimeout = policy.deadlineMs; + server.setTimeout(policy.deadlineMs, socket => socket.destroy()); + server.on('checkContinue', (req, res) => { + reply(res, 400, Buffer.from('{"error":{"code":"INVALID_REQUEST"}}')); + }); + async function handle( + req: IncomingMessage, + res: ServerResponse + ): Promise { + if (req.method === 'GET' && req.url === '/v1/capabilities') { + reply( + res, + backend.healthy ? 200 : 503, + Buffer.from(JSON.stringify(capabilities)) + ); + return; + } + if (req.method !== 'POST' || req.url !== '/v1/extract-text') { + reply( + res, + 404, + Buffer.from('{"error":{"code":"INVALID_REQUEST"}}') + ); + return; + } + let format: Format; + try { + format = requestFormat(req); + } catch (error) { + const code = + error instanceof ExtractionError + ? error.code + : 'INVALID_REQUEST'; + reply( + res, + status(code), + Buffer.from(JSON.stringify({ error: { code } })) + ); + return; + } + if (!backend.healthy || admitted >= 2) { + const code = backend.healthy ? 'BUSY' : 'UNAVAILABLE'; + reply( + res, + status(code), + Buffer.from(JSON.stringify({ error: { code } })) + ); + return; + } + admitted++; + const controller = new AbortController(); + const timeout = setTimeout( + () => controller.abort(new ExtractionError('DEADLINE')), + deadlineMs + ); + const aborted = () => + controller.abort(new ExtractionError('CANCELLED')); + controller.signal.addEventListener( + 'abort', + () => { + if (!req.complete) req.destroy(); + if (res.headersSent) res.destroy(); + }, + { once: true } + ); + req.once('aborted', aborted); + res.once('close', () => { + if (!res.writableFinished) aborted(); + }); + const delivered = new Promise(resolve => { + res.once('finish', resolve); + res.once('close', resolve); + }); + let job: string | undefined; + let bytes: Buffer | undefined; + let failure: unknown; + try { + controller.signal.throwIfAborted(); + job = await backend.create(); + const file = await open(join(job, 'input'), 'wx', 0o600); + const digest = createHash('sha256'); + let size = 0; + try { + for await (const chunk of req) { + controller.signal.throwIfAborted(); + size += chunk.length; + if (size > policy.inputBytes) + throw new ExtractionError('INPUT_LIMIT'); + digest.update(chunk); + await file.writeFile(chunk); + } + } finally { + await file.close(); + } + if (size !== Number(req.headers['content-length'])) + throw new ExtractionError('INVALID_REQUEST'); + controller.signal.throwIfAborted(); + bytes = await backend.run( + job, + format, + digest.digest('hex'), + controller.signal + ); + } catch (error) { + failure = error; + } finally { + if (job) { + try { + await backend.cleanup(job); + } catch (error) { + failure = error; + } + } + } + try { + if (controller.signal.aborted && !failure) + failure = controller.signal.reason; + if (controller.signal.reason instanceof ExtractionError) + failure = controller.signal.reason; + if (!res.destroyed) { + if (failure || !bytes) { + const code = + failure instanceof ExtractionError + ? failure.code + : 'UNAVAILABLE'; + reply( + res, + status(code), + Buffer.from(JSON.stringify({ error: { code } })) + ); + } else reply(res, 200, bytes); + } + await delivered; + } finally { + clearTimeout(timeout); + req.removeListener('aborted', aborted); + admitted--; + } + } + return server; +} diff --git a/packages/extraction/tests/adversarial.py b/packages/extraction/tests/adversarial.py new file mode 100644 index 00000000..710c3a11 --- /dev/null +++ b/packages/extraction/tests/adversarial.py @@ -0,0 +1,488 @@ +"""Create bounded adversarial test documents inside the disposable test container.""" +from pathlib import Path +import struct +import zipfile +from docx import Document +from pypdf import PdfReader, PdfWriter + +root = Path('/tests/fixtures') +(root / 'bad.docx').write_bytes(b'PK\x03\x04not a zip') +with zipfile.ZipFile(root / 'entries.docx', 'w') as file: + for index in range(513): + file.writestr(str(index), 'x') +with zipfile.ZipFile(root / 'duplicate.docx', 'w') as file: + file.writestr('same', 'a') + file.writestr('same', 'b') +with zipfile.ZipFile(root / 'bomb.docx', 'w', compression=zipfile.ZIP_DEFLATED) as file: + file.writestr('content.xml', b'x' * (8 * 1024 * 1024 + 1)) +with zipfile.ZipFile(root / 'aggregate.docx', 'w', compression=zipfile.ZIP_DEFLATED) as file: + for index in range(5): + file.writestr(str(index), b'x' * (7 * 1024 * 1024)) +for name, value in [('utf8.docx', b'\xff'), ('entities.docx', b']>')]: + with zipfile.ZipFile(root / name, 'w') as file: + file.writestr('word/document.xml', value) +with zipfile.ZipFile(root / 'forged.docx', 'w', compression=zipfile.ZIP_DEFLATED) as file: + file.writestr('content.xml', 'forged-content' * 20) +value = bytearray((root / 'forged.docx').read_bytes()) +central = value.index(b'PK\x01\x02') +struct.pack_into('') + 2 + value = value[:declaration_end] + b']>' + value[declaration_end:] + value = value.replace(b'First paragraph', b'&controlled;') + elif payload == 'utf8': + value = value.replace(b'First paragraph', b'\xff') + elif member == 'word/_rels/document.xml.rels': + member = 'word/_rels/document.bin.rels' + target.writestr(member, value) +print('DOCX completeness and content-type fixtures generated') + +# The supported structure is closed, not an expanding list of forbidden tags. +for tag in ['dir', 'bdo', 'smartTag', 'unknownWrapper']: + for placement in ['paragraph', 'cell', 'hyperlink']: + document = Document() + document.add_paragraph('Ordinary text must not hide wrapped content') + paragraph = (document.add_table(rows=1, cols=1).cell(0, 0).add_paragraph() + if placement == 'cell' else document.add_paragraph()) + run = paragraph.add_run('Wrapped run text')._r + parent = run.getparent() + if placement == 'hyperlink': + link = OxmlElement('w:hyperlink') + link.set(qn('w:anchor'), 'test') + parent.append(link) + parent = link + wrapper = OxmlElement('w:' + tag) + if tag in ('dir', 'bdo'): + wrapper.set(qn('w:val'), 'rtl') + wrapper.append(run) + parent.append(wrapper) + document.save(root / f'wrapper-{tag}-{placement}.docx') + +# A malformed unknown block and run child cannot bypass the same invariant. +document = Document() +document.add_paragraph('Ordinary text') +paragraph = document.add_paragraph('Unknown body wrapper text') +parent = paragraph._p.getparent() +wrapper = OxmlElement('w:unknownWrapper') +wrapper.append(paragraph._p) +parent.insert(0, wrapper) +document.save(root / 'wrapper-block.docx') +document = Document() +document.add_paragraph('Ordinary text') +run = document.add_paragraph().add_run('Run wrapper text')._r +text = run[0] +wrapper = OxmlElement('w:unknownWrapper') +wrapper.append(text) +run.append(wrapper) +document.save(root / 'wrapper-run.docx') + +# Accepted formatting, hyperlinks, bookmarks and run text primitives retain order. +document = Document() +paragraph = document.add_paragraph() +bookmark = OxmlElement('w:bookmarkStart') +bookmark.set(qn('w:id'), '1') +bookmark.set(qn('w:name'), 'test') +paragraph._p.append(bookmark) +paragraph.add_run('Bold ').bold = True +link = OxmlElement('w:hyperlink') +link.set(qn('w:anchor'), 'test') +run = OxmlElement('w:r') +text = OxmlElement('w:t') +text.text = 'Link' +run.append(text) +link.append(run) +paragraph._p.append(link) +paragraph.add_run('\tTabbed\nNext') +run = paragraph.add_run()._r +run.append(OxmlElement('w:noBreakHyphen')) +run.append(OxmlElement('w:ptab')) +run.append(OxmlElement('w:cr')) +text = OxmlElement('w:t') +text.text = 'End' +run.append(text) +run.append(OxmlElement('w:lastRenderedPageBreak')) +bookmark = OxmlElement('w:bookmarkEnd') +bookmark.set(qn('w:id'), '1') +paragraph._p.append(bookmark) +document.save(root / 'supported-runs.docx') +print('DOCX closed-structure fixtures generated') + +# Physical cells are unique. A merged origin's text must never be repeated. +for name, end in [('horizontal.docx', (0, 1)), ('vertical.docx', (1, 0)), + ('rectangle.docx', (1, 1))]: + document = Document() + table = document.add_table(rows=2, cols=3) + for row in range(2): + for column in range(3): + table.cell(row, column).text = f'R{row}C{column}' + table.cell(0, 0).merge(table.cell(*end)).text = 'Merged text' + document.save(root / name) + +document = Document() +table = document.add_table(rows=2, cols=2) +origin = table.cell(0, 0).merge(table.cell(1, 1)) +origin.text = 'Before merged nested table' +origin.add_table(rows=1, cols=1).cell(0, 0).text = 'Unique nested content' +origin.add_paragraph('After merged nested table') +document.save(root / 'merged-nested.docx') + +for name, end in [('merged-large.docx', (0, 1)), ('merged-large-vertical.docx', (1, 0))]: + document = Document() + table = document.add_table(rows=2, cols=2) + table.cell(0, 0).merge(table.cell(*end)).text = 'x' * (600 * 1024) + document.save(root / name) + +document = Document() +table = document.add_table(rows=1, cols=3) +table.cell(0, 1).text = 'Middle cell' +row = table.rows[0]._tr +row.remove(row.tc_lst[-1]) +row.remove(row.tc_lst[0]) +properties = row.get_or_add_trPr() +for tag in ('gridBefore', 'gridAfter'): + value = OxmlElement('w:' + tag) + value.set(qn('w:val'), '1') + properties.append(value) +document.save(root / 'omitted-cells.docx') + +for name, mode in [('merge-content.docx', 'content'), ('merge-orphan.docx', 'orphan'), + ('merge-span.docx', 'span'), ('merge-legacy.docx', 'legacy'), + ('wide-table.docx', 'width')]: + document = Document() + table = document.add_table(rows=2, cols=2) + if mode == 'legacy': + value = OxmlElement('w:hMerge') + value.set(qn('w:val'), 'restart') + table.cell(0, 0)._tc.get_or_add_tcPr().append(value) + elif mode == 'width': + for _ in range(511): + table._tbl.tblGrid.append(OxmlElement('w:gridCol')) + else: + table.cell(0, 0).merge(table.cell(1, 0)).text = 'Origin text' + continuation = table.rows[1]._tr.tc_lst[0] + if mode == 'content': + continuation.p_lst[0].append(OxmlElement('w:r')) + text = OxmlElement('w:t') + text.text = 'Unexpected continuation text' + continuation.p_lst[0][-1].append(text) + elif mode == 'orphan': + table.rows[0]._tr.tc_lst[0].get_or_add_tcPr().remove( + table.rows[0]._tr.tc_lst[0].tcPr.vMerge) + elif mode == 'span': + continuation.get_or_add_tcPr().get_or_add_gridSpan().val = 2 + document.save(root / name) +print('DOCX merged and omitted-cell fixtures generated') + +from pypdf.generic import FloatObject, TextStringObject + +def font_pdf(name, subtype, raw=b'A', mapping=None, glyph='/unmappedGlyph', encoding=None, form=False): + writer = PdfWriter() + writer.append(root / 'pages.pdf') + page = writer.pages[0] + font = DictionaryObject({NameObject('/Type'): NameObject('/Font'), + NameObject('/Subtype'): NameObject('/' + subtype), + NameObject('/BaseFont'): NameObject('/Helvetica')}) + if subtype == 'Type3': + proc = DecodedStreamObject() + proc.set_data(b'600 0 0 0 500 700 d1 0 0 500 700 re f') + font[NameObject('/CharProcs')] = DictionaryObject({NameObject(glyph): writer._add_object(proc)}) + font[NameObject('/FontBBox')] = ArrayObject([NumberObject(v) for v in [0, 0, 500, 700]]) + font[NameObject('/FontMatrix')] = ArrayObject([FloatObject(v) for v in [0.001, 0, 0, 0.001, 0, 0]]) + font[NameObject('/Resources')] = DictionaryObject() + font[NameObject('/FirstChar')] = NumberObject(65) + font[NameObject('/LastChar')] = NumberObject(66) + font[NameObject('/Widths')] = ArrayObject([NumberObject(600), NumberObject(600)]) + if subtype == 'Type0': + descendant = DictionaryObject({ + NameObject('/Type'): NameObject('/Font'), NameObject('/Subtype'): NameObject('/CIDFontType2'), + NameObject('/BaseFont'): NameObject('/TestComposite'), + NameObject('/CIDSystemInfo'): DictionaryObject({ + NameObject('/Registry'): TextStringObject('Adobe'), + NameObject('/Ordering'): TextStringObject('Identity'), NameObject('/Supplement'): NumberObject(0), + }), + NameObject('/FontDescriptor'): DictionaryObject({ + NameObject('/Type'): NameObject('/FontDescriptor'), NameObject('/FontName'): NameObject('/TestComposite'), + NameObject('/Flags'): NumberObject(4), NameObject('/Ascent'): NumberObject(800), + NameObject('/Descent'): NumberObject(-200), NameObject('/CapHeight'): NumberObject(700), + NameObject('/ItalicAngle'): NumberObject(0), NameObject('/StemV'): NumberObject(80), + NameObject('/FontBBox'): ArrayObject([NumberObject(v) for v in [0, -200, 1000, 800]]), + }), NameObject('/CIDToGIDMap'): NameObject('/Identity'), + }) + font[NameObject('/DescendantFonts')] = ArrayObject([writer._add_object(descendant)]) + font[NameObject('/Encoding')] = NameObject('/Identity-H') + else: + font[NameObject('/Encoding')] = DictionaryObject({ + NameObject('/BaseEncoding'): NameObject('/WinAnsiEncoding'), + NameObject('/Differences'): ArrayObject([NumberObject(65), NameObject(glyph)]), + }) + if encoding: + font[NameObject('/Encoding')] = NameObject(encoding) + if mapping is not None: + cmap = DecodedStreamObject() + size = 2 if subtype == 'Type0' else 1 + start, end = ('0000', 'FFFF') if size == 2 else ('00', 'FF') + pairs = '\n'.join(f'<{code:0{size*2}X}> <{value.encode("utf-16-be", errors="surrogatepass").hex()}>' + for code, value in mapping.items()) + cmap.set_data((f'/CIDInit /ProcSet findresource begin\n12 dict begin\nbegincmap\n' + f'/CMapName /TestUnicode def\n/CMapType 2 def\n' + f'1 begincodespacerange\n<{start}> <{end}>\nendcodespacerange\n' + f'{len(mapping)} beginbfchar\n{pairs}\nendbfchar\nendcmap\n' + f'CMapName currentdict /CMap defineresource pop\nend\nend').encode('ascii')) + font[NameObject('/ToUnicode')] = writer._add_object(cmap.flate_encode()) + resources = DictionaryObject({NameObject('/Font'): DictionaryObject({NameObject('/F2'): writer._add_object(font)})}) + stream = DecodedStreamObject() + stream.set_data(b'BT /F2 12 Tf 72 700 Td <' + raw.hex().encode() + b'> Tj ET') + if form: + stream[NameObject('/Type')] = NameObject('/XObject') + stream[NameObject('/Subtype')] = NameObject('/Form') + stream[NameObject('/BBox')] = ArrayObject([NumberObject(v) for v in [0, 0, 612, 792]]) + stream[NameObject('/Resources')] = resources + page['/Resources'][NameObject('/XObject')] = DictionaryObject({NameObject('/FontForm'): writer._add_object(stream)}) + content = page['/Contents'].get_object() + content.set_data(content.get_data() + b'\n/FontForm Do') + else: + page[NameObject('/Resources')] = resources + page[NameObject('/Contents')] = writer._add_object(stream) + writer.write(root / name) + +font_pdf('font-type3-unmapped.pdf', 'Type3') +font_pdf('font-simple-unmapped.pdf', 'Type1') +font_pdf('font-form-unmapped.pdf', 'Type3', form=True) +font_pdf('font-partial-map.pdf', 'Type3', raw=b'AB', mapping={65: 'A'}) +font_pdf('font-empty-map.pdf', 'Type3', mapping={}) +font_pdf('font-bad-unicode.pdf', 'Type3', mapping={65: '\ud800'}) +font_pdf('font-composite-unmapped.pdf', 'Type0', raw=b'\x00A') +font_pdf('font-codec-fallback.pdf', 'Type0', raw=b'\x01', mapping={1: 'A'}) +font_pdf('font-composite-partial.pdf', 'Type0', raw=b'\x00\x01\x00\x02', mapping={1: 'A'}) +font_pdf('font-type3-unicode.pdf', 'Type3', mapping={65: '\u03a9'}) +font_pdf('font-simple-unicode.pdf', 'Type1', mapping={65: '\u03a9'}) +font_pdf('font-known-glyph.pdf', 'Type1', glyph='/Aacute') +font_pdf('font-ligature.pdf', 'Type3', mapping={65: 'fi'}) +font_pdf('font-emoji.pdf', 'Type3', mapping={65: '\U0001f600'}) +font_pdf('font-composite-unicode.pdf', 'Type0', raw=b'\x00\x01\x00\x02', mapping={1: 'A', 2: 'fi'}) +font_pdf('font-form-unicode.pdf', 'Type3', mapping={65: '\u03a9'}, form=True) +writer = PdfWriter() +writer.append(root / 'pages.pdf') +content = writer.pages[0]['/Contents'].get_object() +content.set_data(content.get_data().replace(b'/F1', b'/MissingFont')) +writer.write(root / 'font-missing.pdf') +print('PDF font mapping fixtures generated') + +font_pdf('font-replacement.pdf', 'Type3', mapping={65: '\ufffd'}) +font_pdf('font-partial-simple.pdf', 'Type1', raw=b'AB', mapping={65: 'A'}) +writer = PdfWriter() +writer.append(root / 'pages.pdf') +del writer.pages[0]['/Resources'] +writer.write(root / 'font-no-resources.pdf') +writer = PdfWriter() +writer.append(root / 'pages.pdf') +del writer.pages[0]['/Resources'] +writer.pages[0]['/Contents'].get_object().set_data(b'q 0 0 10 10 re f Q') +writer.write(root / 'no-resources-graphics.pdf') +writer = PdfWriter() +writer.append(root / 'pages.pdf') +page = writer.pages[0] +page['/Resources'][NameObject('/ExtGState')] = DictionaryObject({NameObject('/GS1'): DictionaryObject({ + NameObject('/Font'): ArrayObject([page['/Resources']['/Font'].raw_get('/F1'), NumberObject(12)]), +})}) +stream = page['/Contents'].get_object() +stream.set_data(b'/GS1 gs\n' + stream.get_data()) +writer.write(root / 'font-graphics-state.pdf') + +from pypdf.generic import NullObject + +# Null optional dictionary entries mean absent, including indirect nulls. +for subtype in ['Type1', 'TrueType']: + for mode in ['absent', 'null', 'indirect-null', 'empty-dict', 'null-base', 'invalid-number']: + writer = PdfWriter() + writer.append(root / 'pages.pdf') + font = writer.pages[0]['/Resources']['/Font']['/F1'] + font[NameObject('/Subtype')] = NameObject('/' + subtype) + font[NameObject('/BaseFont')] = NameObject('/UnknownCustomFont') + if mode == 'null': + font[NameObject('/Encoding')] = NullObject() + elif mode == 'indirect-null': + font[NameObject('/Encoding')] = writer._add_object(NullObject()) + elif mode == 'empty-dict': + font[NameObject('/Encoding')] = DictionaryObject() + elif mode == 'null-base': + font[NameObject('/Encoding')] = DictionaryObject({NameObject('/BaseEncoding'): NullObject()}) + elif mode == 'invalid-number': + font[NameObject('/Encoding')] = NumberObject(1) + writer.write(root / f'font-{subtype.lower()}-{mode}.pdf') + +for mode in ['null', 'indirect-null', 'null-unicode', 'indirect-null-unicode', 'null-base']: + writer = PdfWriter() + writer.append(root / 'pages.pdf') + font = writer.pages[0]['/Resources']['/Font']['/F1'] + if mode == 'null-base': + font[NameObject('/Encoding')] = DictionaryObject({NameObject('/BaseEncoding'): NullObject()}) + else: + key = '/ToUnicode' if 'unicode' in mode else '/Encoding' + font[NameObject(key)] = writer._add_object(NullObject()) if mode.startswith('indirect') else NullObject() + writer.write(root / f'font-core-{mode}.pdf') + +writer = PdfWriter() +writer.append(root / 'font-simple-unicode.pdf') +font = writer.pages[0]['/Resources']['/Font']['/F2'] +font[NameObject('/BaseFont')] = NameObject('/UnknownCustomFont') +font[NameObject('/Encoding')] = NullObject() +writer.write(root / 'font-null-with-unicode.pdf') +writer = PdfWriter() +writer.append(root / 'font-known-glyph.pdf') +writer.pages[0]['/Resources']['/Font']['/F2'][NameObject('/BaseFont')] = NameObject('/UnknownCustomFont') +writer.write(root / 'font-custom-standard.pdf') +print('PDF null/absent encoding fixtures generated') diff --git a/packages/extraction/tests/block-landlock.c b/packages/extraction/tests/block-landlock.c new file mode 100644 index 00000000..1185ec95 --- /dev/null +++ b/packages/extraction/tests/block-landlock.c @@ -0,0 +1,21 @@ +#define _GNU_SOURCE +#include +#include +#include +#include +#include +#include + +/* Negative startup probe: add a restriction to Docker's existing filter. */ +int main(void) { + struct sock_filter filter[] = { + BPF_STMT(BPF_LD | BPF_W | BPF_ABS, offsetof(struct seccomp_data, nr)), + BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, SYS_landlock_create_ruleset, 0, 1), + BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_ERRNO | 38), + BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_ALLOW) + }; + struct sock_fprog program = {.len = sizeof(filter) / sizeof(filter[0]), .filter = filter}; + if (prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0) || prctl(PR_SET_SECCOMP, SECCOMP_MODE_FILTER, &program)) return 125; + execl("/usr/local/bin/node", "node", "/opt/extraction/dist/main.js", (char *)NULL); + return 125; +} diff --git a/packages/extraction/tests/container-client.mjs b/packages/extraction/tests/container-client.mjs new file mode 100644 index 00000000..14e70f74 --- /dev/null +++ b/packages/extraction/tests/container-client.mjs @@ -0,0 +1,303 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { request } from 'node:http'; +import { createHash } from 'node:crypto'; + +const socketPath = '/socket/extraction.sock'; +function send(format, bytes, path = '/v1/extract-text', extra = {}) { + return new Promise((resolve, reject) => { + const req = request( + { + socketPath, + path, + method: 'POST', + headers: { + 'Content-Type': 'application/octet-stream', + 'Content-Length': bytes.length, + 'X-Extraction-Version': '1', + 'X-Extraction-Format': format, + ...extra, + }, + }, + res => { + const chunks = []; + res.on('data', chunk => chunks.push(chunk)); + res.on('end', () => + resolve({ + status: res.statusCode, + value: JSON.parse( + Buffer.concat(chunks).toString('utf8') + ), + }) + ); + res.on('error', reject); + } + ); + req.on('error', reject); + req.end(bytes); + }); +} +const fixtures = '/tests/fixtures'; +const pages = readFileSync(`${fixtures}/pages.pdf`); +const result = await send('pdf', pages); +assert.equal(result.status, 200); +assert.deepEqual( + result.value.segments.map(s => [s.kind, s.index, s.text]), + [ + ['page', 1, 'First page text'], + ['page', 2, 'Second page text'], + ] +); +assert.equal( + result.value.inputSha256, + createHash('sha256').update(pages).digest('hex') +); +assert.equal( + (await send('pdf', readFileSync(`${fixtures}/compressed.pdf`))).value + .segments[0].text, + 'Compressed page text' +); +const docx = await send('docx', readFileSync(`${fixtures}/body.docx`)); +assert.equal(docx.status, 200); +assert.deepEqual(docx.value.segments, [ + { + kind: 'document', + index: 1, + text: 'First paragraph\nLeft cell\tRight cell\nLast paragraph\n', + }, +]); +const form = await send('pdf', readFileSync(`${fixtures}/form-valid.pdf`)); +assert.equal(form.status, 200); +assert.match(form.value.segments[0].text, /Form content/); +const nested = await send('docx', readFileSync(`${fixtures}/nested.docx`)); +assert.equal(nested.status, 200); +assert.match( + nested.value.segments[0].text, + /Before nested table\nNested left\tNested right[\n]+After nested table/ +); +const nestedOnly = await send( + 'docx', + readFileSync(`${fixtures}/nested-only.docx`) +); +assert.equal(nestedOnly.status, 200); +assert.match(nestedOnly.value.segments[0].text, /Only nested text/); +for (const name of [ + 'renamed-valid.docx', + 'renamed-default.docx', + 'renamed-case.docx', +]) { + const response = await send('docx', readFileSync(`${fixtures}/${name}`)); + assert.equal(response.status, 200, name); + assert.deepEqual(response.value.segments, docx.value.segments, name); +} +const primitives = await send( + 'docx', + readFileSync(`${fixtures}/supported-runs.docx`) +); +assert.equal(primitives.status, 200); +assert.equal( + primitives.value.segments[0].text, + 'Bold Link\tTabbed\nNext-\t\nEnd\n' +); +for (const [name, expected] of [ + ['horizontal.docx', 'Merged text\t\tR0C2\nR1C0\tR1C1\tR1C2\n'], + ['vertical.docx', 'Merged text\tR0C1\tR0C2\n\tR1C1\tR1C2\n'], + ['rectangle.docx', 'Merged text\t\tR0C2\n\t\tR1C2\n'], + ['omitted-cells.docx', '\tMiddle cell\t\n'], +]) { + const response = await send('docx', readFileSync(`${fixtures}/${name}`)); + assert.equal(response.status, 200, name); + assert.equal(response.value.segments[0].text, expected, name); +} +const mergedNested = await send( + 'docx', + readFileSync(`${fixtures}/merged-nested.docx`) +); +assert.equal(mergedNested.status, 200); +assert.equal( + mergedNested.value.segments[0].text.match(/Unique nested content/g).length, + 1 +); +assert.match( + mergedNested.value.segments[0].text, + /Before merged nested table[\n]+Unique nested content[\n]+After merged nested table/ +); +for (const name of ['merged-large.docx', 'merged-large-vertical.docx']) { + const response = await send('docx', readFileSync(`${fixtures}/${name}`)); + assert.equal(response.status, 200, name); + assert.equal( + response.value.segments[0].text.match(/x/g).length, + 600 * 1024, + name + ); +} +for (const [name, expected] of [ + ['font-type3-unicode.pdf', '\u03a9'], + ['font-simple-unicode.pdf', '\u03a9'], + ['font-known-glyph.pdf', '\u00c1'], + ['font-ligature.pdf', 'fi'], + ['font-emoji.pdf', '\ud83d\ude00'], + ['font-composite-unicode.pdf', 'Afi'], +]) { + const response = await send('pdf', readFileSync(`${fixtures}/${name}`)); + assert.equal(response.status, 200, name); + assert.equal(response.value.segments[0].text, expected, name); +} +const fontForm = await send( + 'pdf', + readFileSync(`${fixtures}/font-form-unicode.pdf`) +); +assert.equal(fontForm.status, 200); +assert.match(fontForm.value.segments[0].text, /First page text[\n]+\u03a9/); +const noResources = await send( + 'pdf', + readFileSync(`${fixtures}/no-resources-graphics.pdf`) +); +assert.equal(noResources.status, 200); +assert.deepEqual( + noResources.value.segments.map(segment => segment.text), + ['', 'Second page text'] +); +for (const mode of [ + 'null', + 'indirect-null', + 'null-unicode', + 'indirect-null-unicode', + 'null-base', +]) { + const response = await send( + 'pdf', + readFileSync(`${fixtures}/font-core-${mode}.pdf`) + ); + assert.equal(response.status, 200, mode); + assert.deepEqual(response.value.segments, result.value.segments, mode); +} +for (const [name, expected] of [ + ['font-null-with-unicode.pdf', '\u03a9'], + ['font-custom-standard.pdf', '\u00c1'], +]) { + const response = await send('pdf', readFileSync(`${fixtures}/${name}`)); + assert.equal(response.status, 200, name); + assert.equal(response.value.segments[0].text, expected, name); +} +const rejectedFixtures = [ + ['font-replacement.pdf', 'pdf', 'UNSUPPORTED_ENCODING'], + ['font-partial-simple.pdf', 'pdf', 'UNSUPPORTED_ENCODING'], + ['font-no-resources.pdf', 'pdf', 'UNSUPPORTED_ENCODING'], + ['font-graphics-state.pdf', 'pdf', 'UNSUPPORTED_ENCODING'], + ['font-type3-unmapped.pdf', 'pdf', 'UNSUPPORTED_ENCODING'], + ['font-simple-unmapped.pdf', 'pdf', 'UNSUPPORTED_ENCODING'], + ['font-form-unmapped.pdf', 'pdf', 'UNSUPPORTED_ENCODING'], + ['font-partial-map.pdf', 'pdf', 'UNSUPPORTED_ENCODING'], + ['font-empty-map.pdf', 'pdf', 'UNSUPPORTED_ENCODING'], + ['font-bad-unicode.pdf', 'pdf', 'UNSUPPORTED_ENCODING'], + ['font-composite-unmapped.pdf', 'pdf', 'UNSUPPORTED_ENCODING'], + ['font-codec-fallback.pdf', 'pdf', 'UNSUPPORTED_ENCODING'], + ['font-composite-partial.pdf', 'pdf', 'UNSUPPORTED_ENCODING'], + ['font-missing.pdf', 'pdf', 'UNSUPPORTED_ENCODING'], + ['merge-content.docx', 'docx', 'UNSUPPORTED_CONTENT'], + ['merge-orphan.docx', 'docx', 'INVALID_DOCUMENT'], + ['merge-span.docx', 'docx', 'INVALID_DOCUMENT'], + ['merge-legacy.docx', 'docx', 'UNSUPPORTED_CONTENT'], + ['wide-table.docx', 'docx', 'STRUCTURE_LIMIT'], + ['control.docx', 'docx', 'UNSUPPORTED_CONTENT'], + ['control-cell.docx', 'docx', 'UNSUPPORTED_CONTENT'], + ['control-inline.docx', 'docx', 'UNSUPPORTED_CONTENT'], + ['control-only.docx', 'docx', 'UNSUPPORTED_CONTENT'], + ['custom-wrapper.docx', 'docx', 'UNSUPPORTED_CONTENT'], + ['revision.docx', 'docx', 'UNSUPPORTED_CONTENT'], + ['simple-field.docx', 'docx', 'UNSUPPORTED_CONTENT'], + ['renamed-entities.docx', 'docx', 'INVALID_DOCUMENT'], + ['renamed-default-entities.docx', 'docx', 'INVALID_DOCUMENT'], + ['renamed-case-entities.docx', 'docx', 'INVALID_DOCUMENT'], + ['renamed-utf8.docx', 'docx', 'INVALID_DOCUMENT'], + ['empty.pdf', 'pdf', 'EMPTY_OUTPUT'], + ['empty.docx', 'docx', 'EMPTY_OUTPUT'], + ['inflate.pdf', 'pdf', 'DECOMPRESSION_LIMIT'], + ['aggregate.pdf', 'pdf', 'DECOMPRESSION_LIMIT'], + ['raw-limit.pdf', 'pdf', 'DECOMPRESSION_LIMIT'], + ['form-limit.pdf', 'pdf', 'DECOMPRESSION_LIMIT'], + ['form-filter.pdf', 'pdf', 'UNSUPPORTED_ENCODING'], + ['aggregate.docx', 'docx', 'DECOMPRESSION_LIMIT'], + ['bad.docx', 'docx', 'INVALID_DOCUMENT'], + ['entries.docx', 'docx', 'STRUCTURE_LIMIT'], + ['duplicate.docx', 'docx', 'INVALID_DOCUMENT'], + ['forged.docx', 'docx', 'INVALID_DOCUMENT'], + ['forged-bomb.docx', 'docx', 'DECOMPRESSION_LIMIT'], + ['utf8.docx', 'docx', 'INVALID_DOCUMENT'], + ['entities.docx', 'docx', 'INVALID_DOCUMENT'], + ['bomb.docx', 'docx', 'DECOMPRESSION_LIMIT'], + ['output.docx', 'docx', 'OUTPUT_LIMIT'], + ['encrypted.pdf', 'pdf', 'ENCRYPTED_DOCUMENT'], + ['too-many-pages.pdf', 'pdf', 'STRUCTURE_LIMIT'], +]; +for (const subtype of ['type1', 'truetype']) { + for (const mode of [ + 'absent', + 'null', + 'indirect-null', + 'empty-dict', + 'null-base', + 'invalid-number', + ]) { + rejectedFixtures.push([ + `font-${subtype}-${mode}.pdf`, + 'pdf', + 'UNSUPPORTED_ENCODING', + ]); + } +} +for (const tag of ['dir', 'bdo', 'smartTag', 'unknownWrapper']) { + for (const placement of ['paragraph', 'cell', 'hyperlink']) { + rejectedFixtures.push([ + `wrapper-${tag}-${placement}.docx`, + 'docx', + 'UNSUPPORTED_CONTENT', + ]); + } +} +for (const name of ['wrapper-block.docx', 'wrapper-run.docx']) { + rejectedFixtures.push([name, 'docx', 'UNSUPPORTED_CONTENT']); +} +for (const [name, format, code] of rejectedFixtures) { + const response = await send(format, readFileSync(`${fixtures}/${name}`)); + assert.equal( + response.value.error?.code, + code, + `${name}: ${JSON.stringify(response)}` + ); +} +assert.equal( + (await send('pdf', readFileSync(`${fixtures}/raw-exact.pdf`))).status, + 200 +); +const exact = await send('docx', readFileSync(`${fixtures}/exact.docx`)); +assert.equal(exact.status, 200); +assert.equal(exact.value.textBytes, 1024 * 1024); +assert.equal( + (await send('pdf', Buffer.from('not a pdf'))).value.error.code, + 'INVALID_DOCUMENT' +); +assert.equal((await send('docx', pages)).value.error.code, 'INVALID_DOCUMENT'); +assert.equal((await send('xls', pages)).status, 400); +assert.equal((await send('pdf', pages, '/exec')).status, 404); +assert.equal( + (await send('pdf', pages, '/v1/extract-text?script=x')).status, + 404 +); +assert.equal( + ( + await send('pdf', pages, '/v1/extract-text', { + 'X-Extraction-Url': 'http://example.com', + }) + ).status, + 400 +); +// Exact input cap is accepted; one byte more is rejected before parsing. +const padded = Buffer.alloc(10 * 1024 * 1024, 32); +pages.copy(padded); +assert.equal((await send('pdf', padded)).status, 200); +assert.equal((await send('pdf', Buffer.alloc(padded.length + 1))).status, 413); +console.log( + `Container endpoint: PDF/DOCX success, structure, input limits and ${rejectedFixtures.length} adversarial fixtures passed` +); diff --git a/packages/extraction/tests/container.sh b/packages/extraction/tests/container.sh new file mode 100755 index 00000000..839670c5 --- /dev/null +++ b/packages/extraction/tests/container.sh @@ -0,0 +1,61 @@ +#!/usr/bin/env bash +set -euo pipefail +cd "$(dirname "$0")/../../.." +run_id=$(node -e 'console.log(require("node:crypto").randomUUID())') +project="extraction-test-$run_id" +image="librechat-extraction-test:$run_id" +export EXTRACTION_IMAGE="librechat-extraction:$run_id" +compose=(docker compose -p "$project" -f packages/extraction/compose.yaml) +cleanup() { + "${compose[@]}" down -v --remove-orphans >/dev/null + docker image rm "$image" "$EXTRACTION_IMAGE" >/dev/null 2>&1 || true +} +trap cleanup EXIT +"${compose[@]}" build extraction +docker build --target qualification -t "$image" -f packages/extraction/Dockerfile . +"${compose[@]}" up -d --wait --wait-timeout 30 extraction +id=$("${compose[@]}" ps -q extraction) +volume="${project}_extraction-socket" +# Inspect exactly the shipped recipe, not a relaxed test variant. +docker inspect "$id" | python3 -c ' +import json,sys +c=json.load(sys.stdin)[0]; h=c["HostConfig"] +assert h["NetworkMode"] == "none" and h["ReadonlyRootfs"] and not h["Privileged"] +assert h["CapDrop"] == ["ALL"] and h["PidsLimit"] == 32 +assert h["Memory"] == 768*1024*1024 and h["NanoCpus"] == 2*10**9 +assert "no-new-privileges:true" in h["SecurityOpt"] +assert c["Config"]["User"] == "10001:10001" +assert len(c["Mounts"]) == 1 and c["Mounts"][0]["Destination"] == "/socket" +assert set(h["Tmpfs"]) == {"/jobs"} +for flag in ("noexec", "nosuid", "nodev", "size=96m", "nr_inodes=1024", "uid=10001", "gid=10001", "mode=0700"): + assert flag in h["Tmpfs"]["/jobs"] +assert not c["Config"]["ExposedPorts"] if "ExposedPorts" in c["Config"] else True +print("Shipped Compose mounts, resource limits and security options verified")' +docker run --rm --init --network none --cap-drop ALL --security-opt no-new-privileges \ + --mount "type=volume,src=$volume,dst=/socket,readonly" "$image" \ + sh -c 'python /tests/adversarial.py && node /tests/container-client.mjs' +docker exec "$id" node -e 'require("node:fs").readdir("/jobs",(e,v)=>{if(e||v.length)process.exit(1); console.log("Endpoint scratch cleanup verified")})' +# Same outer boundary, with a test-only worker that attempts forbidden operations. +docker run --rm --init --network none --cap-drop ALL --security-opt no-new-privileges \ + --read-only --memory 768m --cpus 2 --pids-limit 32 \ + --tmpfs /jobs:rw,noexec,nosuid,nodev,size=96m,nr_inodes=1024,uid=10001,gid=10001,mode=0700 \ + --tmpfs /socket:rw,noexec,nosuid,nodev,size=1m,uid=10001,gid=10001,mode=0700 \ + "$image" node /tests/isolation.mjs +# Denying the mandatory isolation syscall must fail closed, never start unsandboxed. +set +e +docker run --rm --init --network none --cap-drop ALL --security-opt no-new-privileges \ + --read-only --memory 768m --cpus 2 --pids-limit 32 \ + --tmpfs /jobs:rw,noexec,nosuid,nodev,size=96m,uid=10001,gid=10001,mode=0700 \ + --tmpfs /socket:rw,noexec,nosuid,nodev,size=1m,uid=10001,gid=10001,mode=0700 \ + "$image" /tests/block-landlock > /dev/null 2>&1 +blocked=$? +set -e +if [ "$blocked" -ne 1 ]; then + printf 'FAIL: missing isolation did not reach fail-closed startup (exit %s)\n' "$blocked" >&2 + exit 1 +fi +printf 'Unavailable isolation fails closed\n' +# A stale socket left by SIGKILL must not prevent a safe restart. +docker kill -s KILL "$id" >/dev/null +"${compose[@]}" up -d --wait --wait-timeout 30 extraction +printf 'Hardened container restart passed\n' diff --git a/packages/extraction/tests/fixtures/aggregate.pdf b/packages/extraction/tests/fixtures/aggregate.pdf new file mode 100644 index 00000000..70f476e3 Binary files /dev/null and b/packages/extraction/tests/fixtures/aggregate.pdf differ diff --git a/packages/extraction/tests/fixtures/body.docx b/packages/extraction/tests/fixtures/body.docx new file mode 100644 index 00000000..85a4932c Binary files /dev/null and b/packages/extraction/tests/fixtures/body.docx differ diff --git a/packages/extraction/tests/fixtures/compressed.pdf b/packages/extraction/tests/fixtures/compressed.pdf new file mode 100644 index 00000000..90bbbf92 Binary files /dev/null and b/packages/extraction/tests/fixtures/compressed.pdf differ diff --git a/packages/extraction/tests/fixtures/empty.docx b/packages/extraction/tests/fixtures/empty.docx new file mode 100644 index 00000000..38ea79ce Binary files /dev/null and b/packages/extraction/tests/fixtures/empty.docx differ diff --git a/packages/extraction/tests/fixtures/empty.pdf b/packages/extraction/tests/fixtures/empty.pdf new file mode 100644 index 00000000..fc99c829 Binary files /dev/null and b/packages/extraction/tests/fixtures/empty.pdf differ diff --git a/packages/extraction/tests/fixtures/generate.py b/packages/extraction/tests/fixtures/generate.py new file mode 100644 index 00000000..70f01625 --- /dev/null +++ b/packages/extraction/tests/fixtures/generate.py @@ -0,0 +1,37 @@ +"""Regenerate synthetic, redistributable fixtures with the pinned worker libraries.""" +from pathlib import Path +from pypdf import PdfWriter +from pypdf.generic import DecodedStreamObject, DictionaryObject, NameObject +from docx import Document + +root = Path(__file__).parent + +def pdf(name, pages, compressed=False, padding=0): + writer = PdfWriter() + font = writer._add_object(DictionaryObject({ + NameObject('/Type'): NameObject('/Font'), NameObject('/Subtype'): NameObject('/Type1'), + NameObject('/BaseFont'): NameObject('/Helvetica'), + })) + for text in pages: + page = writer.add_blank_page(width=612, height=792) + page[NameObject('/Resources')] = DictionaryObject({NameObject('/Font'): DictionaryObject({NameObject('/F1'): font})}) + stream = DecodedStreamObject() + stream.set_data(b' ' * padding + b'BT /F1 12 Tf 72 720 Td (' + text.encode() + b') Tj ET') + page[NameObject('/Contents')] = writer._add_object(stream.flate_encode() if compressed else stream) + with (root / name).open('wb') as file: + writer.write(file) + +pdf('pages.pdf', ['First page text', 'Second page text']) +pdf('compressed.pdf', ['Compressed page text'], True) +pdf('empty.pdf', ['']) +pdf('inflate.pdf', [' ' * (8 * 1024 * 1024 + 1)], True) +pdf('aggregate.pdf', ['small'] * 5, True, padding=7 * 1024 * 1024) +document = Document() +document.add_paragraph('First paragraph') +table = document.add_table(rows=1, cols=2) +table.cell(0, 0).text = 'Left cell' +table.cell(0, 1).text = 'Right cell' +document.add_paragraph('Last paragraph') +document.save(root / 'body.docx') +Document().save(root / 'empty.docx') +print('Generated PDF and DOCX fixtures') diff --git a/packages/extraction/tests/fixtures/inflate.pdf b/packages/extraction/tests/fixtures/inflate.pdf new file mode 100644 index 00000000..2fe8291e Binary files /dev/null and b/packages/extraction/tests/fixtures/inflate.pdf differ diff --git a/packages/extraction/tests/fixtures/pages.pdf b/packages/extraction/tests/fixtures/pages.pdf new file mode 100644 index 00000000..1e7acc24 Binary files /dev/null and b/packages/extraction/tests/fixtures/pages.pdf differ diff --git a/packages/extraction/tests/isolation.mjs b/packages/extraction/tests/isolation.mjs new file mode 100644 index 00000000..d44ef800 --- /dev/null +++ b/packages/extraction/tests/isolation.mjs @@ -0,0 +1,64 @@ +import assert from 'node:assert/strict'; +import { spawn } from 'node:child_process'; +import { mkdir, mkdtemp, writeFile, rm, readdir } from 'node:fs/promises'; +import { once } from 'node:events'; +import { RestrictedBackend } from '/opt/extraction/dist/backend.js'; + +await mkdir('/jobs/other'); +await writeFile('/jobs/other/secret', 'cross-job-canary'); +await writeFile('/socket/secret', 'transport-canary'); +const job = await mkdtemp('/jobs/job-'); +const child = spawn( + '/opt/extraction/guard', + [job, '/usr/local/bin/python3.13', '/tests/isolation.py', 'probe'], + { + env: { EXTRACTION_SECRET_CANARY: 'private-canary' }, + stdio: ['ignore', 'pipe', 'pipe'], + } +); +let output = '', + errors = ''; +child.stdout.on('data', bytes => { + output += bytes; +}); +child.stderr.on('data', bytes => { + errors += bytes; +}); +const [code] = await once(child, 'close'); +assert.equal(code, 0, errors); +assert.equal(output.trim(), 'ISOLATED'); +await rm(job, { recursive: true }); +const runtime = { + jobs: '/jobs', + guard: '/opt/extraction/guard', + python: '/usr/local/bin/python3.13', + worker: '/tests/isolation.py', +}; +const backend = new RestrictedBackend(runtime); +for (const abort of ['immediate', 'running']) { + const job = await backend.create(); + const signal = new AbortController(); + const pending = backend.run(job, 'hang', 'a'.repeat(64), signal.signal); + if (abort === 'running') + await new Promise(resolve => setTimeout(resolve, 100)); + signal.abort(new Error('CANCELLED')); + await assert.rejects(pending); + await backend.cleanup(job); +} +const job2 = await backend.create(); +await assert.rejects( + backend.run(job2, 'hang', 'a'.repeat(64), AbortSignal.timeout(100)) +); +await backend.cleanup(job2); +await rm('/jobs/other', { recursive: true }); +await rm('/socket/secret'); +assert.deepEqual(await readdir('/jobs'), []); +const missing = new RestrictedBackend({ ...runtime, guard: '/nonexistent' }); +const job3 = await missing.create(); +await assert.rejects( + missing.run(job3, 'pdf', 'a'.repeat(64), AbortSignal.timeout(1000)) +); +await missing.cleanup(job3); +console.log( + 'Container isolation: cross-job/credential paths, sockets, fork, signals, limits, startup/running cancellation, deadline and missing launcher passed' +); diff --git a/packages/extraction/tests/isolation.py b/packages/extraction/tests/isolation.py new file mode 100644 index 00000000..a50d0084 --- /dev/null +++ b/packages/extraction/tests/isolation.py @@ -0,0 +1,73 @@ +import ctypes +import errno +import fcntl +import os +import resource +import signal +import socket +import struct +import sys + +if sys.argv[1] == 'hang': + # Exercise supervisor cancellation/deadline termination, not a cooperative parser. + while True: + pass + +for path, mode in [('/jobs/other/secret', 'rb'), ('/jobs/other/secret', 'wb'), + ('/socket/secret', 'rb'), ('/proc/self/environ', 'rb'), ('/etc/passwd', 'rb')]: + try: + with open(path, mode) as file: + if mode == 'rb': + file.read() + except PermissionError: + pass + else: + raise RuntimeError('Cross-boundary file access') +for operation in [lambda: socket.socket(), lambda: socket.socket(socket.AF_UNIX), + lambda: os.fork(), lambda: os.kill(os.getppid(), 0), + lambda: os.chmod('/jobs/other/secret', 0o777), + lambda: fcntl.fcntl(1, fcntl.F_SETOWN, os.getppid()), + lambda: fcntl.fcntl(1, 15, struct.pack("ii", 1, os.getppid())), + lambda: fcntl.fcntl(1, fcntl.F_SETSIG, 0), + lambda: fcntl.ioctl(1, 0x8901, struct.pack("i", os.getppid()))]: + try: + operation() + except PermissionError: + pass + else: + raise RuntimeError('Forbidden syscall') +assert 'EXTRACTION_SECRET_CANARY' not in os.environ +assert resource.getrlimit(resource.RLIMIT_AS) == (512 * 1024 * 1024,) * 2 +assert resource.getrlimit(resource.RLIMIT_CPU) == (8, 8) +assert resource.getrlimit(resource.RLIMIT_FSIZE) == (4 * 1024 * 1024,) * 2 +assert resource.getrlimit(resource.RLIMIT_NOFILE) == (64, 64) + + +# Harmless signal-zero probes distinguish filtering from ordinary permissions. +class Sigval(ctypes.Union): + _fields_ = [("integer", ctypes.c_int), ("pointer", ctypes.c_void_p)] +libc = ctypes.CDLL(None, use_errno=True) +libc.sigqueue.argtypes = [ctypes.c_int, ctypes.c_int, Sigval] +assert libc.sigqueue(os.getppid(), 0, Sigval(integer=0)) == -1 +assert ctypes.get_errno() == errno.EPERM +number = {"x86_64": 297, "aarch64": 240}[os.uname().machine] +info = (ctypes.c_int * 32)() +info[2] = -1 # SI_QUEUE, with signal zero: never delivers a signal. +assert libc.syscall(number, os.getppid(), os.getppid(), 0, ctypes.byref(info)) == -1 +assert ctypes.get_errno() == errno.EPERM + + + +try: + fcntl.ioctl(1, 0, 0) +except PermissionError: + pass +else: + raise RuntimeError('Forbidden ioctl') + + + +libc.mq_open.argtypes = [ctypes.c_char_p, ctypes.c_int] +assert libc.mq_open(b"/extraction-negative-probe", os.O_RDONLY) == -1 +assert ctypes.get_errno() == errno.EPERM +print("ISOLATED", flush=True) diff --git a/packages/extraction/tsconfig.json b/packages/extraction/tsconfig.json new file mode 100644 index 00000000..aff02060 --- /dev/null +++ b/packages/extraction/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ES2023", + "lib": ["ES2024"], + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "noUncheckedIndexedAccess": true, + "esModuleInterop": true, + "skipLibCheck": true, + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src/**/*.ts"] +} diff --git a/packages/extraction/worker/extract.py b/packages/extraction/worker/extract.py new file mode 100644 index 00000000..20f0360f --- /dev/null +++ b/packages/extraction/worker/extract.py @@ -0,0 +1,503 @@ +"""Request-local parser. Only the supervisor can choose its mode and files.""" +import hashlib +import io +import json +import logging +import os +from pathlib import PurePosixPath +import socket +import struct +import sys +import xml.etree.ElementTree as ET +import zipfile +import zlib + +INPUT_BYTES = 10 * 1024 * 1024 +TEXT_BYTES = 1024 * 1024 +RESULT_BYTES = 3 * 1024 * 1024 +EXPANDED_BYTES = 32 * 1024 * 1024 +ENTRY_BYTES = 8 * 1024 * 1024 +SEGMENTS = 128 + + +class Rejected(Exception): + def __init__(self, code): + self.code = code + + +class Text: + def __init__(self): + self.size = 0 + self.parts = [] + + def add(self, value): + size = len(value.encode("utf-8", errors="strict")) + if self.size + size > TEXT_BYTES: + raise Rejected("OUTPUT_LIMIT") + self.size += size + self.parts.append(value) + + def value(self): + return "".join(self.parts) + + +def pdf_segments(data): + import pypdf + from pypdf import filters, _page + from pypdf.generic import DecodedStreamObject, DictionaryObject, NameObject, NullObject, StreamObject + from pypdf._cmap import charset_encoding + from pypdf._codecs.core_font_metrics import CORE_FONT_METRICS + from pypdf._text_extraction._text_extractor import TextExtraction + + used = 0 + failure = None + credited = {} + shown_bytes = 0 + + def reject(code): + nonlocal failure + failure = failure or code + raise Rejected(failure) + + def check_failure(): + if failure is not None: + raise Rejected(failure) + + class Warnings(logging.Handler): + def emit(self, record): + nonlocal failure + failure = failure or "INVALID_DOCUMENT" + + logger = logging.getLogger("pypdf") + logger.addHandler(Warnings()) + logger.setLevel(logging.WARNING) + logger.propagate = False + original = filters.decode_stream_data + original_data = DecodedStreamObject.get_data + original_show = TextExtraction._handle_tj_operation + original_resources = _page._get_page_resources + + def inflate(value): + nonlocal used + # Strict bounded decoding, without pypdf's recovery/fallback paths. + cap = min(ENTRY_BYTES, EXPANDED_BYTES - used) + decoder = zlib.decompressobj() + try: + result = decoder.decompress(value, cap + 1) + except zlib.error: + reject("INVALID_DOCUMENT") + if len(result) > cap or decoder.unconsumed_tail: + reject("DECOMPRESSION_LIMIT") + if not decoder.eof or decoder.unused_data: + reject("INVALID_DOCUMENT") + used += len(result) + credited[id(result)] = result + return result + + def decoded_data(stream): + nonlocal used + value = original_data(stream) + if not getattr(stream, "_extraction_counted", False): + if credited.get(id(value)) is not value: + used += len(value) + if len(value) > ENTRY_BYTES or used > EXPANDED_BYTES: + reject("DECOMPRESSION_LIMIT") + stream._extraction_counted = True + return value + + def decode(stream): + names = stream.get("/Filter", []) + if not isinstance(names, list): + names = [names] + if len(names) > 1 or any(str(name) not in { + "/FlateDecode", "/Fl", "/ASCIIHexDecode", "/AHx", "/ASCII85Decode", "/A85" + } for name in names): + reject("UNSUPPORTED_ENCODING") + try: + return original(stream) + except Rejected: + raise + except MemoryError: + reject("RESOURCE_LIMIT") + except Exception: + reject("INVALID_DOCUMENT") + + def resources_for_text(obj): + resources = original_resources(obj) + if resources: + fonts = resources.get("/Font") + if fonts is not None and not isinstance(fonts.get_object(), NullObject): + for ref in fonts.get_object().values(): + resource = ref.get_object() + for key in ("/Encoding", "/ToUnicode"): + if key in resource and isinstance(resource[key], NullObject): + del resource[key] + encoding = resource.get("/Encoding") + if encoding is not None: + encoding = encoding.get_object() + if not isinstance(encoding, (NameObject, DictionaryObject)): + reject("UNSUPPORTED_ENCODING") + if isinstance(encoding, DictionaryObject): + base = encoding.get("/BaseEncoding") + if base is not None and isinstance(base.get_object(), NullObject): + del encoding["/BaseEncoding"] + unicode_map = resource.get("/ToUnicode") + if unicode_map is not None and not isinstance(unicode_map.get_object(), StreamObject): + reject("UNSUPPORTED_ENCODING") + for state in resources.get("/ExtGState", DictionaryObject()).get_object().values(): + if "/Font" in state.get_object(): + reject("UNSUPPORTED_ENCODING") + return resources + # Do not let the parser skip text operators solely because resources + # are absent. A used missing font must reach the rejection below. + return DictionaryObject({NameObject("/Font"): DictionaryObject()}) + + def show_text(extractor, operands): + nonlocal shown_bytes + if operands and operands[0]: + font, resource = extractor.font, extractor.font_resource + if resource is None or not font.interpretable: + reject("UNSUPPORTED_ENCODING") + if font.sub_type not in {"Type1", "MMType1", "TrueType", "Type3", "Type0"}: + reject("UNSUPPORTED_ENCODING") + encoding = resource.get("/Encoding") + if encoding is not None: + encoding = encoding.get_object() + if isinstance(encoding, DictionaryObject): + encoding = encoding.get("/BaseEncoding") + if encoding is not None: + encoding = encoding.get_object() + standard_encoding = isinstance(encoding, NameObject) and encoding in charset_encoding + require_map = ("/ToUnicode" in resource or font.sub_type in {"Type3", "Type0"} + or isinstance(font.encoding, str) + or (not standard_encoding and font.name not in CORE_FONT_METRICS)) + value = operands[0] + if isinstance(value, bytes): + try: + if isinstance(font.encoding, str): + # Never take pypdf's charmap recovery on codec failure. + characters = value.decode(font.encoding, errors="surrogatepass") + else: + characters = (font.encoding[code] for code in value) + for character in characters: + if require_map and character not in font.character_map: + reject("UNSUPPORTED_ENCODING") + mapped = font.character_map.get(character, character) + # Unknown Adobe glyph names are preserved as NameObject. + if (not isinstance(mapped, str) or isinstance(mapped, NameObject) + or not mapped or "\ufffd" in mapped + or any(ord(char) < 32 and char not in "\t\n\r" for char in mapped)): + reject("UNSUPPORTED_ENCODING") + shown_bytes += len(mapped.encode("utf-8", errors="strict")) + if shown_bytes > TEXT_BYTES: + reject("OUTPUT_LIMIT") + except (LookupError, UnicodeError, TypeError): + reject("UNSUPPORTED_ENCODING") + elif not isinstance(value, str): + reject("UNSUPPORTED_ENCODING") + return original_show(extractor, operands) + + _page._get_page_resources = resources_for_text + TextExtraction._handle_tj_operation = show_text + DecodedStreamObject.get_data = decoded_data + filters.decompress = inflate + filters.decode_stream_data = decode + reader = pypdf.PdfReader(io.BytesIO(data), strict=True) + check_failure() + if reader.is_encrypted: + raise Rejected("ENCRYPTED_DOCUMENT") + if len(reader.pages) > SEGMENTS: + raise Rejected("STRUCTURE_LIMIT") + result = [] + text = Text() + for index, page in enumerate(reader.pages): + value = page.extract_text() or "" + check_failure() + text.add(value) + result.append({"kind": "page", "index": index + 1, "text": value}) + check_failure() + return result, text.size + + +def validate_xml(value): + xml = value.decode("utf-8", errors="strict") + if "\x00" in xml or " 512: + raise Rejected("STRUCTURE_LIMIT") + for entry in archive.infolist(): + name = entry.filename + if entry.orig_filename != name: + raise Rejected("INVALID_DOCUMENT") + path = PurePosixPath(name) + if (name in names or path.is_absolute() or ".." in path.parts or "\\" in name + or "\x00" in name or (entry.external_attr >> 16) & 0o170000 == 0o120000 + or entry.flag_bits & 1 or entry.compress_type not in (0, 8)): + raise Rejected("INVALID_DOCUMENT") + names.add(name) + if entry.file_size > ENTRY_BYTES: + raise Rejected("DECOMPRESSION_LIMIT") + # Let ZipFile validate local names, flags and entry overlap, but do + # not use ZipExtFile.read(): it trims data to untrusted file_size. + with archive.open(entry): + offset = entry.header_offset + if offset < 0 or offset + 30 > len(data): + raise Rejected("INVALID_DOCUMENT") + method = struct.unpack_from(" len(data): + raise Rejected("INVALID_DOCUMENT") + decoder = zlib.decompressobj(-zlib.MAX_WBITS) if entry.compress_type == 8 else None + chunks = [] + size = 0 + crc = 0 + for position in range(start, end, 64 * 1024): + chunk = data[position:min(position + 64 * 1024, end)] + if decoder: + cap = min(ENTRY_BYTES - size, EXPANDED_BYTES - total) + chunk = decoder.decompress(chunk, cap + 1) + size += len(chunk) + total += len(chunk) + if size > ENTRY_BYTES or total > EXPANDED_BYTES: + raise Rejected("DECOMPRESSION_LIMIT") + crc = zlib.crc32(chunk, crc) + chunks.append(chunk) + if decoder and (not decoder.eof or decoder.unused_data or decoder.unconsumed_tail): + raise Rejected("INVALID_DOCUMENT") + if size != entry.file_size or crc != entry.CRC: + raise Rejected("INVALID_DOCUMENT") + value = b"".join(chunks) + parts[name] = value + xml_parts(parts) + output = io.BytesIO() + with zipfile.ZipFile(output, "w") as clean: + for name, value in parts.items(): + # The document parser sees only measured, validated parts. + clean.writestr(name, value, compress_type=zipfile.ZIP_STORED) + return output.getvalue() + + +def validate_docx_body(body): + from lxml.etree import _Element + + literal = _Element.text.__get__ + word = "{http://schemas.openxmlformats.org/wordprocessingml/2006/main}" + # These are the exact content paths read by pinned python-docx. Unknown + # wrappers fail closed rather than hiding text from its direct-child XPath. + grammar = { + "body": {"p", "tbl", "sectPr"}, + "tbl": {"tblPr", "tblGrid", "tr"}, + "tr": {"trPr", "tc"}, + "tc": {"tcPr", "p", "tbl"}, + "p": {"pPr", "r", "hyperlink"}, + "hyperlink": {"r"}, + "r": {"rPr", "t", "tab", "br", "cr", "noBreakHyphen", "ptab", "lastRenderedPageBreak"}, + } + properties = {"sectPr", "tblPr", "tblGrid", "trPr", "tcPr", "pPr", "rPr"} + markers = {"bookmarkStart", "bookmarkEnd", "commentRangeStart", "commentRangeEnd", + "permStart", "permEnd", "proofErr"} + stack = [(body, "body")] + while stack: + parent, kind = stack.pop() + if literal(parent) and literal(parent).strip(): + raise Rejected("UNSUPPORTED_CONTENT") + for child in parent: + tag = child.tag.removeprefix(word) if isinstance(child.tag, str) else "" + marker = kind in {"body", "p", "hyperlink", "tc"} and tag in markers + if child.tag != word + tag or (tag not in grammar[kind] and not marker): + raise Rejected("UNSUPPORTED_CONTENT") + if child.tail and child.tail.strip(): + raise Rejected("UNSUPPORTED_CONTENT") + if tag in properties: + # Formatting is ignored, but cannot conceal textual content. + if any(node.tag in {word + "t", word + "instrText", word + "delText"} + or (literal(node) and literal(node).strip()) + or (node.tail and node.tail.strip()) for node in child.iter()): + raise Rejected("UNSUPPORTED_CONTENT") + elif tag in grammar: + stack.append((child, tag)) + elif len(child) or (tag != "t" and literal(child) and literal(child).strip()): + raise Rejected("UNSUPPORTED_CONTENT") + + +def docx_segments(data): + from docx import Document + from docx.table import Table, _Cell + + document = Document(io.BytesIO(safe_docx(data))) + validate_docx_body(document.element.body) + text = Text() + def blocks(container, depth=0, cell=False): + if depth > 32: + raise Rejected("STRUCTURE_LIMIT") + first = True + for block in container.iter_inner_content(): + if cell and not first: + text.add("\n") + first = False + if isinstance(block, Table): + width = len(block._tbl.tblGrid.gridCol_lst) + if width < 1 or width > 512: + raise Rejected("STRUCTURE_LIMIT") + previous = set() + for row_index, row in enumerate(block.rows): + if row_index: + text.add("\n") + before, after = row.grid_cols_before, row.grid_cols_after + if before < 0 or after < 0 or before + after >= width: + raise Rejected("INVALID_DOCUMENT") + column = before + text.add("\t" * before) + current = set() + for index, tc in enumerate(row._tr.tc_lst): + span, merge = tc.grid_span, tc.vMerge + if span < 1 or column + span > width - after: + raise Rejected("INVALID_DOCUMENT") + if tc.tcPr is not None and tc.tcPr.find( + "{http://schemas.openxmlformats.org/wordprocessingml/2006/main}hMerge") is not None: + raise Rejected("UNSUPPORTED_CONTENT") + if index: + text.add("\t") + position = (column, span) + if merge == "continue": + if position not in previous: + raise Rejected("INVALID_DOCUMENT") + # Continuations occupy columns, not another copy of + # the originating cell. Hidden content is rejected. + if tc.xpath(".//w:t | .//w:tbl | .//w:tab | .//w:br | .//w:cr | .//w:noBreakHyphen | .//w:ptab"): + raise Rejected("UNSUPPORTED_CONTENT") + else: + blocks(_Cell(tc, block), depth + 1, cell=True) + if merge is not None: + if merge not in {"restart", "continue"}: + raise Rejected("INVALID_DOCUMENT") + current.add(position) + text.add("\t" * (span - 1)) + column += span + if column + after != width: + raise Rejected("INVALID_DOCUMENT") + text.add("\t" * after) + previous = current + if not cell: + text.add("\n") + else: + text.add(block.text) + if not cell: + text.add("\n") + + blocks(document) + return [{"kind": "document", "index": 1, "text": text.value()}], text.size + + +def probe(): + import pypdf + import docx + import lxml.etree + + for path in ("/socket", "/jobs", "/proc/self/environ", "/etc/passwd"): + try: + if path in ("/socket", "/jobs"): + os.listdir(path) + else: + open(path, "rb").read(1) + except PermissionError: + continue + raise Rejected("ISOLATION_UNAVAILABLE") + try: + socket.socket(socket.AF_INET, socket.SOCK_STREAM) + except PermissionError: + pass + else: + raise Rejected("ISOLATION_UNAVAILABLE") + try: + os.fork() + except PermissionError: + pass + else: + raise Rejected("ISOLATION_UNAVAILABLE") + if "EXTRACTION_SECRET_CANARY" in os.environ: + raise Rejected("ISOLATION_UNAVAILABLE") + print("READY", flush=True) + + +def main(): + if sys.argv[1] == "probe": + probe() + return + data = open("input", "rb").read(INPUT_BYTES + 1) + if not data or len(data) > INPUT_BYTES: + raise Rejected("INPUT_LIMIT") + mode = sys.argv[1] + if mode == "pdf": + if not data.startswith(b"%PDF-"): + raise Rejected("INVALID_DOCUMENT") + segments, size = pdf_segments(data) + elif mode == "docx": + if not data.startswith(b"PK\x03\x04"): + raise Rejected("INVALID_DOCUMENT") + segments, size = docx_segments(data) + else: + raise Rejected("UNSUPPORTED_FORMAT") + if not any(item["text"].strip() for item in segments): + raise Rejected("EMPTY_OUTPUT") + result = json.dumps({"version": 1, "operation": "document.extract-text", "format": mode, + "inputSha256": hashlib.sha256(data).hexdigest(), "textBytes": size, + "segments": segments}, ensure_ascii=False, separators=(",", ":")).encode("utf-8") + if len(result) > RESULT_BYTES: + raise Rejected("OUTPUT_LIMIT") + with open("result", "xb") as target: + target.write(result) + + +if __name__ == "__main__": + try: + main() + except Rejected as error: + print(error.code, flush=True) + sys.exit(2) + except MemoryError: + print("RESOURCE_LIMIT", flush=True) + sys.exit(2) + except Exception: + print("INVALID_DOCUMENT", flush=True) + sys.exit(2) diff --git a/packages/extraction/worker/requirements.txt b/packages/extraction/worker/requirements.txt new file mode 100644 index 00000000..25ad0ede --- /dev/null +++ b/packages/extraction/worker/requirements.txt @@ -0,0 +1,4 @@ +pypdf==6.19.0 +python-docx==1.2.0 +lxml==6.0.2 +typing_extensions==4.15.0 diff --git a/service/Dockerfile.node b/service/Dockerfile.node index 8a6006e4..dc964fce 100644 --- a/service/Dockerfile.node +++ b/service/Dockerfile.node @@ -1,5 +1,5 @@ # Base stage for shared steps -FROM node:22-slim AS base +FROM node:22.23.4-slim AS base WORKDIR /app COPY service/package*.json ./ RUN npm ci @@ -13,7 +13,7 @@ COPY service/tsconfig.json ./ RUN npx tsc -p tsconfig.json # Production stage -FROM node:22-slim AS production +FROM node:22.23.4-slim AS production ENV NODE_ENV=production WORKDIR /app COPY service/package*.json ./ diff --git a/service/bun.lock b/service/bun.lock index 9aacef7e..ce6518ee 100644 --- a/service/bun.lock +++ b/service/bun.lock @@ -52,6 +52,7 @@ }, "overrides": { "decode-uri-component": "0.5.0", + "proxy-addr": "2.0.8", "qs": "6.16.0", "uuid": "11.1.1", }, @@ -852,7 +853,7 @@ "prom-client": ["prom-client@15.1.3", "", { "dependencies": { "@opentelemetry/api": "^1.4.0", "tdigest": "^0.1.1" } }, "sha512-6ZiOBfCywsD4k1BN9IX0uZhF+tJkV8q8llP64G5Hajs4JOeVLPCwpPVcpXy3BwYiUGgyJzsJJQeOIv7+hDSq8g=="], - "proxy-addr": ["proxy-addr@2.0.7", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg=="], + "proxy-addr": ["proxy-addr@2.0.8", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-5nnx0yGyVUcY6t9RnWcARWtwT9F1D8O9rt08htPvnd49W1IgZtmLkhu9WfMzQj1cFxjHIO6connUNVW5k7AVyQ=="], "proxy-from-env": ["proxy-from-env@2.1.0", "", {}, "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA=="], diff --git a/service/openapi.yml b/service/openapi.yml index b3fc6052..1e2a8d7c 100644 --- a/service/openapi.yml +++ b/service/openapi.yml @@ -175,7 +175,12 @@ components: protocolVersion: { type: integer, enum: [1] } operation: type: string - enum: [list_files, search_text, read_file, write_file, preview_edit, edit_file, execute_command] + enum: [list_files, search_text, read_file, write_file, preview_edit, edit_file, execute_command, import_file] + description: >- + `import_file` copies a stored object the caller owns into the + workspace: `path`, `source` ({ storage_session_id, id }), `bytes`, + lowercase hex `sha256`, optional `overwrite`. Send it only when the + worker advertises it. Callers never send `egressGrant`. workspaceId: { type: string } additionalProperties: true diff --git a/service/package.json b/service/package.json index a3679c8f..caa64e1b 100644 --- a/service/package.json +++ b/service/package.json @@ -76,6 +76,7 @@ "overrides": { "decode-uri-component": "0.5.0", "uuid": "11.1.1", - "qs": "6.16.0" + "qs": "6.16.0", + "proxy-addr": "2.0.8" } } diff --git a/service/rollup.config.js b/service/rollup.config.js index 9fe65f01..b0953f29 100644 --- a/service/rollup.config.js +++ b/service/rollup.config.js @@ -45,6 +45,7 @@ export default { '../shared/live-cancellation-registration.ts', '../packages/code/src/protocol.ts', '../packages/code/src/identity.ts', + '../packages/code/src/browser-enrollment-protocol.ts', ], sourceMap: true, declaration: false, diff --git a/service/src/bridge/browser-enrollment-router.test.ts b/service/src/bridge/browser-enrollment-router.test.ts new file mode 100644 index 00000000..f0418d24 --- /dev/null +++ b/service/src/bridge/browser-enrollment-router.test.ts @@ -0,0 +1,184 @@ +import { randomBytes } from 'crypto'; +import { createServer, type Server } from 'http'; + +import { afterAll, afterEach, describe, expect, test } from 'bun:test'; +import express, { json } from 'express'; +import Redis from 'ioredis'; +import RedisMock from 'ioredis-mock'; + +import { + browserSetupRequestHash, + signEnrollmentRedemption, +} from '../../../packages/code/src/browser-enrollment-protocol'; +import type { BrowserEnrollmentApproval, BrowserEnrollmentRedemption } from '../../../packages/code/src/browser-enrollment-protocol'; +import { createBridgeIdentity } from '../../../packages/code/src/identity'; +import { RedisBridgePairingStore } from './pairing'; +import type { BridgeRecoveryOptions } from './pairing'; +import { createBridgeRouter } from './router'; +import type { BridgeRouterOptions } from './router'; +import { RedisBridgeStore } from './store'; + +const redis = process.env.BROWSER_ENROLLMENT_REDIS_PORT + ? new Redis({ host: '127.0.0.1', port: Number(process.env.BROWSER_ENROLLMENT_REDIS_PORT) }) + : new RedisMock() as unknown as Redis; +const serverId = 'https://code.example.test'; +const adminToken = 'browser-enrollment-test-admin'; +const identity = createBridgeIdentity(); +const outsider = createBridgeIdentity(); +const servers: Server[] = []; + +function pairings(options: Partial = {}): RedisBridgePairingStore { + return new RedisBridgePairingStore(redis, 600, 900, 5_000, '', { serverId, ...options }); +} + +async function router(options: Partial = {}): Promise { + const app = express(); + app.set('trust proxy', 1); + app.use(json()); + app.use('/bridge', createBridgeRouter({ + store: new RedisBridgeStore(redis), pairings: pairings(), authMode: 'paired', adminToken, + allowDynamicWorkers: true, configuredWorkerId: 'browser-http-worker', ...options, + })); + const server = createServer(app); + servers.push(server); + await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); + const address = server.address(); + if (address == null || typeof address === 'string') throw new Error('Expected TCP listener'); + return `http://127.0.0.1:${address.port}/bridge`; +} + +function approved(): BrowserEnrollmentApproval { + const request = { + version: 1 as const, instance: 'https://chat.example.test', attemptId: randomBytes(32).toString('base64url'), + createdAt: new Date().toISOString(), publicKey: identity.publicKey, machineName: 'HTTP worker', + workspace: { type: 'private' as const }, allowWorkspaceWrites: false, allowWorkspaceCommands: false, + }; + return { + protocolVersion: 1, operation: 'enrollment.redeem', serverId, instance: request.instance, + attemptId: request.attemptId, requestHash: browserSetupRequestHash(request), workerId: 'browser-http-worker', + expiresAt: new Date(Date.now() + 60_000).toISOString(), publicKey: identity.publicKey, + binding: { tenantId: 'tenant-one', principal: { type: 'user', id: 'owner-one' } }, + }; +} + +function proof(approval: BrowserEnrollmentApproval, privateKey = identity.privateKey): BrowserEnrollmentRedemption { + const { publicKey: _publicKey, binding: _binding, ...contract } = approval; + return { ...contract, signature: signEnrollmentRedemption(privateKey, contract) }; +} + +function post(url: string, body: object, token = adminToken, headers: Record = {}): Promise { + return fetch(url, { + method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}`, ...headers }, + body: JSON.stringify(body), + }); +} + +async function capability(url: string, token = adminToken): Promise { + return fetch(`${url}/enrollments/capabilities`, { headers: { Authorization: `Bearer ${token}` } }); +} + +afterEach(async () => { + await Promise.all(servers.splice(0).map(server => new Promise((resolve, reject) => { + server.close(error => error ? reject(error) : resolve()); + server.closeAllConnections(); + }))); + await redis.flushall(); +}); +afterAll(() => { redis.disconnect(); }); + +describe('browser enrollment HTTP boundary', () => { + test('advertises the version only for paired auth, durable recovery, dynamic workers and an authenticated admin', async () => { + const url = await router(); + const response = await capability(url); + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ browserEnrollmentVersion: 1, serverId }); + expect((await capability(url, 'outsider')).status).toBe(401); + for (const options of [ + { enabled: false }, { authMode: 'static' as const }, { allowDynamicWorkers: false }, + { pairings: new RedisBridgePairingStore(redis) }, + ]) { + const disabled = await router(options); + expect((await capability(disabled)).status).toBe(404); + expect((await post(`${disabled}/enrollments`, approved())).status).toBe(404); + expect((await post(`${disabled}/enrollments/redeem`, proof(approved()))).status).toBe(404); + } + const unconfigured = await router({ adminToken: '' }); + expect((await capability(unconfigured)).status).toBe(503); + expect((await post(`${unconfigured}/enrollments`, approved())).status).toBe(503); + }); + + test('preserves old manual pairing when browser enrollment is disabled', async () => { + const url = await router({ allowDynamicWorkers: false, pairings: new RedisBridgePairingStore(redis) }); + const issue = await post(`${url}/pairings`, { workerId: 'browser-http-worker' }); + expect(issue.status).toBe(200); + const pairing = await issue.json() as { code: string }; + const result = await post(`${url}/pairings/redeem`, { + protocolVersion: 1, workerId: 'browser-http-worker', code: pairing.code, publicKey: identity.publicKey, + }, ''); + expect(result.status).toBe(200); + expect(await result.json()).toMatchObject({ protocolVersion: 1, workerId: 'browser-http-worker' }); + }); + + test('requires admin auth for approval, redemption and reconciliation, independently of machine proof', async () => { + const url = await router(); + const approval = approved(); + const redemption = proof(approval); + const { signature: _signature, ...statusRequest } = redemption; + for (const [path, body] of [ + ['enrollments', approval], ['enrollments/redeem', redemption], ['enrollments/status', statusRequest], + ] as const) expect((await post(`${url}/${path}`, body, 'outsider')).status).toBe(401); + expect(await redis.keys('codeapi:bridge:v1:browser-enrollment:*')).toHaveLength(0); + }); + + test('validates envelopes, binds approvals, and idempotently returns the same credential across HTTP replicas', async () => { + const first = await router(); + const second = await router(); + const approval = approved(); + for (const body of [ + { ...approval, protocolVersion: 2 }, { ...approval, publicKey: outsider.privateKey }, + { ...approval, binding: null }, { ...approval, extra: true }, + ]) expect((await post(`${first}/enrollments`, body)).status).toBe(400); + const issued = await post(`${first}/enrollments`, approval); + expect(issued.status).toBe(200); + expect(await issued.json()).toEqual(approval); + expect((await post(`${second}/enrollments`, approval)).status).toBe(200); + const { signature: _pendingSignature, ...pendingRequest } = proof(approval); + const pending = await post(`${second}/enrollments/status`, pendingRequest); + expect(pending.status).toBe(200); + expect(await pending.json()).toEqual({ protocolVersion: 1, status: 'pending', workerId: approval.workerId }); + expect((await post(`${second}/enrollments`, { ...approval, publicKey: outsider.publicKey })).status).toBe(409); + expect((await post(`${first}/enrollments/redeem`, { ...proof(approval), signature: '?' })).status).toBe(400); + expect((await post(`${first}/enrollments/redeem`, { ...proof(approval), operation: 'setup.complete' })).status).toBe(400); + expect((await post(`${first}/enrollments/redeem`, proof(approval, outsider.privateKey))).status).toBe(401); + expect((await post(`${first}/enrollments/redeem`, proof({ ...approval, serverId: 'https://other.example.test' }))).status).toBe(401); + expect((await post(`${first}/enrollments/redeem`, proof({ ...approval, instance: 'https://other.example.test' }))).status).toBe(409); + const responses = await Promise.all([first, second].map(url => post(`${url}/enrollments/redeem`, proof(approval)))); + expect(responses.map(response => response.status)).toEqual([200, 200]); + const [original, replay] = await Promise.all(responses.map(response => response.json())); + expect(original).toEqual(replay); + expect(original).toMatchObject({ protocolVersion: 1, workerId: approval.workerId }); + expect(JSON.stringify(original)).not.toMatch(/tenant-one|owner-one|publicKey|privateKey|binding/); + const { signature: _signature, ...statusRequest } = proof(approval); + expect((await post(`${second}/enrollments/status`, statusRequest)).status).toBe(200); + expect((await post(`${second}/enrollments/status`, { ...statusRequest, requestHash: 'f'.repeat(64) })).status).toBe(409); + expect((await post(`${first}/workers/${approval.workerId}/revoke`, {})).status).toBe(200); + expect((await post(`${second}/enrollments/redeem`, proof(approval))).status).toBe(409); + const revoked = await post(`${second}/enrollments/status`, statusRequest); + expect(revoked.status).toBe(200); + expect(await revoked.json()).toEqual({ protocolVersion: 1, status: 'revoked', workerId: approval.workerId }); + }); + + test('shares a connection-peer budget across replicas and ignores spoofed forwarded IPs', async () => { + const first = await router({ pairings: pairings({ maxUntrustedRequestsPerMinute: 2 }) }); + const second = await router({ pairings: pairings({ maxUntrustedRequestsPerMinute: 2 }) }); + const approval = approved(); + await post(`${first}/enrollments`, approval); + const forged = proof(approval, outsider.privateKey); + expect((await post(`${first}/enrollments/redeem`, forged, adminToken, { 'X-Forwarded-For': '198.51.100.1' })).status).toBe(401); + expect((await post(`${second}/enrollments/redeem`, forged, adminToken, { 'X-Forwarded-For': '198.51.100.2' })).status).toBe(401); + const blocked = await post(`${second}/enrollments/redeem`, proof(approved()), adminToken, { 'X-Forwarded-For': '198.51.100.3' }); + expect(blocked.status).toBe(429); + expect(blocked.headers.get('retry-after')).toBe('60'); + expect(await redis.keys('codeapi:bridge:v1:credential:*')).toHaveLength(0); + }); +}); diff --git a/service/src/bridge/browser-enrollment.test.ts b/service/src/bridge/browser-enrollment.test.ts new file mode 100644 index 00000000..ca2b6844 --- /dev/null +++ b/service/src/bridge/browser-enrollment.test.ts @@ -0,0 +1,447 @@ +import { createHash, randomBytes } from 'crypto'; + +import { afterAll, afterEach, describe, expect, test } from 'bun:test'; +import Redis from 'ioredis'; +import RedisMock from 'ioredis-mock'; + +import { + browserSetupRequestHash, + canonicalBrowserSetupProof, + canonicalBrowserSetupRequest, + canonicalEnrollmentRedemption, + isBrowserEnrollmentApproval, + isBrowserEnrollmentRedemption, + normalizeBrowserEnrollmentInstance, + signBrowserSetupProof, + signEnrollmentRedemption, + validateBrowserSetupProof, + validateBrowserSetupRequest, + verifyBrowserSetupProof, + verifyEnrollmentRedemption, +} from '../../../packages/code/src/browser-enrollment-protocol'; +import type { + BrowserEnrollmentApproval, + BrowserEnrollmentRedemption, + BrowserSetupProof, + BrowserSetupRequest, +} from '../../../packages/code/src/browser-enrollment-protocol'; +import { + createBridgeIdentity, + signBridgeRecovery, + signBridgeRecoveryStart, + signBridgeRequest, +} from '../../../packages/code/src/identity'; +import { RedisBridgePairingStore } from './pairing'; +import type { BridgeRecoveryOptions, BridgeWorkerCredential } from './pairing'; +import { RedisBridgeStore } from './store'; + +const redis = process.env.BROWSER_ENROLLMENT_REDIS_PORT + ? new Redis({ host: '127.0.0.1', port: Number(process.env.BROWSER_ENROLLMENT_REDIS_PORT) }) + : new RedisMock() as unknown as Redis; +const serverId = 'https://code.example.test'; +const adminToken = 'test-browser-enrollment-administrator-secret'; +const identity = createBridgeIdentity(); +const outsider = createBridgeIdentity(); +const workerId = 'browser-enrolled-worker'; + +function store(options: Partial = {}, credentialTtl = 900): RedisBridgePairingStore { + return new RedisBridgePairingStore(redis, 600, credentialTtl, 5_000, '', { serverId, ...options }); +} + +function request(): BrowserSetupRequest { + return { + version: 1, + instance: 'https://chat.example.test/base', + attemptId: randomBytes(32).toString('base64url'), + createdAt: new Date().toISOString(), + publicKey: identity.publicKey, + machineName: 'My VM (development)', + workspace: { type: 'directory' }, + allowWorkspaceWrites: true, + allowWorkspaceCommands: false, + }; +} + +function approval(overrides: Partial = {}): BrowserEnrollmentApproval { + const setup = request(); + return { + protocolVersion: 1, + operation: 'enrollment.redeem', + serverId, + instance: setup.instance, + attemptId: setup.attemptId, + requestHash: browserSetupRequestHash(setup), + workerId, + expiresAt: new Date(Date.now() + 60_000).toISOString(), + publicKey: identity.publicKey, + binding: { tenantId: 'tenant-one', principal: { type: 'user', id: 'owner-one' } }, + ...overrides, + }; +} + +function redemption(input: BrowserEnrollmentApproval, privateKey = identity.privateKey): BrowserEnrollmentRedemption { + const { operation, serverId, instance, attemptId, requestHash, workerId, expiresAt } = input; + const contract = { operation, serverId, instance, attemptId, requestHash, workerId, expiresAt }; + return { protocolVersion: 1, ...contract, signature: signEnrollmentRedemption(privateKey, contract) }; +} + +function authorized(credential: BridgeWorkerCredential): Parameters[0] { + const proof = { + credential: credential.credential, + method: 'POST', + path: `/bridge/workers/${workerId}/lease`, + timestamp: new Date().toISOString(), + nonce: randomBytes(32).toString('base64url'), + body: '{}', + }; + return { ...proof, workerId, signature: signBridgeRequest(identity.privateKey, proof) }; +} + +afterEach(async () => { await redis.flushall(); }); +afterAll(() => { redis.disconnect(); }); + +describe('browser enrollment proof contract', () => { + test('canonicalizes fixed request fields and preserves every authorization choice', () => { + const input = request(); + expect(normalizeBrowserEnrollmentInstance('https://CHAT.example.test:443/base///')).toBe(input.instance); + expect(() => normalizeBrowserEnrollmentInstance('http://untrusted.example.test')).toThrow(); + expect(() => normalizeBrowserEnrollmentInstance('https://user:password@chat.example.test')).toThrow(); + expect(() => normalizeBrowserEnrollmentInstance('https://chat.example.test/?query=1')).toThrow(); + const canonical = canonicalBrowserSetupRequest(input); + expect(Object.keys(JSON.parse(canonical))).toEqual([ + 'version', 'instance', 'attemptId', 'createdAt', 'publicKey', 'machineName', 'workspace', + 'allowWorkspaceWrites', 'allowWorkspaceCommands', + ]); + expect(browserSetupRequestHash(input)).toBe(createHash('sha256').update(canonical).digest('hex')); + const reordered = Object.fromEntries(Object.entries(input).reverse()) as unknown as BrowserSetupRequest; + expect(browserSetupRequestHash(reordered)).toBe(browserSetupRequestHash(input)); + for (const change of [ + { allowWorkspaceWrites: false }, { allowWorkspaceCommands: true }, { workspace: { type: 'private' as const } }, + { machineName: 'Different VM' }, { publicKey: outsider.publicKey }, + ]) expect(browserSetupRequestHash({ ...input, ...change })).not.toBe(browserSetupRequestHash(input)); + }); + + test('rejects noncanonical requests and malformed proofs without throwing from verification', () => { + const input = request(); + for (const malformed of [ + null, [], { ...input, extra: true }, { ...input, version: 2 }, { ...input, machineName: 'x\ny' }, + { ...input, machineName: 'x'.repeat(81) }, { ...input, machineName: ' leading' }, + { ...input, instance: `${input.instance}/` }, { ...input, attemptId: 'x'.repeat(43) }, + { ...input, createdAt: '2026-10-21' }, { ...input, publicKey: outsider.privateKey }, + { ...input, workspace: { type: 'directory', path: '/secret' } }, { ...input, allowWorkspaceWrites: 1 }, + ]) expect(validateBrowserSetupRequest(malformed)).toBe(false); + expect(verifyBrowserSetupProof('invalid key', {} as BrowserSetupProof, '!')).toBe(false); + }); + + test('binds operation, audience, attempt, request hash, time and nonce, with separate signing domains', () => { + const input = request(); + const proof: BrowserSetupProof = { + operation: 'setup.start', instance: input.instance, attemptId: input.attemptId, + requestHash: browserSetupRequestHash(input), timestamp: input.createdAt, + nonce: randomBytes(32).toString('base64url'), + }; + const signature = signBrowserSetupProof(identity.privateKey, proof); + expect(canonicalBrowserSetupProof(proof).split('\n')).toEqual([ + 'librechat-code:browser-setup:v1', proof.operation, proof.instance, proof.attemptId, + proof.requestHash, proof.timestamp, proof.nonce, + ]); + expect(validateBrowserSetupProof(proof)).toBe(true); + expect(verifyBrowserSetupProof(identity.publicKey, proof, signature)).toBe(true); + expect(verifyBrowserSetupProof(outsider.publicKey, proof, signature)).toBe(false); + for (const change of [ + { operation: 'setup.poll' as const }, { operation: 'setup.cancel' as const }, { operation: 'setup.complete' as const }, + { instance: 'https://different.example.test' }, { attemptId: randomBytes(32).toString('base64url') }, + { requestHash: 'f'.repeat(64) }, { timestamp: new Date(Date.now() + 1_000).toISOString() }, + { nonce: randomBytes(32).toString('base64url') }, + ]) expect(verifyBrowserSetupProof(identity.publicKey, { ...proof, ...change }, signature)).toBe(false); + const approved = approval({ instance: proof.instance, attemptId: proof.attemptId, requestHash: proof.requestHash }); + expect(canonicalEnrollmentRedemption(approved).split('\n')).toEqual([ + 'librechat-code:browser-enrollment-redeem:v1', approved.operation, approved.serverId, + approved.instance, approved.attemptId, approved.requestHash, approved.workerId, approved.expiresAt, + ]); + expect(verifyEnrollmentRedemption(identity.publicKey, approved, signature)).toBe(false); + expect(verifyBrowserSetupProof(identity.publicKey, proof, signEnrollmentRedemption(identity.privateKey, approved))).toBe(false); + expect(isBrowserEnrollmentRedemption({ ...redemption(approved), publicKey: identity.publicKey })).toBe(false); + expect(isBrowserEnrollmentApproval({ ...approved, binding: { tenantId: 'x', principal: { type: 'bad', id: 'x' } } })).toBe(false); + }); +}); + +describe('Redis browser enrollment ownership', () => { + test('retries lost issue and redemption responses across replicas without rotating keys or widening access', async () => { + const first = store(); + const approved = approval(); + await first.issueBrowserEnrollment(approved); + const second = store(); + expect(await second.issueBrowserEnrollment(approved)).toEqual(approved); + const original = await first.redeemBrowserEnrollment(redemption(approved), adminToken); + expect(await second.issueBrowserEnrollment(approved)).toEqual(approved); + expect(await second.redeemBrowserEnrollment(redemption(approved), adminToken)).toEqual(original); + const auth = await second.authorize(authorized(original)); + expect(auth).toMatchObject({ workerId, binding: approved.binding, pairingGeneration: 0 }); + const enrollmentRaw = await redis.get(`codeapi:bridge:v1:enrollment:${workerId}`); + const allValues = await redis.mget(...await redis.keys('codeapi:bridge:v1:*')); + expect(allValues.join('\n')).not.toContain(original.credential); + expect(enrollmentRaw).toContain(identity.publicKey.trim().split('\n')[1]); + await expect(second.redeemBrowserEnrollment(redemption(approved), 'wrong-encryption-key')).rejects.toMatchObject({ code: 'ENROLLMENT_INVALID' }); + expect(await redis.get(`codeapi:bridge:v1:enrollment:${workerId}`)).toBe(enrollmentRaw); + }); + + test('concurrent duplicates commit exactly one enrollment and return the identical response', async () => { + const approved = approval(); + const replicas = [store(), store(), store()]; + const issued = await Promise.all(replicas.map(replica => replica.issueBrowserEnrollment(approved))); + expect(issued).toEqual([approved, approved, approved]); + const completed = await Promise.all(replicas.map(replica => replica.redeemBrowserEnrollment(redemption(approved), adminToken))); + expect(completed[1]).toEqual(completed[0]); + expect(completed[2]).toEqual(completed[0]); + expect(await redis.keys('codeapi:bridge:v1:credential:*')).toHaveLength(1); + expect(await redis.keys('codeapi:bridge:v1:enrollment:*')).toHaveLength(1); + }); + + test('does not depend on binding property order and retries after worker registration', async () => { + const replica = store(); + const approved = approval({ binding: { principal: { id: 'owner-one', type: 'user' }, tenantId: 'tenant-one' } }); + await replica.issueBrowserEnrollment(approved); + const credential = await replica.redeemBrowserEnrollment(redemption(approved), adminToken); + const auth = await replica.authorize(authorized(credential)); + const workers = new RedisBridgeStore(redis); + await workers.register({ + protocolVersion: 1, workerId, incarnationId: 'browser-test-incarnation', + identityId: auth.identityId, credentialId: auth.credentialId, binding: approved.binding, + capabilities: { statefulWorkspace: false, runtimes: [], sandboxProfile: 'native-srt' }, + }, auth); + expect(await replica.issueBrowserEnrollment(approved)).toEqual(approved); + expect(await replica.redeemBrowserEnrollment(redemption(approved), adminToken)).toEqual(credential); + }); + + test('conflicting concurrent approvals cannot replace the winner or allocate a second worker', async () => { + const approved = approval(); + const changed = { ...approved, workerId: 'other-worker', publicKey: outsider.publicKey }; + const results = await Promise.allSettled([store().issueBrowserEnrollment(approved), store().issueBrowserEnrollment(changed)]); + expect(results.filter(result => result.status === 'fulfilled')).toHaveLength(1); + expect(results.filter(result => result.status === 'rejected')).toHaveLength(1); + const winner = results.find(result => result.status === 'fulfilled'); + if (winner?.status !== 'fulfilled') throw new Error('Expected one committed approval'); + const key = winner.value.publicKey === identity.publicKey ? identity.privateKey : outsider.privateKey; + await store().redeemBrowserEnrollment(redemption(winner.value, key), adminToken); + expect(await redis.keys('codeapi:bridge:v1:enrollment:*')).toHaveLength(1); + }); + + test('bounded credential replay cannot remint after its TTL but durable status remains available', async () => { + const replica = store({}, 1); + const approved = approval(); + await replica.issueBrowserEnrollment(approved); + await replica.redeemBrowserEnrollment(redemption(approved), adminToken); + await new Promise(resolve => setTimeout(resolve, 1_100)); + await expect(store({}, 1).redeemBrowserEnrollment(redemption(approved), adminToken)).rejects.toMatchObject({ code: 'ENROLLMENT_INVALID' }); + const { signature: _signature, ...statusRequest } = redemption(approved); + expect(await store().browserEnrollmentStatus(statusRequest)).toMatchObject({ status: 'completed', workerId }); + expect(await redis.keys('codeapi:bridge:v1:credential:*')).toHaveLength(0); + }); + + test('rejects replacement of immutable approval fields and reuse of the allocated worker', async () => { + const replica = store(); + const approved = approval(); + await replica.issueBrowserEnrollment(approved); + for (const change of [ + { workerId: 'another-worker' }, { publicKey: outsider.publicKey }, { requestHash: 'f'.repeat(64) }, + { expiresAt: new Date(Date.now() + 120_000).toISOString() }, + { binding: { tenantId: 'tenant-two', principal: { type: 'deployment' as const, id: 'everyone' } } }, + ]) await expect(replica.issueBrowserEnrollment({ ...approved, ...change })).rejects.toMatchObject({ code: 'ENROLLMENT_INVALID' }); + await expect(replica.issueBrowserEnrollment(approval())).rejects.toMatchObject({ code: 'ENROLLMENT_INVALID' }); + const first = await replica.redeemBrowserEnrollment(redemption(approved), adminToken); + await expect(replica.issueBrowserEnrollment(approval())).rejects.toMatchObject({ code: 'ENROLLMENT_INVALID' }); + expect(await replica.authorize(authorized(first))).toMatchObject({ binding: approved.binding }); + }); + + test('rejects wrong key, audience, operation and every contract mismatch', async () => { + const replica = store(); + const approved = approval(); + await replica.issueBrowserEnrollment(approved); + await expect(replica.redeemBrowserEnrollment(redemption(approved, outsider.privateKey), adminToken)).rejects.toMatchObject({ code: 'PROOF_INVALID' }); + for (const change of [ + { serverId: 'https://other-code.example.test' }, { instance: 'https://other-chat.example.test' }, + { attemptId: randomBytes(32).toString('base64url') }, { requestHash: 'f'.repeat(64) }, + { workerId: 'another-worker' }, { expiresAt: new Date(Date.now() + 120_000).toISOString() }, + ]) await expect(replica.redeemBrowserEnrollment(redemption({ ...approved, ...change }), adminToken)).rejects.toBeDefined(); + await expect(replica.redeemBrowserEnrollment({ ...redemption(approved), operation: 'setup.complete' } as unknown as BrowserEnrollmentRedemption, adminToken)).rejects.toMatchObject({ code: 'PROOF_INVALID' }); + await expect(replica.redeemBrowserEnrollment({ ...redemption(approved), signature: '?' }, adminToken)).rejects.toMatchObject({ code: 'PROOF_INVALID' }); + expect(await redis.keys('codeapi:bridge:v1:credential:*')).toHaveLength(0); + await replica.redeemBrowserEnrollment(redemption(approved), adminToken); + }); + + test('never extends the immutable expiry or recreates an expired approval', async () => { + const replica = store(); + for (const expiresAt of [new Date(Date.now() - 1).toISOString(), new Date(Date.now() + 601_000).toISOString()]) { + await expect(replica.issueBrowserEnrollment(approval({ expiresAt }))).rejects.toMatchObject({ code: 'ENROLLMENT_INVALID' }); + } + const approved = approval({ expiresAt: new Date(Date.now() + 2_000).toISOString() }); + await replica.issueBrowserEnrollment(approved); + await new Promise(resolve => setTimeout(resolve, 2_100)); + await expect(replica.issueBrowserEnrollment(approved)).rejects.toMatchObject({ code: 'ENROLLMENT_INVALID' }); + await expect(replica.redeemBrowserEnrollment(redemption(approved), adminToken)).rejects.toMatchObject({ code: 'ENROLLMENT_INVALID' }); + const { signature: _signature, ...statusRequest } = redemption(approved); + expect(await replica.browserEnrollmentStatus(statusRequest)).toMatchObject({ status: 'revoked' }); + await expect(replica.issueBrowserEnrollment({ ...approved, workerId: 'replacement-worker', expiresAt: new Date(Date.now() + 60_000).toISOString() })).rejects.toMatchObject({ code: 'ENROLLMENT_INVALID' }); + }); + + test('durable enrollment outlives setup expiry and recovers after restart and access credential loss', async () => { + const replica = store(); + const approved = approval({ expiresAt: new Date(Date.now() + 2_000).toISOString() }); + await replica.issueBrowserEnrollment(approved); + const original = await replica.redeemBrowserEnrollment(redemption(approved), adminToken); + const originalAuth = await replica.authorize(authorized(original)); + await new Promise(resolve => setTimeout(resolve, 2_100)); + expect(await store().redeemBrowserEnrollment(redemption(approved), adminToken)).toEqual(original); + await redis.del( + `codeapi:bridge:v1:identity:${workerId}`, `codeapi:bridge:v1:stable-identity:${workerId}`, + `codeapi:bridge:v1:credential:${createHash('sha256').update(original.credential).digest('hex')}`, + ); + const restarted = store(); + const { signature: _signature, ...statusRequest } = redemption(approved); + expect(await restarted.browserEnrollmentStatus(statusRequest)).toEqual({ protocolVersion: 1, status: 'completed', workerId }); + await expect(restarted.browserEnrollmentStatus({ ...statusRequest, requestHash: 'f'.repeat(64) })).rejects.toMatchObject({ code: 'ENROLLMENT_INVALID' }); + const start = { + operation: 'credential.challenge' as const, serverId, workerId, + timestamp: new Date().toISOString(), nonce: randomBytes(32).toString('base64url'), + }; + const challenge = await restarted.createRecoveryChallenge(workerId, start, signBridgeRecoveryStart(identity.privateKey, start)); + const recovered = await restarted.recoverCredential(workerId, challenge, signBridgeRecovery(identity.privateKey, challenge)); + expect(await restarted.authorize(authorized(recovered))).toMatchObject({ identityId: originalAuth.identityId, binding: approved.binding }); + await restarted.revoke(workerId); + expect(await restarted.browserEnrollmentStatus(statusRequest)).toMatchObject({ status: 'revoked', workerId }); + await expect(restarted.redeemBrowserEnrollment(redemption(approved), adminToken)).rejects.toMatchObject({ code: 'ENROLLMENT_INVALID' }); + }); + + test('revocation fences approval, completion, exact replay and recovery without clearing quarantine', async () => { + const replica = store(); + const approved = approval(); + const quarantine = `codeapi:bridge:v1:worker:${workerId}:workspace:root:quarantined`; + await redis.set(quarantine, 'existing-fence'); + await replica.issueBrowserEnrollment(approved); + await replica.revoke(workerId); + await expect(replica.issueBrowserEnrollment(approved)).rejects.toMatchObject({ code: 'ENROLLMENT_INVALID' }); + await expect(replica.redeemBrowserEnrollment(redemption(approved), adminToken)).rejects.toMatchObject({ code: 'ENROLLMENT_INVALID' }); + await expect(replica.issueBrowserEnrollment(approval())).rejects.toMatchObject({ code: 'ENROLLMENT_INVALID' }); + expect(await redis.get(quarantine)).toBe('existing-fence'); + expect(await redis.get(`codeapi:bridge:v1:pairing-generation:${workerId}`)).toBe('1'); + + const other = approval({ workerId: 'second-worker' }); + await replica.issueBrowserEnrollment(other); + const credential = await replica.redeemBrowserEnrollment(redemption(other), adminToken); + await replica.revoke(other.workerId); + await expect(replica.redeemBrowserEnrollment(redemption(other), adminToken)).rejects.toMatchObject({ code: 'ENROLLMENT_INVALID' }); + expect(await redis.get(`codeapi:bridge:v1:enrollment:${other.workerId}`)).toBeNull(); + expect(credential.workerId).toBe(other.workerId); + }); + + test('status distinguishes pending, completed, revoked and unknown exact contracts without issuing credentials', async () => { + const replica = store(); + const approved = approval(); + const { signature: _signature, ...statusRequest } = redemption(approved); + await expect(replica.browserEnrollmentStatus(statusRequest)).rejects.toMatchObject({ code: 'ENROLLMENT_INVALID' }); + await replica.issueBrowserEnrollment(approved); + expect(await store().browserEnrollmentStatus(statusRequest)).toEqual({ protocolVersion: 1, status: 'pending', workerId }); + expect(await redis.keys('codeapi:bridge:v1:credential:*')).toHaveLength(0); + await expect(replica.browserEnrollmentStatus({ ...statusRequest, workerId: 'different-worker' })).rejects.toMatchObject({ code: 'ENROLLMENT_INVALID' }); + await replica.revoke(workerId); + expect(await store().browserEnrollmentStatus(statusRequest)).toEqual({ protocolVersion: 1, status: 'revoked', workerId }); + await expect(replica.issueBrowserEnrollment(approved)).rejects.toMatchObject({ code: 'ENROLLMENT_INVALID' }); + expect(await redis.keys('codeapi:bridge:v1:credential:*')).toHaveLength(0); + }); + + test('manual issue supersedes only the pending fence and cannot be undone by a retry', async () => { + const replica = store(); + const approved = approval(); + await replica.issueBrowserEnrollment(approved); + const manual = await replica.issue(workerId, approved.binding); + await expect(replica.issueBrowserEnrollment(approved)).rejects.toMatchObject({ code: 'ENROLLMENT_INVALID' }); + await expect(replica.redeemBrowserEnrollment(redemption(approved), adminToken)).rejects.toMatchObject({ code: 'ENROLLMENT_INVALID' }); + const issued = await replica.redeem({ workerId, code: manual.code, publicKey: outsider.publicKey }); + await expect(replica.issueBrowserEnrollment(approval())).rejects.toMatchObject({ code: 'ENROLLMENT_INVALID' }); + expect(issued.workerId).toBe(workerId); + }); + + for (const afterScanWindow of [false, true]) test(`unindexed legacy codes cannot supersede browser approval ${afterScanWindow ? 'after' : 'during'} migration`, async () => { + const replica = store(); + const approved = approval(); + const legacyCode = randomBytes(24).toString('base64url'); + const legacyKey = `codeapi:bridge:v1:pairing:${createHash('sha256').update(legacyCode).digest('hex')}`; + await redis.set(legacyKey, JSON.stringify({ workerId, expiresAt: new Date(Date.now() + 60_000).toISOString(), + binding: { tenantId: 'other-tenant', principal: { type: 'user', id: 'other-owner' } } }), 'EX', 60); + if (afterScanWindow) await redis.set('codeapi:bridge:v1:migration:legacy-pairing-scan-until', String(Date.now() - 1)); + await replica.issueBrowserEnrollment(approved); + expect(await redis.get(`codeapi:bridge:v1:pairing-generation:${workerId}`)).toBe('0'); + if (!afterScanWindow) expect(await redis.get(legacyKey)).toBeNull(); + await expect(replica.redeem({ workerId, code: legacyCode, publicKey: outsider.publicKey })).rejects.toMatchObject({ code: 'PAIRING_INVALID' }); + const credential = await replica.redeemBrowserEnrollment(redemption(approved), adminToken); + expect(await replica.authorize(authorized(credential))).toMatchObject({ workerId, binding: approved.binding }); + }); + + test('cannot replace a preexisting manual enrollment or pending pairing', async () => { + const replica = store(); + const manual = await replica.issue(workerId); + await expect(replica.issueBrowserEnrollment(approval())).rejects.toMatchObject({ code: 'ENROLLMENT_INVALID' }); + await replica.redeem({ workerId, code: manual.code, publicKey: outsider.publicKey }); + await expect(replica.issueBrowserEnrollment(approval())).rejects.toMatchObject({ code: 'ENROLLMENT_INVALID' }); + await expect(replica.issueBrowserEnrollment(approval({ serverId: 'https://wrong.example.test' }))).rejects.toMatchObject({ code: 'ENROLLMENT_INVALID' }); + await expect(new RedisBridgePairingStore(redis).issueBrowserEnrollment(approval())).rejects.toMatchObject({ code: 'ENROLLMENT_INVALID' }); + }); + + test('per-attempt budgets are shared by replicas and wrong signatures cannot create credentials', async () => { + const approved = approval(); + const first = store({ maxAttemptsPerMinute: 2 }); + const second = store({ maxAttemptsPerMinute: 2 }); + await first.issueBrowserEnrollment(approved); + const forged = redemption(approved, outsider.privateKey); + await expect(first.redeemBrowserEnrollment(forged, adminToken)).rejects.toMatchObject({ code: 'PROOF_INVALID' }); + await expect(second.redeemBrowserEnrollment(forged, adminToken)).rejects.toMatchObject({ code: 'PROOF_INVALID' }); + await expect(first.redeemBrowserEnrollment(redemption(approved), adminToken)).rejects.toMatchObject({ code: 'RECOVERY_RATE_LIMITED' }); + expect(await redis.keys('codeapi:bridge:v1:credential:*')).toHaveLength(0); + }); + + test('manual issue between signature verification and completion permanently supersedes the browser attempt', async () => { + const replica = store(); + const approved = approval(); + await replica.issueBrowserEnrollment(approved); + const originalEval = redis.eval.bind(redis); + let manualCode = ''; + redis.eval = (async (...args: Parameters): Promise => { + if (!manualCode && String(args[0]).includes('local replay =')) { + manualCode = (await replica.issue(workerId)).code; + } + return originalEval(...args); + }) as Redis['eval']; + try { + await expect(replica.redeemBrowserEnrollment(redemption(approved), adminToken)).rejects.toMatchObject({ code: 'ENROLLMENT_INVALID' }); + expect(manualCode.length).toBeGreaterThan(0); + await replica.redeem({ workerId, code: manualCode, publicKey: outsider.publicKey }); + await expect(replica.issueBrowserEnrollment(approved)).rejects.toMatchObject({ code: 'ENROLLMENT_INVALID' }); + } finally { + redis.eval = originalEval as Redis['eval']; + } + }); + + test('atomic generation comparison wins a revoke between proof validation and completion', async () => { + const replica = store(); + const approved = approval(); + await replica.issueBrowserEnrollment(approved); + const originalEval = redis.eval.bind(redis); + let intercepted = false; + redis.eval = (async (...args: Parameters): Promise => { + if (!intercepted && String(args[0]).includes('local replay =')) { + intercepted = true; + await replica.revoke(workerId); + } + return originalEval(...args); + }) as Redis['eval']; + try { + await expect(replica.redeemBrowserEnrollment(redemption(approved), adminToken)).rejects.toMatchObject({ code: 'ENROLLMENT_INVALID' }); + expect(intercepted).toBe(true); + expect(await redis.get(`codeapi:bridge:v1:enrollment:${workerId}`)).toBeNull(); + expect(await redis.keys('codeapi:bridge:v1:credential:*')).toHaveLength(0); + } finally { + redis.eval = originalEval as Redis['eval']; + } + }); +}); diff --git a/service/src/bridge/enabled.ts b/service/src/bridge/enabled.ts index 540a844a..e0cba569 100644 --- a/service/src/bridge/enabled.ts +++ b/service/src/bridge/enabled.ts @@ -7,3 +7,8 @@ export function isBridgeEnabled(): boolean { || env.BRIDGE_DYNAMIC_WORKERS || env.BRIDGE_TOKEN.length > 0; } + +/** `import_file` relays need the egress gateway to mint each one-object read grant. */ +export function isWorkspaceImportEnabled(): boolean { + return env.EGRESS_GATEWAY_URL.trim() !== ''; +} diff --git a/service/src/bridge/index.ts b/service/src/bridge/index.ts index f7ba03b4..93370dfa 100644 --- a/service/src/bridge/index.ts +++ b/service/src/bridge/index.ts @@ -3,7 +3,7 @@ import { env } from '../config'; import { RedisBridgePairingStore } from './pairing'; import { createBridgeRouter } from './router'; import { RedisBridgeStore } from './store'; -import { isBridgeEnabled } from './enabled'; +import { isBridgeEnabled, isWorkspaceImportEnabled } from './enabled'; export const bridgeStore = new RedisBridgeStore( connection, @@ -39,4 +39,5 @@ export default createBridgeRouter({ configuredWorkerId: env.BRIDGE_WORKER_ID, allowDynamicWorkers: env.BRIDGE_DYNAMIC_WORKERS, maxCommandTimeoutMs: env.JOB_TIMEOUT, + workspaceImportFile: isWorkspaceImportEnabled(), }); diff --git a/service/src/bridge/pairing.ts b/service/src/bridge/pairing.ts index b8a32162..c8170b69 100644 --- a/service/src/bridge/pairing.ts +++ b/service/src/bridge/pairing.ts @@ -1,4 +1,6 @@ import { + createCipheriv, + createDecipheriv, createHash, createPublicKey, randomBytes, @@ -6,6 +8,21 @@ import { import type Redis from 'ioredis'; +import { + BROWSER_ENROLLMENT_MAX_TTL_MS, + canonicalBrowserEnrollmentRedemption, + isBrowserEnrollmentApproval, + isBrowserEnrollmentRedemption, + isBrowserEnrollmentRedemptionContract, + isBrowserEnrollmentStatusRequest, + verifyBrowserEnrollmentRedemption, +} from '../../../packages/code/src/browser-enrollment-protocol'; +import type { + BrowserEnrollmentApproval, + BrowserEnrollmentRedemption, + BrowserEnrollmentRedemptionContract, + BrowserEnrollmentStatusRequest, +} from '../../../packages/code/src/browser-enrollment-protocol'; import { verifyBridgeRecovery, verifyBridgeRecoveryStart, @@ -74,6 +91,66 @@ else end return 1 `; +const ISSUE_BROWSER_ENROLLMENT_SCRIPT = ` +local now = redis.call('TIME') +local remaining = tonumber(ARGV[3]) - (tonumber(now[1]) * 1000.0 + math.floor(tonumber(now[2]) / 1000)) +if remaining <= 0 or remaining > tonumber(ARGV[4]) then return 0 end +if (redis.call('GET', KEYS[5]) or '0') ~= '0' then return 0 end +local saved = redis.call('GET', KEYS[1]) +local tombstone = redis.call('GET', KEYS[13]) +if tombstone and (not saved or tombstone ~= ARGV[5]) then return 0 end +if saved then + if saved ~= ARGV[1] or redis.call('GET', KEYS[4]) ~= KEYS[3] or redis.call('GET', KEYS[3]) ~= 'browser-enrollment' then return 0 end + local enrollment = redis.call('GET', KEYS[8]) + if enrollment then + if enrollment ~= redis.call('GET', KEYS[2]) then return 0 end + else + for i = 6, 12 do + if i ~= 8 and redis.call('EXISTS', KEYS[i]) == 1 then return 0 end + end + end + redis.call('SETNX', KEYS[5], '0') + return 1 +end +for i = 4, 12 do + if i ~= 5 and redis.call('EXISTS', KEYS[i]) == 1 then return 0 end +end +redis.call('SETNX', KEYS[5], '0') +redis.call('SET', KEYS[13], ARGV[5]) +redis.call('SET', KEYS[1], ARGV[1], 'PX', remaining) +redis.call('SET', KEYS[2], ARGV[2], 'PX', remaining) +redis.call('SET', KEYS[3], 'browser-enrollment', 'PX', remaining) +redis.call('SET', KEYS[4], KEYS[3], 'PX', remaining) +return 1 +`; +const REDEEM_BROWSER_ENROLLMENT_SCRIPT = ` +local now = redis.call('TIME') +local remaining = tonumber(ARGV[3]) - (tonumber(now[1]) * 1000.0 + math.floor(tonumber(now[2]) / 1000)) +if redis.call('GET', KEYS[1]) ~= ARGV[1] or redis.call('GET', KEYS[2]) ~= ARGV[2] or + redis.call('GET', KEYS[3]) ~= 'browser-enrollment' or redis.call('GET', KEYS[4]) ~= KEYS[3] or + (redis.call('GET', KEYS[5]) or '0') ~= '0' then return nil end +local replay = redis.call('GET', KEYS[11]) +if replay then + if redis.call('GET', KEYS[9]) ~= ARGV[2] or redis.call('GET', KEYS[10]) ~= ARGV[9] then return nil end + return replay +end +if remaining <= 0 or ARGV[11] ~= '1' then return nil end +for i = 7, 14 do + if i ~= 11 and redis.call('EXISTS', KEYS[i]) == 1 then return nil end +end +redis.call('SET', KEYS[6], ARGV[4], 'EX', ARGV[5]) +redis.call('SET', KEYS[7], ARGV[6], 'EX', ARGV[5]) +redis.call('SET', KEYS[8], ARGV[7], 'EX', ARGV[5]) +if tonumber(ARGV[8]) > 0 then + redis.call('SET', KEYS[9], ARGV[2], 'EX', ARGV[8]) +else + redis.call('SET', KEYS[9], ARGV[2]) +end +redis.call('SET', KEYS[10], ARGV[9]) +redis.call('SET', KEYS[11], ARGV[10], 'EX', ARGV[5]) +for i = 1, 4 do redis.call('EXPIRE', KEYS[i], ARGV[5]) end +return ARGV[10] +`; const ROTATE_CREDENTIAL_SCRIPT = ` if ARGV[6] ~= '' then if redis.call('GET', KEYS[5]) ~= ARGV[6] or (redis.call('GET', KEYS[6]) or '0') ~= ARGV[7] or redis.call('GET', KEYS[7]) ~= ARGV[8] then @@ -127,7 +204,7 @@ if redis.call('EXISTS', KEYS[6]) == 1 then return -2 end local count = redis.call('INCR', KEYS[2]) if count == 1 then redis.call('EXPIRE', KEYS[2], 60) end if count > tonumber(ARGV[2]) then return -1 end -if redis.call('SET', KEYS[3], ARGV[3], 'EX', ARGV[5], 'NX') ~= 'OK' then return 0 end +if not redis.call('SET', KEYS[3], ARGV[3], 'EX', ARGV[5], 'NX') then return 0 end redis.call('SET', KEYS[6], '1', 'EX', ARGV[7]) return 1 `; @@ -223,6 +300,7 @@ interface StoredEnrollment { pairingGeneration: number; publicKey: string; binding?: BridgeWorkerBinding; + browserEnrollment?: BrowserEnrollmentRedemptionContract; } export interface BridgeRecoveryOptions { @@ -277,6 +355,54 @@ function pairingKey(code: string): string { return `${PREFIX}:pairing:${digest(code)}`; } +function browserEnrollmentKey(instance: string, attemptId: string): string { + return `${PREFIX}:browser-enrollment:${digest(JSON.stringify([instance, attemptId]))}`; +} + +function canonicalApproval(approval: BrowserEnrollmentApproval): string { + return JSON.stringify({ + protocolVersion: 1, + operation: approval.operation, + serverId: approval.serverId, + instance: approval.instance, + attemptId: approval.attemptId, + requestHash: approval.requestHash, + workerId: approval.workerId, + expiresAt: approval.expiresAt, + publicKey: approval.publicKey, + binding: { + tenantId: approval.binding.tenantId, + principal: { type: approval.binding.principal.type, id: approval.binding.principal.id }, + }, + }); +} + +function enrollmentReplayKey(adminToken: string, serverId: string): Buffer { + if (!adminToken) throw new BridgePairingError('ENROLLMENT_INVALID', 'Browser enrollment requires an administrator token'); + return createHash('sha256').update('librechat-code:browser-enrollment-replay:v1\0') + .update(serverId).update('\0').update(adminToken).digest(); +} + +function encryptEnrollmentReplay(credential: BridgeWorkerCredential, approval: string, key: Buffer): string { + const iv = randomBytes(12); + const cipher = createCipheriv('aes-256-gcm', key, iv); + cipher.setAAD(Buffer.from(approval)); + const ciphertext = Buffer.concat([cipher.update(JSON.stringify(credential), 'utf8'), cipher.final()]); + return Buffer.concat([iv, cipher.getAuthTag(), ciphertext]).toString('base64url'); +} + +function decryptEnrollmentReplay(ciphertext: string, approval: string, key: Buffer): BridgeWorkerCredential { + try { + const bytes = Buffer.from(ciphertext, 'base64url'); + const decipher = createDecipheriv('aes-256-gcm', key, bytes.subarray(0, 12)); + decipher.setAAD(Buffer.from(approval)); + decipher.setAuthTag(bytes.subarray(12, 28)); + return JSON.parse(Buffer.concat([decipher.update(bytes.subarray(28)), decipher.final()]).toString('utf8')) as BridgeWorkerCredential; + } catch { + throw new BridgePairingError('ENROLLMENT_INVALID', 'Browser enrollment replay is unavailable'); + } +} + function credentialDigestKey(credentialDigest: string): string { return `${PREFIX}:credential:${credentialDigest}`; } @@ -408,6 +534,215 @@ export class RedisBridgePairingStore { return this.recovery != null; } + get recoveryServerId(): string | undefined { + return this.recovery?.serverId; + } + + async limitBrowserEnrollmentPeer(peer: string): Promise { + if (this.recovery == null) { + throw new BridgePairingError('ENROLLMENT_INVALID', 'Browser enrollment is disabled'); + } + const allowed = await this.redis.eval( + LIMIT_RECOVERY_ATTEMPTS_SCRIPT, + 1, + `${PREFIX}:browser-enrollment:peer:${digest(peer)}`, + String(this.recovery.maxUntrustedRequestsPerMinute ?? DEFAULT_UNTRUSTED_RECOVERY_REQUESTS_PER_MINUTE), + ); + if (allowed !== 1) { + throw new BridgePairingError('RECOVERY_RATE_LIMITED', 'Too many browser enrollment requests from this peer'); + } + } + + async issueBrowserEnrollment(approval: BrowserEnrollmentApproval): Promise { + if (!isBrowserEnrollmentApproval(approval) || this.recovery == null || approval.serverId !== this.recovery.serverId || + Date.parse(approval.expiresAt) <= Date.now() || Date.parse(approval.expiresAt) - Date.now() > BROWSER_ENROLLMENT_MAX_TTL_MS) { + throw new BridgePairingError('ENROLLMENT_INVALID', 'Invalid browser enrollment approval'); + } + await this.removeLegacyPairings(approval.workerId); + const approvalRaw = canonicalApproval(approval); + const normalized = JSON.parse(approvalRaw) as BrowserEnrollmentApproval; + const enrollment: StoredEnrollment = { + workerId: approval.workerId, + serverId: approval.serverId, + identityId: randomBytes(18).toString('base64url'), + generation: randomBytes(18).toString('base64url'), + pairingGeneration: 0, + browserEnrollment: { + operation: approval.operation, + serverId: approval.serverId, + instance: approval.instance, + attemptId: approval.attemptId, + requestHash: approval.requestHash, + workerId: approval.workerId, + expiresAt: approval.expiresAt, + }, + publicKey: approval.publicKey, + binding: normalized.binding, + }; + const key = browserEnrollmentKey(approval.instance, approval.attemptId); + const workerPrefix = `${PREFIX}:worker:${encodeURIComponent(approval.workerId)}`; + // The manual-pairing index points at a separate fence, never the immutable + // approval. Manual issue/revoke consumes that fence without erasing the tombstone. + const accepted = await this.redis.eval( + ISSUE_BROWSER_ENROLLMENT_SCRIPT, + 13, + key, + `${key}:enrollment`, + `${key}:fence`, + workerPairingIndexKey(approval.workerId), + workerPairingGenerationKey(approval.workerId), + workerIdentityKey(approval.workerId), + workerStableIdentityKey(approval.workerId), + workerEnrollmentKey(approval.workerId), + workerEnrollmentRequiredKey(approval.workerId), + workerPrefix, + `${workerPrefix}:incarnation`, + `${workerPrefix}:registration-generation`, + `${key}:tombstone`, + approvalRaw, + JSON.stringify(enrollment), + String(Date.parse(approval.expiresAt)), + String(BROWSER_ENROLLMENT_MAX_TTL_MS), + `${digest(approvalRaw)}:${digest(canonicalBrowserEnrollmentRedemption(approval))}`, + ); + if (accepted !== 1) { + throw new BridgePairingError('ENROLLMENT_INVALID', 'Browser enrollment is expired, revoked, superseded, or conflicts with an existing worker'); + } + return normalized; + } + + async browserEnrollmentStatus(proof: BrowserEnrollmentStatusRequest): Promise<{ + protocolVersion: 1; + status: 'pending' | 'completed' | 'revoked'; + workerId: string; + }> { + if (!isBrowserEnrollmentStatusRequest(proof) || this.recovery == null || proof.serverId !== this.recovery.serverId) { + throw new BridgePairingError('ENROLLMENT_INVALID', 'Invalid browser enrollment status request'); + } + const key = browserEnrollmentKey(proof.instance, proof.attemptId); + const [raw, generation, requiredGeneration, approvalRaw, tombstone, fence, indexed, planned] = await this.redis.mget( + workerEnrollmentKey(proof.workerId), + workerPairingGenerationKey(proof.workerId), + workerEnrollmentRequiredKey(proof.workerId), + key, + `${key}:tombstone`, + `${key}:fence`, + workerPairingIndexKey(proof.workerId), + `${key}:enrollment`, + ); + const contract = canonicalBrowserEnrollmentRedemption(proof); + try { + const enrollment = this.checkedEnrollment(proof.workerId, raw, generation, requiredGeneration); + if (isBrowserEnrollmentRedemptionContract(enrollment.browserEnrollment) && + canonicalBrowserEnrollmentRedemption(enrollment.browserEnrollment) === contract) { + return { protocolVersion: 1, status: 'completed', workerId: proof.workerId }; + } + } catch (error) { + if (!(error instanceof BridgePairingError)) throw error; + } + if (tombstone?.split(':')[1] !== digest(contract)) { + throw new BridgePairingError('ENROLLMENT_INVALID', 'Browser enrollment does not match a known contract'); + } + let pending = false; + try { + const approval = JSON.parse(approvalRaw ?? 'null') as unknown; + pending = (generation ?? '0') === '0' && raw == null && requiredGeneration == null && planned != null && + isBrowserEnrollmentApproval(approval) && canonicalBrowserEnrollmentRedemption(approval) === contract && + Date.parse(approval.expiresAt) > Date.now() && fence === 'browser-enrollment' && indexed === `${key}:fence`; + } catch { + pending = false; + } + return { protocolVersion: 1, status: pending ? 'pending' : 'revoked', workerId: proof.workerId }; + } + + async redeemBrowserEnrollment( + proof: BrowserEnrollmentRedemption, + adminToken: string, + ): Promise { + if (!isBrowserEnrollmentRedemption(proof) || this.recovery == null || proof.serverId !== this.recovery.serverId) { + throw new BridgePairingError('PROOF_INVALID', 'Invalid browser enrollment proof'); + } + const replayKey = enrollmentReplayKey(adminToken, proof.serverId); + const key = browserEnrollmentKey(proof.instance, proof.attemptId); + const attempts = await this.redis.eval( + LIMIT_RECOVERY_ATTEMPTS_SCRIPT, + 1, + `${key}:attempts`, + String(this.recovery.maxAttemptsPerMinute ?? DEFAULT_RECOVERY_ATTEMPTS_PER_MINUTE), + ); + if (attempts !== 1) { + throw new BridgePairingError('RECOVERY_RATE_LIMITED', 'Too many browser enrollment attempts'); + } + const [raw, enrollmentRaw] = await this.redis.mget(key, `${key}:enrollment`); + let approval: BrowserEnrollmentApproval; + let enrollment: StoredEnrollment; + try { + approval = JSON.parse(raw ?? 'null') as BrowserEnrollmentApproval; + enrollment = JSON.parse(enrollmentRaw ?? 'null') as StoredEnrollment; + if (!isBrowserEnrollmentApproval(approval) || canonicalBrowserEnrollmentRedemption(approval) !== canonicalBrowserEnrollmentRedemption(proof) || + enrollment?.workerId !== approval.workerId || enrollment.serverId !== approval.serverId || + enrollment.publicKey !== approval.publicKey || enrollment.pairingGeneration !== 0 || + enrollment.browserEnrollment == null || canonicalBrowserEnrollmentRedemption(enrollment.browserEnrollment) !== canonicalBrowserEnrollmentRedemption(proof) || + JSON.stringify(enrollment.binding) !== JSON.stringify(approval.binding) || + !/^[A-Za-z0-9_-]{24}$/.test(enrollment.identityId) || !/^[A-Za-z0-9_-]{24}$/.test(enrollment.generation)) { + throw new Error('Invalid approval'); + } + } catch { + throw new BridgePairingError('ENROLLMENT_INVALID', 'Browser enrollment is unavailable or does not match its approval'); + } + if (!verifyBrowserEnrollmentRedemption(approval.publicKey, proof, proof.signature)) { + throw new BridgePairingError('PROOF_INVALID', 'Browser enrollment requires the approved machine key'); + } + const credential: BridgeWorkerCredential = { + workerId: approval.workerId, + credential: randomBytes(32).toString('base64url'), + expiresAt: new Date(Date.now() + this.credentialTtlSeconds * 1000).toISOString(), + }; + const credentialDigest = digest(credential.credential); + const stored: StoredCredential = { + workerId: approval.workerId, + identityId: enrollment.identityId, + publicKey: approval.publicKey, + expiresAt: credential.expiresAt, + binding: approval.binding, + enrollmentGeneration: enrollment.generation, + }; + const workerPrefix = `${PREFIX}:worker:${encodeURIComponent(approval.workerId)}`; + const encrypted = await this.redis.eval( + REDEEM_BROWSER_ENROLLMENT_SCRIPT, + 14, + key, + `${key}:enrollment`, + `${key}:fence`, + workerPairingIndexKey(approval.workerId), + workerPairingGenerationKey(approval.workerId), + credentialDigestKey(credentialDigest), + workerIdentityKey(approval.workerId), + workerStableIdentityKey(approval.workerId), + workerEnrollmentKey(approval.workerId), + workerEnrollmentRequiredKey(approval.workerId), + `${key}:result`, + workerPrefix, + `${workerPrefix}:incarnation`, + `${workerPrefix}:registration-generation`, + raw!, + enrollmentRaw!, + String(Date.parse(approval.expiresAt)), + JSON.stringify(stored), + String(this.credentialTtlSeconds), + credentialDigest, + enrollment.identityId, + String(this.recovery.enrollmentTtlSeconds ?? 0), + enrollment.generation, + encryptEnrollmentReplay(credential, raw!, replayKey), + Date.parse(proof.expiresAt) > Date.now() ? '1' : '0', + ); + if (typeof encrypted !== 'string') { + throw new BridgePairingError('ENROLLMENT_INVALID', 'Browser enrollment is expired, revoked, or superseded'); + } + return decryptEnrollmentReplay(encrypted, raw!, replayKey); + } + async limitUntrustedRecovery( peer: string, operation: 'challenge' | 'recover', diff --git a/service/src/bridge/router.test.ts b/service/src/bridge/router.test.ts index b34df34d..97840768 100644 --- a/service/src/bridge/router.test.ts +++ b/service/src/bridge/router.test.ts @@ -296,6 +296,53 @@ describe('paired bridge HTTP API', () => { }); } + for (const workspaceImportFile of [false, true]) { + test(`registration ${workspaceImportFile ? 'offers' : 'omits'} import_file when grants ${workspaceImportFile ? 'can' : 'cannot'} be minted`, async () => { + const app = express(); + app.use(json()); + app.use( + '/v1/bridge', + createBridgeRouter({ + store: new RedisBridgeStore(redis), + pairings: new RedisBridgePairingStore(redis), + authMode: 'static', + adminToken: 'strong-administrator-bootstrap-token', + configuredWorkerId: 'vm-1', + workspaceImportFile, + }), + ); + server = createServer(app); + await new Promise((resolve) => server?.listen(0, '127.0.0.1', resolve)); + const address = server.address(); + if (address == null || typeof address === 'string') throw new Error('Expected TCP listener'); + const response = await fetch(`http://127.0.0.1:${address.port}/v1/bridge/workers/register`, { + method: 'POST', + headers: { + Authorization: 'Bearer strong-administrator-bootstrap-token', + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + protocolVersion: BRIDGE_PROTOCOL_VERSION, + workerId: 'vm-1', + incarnationId: 'incarnation-00000001', + capabilities: { + statefulWorkspace: false, + sandboxProfile: 'anthropic-srt', + runtimes: [], + workspaceTools: { + protocolVersion: BRIDGE_PROTOCOL_VERSION, + operations: ['read_file', 'write_file', 'import_file'], + workspaces: [{ id: 'primary' }], + }, + }, + }), + }); + expect(response.status).toBe(200); + const body = (await response.json()) as { supportedWorkspaceToolOperations: string[] }; + expect(body.supportedWorkspaceToolOperations.includes('import_file')).toBe(workspaceImportFile); + }); + } + test('rejects a malformed optional binding for a configured worker', async () => { const app = express(); app.use(json()); diff --git a/service/src/bridge/router.ts b/service/src/bridge/router.ts index 123f8c75..c3b5aead 100644 --- a/service/src/bridge/router.ts +++ b/service/src/bridge/router.ts @@ -6,6 +6,12 @@ import type { BridgeWorkerRegistration } from '../../../packages/code/src/protoc import type { BridgePrincipalType, BridgeWorkerBinding } from './pairing'; import type { CodeBridgeAssignment, CodeBridgeSettlement } from './store'; +import { + BROWSER_ENROLLMENT_PROTOCOL_VERSION, + isBrowserEnrollmentApproval, + isBrowserEnrollmentRedemption, + isBrowserEnrollmentStatusRequest, +} from '../../../packages/code/src/browser-enrollment-protocol'; import { BRIDGE_WORKSPACE_COMMAND_MAX_TIMEOUT_MS, BRIDGE_PROTOCOL_VERSION, @@ -39,6 +45,8 @@ export interface BridgeRouterOptions { configuredWorkerId?: string; allowDynamicWorkers?: boolean; maxCommandTimeoutMs?: number; + /** Offer `import_file`; true only when this Code API can mint its egress grant. */ + workspaceImportFile?: boolean; } function sameToken(left: string, right: string): boolean { @@ -243,6 +251,73 @@ export function createBridgeRouter(options: BridgeRouterOptions): Router { const workerAuth = options.authMode === 'paired' ? pairedWorkerAuth : staticWorkerAuth; + const browserEnrollmentEnabled = options.authMode === 'paired' && + options.pairings.recoveryEnabled && options.allowDynamicWorkers === true && + options.adminToken.length > 0; + + const browserEnrollmentAvailable: RequestHandler = (_req, res, next) => { + if (!browserEnrollmentEnabled) { + res.status(404).json({ error: 'Browser enrollment is disabled' }); + return; + } + next(); + }; + + const sendEnrollmentError = (error: unknown, res: Response): void => { + if (!(error instanceof BridgePairingError)) throw error; + if (error.code === 'RECOVERY_RATE_LIMITED') { + res.set('Retry-After', '60').status(429).json({ error: error.message, code: error.code }); + return; + } + res.status(error.code === 'PROOF_INVALID' ? 401 : 409) + .json({ error: error.message, code: error.code }); + }; + + router.get('/enrollments/capabilities', adminAuth, browserEnrollmentAvailable, (_req, res) => { + res.json({ + browserEnrollmentVersion: BROWSER_ENROLLMENT_PROTOCOL_VERSION, + serverId: options.pairings.recoveryServerId, + }); + }); + + router.post('/enrollments', adminAuth, browserEnrollmentAvailable, asyncRoute(async (req, res) => { + if (!isBrowserEnrollmentApproval(req.body)) { + res.status(400).json({ error: 'Invalid browser enrollment approval' }); + return; + } + try { + res.json(await options.pairings.issueBrowserEnrollment(req.body)); + } catch (error) { + sendEnrollmentError(error, res); + } + })); + + router.post('/enrollments/status', adminAuth, browserEnrollmentAvailable, asyncRoute(async (req, res) => { + if (!isBrowserEnrollmentStatusRequest(req.body)) { + res.status(400).json({ error: 'Invalid browser enrollment status request' }); + return; + } + try { + res.json(await options.pairings.browserEnrollmentStatus(req.body)); + } catch (error) { + sendEnrollmentError(error, res); + } + })); + + router.post('/enrollments/redeem', adminAuth, browserEnrollmentAvailable, asyncRoute(async (req, res) => { + try { + await options.pairings.limitBrowserEnrollmentPeer(req.socket.remoteAddress ?? ''); + if (!isBrowserEnrollmentRedemption(req.body)) { + res.status(400).json({ error: 'Invalid browser enrollment redemption' }); + return; + } + const credential = await options.pairings.redeemBrowserEnrollment(req.body, options.adminToken); + res.json({ protocolVersion: BROWSER_ENROLLMENT_PROTOCOL_VERSION, ...credential }); + } catch (error) { + sendEnrollmentError(error, res); + } + })); + router.post('/pairings', adminAuth, asyncRoute(async (req, res) => { if (options.authMode !== 'paired') { res.status(409).json({ error: 'Paired worker authentication is disabled' }); @@ -533,6 +608,7 @@ router.post( 'preview_edit', 'edit_file', 'execute_command', + ...(options.workspaceImportFile === true ? ['import_file' as const] : []), ], supportedWorkspaceWriteFileModes: ['replace', 'create'], supportedWorkspaceEditFileModes: ['single', 'batch'], diff --git a/service/src/bridge/store.ts b/service/src/bridge/store.ts index 0ca74c45..f9919f49 100644 --- a/service/src/bridge/store.ts +++ b/service/src/bridge/store.ts @@ -15,6 +15,7 @@ import { BRIDGE_PROTOCOL_VERSION, isValidBridgeWorkerCapabilities, isValidBridgeWorkerId, + isWorkspaceMutationOperation, isWorkspaceToolRequest, isWorkspaceToolResult, workspaceIsolationKey, @@ -169,7 +170,7 @@ export interface BridgeWorkerStatus { } function isWorkspaceMutation(request: WorkspaceToolRequest): boolean { - return request.operation === 'write_file' || request.operation === 'edit_file' || request.operation === 'execute_command'; + return isWorkspaceMutationOperation(request.operation); } function supportsWorkspaceTool( diff --git a/service/src/config.spec.ts b/service/src/config.spec.ts index 3c2ca9d2..45ae5700 100644 --- a/service/src/config.spec.ts +++ b/service/src/config.spec.ts @@ -1,4 +1,6 @@ import { describe, expect, it } from 'bun:test'; +import { readFileSync } from 'fs'; +import * as path from 'path'; import { jobCompletionWaitTimeoutMs, jobDeadlineAtMs, @@ -22,11 +24,22 @@ describe('node language configuration', () => { it('maps Node.js requests to the node sandbox runtime', () => { expect(languageConfig[Languages.node]).toEqual({ language: 'node', - version: '24.15.0', + version: '24.22.1', fileName: 'index.js', }); }); + it('aligns package installers and Helm with the selected Node runtime', () => { + const root = path.resolve(__dirname, '../..'); + const version = languageConfig[Languages.node]!.version; + for (const file of ['build-packages.sh', 'docker/package-init.sh']) { + expect(readFileSync(path.join(root, file), 'utf8')) + .toContain(`NODE_VERSION="\${NODE_VERSION:-${version}}"`); + } + expect(readFileSync(path.join(root, 'helm/codeapi/values.yaml'), 'utf8')) + .toContain(`nodeVersion: "${version}"`); + }); + it('creates a valid Node.js execution payload', () => { const req = { body: { @@ -45,7 +58,7 @@ describe('node language configuration', () => { expect(payload).toMatchObject({ language: 'node', - version: '24.15.0', + version: '24.22.1', session_id: 'session-node', args: ['--trace-warnings'], files: [ diff --git a/service/src/config.ts b/service/src/config.ts index b3e397b8..6a49ea36 100644 --- a/service/src/config.ts +++ b/service/src/config.ts @@ -7,11 +7,12 @@ import { resolveExecutionProfile, resolveExecutionProfileSource, } from './execution-profile'; +import { BRIDGE_WORKSPACE_IMPORT_FILE_DEFAULT_MAX_BYTES } from '../../packages/code/src/protocol'; export const languageConfig: Record = { [Languages.bash]: { language: 'bash', version: '5.2.0', fileName: 'script.sh' }, [Languages.js]: { language: 'bun-js', version: '1.4.2', fileName: 'index.js' }, - [Languages.node]: { language: 'node', version: '24.15.0', fileName: 'index.js' }, + [Languages.node]: { language: 'node', version: '24.22.1', fileName: 'index.js' }, [Languages.py]: { language: 'python', version: '3.14.4', fileName: 'main.py' }, [Languages.ts]: { language: 'bun-ts', version: '1.4.2', fileName: 'main.ts' }, }; @@ -425,6 +426,14 @@ export const env = { * Off by default: enable only after the LibreChat talking to this service accepts the field. */ BRIDGE_LANE_GIT: process.env.CODEAPI_BRIDGE_LANE_GIT === 'true', + /** + * Largest stored object relayed into a worker by `import_file`. The operation + * is offered only when EGRESS_GATEWAY_URL can mint its one-object grant. + */ + BRIDGE_IMPORT_FILE_MAX_BYTES: resolvePositiveIntEnv( + process.env.CODEAPI_BRIDGE_IMPORT_FILE_MAX_BYTES, + BRIDGE_WORKSPACE_IMPORT_FILE_DEFAULT_MAX_BYTES, + ), /** Outbound worker selected by the remote-bridge backend. */ BRIDGE_WORKER_ID: process.env.CODEAPI_BRIDGE_WORKER_ID ?? '', /** Static compatibility auth or short-lived proof-of-possession credentials. */ diff --git a/service/src/session-ownership.ts b/service/src/session-ownership.ts index bf4ba6dd..1841ed06 100644 --- a/service/src/session-ownership.ts +++ b/service/src/session-ownership.ts @@ -94,7 +94,7 @@ export type SessionOwnershipResult = * simply gone. */ export async function authorizeSessionOwnership( - store: SessionOwnershipStore, + store: Pick, args: { session_id: string; expectedSessionKey: string; diff --git a/service/src/workspace-tools/import.test.ts b/service/src/workspace-tools/import.test.ts new file mode 100644 index 00000000..7aa22aef --- /dev/null +++ b/service/src/workspace-tools/import.test.ts @@ -0,0 +1,295 @@ +import { createServer } from 'node:http'; +import type { Server } from 'node:http'; + +import { afterEach, expect, test } from 'bun:test'; +import express, { json } from 'express'; +import RedisMock from 'ioredis-mock'; +import type Redis from 'ioredis'; + +import type * as t from '../types'; +import type { CodeApiPrincipal } from '../auth/principal'; +import type { WorkspaceImportFileRequest, WorkspaceToolRequest } from '../../../packages/code/src/protocol'; + +import { applyPrincipal } from '../auth/principal'; +import { RedisBridgeStore } from '../bridge/store'; +import { resolveSessionKey } from '../session-key'; +import { recordSessionOwnership } from '../session-ownership'; +import { openEgressGrant, openEgressHandle, prepareSandboxEgress } from '../egress-grant'; +import { createWorkspaceToolsRouter } from './router'; +import { RedisWorkspaceRequests } from './requests'; +import { + WorkspaceImportError, + authorizeWorkspaceImportSource, + createWorkspaceImportService, +} from './import'; + +const SECRET = 'workspace-import-test-secret-of-sufficient-length'; +const SESSION = 'sessAAAAAAAAAAAAAAAAA'; +const OBJECT = 'fileAAAAAAAAAAAAAAAAA'; +const OTHER_SESSION = 'sessBBBBBBBBBBBBBBBBB'; +const OTHER_OBJECT = 'fileBBBBBBBBBBBBBBBBB'; +const owner: CodeApiPrincipal = { userId: 'user-1', tenantId: 'tenant-1', principalSource: 'librechat_jwt', codeWorkerId: 'worker' }; +const stranger: CodeApiPrincipal = { userId: 'user-2', tenantId: 'tenant-1', principalSource: 'librechat_jwt', codeWorkerId: 'worker' }; + +const importBody = (overrides: Record = {}): WorkspaceImportFileRequest => ({ + protocolVersion: 1, + operation: 'import_file', + workspaceId: 'primary', + path: 'uploads/report.pdf', + source: { storage_session_id: SESSION, id: OBJECT }, + bytes: 4096, + sha256: 'a'.repeat(64), + ...overrides, +}); + +function principalRequest(principal: CodeApiPrincipal): t.AuthenticatedRequest { + const req = { headers: {} } as t.AuthenticatedRequest; + applyPrincipal(req, principal); + return req; +} + +/** Uploads as LibreChat's code-environment upload does: user-kind session key plus upload record. */ +async function upload(redis: Redis, principal: CodeApiPrincipal, sessionId: string, objectId: string): Promise { + const sessionKey = resolveSessionKey(principalRequest(principal), { kind: 'user', id: principal.userId }); + await recordSessionOwnership(redis, sessionId, sessionKey); + await redis.set(`upload:${sessionKey}${sessionId}${objectId}`, '1'); +} + +function importService(redis: Redis, minted: string[] = [], revoked: string[] = [], maxBytes = 1024 * 1024) { + return createWorkspaceImportService({ + maxBytes, + store: redis, + async createGrant({ payload, claims }) { + const grantId = `grant-${minted.length + 1}`; + minted.push(grantId); + return { grant_id: grantId, ...prepareSandboxEgress({ payload, claims, grantId, secret: SECRET }) }; + }, + async revokeGrant(grantId) { + revoked.push(grantId); + }, + }); +} + +// ioredis-mock instances share one keyspace across test files; clear it like the bridge suites do. +const redis = new RedisMock() as unknown as Redis; +let server: Server | undefined; +afterEach(async () => { + server?.close(); + server = undefined; + await redis.flushall(); +}); + +async function listen(app: express.Express): Promise { + server = createServer(app); + await new Promise(resolve => server!.listen(0, '127.0.0.1', resolve)); + const address = server.address(); + if (address == null || typeof address === 'string') throw new Error('Missing listener'); + return `http://127.0.0.1:${address.port}`; +} + +function appFor(router: express.Router): express.Express { + const app = express(); + app.use(json()); + app.use((req, _res, next) => { + applyPrincipal(req, req.header('X-Test-User') === 'user-2' ? stranger : owner); + next(); + }); + app.use(router); + return app; +} + +function post(url: string, body: unknown, headers: Record = {}, signal?: AbortSignal): Promise { + return fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json', ...headers }, body: JSON.stringify(body), signal }); +} + +test('ownership follows the download rule and rejects another principal\'s object', async () => { + await upload(redis, owner, SESSION, OBJECT); + await upload(redis, stranger, OTHER_SESSION, OTHER_OBJECT); + const req = principalRequest(owner); + + expect(await authorizeWorkspaceImportSource({ req, store: redis, source: { storage_session_id: SESSION, id: OBJECT } })) + .toBe(resolveSessionKey(req, { kind: 'user', id: 'user-1' })); + const denials = [ + { storage_session_id: OTHER_SESSION, id: OTHER_OBJECT }, + { storage_session_id: SESSION, id: OTHER_OBJECT }, + { storage_session_id: OTHER_SESSION, id: OBJECT }, + ]; + for (const source of denials) { + const error = await authorizeWorkspaceImportSource({ req, store: redis, source }).catch(caught => caught); + expect(error).toBeInstanceOf(WorkspaceImportError); + expect(error).toMatchObject({ status: 403, code: 'IMPORT_SOURCE_UNAUTHORIZED' }); + } + const malformed = await authorizeWorkspaceImportSource({ + req, store: redis, source: { storage_session_id: '../escape', id: OBJECT }, + }).catch(caught => caught); + expect(malformed).toMatchObject({ status: 400, code: 'INVALID_WORKSPACE_TOOL_REQUEST' }); + + await redis.del(`session:${SESSION}`); + const expired = await authorizeWorkspaceImportSource({ req, store: redis, source: { storage_session_id: SESSION, id: OBJECT } }) + .catch(caught => caught); + expect(expired).toMatchObject({ status: 403, code: 'IMPORT_SOURCE_UNAUTHORIZED' }); +}); + +test('the grant reads exactly one object, briefly, and can upload nothing', async () => { + await upload(redis, owner, SESSION, OBJECT); + const imports = importService(redis); + const req = principalRequest(owner); + const sessionKey = await imports.authorize(req, importBody()); + const before = Math.floor(Date.now() / 1000); + const prepared = await imports.prepare({ req, request: importBody(), sessionKey, ttlSeconds: 60 }); + + const grant = openEgressGrant(prepared.request.egressGrant, SECRET); + expect(grant.input_files).toEqual([{ id: OBJECT, session_id: SESSION, name: 'report.pdf' }]); + expect(grant.read_sessions).toEqual([SESSION]); + expect(grant).toMatchObject({ max_upload_bytes: 0, max_output_files: 0, max_requests: 2, session_key: sessionKey }); + expect(grant.exp - before).toBeGreaterThanOrEqual(90); + expect(grant.exp - before).toBeLessThanOrEqual(91); + expect(prepared.request.source.id).not.toBe(OBJECT); + expect(prepared.request.source.storage_session_id).not.toBe(SESSION); + expect(openEgressHandle(prepared.request.source.id, SECRET)).toMatchObject({ typ: 'object', object_id: OBJECT, session_id: SESSION }); + expect(openEgressHandle(prepared.request.source.storage_session_id, SECRET)).toMatchObject({ typ: 'session', dir: 'read', session_id: SESSION }); + const { egressGrant: _grant, source: _source, ...unchanged } = prepared.request; + const { source: _callerSource, ...callerFields } = importBody(); + expect(unchanged).toEqual(callerFields); +}); + +test('a synchronous import relays the granted request and revokes its grant', async () => { + await upload(redis, owner, SESSION, OBJECT); + await upload(redis, stranger, OTHER_SESSION, OTHER_OBJECT); + const minted: string[] = []; + const revoked: string[] = []; + const dispatched: Array<{ request: WorkspaceToolRequest; executionTimeoutMs?: number }> = []; + const url = await listen(appFor(createWorkspaceToolsRouter({ + backend: 'remote-bridge', configuredWorkerId: 'worker', dynamicWorkers: false, timeoutMs: 120_000, + imports: importService(redis, minted, revoked, 8192), + store: { async dispatchWorkspaceTool(args) { + dispatched.push({ request: args.request, executionTimeoutMs: args.executionTimeoutMs }); + const request = args.request as WorkspaceImportFileRequest; + return { protocolVersion: 1, generation: 1, leaseToken: 'lease', incarnationId: 'incarnation', status: 'fulfilled', result: { + protocolVersion: 1, operation: 'import_file', workspaceId: 'primary', path: request.path, + created: true, bytesWritten: request.bytes, sha256: request.sha256, + } }; + } }, + }))); + const endpoint = `${url}/workspace-tools/execute`; + + const response = await post(endpoint, importBody()); + expect(response.status).toBe(200); + expect(await response.json()).toMatchObject({ operation: 'import_file', created: true, bytesWritten: 4096 }); + expect(dispatched).toHaveLength(1); + const relayed = dispatched[0]!.request as WorkspaceImportFileRequest; + expect(typeof relayed.egressGrant).toBe('string'); + expect(relayed.source.id).not.toBe(OBJECT); + expect(dispatched[0]!.executionTimeoutMs).toBe(120_000); + await Bun.sleep(0); + expect(revoked).toEqual(minted); + + const callerGrant = await post(endpoint, { ...importBody(), egressGrant: 'ceg1.forged' }); + expect(callerGrant.status).toBe(400); + const foreign = await post(endpoint, importBody({ source: { storage_session_id: OTHER_SESSION, id: OTHER_OBJECT } })); + expect(foreign.status).toBe(403); + expect(await foreign.json()).toMatchObject({ code: 'IMPORT_SOURCE_UNAUTHORIZED' }); + const stolen = await post(endpoint, importBody(), { 'X-Test-User': 'user-2' }); + expect(stolen.status).toBe(403); + const oversized = await post(endpoint, importBody({ bytes: 8193 })); + expect(oversized.status).toBe(413); + expect(await oversized.json()).toMatchObject({ code: 'WRITE_LIMIT_EXCEEDED' }); + expect(dispatched).toHaveLength(1); + expect(minted).toHaveLength(1); +}); + +test('a caller that disconnects while its grant is minted never dispatches, and the grant is revoked', async () => { + await upload(redis, owner, SESSION, OBJECT); + const minted: string[] = []; + const revoked: string[] = []; + const base = importService(redis, minted, revoked); + const grantRequested = Promise.withResolvers(); + const releaseGrant = Promise.withResolvers(); + let dispatched = 0; + const url = await listen(appFor(createWorkspaceToolsRouter({ + backend: 'remote-bridge', configuredWorkerId: 'worker', dynamicWorkers: false, + imports: { ...base, async prepare(args) { + grantRequested.resolve(); + await releaseGrant.promise; + return base.prepare(args); + } }, + store: { async dispatchWorkspaceTool(): Promise { dispatched++; throw new Error('must not dispatch'); } }, + }))); + const caller = new AbortController(); + const response = post(`${url}/workspace-tools/execute`, importBody(), {}, caller.signal).catch(() => undefined); + await grantRequested.promise; + caller.abort(); + await response; + await Bun.sleep(50); + releaseGrant.resolve(); + for (let attempt = 0; attempt < 100 && revoked.length === 0; attempt++) await Bun.sleep(10); + expect(dispatched).toBe(0); + expect(revoked).toEqual(minted); + expect(minted).toHaveLength(1); +}); + +test('a Code API that cannot mint grants refuses import_file without dispatching', async () => { + let dispatched = 0; + const url = await listen(appFor(createWorkspaceToolsRouter({ + backend: 'remote-bridge', configuredWorkerId: 'worker', dynamicWorkers: false, + store: { async dispatchWorkspaceTool(): Promise { dispatched++; throw new Error('must not dispatch'); } }, + }))); + const response = await post(`${url}/workspace-tools/execute`, importBody()); + expect(response.status).toBe(503); + expect(await response.json()).toMatchObject({ code: 'IMPORT_UNAVAILABLE' }); + expect(dispatched).toBe(0); +}); + +test('a durable import fingerprints the caller request and mints its grant once', async () => { + await upload(redis, owner, SESSION, OBJECT); + const bridge = new RedisBridgeStore(redis); + await bridge.register({ protocolVersion: 1, workerId: 'worker', incarnationId: 'incarnation-00000001', capabilities: { + sandboxProfile: 'anthropic-srt', statefulWorkspace: false, runtimes: [], + workspaceTools: { protocolVersion: 1, operations: ['read_file', 'import_file'], workspaces: [{ id: 'primary' }] }, + } }); + const minted: string[] = []; + const revoked: string[] = []; + const url = await listen(appFor(createWorkspaceToolsRouter({ + backend: 'remote-bridge', configuredWorkerId: 'worker', dynamicWorkers: false, + imports: importService(redis, minted, revoked), + requests: new RedisWorkspaceRequests(redis, bridge), + store: { async dispatchWorkspaceTool(): Promise { throw new Error('durable requests never dispatch synchronously'); } }, + }))); + const headers = { 'X-LibreChat-Workspace-Request-Id': 'import-request-000001' }; + for (let attempt = 0; attempt < 2; attempt++) { + const response = await post(`${url}/workspace-tools/requests`, importBody(), headers); + expect(response.status).toBe(202); + expect(await response.json()).toMatchObject({ state: 'queued', workspaceId: 'primary' }); + } + expect(minted).toHaveLength(1); + + // A retry of the same request answers from its record even once the upload is gone. + await redis.del(`session:${SESSION}`); + const retried = await post(`${url}/workspace-tools/requests`, importBody(), headers); + expect(retried.status).toBe(202); + expect(minted).toHaveLength(1); + + const keys = (await redis.keys('codeapi:workspace-requests:v1:*')).filter(key => !key.endsWith(':active')); + const records = await Promise.all(keys.map(key => redis.hget(key, 'record'))); + const stored = records.flatMap(record => record == null ? [] : [JSON.parse(record).request as WorkspaceImportFileRequest]); + expect(stored.length).toBeGreaterThan(0); + for (const request of stored) { + expect(openEgressGrant(request.egressGrant!, SECRET).grant_id).toBe(minted[0]); + expect(request.source.id).not.toBe(OBJECT); + } + + // Racing submissions of one new request ID: only the adopted grant survives. + const raceHeaders = { 'X-LibreChat-Workspace-Request-Id': 'import-request-000002' }; + await upload(redis, owner, SESSION, OBJECT); + const before = minted.length; + const raced = await Promise.all([0, 1].map(() => post(`${url}/workspace-tools/requests`, importBody(), raceHeaders))); + expect(raced.map(response => response.status)).toEqual([202, 202]); + const racedGrants = minted.slice(before); + expect(racedGrants.length).toBeGreaterThan(0); + expect(revoked).toHaveLength(racedGrants.length - 1); + + const mintedBeforeConflict = minted.length; + const conflict = await post(`${url}/workspace-tools/requests`, importBody({ bytes: 1 }), headers); + expect(conflict.status).toBe(409); + expect(minted).toHaveLength(mintedBeforeConflict); +}); diff --git a/service/src/workspace-tools/import.ts b/service/src/workspace-tools/import.ts new file mode 100644 index 00000000..6f22b55b --- /dev/null +++ b/service/src/workspace-tools/import.ts @@ -0,0 +1,182 @@ +import { posix } from 'path'; +import { nanoid } from 'nanoid'; + +import type * as t from '../types'; +import type { GatewayPreparedEgress } from '../egress-gateway-client'; +import type { ExecutionManifestClaims } from '../execution-manifest'; +import type { SessionOwnershipStore } from '../session-ownership'; +import type { WorkspaceImportFileRequest } from '../../../packages/code/src/protocol'; + +import { isValidId } from '../utils'; +import { authorizeSessionOwnership } from '../session-ownership'; +import { resolveSessionKey, SessionKeyResolutionError } from '../session-key'; +import { buildExecutionManifestClaims } from '../execution-manifest-claims'; + +/** One download plus one retry of the same object; nothing else is readable. */ +const IMPORT_GRANT_MAX_REQUESTS = 2; +/** Clock-skew slack beyond the request's own admission and execution budget. */ +const IMPORT_GRANT_TTL_SLACK_SECONDS = 30; + +export type WorkspaceImportErrorCode = + | 'INVALID_WORKSPACE_TOOL_REQUEST' + | 'IMPORT_SOURCE_UNAUTHORIZED' + | 'IMPORT_UNAVAILABLE'; + +export class WorkspaceImportError extends Error { + constructor( + readonly status: 400 | 403 | 503, + readonly code: WorkspaceImportErrorCode, + message: string, + ) { + super(message); + this.name = 'WorkspaceImportError'; + } +} + +/** Redis reads used to prove ownership: the session cache key and the upload record. */ +export interface WorkspaceImportSourceStore extends Pick { + exists(key: string): Promise; +} + +export type WorkspaceImportGrantFactory = (args: { + payload: t.PayloadBody; + claims: ExecutionManifestClaims; +}) => Promise; + +export interface PreparedWorkspaceImport { + grantId: string; + request: WorkspaceImportFileRequest & { egressGrant: string }; +} + +export interface WorkspaceImportService { + /** Largest file Code API relays, checked before ownership or dispatch. */ + readonly maxBytes: number; + /** Proves the caller owns `source` and returns the storage session key that owns it. */ + authorize(req: t.AuthenticatedRequest, request: WorkspaceImportFileRequest): Promise; + /** Mints the one-object grant and rewrites `source` into its opaque handles. */ + prepare(args: { + req: t.AuthenticatedRequest; + request: WorkspaceImportFileRequest; + sessionKey: string; + ttlSeconds: number; + }): Promise; + revoke(grantId: string): Promise; +} + +const unauthorized = (): WorkspaceImportError => + new WorkspaceImportError(403, 'IMPORT_SOURCE_UNAUTHORIZED', 'Unauthorized import source'); + +/** + * Applies the `GET /download/:session_id/:fileId` rule for a user-kind object: + * the live session cache key must name the caller's own storage key, and the + * object must have been uploaded under that key and session. + */ +export async function authorizeWorkspaceImportSource(args: { + req: t.AuthenticatedRequest; + source: WorkspaceImportFileRequest['source']; + store: WorkspaceImportSourceStore; +}): Promise { + const { req, source, store } = args; + if (!isValidId(source.storage_session_id) || !isValidId(source.id)) { + throw new WorkspaceImportError(400, 'INVALID_WORKSPACE_TOOL_REQUEST', 'Invalid import source'); + } + const userId = req.codeApiAuthContext?.userId; + if (!userId) throw unauthorized(); + let sessionKey: string; + try { + sessionKey = resolveSessionKey(req, { kind: 'user', id: userId }); + } catch (error) { + if (error instanceof SessionKeyResolutionError && error.status === 400) throw unauthorized(); + throw error; + } + const ownership = await authorizeSessionOwnership(store, { + session_id: source.storage_session_id, + expectedSessionKey: sessionKey, + allowExpiredCache: false, + }); + if (!ownership.authorized) throw unauthorized(); + const uploaded = await store.exists(`upload:${sessionKey}${source.storage_session_id}${source.id}`); + if (uploaded !== 1) throw unauthorized(); + return sessionKey; +} + +/** Claims for a read-only grant over exactly one object, with no upload capacity. */ +export function workspaceImportGrantClaims(args: { + req: t.AuthenticatedRequest; + request: WorkspaceImportFileRequest; + sessionKey: string; + ttlSeconds: number; + nowSeconds?: number; +}): { payload: t.PayloadBody; claims: ExecutionManifestClaims } { + const now = args.nowSeconds ?? Math.floor(Date.now() / 1000); + const payload: t.PayloadBody = { + language: 'workspace-import', + version: '1', + files: [{ + id: args.request.source.id, + storage_session_id: args.request.source.storage_session_id, + name: posix.basename(args.request.path), + }], + }; + const claims = buildExecutionManifestClaims({ + req: args.req, + executionId: `import_${nanoid()}`, + userId: args.req.codeApiAuthContext?.userId ?? '', + sessionKey: args.sessionKey, + outputSessionId: `import_${nanoid()}`, + payload, + nowSeconds: now, + }); + return { + payload, + claims: { + ...claims, + max_upload_bytes: 0, + max_output_files: 0, + max_requests: IMPORT_GRANT_MAX_REQUESTS, + tool_call_socket: false, + exp: now + Math.max(1, Math.ceil(args.ttlSeconds)) + IMPORT_GRANT_TTL_SLACK_SECONDS, + }, + }; +} + +function isPayloadFileRef(file: t.PayloadBody['files'][number] | undefined): file is t.PayloadFileRef { + return file != null && 'id' in file && typeof file.id === 'string' && + typeof file.storage_session_id === 'string'; +} + +export function createWorkspaceImportService(options: { + maxBytes: number; + store: WorkspaceImportSourceStore; + createGrant: WorkspaceImportGrantFactory; + revokeGrant: (grantId: string) => Promise; +}): WorkspaceImportService { + if (!Number.isSafeInteger(options.maxBytes) || options.maxBytes < 1) { + throw new RangeError('Workspace import file limit must be a positive safe integer'); + } + return { + maxBytes: options.maxBytes, + authorize: (req, request) => authorizeWorkspaceImportSource({ req, source: request.source, store: options.store }), + async prepare({ req, request, sessionKey, ttlSeconds }) { + let prepared: GatewayPreparedEgress; + try { + prepared = await options.createGrant(workspaceImportGrantClaims({ req, request, sessionKey, ttlSeconds })); + } catch { + throw new WorkspaceImportError(503, 'IMPORT_UNAVAILABLE', 'Import source grant could not be issued'); + } + const [file] = prepared.payload.files; + if (!isPayloadFileRef(file) || prepared.payload.files.length !== 1 || !prepared.egressGrantToken) { + throw new WorkspaceImportError(503, 'IMPORT_UNAVAILABLE', 'Import source grant is invalid'); + } + return { + grantId: prepared.grant_id, + request: { + ...request, + source: { storage_session_id: file.storage_session_id, id: file.id }, + egressGrant: prepared.egressGrantToken, + }, + }; + }, + revoke: options.revokeGrant, + }; +} diff --git a/service/src/workspace-tools/index.ts b/service/src/workspace-tools/index.ts index 3c7caee2..58c043d9 100644 --- a/service/src/workspace-tools/index.ts +++ b/service/src/workspace-tools/index.ts @@ -9,7 +9,9 @@ import { connection } from '../queue'; import logger from '../logger'; import { checkServiceStartUp, checkServiceShutDown } from '../lifecycle'; import { withSpan } from '../telemetry'; -import { isBridgeEnabled } from '../bridge/enabled'; +import { isBridgeEnabled, isWorkspaceImportEnabled } from '../bridge/enabled'; +import { createWorkspaceImportService } from './import'; +import { createGatewayEgressGrant, revokeGatewayEgressGrant } from '../egress-gateway-client'; import { workspaceRequestCoordinationScope } from './coordination'; const router = Router(); @@ -53,6 +55,14 @@ router.use(['/workspace-tools/execute', '/workspace-tools/requests'], (req, res, if (req.method === 'POST') executionLimiter(req, res, next); else next(); }); +const imports = isWorkspaceImportEnabled() + ? createWorkspaceImportService({ + maxBytes: env.BRIDGE_IMPORT_FILE_MAX_BYTES, + store: connection, + createGrant: args => createGatewayEgressGrant(args), + revokeGrant: grantId => revokeGatewayEgressGrant({ grantId, reason: 'completed' }), + }) + : undefined; router.use( createWorkspaceToolsRouter({ store: bridgeStore, @@ -61,6 +71,7 @@ router.use( configuredWorkerId: env.BRIDGE_WORKER_ID, dynamicWorkers: env.BRIDGE_DYNAMIC_WORKERS, timeoutMs: env.JOB_TIMEOUT, + imports, }), ); diff --git a/service/src/workspace-tools/requests.ts b/service/src/workspace-tools/requests.ts index 2988c7cc..902651fc 100644 --- a/service/src/workspace-tools/requests.ts +++ b/service/src/workspace-tools/requests.ts @@ -99,6 +99,13 @@ export class RedisWorkspaceRequests { request: WorkspaceToolRequest; queueWaitMs: number; executionTimeoutMs: number; + /** + * Derives the request a worker receives, such as an import carrying its + * egress grant. Runs only for a new request ID; the fingerprint, and so + * idempotent retries, cover the caller's request. `release` undoes the + * preparation when the queue does not adopt this submission. + */ + prepare?: () => Promise<{ request: WorkspaceToolRequest; release?: () => Promise }>; }): Promise { if (!WORKSPACE_REQUEST_ID_PATTERN.test(args.requestId)) { throw new BridgeStoreError('ASSIGNMENT_INVALID', 'Invalid durable workspace request ID'); @@ -117,21 +124,31 @@ export class RedisWorkspaceRequests { const registration = await this.bridge.prepareDurableWorkspaceTool({ ...args, tenantId: args.owner.tenantId, }); + const prepared = args.prepare == null ? undefined : await args.prepare(); + const release = async (): Promise => { await prepared?.release?.().catch(() => undefined); }; + const request = prepared?.request ?? args.request; const now = Date.now(); const record: StoredWorkspaceRequest = { id: `durable-${randomBytes(18).toString('base64url')}`, key, fingerprint, workerId: args.workerId, tenantId: args.owner.tenantId, requireTenantBinding: args.requireTenantBinding, - request: args.request, registration, createdAtMs: now, + request, registration, createdAtMs: now, queueDeadlineAtMs: now + args.queueWaitMs, executionTimeoutMs: args.executionTimeoutMs, state: 'queued', }; const queue = new BridgeAdmissionQueue(this.redis); - const accepted = await queue.submit({ - workerId: record.workerId, id: record.id, deadlineAtMs: record.queueDeadlineAtMs, - workspaceId: workspaceAdmissionId(record.request.workspaceId, record.request.workspaceInstanceId, record.request.worktree), - key, activeKey: this.activeKey, fingerprint, record: JSON.stringify(record), retentionMs: RETENTION_MS, - }); + let accepted: Awaited>; + try { + accepted = await queue.submit({ + workerId: record.workerId, id: record.id, deadlineAtMs: record.queueDeadlineAtMs, + workspaceId: workspaceAdmissionId(record.request.workspaceId, record.request.workspaceInstanceId, record.request.worktree), + key, activeKey: this.activeKey, fingerprint, record: JSON.stringify(record), retentionMs: RETENTION_MS, + }); + } catch (error) { + await release(); + throw error; + } + if (accepted !== 'accepted') await release(); if (accepted === 'conflict') throw new WorkspaceRequestConflict('Request ID was already used for different work'); if (accepted === 'full') throw new BridgeStoreError('WORKER_QUEUE_FULL', 'Bridge worker pending request limit reached'); const submitted = (await this.read(key))!; diff --git a/service/src/workspace-tools/router.ts b/service/src/workspace-tools/router.ts index d4d65d14..bf31e272 100644 --- a/service/src/workspace-tools/router.ts +++ b/service/src/workspace-tools/router.ts @@ -4,14 +4,19 @@ import type { RequestHandler, Response } from 'express'; import type { AuthenticatedRequest } from '../types'; import type { RedisBridgeStore } from '../bridge/store'; import type { WorkspaceToolRequest } from '../../../packages/code/src/protocol'; +import type { PreparedWorkspaceImport, WorkspaceImportService } from './import'; +import logger from '../logger'; import { getWorkspaceToolOutcome } from './outcome'; +import { WorkspaceImportError } from './import'; import { getPrincipalOrReject } from '../auth/principal'; import { BridgeStoreError } from '../bridge/store'; import { checkServiceShutDown } from '../lifecycle'; import { isWorkspaceToolRequest, + isBridgeWorkspaceImportFileRequest, BRIDGE_WORKSPACE_COMMAND_DEFAULT_TIMEOUT_MS, + BRIDGE_WORKSPACE_IMPORT_FILE_TIMEOUT_MS, } from '../../../packages/code/src/protocol'; import { CODEAPI_BRIDGE_WORKER_HEADER, @@ -35,6 +40,8 @@ interface WorkspaceToolsRouterOptions { timeoutMs?: number; queueTimeoutMs?: number; isShuttingDown?: () => boolean; + /** Present only when this Code API can mint egress grants for `import_file`. */ + imports?: WorkspaceImportService; } function asyncRoute(handler: (req: AuthenticatedRequest, res: Response) => Promise): RequestHandler { @@ -82,13 +89,28 @@ export function createWorkspaceToolsRouter(options: WorkspaceToolsRouterOptions) res.status(503).json({ error: 'Service is shutting down' }); return; } - if (!isWorkspaceToolRequest(req.body)) { + if ( + !isWorkspaceToolRequest(req.body) || + (req.body.operation === 'import_file' && !isBridgeWorkspaceImportFileRequest(req.body)) + ) { outcome.errorCode = 'INVALID_WORKSPACE_TOOL_REQUEST'; res.status(400).json({ error: 'Invalid workspace tool request', }); return; } + if (req.body.operation === 'import_file') { + if (options.imports == null) { + outcome.errorCode = 'IMPORT_UNAVAILABLE'; + res.status(503).json({ error: 'Workspace file import is not available on this Code API', code: 'IMPORT_UNAVAILABLE' }); + return; + } + if (req.body.bytes > options.imports.maxBytes) { + outcome.errorCode = 'WRITE_LIMIT_EXCEEDED'; + res.status(413).json({ error: 'Imported file exceeds the Code API file limit', code: 'WRITE_LIMIT_EXCEEDED' }); + return; + } + } const advertisedQueueWait = req.header(WORKSPACE_QUEUE_WAIT_HEADER); if (advertisedQueueWait !== undefined && !/^[1-9]\d*$/.test(advertisedQueueWait)) { outcome.errorCode = 'INVALID_WORKSPACE_QUEUE_WAIT'; @@ -121,9 +143,14 @@ export function createWorkspaceToolsRouter(options: WorkspaceToolsRouterOptions) options.timeoutMs ?? Number.MAX_SAFE_INTEGER, ) } : principalRequest; - const executionBudgetMs = request.operation === 'execute_command' - ? request.timeoutMs! + 5_000 - : Math.min(options.timeoutMs ?? 30_000, 30_000); + let executionBudgetMs = Math.min(options.timeoutMs ?? 30_000, 30_000); + if (request.operation === 'execute_command') executionBudgetMs = request.timeoutMs! + 5_000; + if (request.operation === 'import_file') { + executionBudgetMs = Math.min( + options.timeoutMs ?? BRIDGE_WORKSPACE_IMPORT_FILE_TIMEOUT_MS, + BRIDGE_WORKSPACE_IMPORT_FILE_TIMEOUT_MS, + ); + } outcome.deadlineBudgetMs = queueBudgetMs + executionBudgetMs; let selection: { workerId: string; explicit: boolean } | undefined; @@ -152,20 +179,47 @@ export function createWorkspaceToolsRouter(options: WorkspaceToolsRouterOptions) } outcome.workerId = selection.workerId; + const sendImportError = (error: unknown): boolean => { + if (!(error instanceof WorkspaceImportError)) return false; + outcome.errorCode = error.code; + res.status(error.status).json({ error: error.message, code: error.code }); + return true; + }; + const imports = options.imports; + const importRequest = request.operation === 'import_file' ? request : undefined; + /** Ownership and the grant are settled only for work about to be queued or dispatched, + * so a durable retry of an existing request never depends on the upload still existing. */ + const prepareImport = imports == null || importRequest == null + ? undefined + : async (): Promise => { + const sessionKey = await imports.authorize(req, importRequest); + // The grant must outlive admission and execution, never more. + const ttlSeconds = Math.ceil((queueBudgetMs + executionBudgetMs) / 1000); + return imports.prepare({ req, request: importRequest, sessionKey, ttlSeconds }); + }; + if (durable) { if (options.requests == null) { res.status(404).json({ error: 'Durable workspace requests are unavailable' }); return; } try { + const prepare = prepareImport; const status = await options.requests.submit({ owner: principal, requestId: req.header('X-LibreChat-Workspace-Request-Id') ?? '', workerId: selection.workerId, requireTenantBinding: selection.explicit && (options.dynamicWorkers || selection.workerId !== options.configuredWorkerId), request, queueWaitMs: queueBudgetMs, executionTimeoutMs: executionBudgetMs, + ...(prepare == null || imports == null ? {} : { + prepare: async () => { + const prepared = await prepare(); + return { request: prepared.request, release: () => imports.revoke(prepared.grantId) }; + }, + }), }); res.status(202).json(status); } catch (error) { + if (sendImportError(error)) return; if (error instanceof WorkspaceRequestConflict) { res.status(409).json({ error: error.message, code: 'REQUEST_CONFLICT' }); } else if (error instanceof BridgeStoreError) { @@ -182,7 +236,24 @@ export function createWorkspaceToolsRouter(options: WorkspaceToolsRouterOptions) if (!res.writableEnded) abort(); }; res.once('close', abortClosedResponse); + // Events that fired before these listeners existed are visible only on the socket. + if (req.socket?.destroyed === true) abort(); + let preparedImport: PreparedWorkspaceImport | undefined; try { + if (prepareImport != null) { + try { + preparedImport = await prepareImport(); + } catch (error) { + if (sendImportError(error)) return; + throw error; + } + // A caller that left while ownership or the grant was settled must not start a mutation. + if (controller.signal.aborted) { + outcome.errorCode = 'CLIENT_DISCONNECTED'; + return; + } + } + const dispatchRequest: WorkspaceToolRequest = preparedImport?.request ?? request; outcome.dispatchPending = true; const dispatchStartedAt = performance.now(); const settlement = await options.store.dispatchWorkspaceTool({ @@ -190,7 +261,7 @@ export function createWorkspaceToolsRouter(options: WorkspaceToolsRouterOptions) tenantId: principal.tenantId, requireTenantBinding: selection.explicit && (options.dynamicWorkers || selection.workerId !== options.configuredWorkerId), - request, + request: dispatchRequest, deadlineAtMs: Date.now() + queueBudgetMs, executionTimeoutMs: executionBudgetMs, signal: controller.signal, @@ -243,6 +314,12 @@ export function createWorkspaceToolsRouter(options: WorkspaceToolsRouterOptions) outcome.flush(); req.removeListener('aborted', abort); res.removeListener('close', abortClosedResponse); + if (preparedImport != null && options.imports != null) { + const { grantId } = preparedImport; + void options.imports.revoke(grantId).catch(error => { + logger.warn('Failed to revoke workspace import grant', { grantId, error }); + }); + } } }); router.post('/workspace-tools/execute', execute(false)); diff --git a/tests/compose-bridge-config.cjs b/tests/compose-bridge-config.cjs index bafea2fe..79268b52 100644 --- a/tests/compose-bridge-config.cjs +++ b/tests/compose-bridge-config.cjs @@ -16,6 +16,7 @@ function render(overrides) { CODEAPI_BRIDGE_TOKEN: '', CODEAPI_BRIDGE_MAX_WORKSPACE_LEASE_SLOTS: '', CODEAPI_BRIDGE_LANE_GIT: '', + CODEAPI_BRIDGE_IMPORT_FILE_MAX_BYTES: '', CODEAPI_BRIDGE_RECOVERY_SERVER_ID: '', CODEAPI_BRIDGE_ENROLLMENT_TTL_SECONDS: '', CODEAPI_BRIDGE_RECOVERY_CHALLENGE_TTL_SECONDS: '', @@ -36,6 +37,7 @@ for (const overrides of [ CODEAPI_BRIDGE_WORKER_ID: 'test-worker', CODEAPI_BRIDGE_MAX_WORKSPACE_LEASE_SLOTS: '4', CODEAPI_BRIDGE_LANE_GIT: 'true', + CODEAPI_BRIDGE_IMPORT_FILE_MAX_BYTES: '1048576', CODEAPI_BRIDGE_RECOVERY_SERVER_ID: 'https://code.example.test', CODEAPI_BRIDGE_ENROLLMENT_TTL_SECONDS: '86400', CODEAPI_BRIDGE_RECOVERY_CHALLENGE_TTL_SECONDS: '90', @@ -61,6 +63,10 @@ for (const overrides of [ assert.equal(env.CODEAPI_BRIDGE_RECOVERY_MAX_ATTEMPTS_PER_MINUTE, overrides.CODEAPI_BRIDGE_RECOVERY_MAX_ATTEMPTS_PER_MINUTE ?? '30'); assert.equal(env.CODEAPI_BRIDGE_RECOVERY_MAX_UNTRUSTED_PER_MINUTE, overrides.CODEAPI_BRIDGE_RECOVERY_MAX_UNTRUSTED_PER_MINUTE ?? '240'); } + assert.equal( + config.services.api.environment.CODEAPI_BRIDGE_IMPORT_FILE_MAX_BYTES, + overrides.CODEAPI_BRIDGE_IMPORT_FILE_MAX_BYTES ?? '104857600', + ); for (const name of ['egress_gateway', 'sandbox-runner']) { assert.equal(config.services[name].environment.CODEAPI_BRIDGE_TOKEN, undefined); assert.equal(config.services[name].environment.CODEAPI_BRIDGE_RECOVERY_SERVER_ID, undefined); diff --git a/tests/package_init_runtime_rollout.sh b/tests/package_init_runtime_rollout.sh index 3bb8cf6c..9c09d542 100755 --- a/tests/package_init_runtime_rollout.sh +++ b/tests/package_init_runtime_rollout.sh @@ -15,6 +15,11 @@ render() { --set workerSandbox.enabled=true \ --set workerSandbox.packages.source=pvc "$@" } +# The hook installs the runtime selected by service requests. +render | grep -A 1 'name: NODE_VERSION' | grep -q 'value: "24.22.1"' +# Explicit runtime selections still override the chart default. +render --set workerSandbox.packages.initJob.nodeVersion=24.22.0 \ + | grep -A 1 'name: NODE_VERSION' | grep -q 'value: "24.22.0"' # Default mutable image must refresh on every pre-install/pre-upgrade hook. render | grep -q 'imagePullPolicy: Always' # Reused historical values must not bypass the runtime migration. diff --git a/tests/test_tool_call_pipes.py b/tests/test_tool_call_pipes.py new file mode 100644 index 00000000..79a6ed07 --- /dev/null +++ b/tests/test_tool_call_pipes.py @@ -0,0 +1,40 @@ +import unittest +from unittest.mock import patch + +from tool_call_pipes import wait_for_killed_descendant + + +class DescendantReapingTest(unittest.TestCase): + def test_already_reaped_by_parent(self): + with patch('tool_call_pipes.os.waitpid', side_effect=ChildProcessError), \ + patch('tool_call_pipes.os.path.exists', return_value=False), \ + patch('tool_call_pipes.time.monotonic', side_effect=[0, 0, 6]), \ + patch('tool_call_pipes.time.sleep'), \ + patch('tool_call_pipes.os.kill') as kill: + self.assertIsNone(wait_for_killed_descendant(123)) + kill.assert_not_called() + + def test_waits_for_adoption(self): + with patch('tool_call_pipes.os.waitpid', side_effect=[ChildProcessError, (123, 9)]), \ + patch('tool_call_pipes.os.path.exists', return_value=True), \ + patch('tool_call_pipes.time.sleep'): + wait_for_killed_descendant(123) + + def test_rejects_wrong_exit_status(self): + with patch('tool_call_pipes.os.waitpid', return_value=(123, 0)): + with self.assertRaises(AssertionError): + wait_for_killed_descendant(123) + + def test_live_nonchild_still_fails_at_deadline(self): + with patch('tool_call_pipes.os.waitpid', side_effect=ChildProcessError), \ + patch('tool_call_pipes.os.path.exists', return_value=True), \ + patch('tool_call_pipes.time.monotonic', side_effect=[0, 0, 6]), \ + patch('tool_call_pipes.time.sleep'), \ + patch('tool_call_pipes.os.kill') as kill: + with self.assertRaises(AssertionError): + wait_for_killed_descendant(123) + kill.assert_called_once_with(123, 9) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/tool_call_pipes.py b/tests/tool_call_pipes.py index 659993b2..df9f296a 100644 --- a/tests/tool_call_pipes.py +++ b/tests/tool_call_pipes.py @@ -37,6 +37,25 @@ def do_POST(self): self.wfile.write(body) +def wait_for_killed_descendant(pid, timeout=5): + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + try: + reaped, status = os.waitpid(pid, os.WNOHANG) + except ChildProcessError: + # Adoption is asynchronous, and the original parent may reap it. + # ECHILD alone does not prove that a live descendant has exited. + if not os.path.exists(f'/proc/{pid}'): + return + else: + if reaped: + assert os.waitstatus_to_exitcode(status) == -9 + return + time.sleep(0.01) + os.kill(pid, 9) + raise AssertionError('orphan survived broker death') + + def main(): policy, broker, preamble = sys.argv[1:] server = http.server.ThreadingHTTPServer(('127.0.0.1', 0), Upstream) @@ -159,20 +178,7 @@ async def check(): running.kill() running.communicate(timeout=5) for pid in [job_pid, relay_pid, broker_pid]: - deadline = time.monotonic() + 5 - while time.monotonic() < deadline: - try: - reaped, status = os.waitpid(pid, os.WNOHANG) - except ChildProcessError: - # Adoption by this subreaper can lag the controller's exit. - reaped = 0 - if reaped: - assert os.waitstatus_to_exitcode(status) == -9, (pid, os.waitstatus_to_exitcode(status)) - break - time.sleep(0.01) - else: - os.kill(pid, 9) - raise AssertionError('orphan survived broker death') + wait_for_killed_descendant(pid) finally: if running.poll() is None: running.kill()