diff --git a/.github/workflows/live-inference.yml b/.github/workflows/live-inference.yml new file mode 100644 index 0000000..8a018fa --- /dev/null +++ b/.github/workflows/live-inference.yml @@ -0,0 +1,86 @@ +name: Local live inference + +on: + pull_request: + types: [opened, synchronize, reopened, labeled] + workflow_dispatch: + inputs: + model: + description: Explicit free OpenCode model; there is no fallback + default: opencode/glm-5.3-flash-free + required: true + type: string + +permissions: + contents: read + +concurrency: + group: live-inference-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + opencode: + # Explicit owner-only live check, separate from the normal merge gate. + if: >- + (github.event_name == 'workflow_dispatch' && github.actor == github.repository_owner) || + (github.event.pull_request.head.repo.full_name == github.repository && + github.event.pull_request.user.login == github.repository_owner && + contains(github.event.pull_request.labels.*.name, 'live-inference')) + # Pin the image rather than changing host namespace security policy. + runs-on: ubuntu-22.04 + timeout-minutes: 15 + steps: + - uses: actions/checkout@v7 + with: + persist-credentials: false + - uses: pnpm/action-setup@v6.1.0 + - uses: actions/setup-node@v7 + with: + node-version: 22 + cache: pnpm + - name: Install and probe the filesystem jail on this disposable runner + shell: bash + run: | + set -euo pipefail + sudo apt-get update -qq + sudo apt-get install --no-install-recommends -y bubblewrap + bwrap --unshare-user --ro-bind / / -- /bin/true + - run: pnpm install --frozen-lockfile --strict-peer-dependencies + - name: Typecheck, complete suite, and published Runtime consumer + run: pnpm typecheck && pnpm test + - name: Prove DNS preservation and sibling-file isolation in the actual jail + run: pnpm exec vitest run tests/jail-resolver.test.ts + env: + CLI_BRIDGE_REAL_OPENCODE_TESTS: '1' + - name: Install checksum-pinned OpenCode into this job only + shell: bash + run: | + set -euo pipefail + test "$(uname -m)" = x86_64 + tool="$RUNNER_TEMP/opencode-$GITHUB_RUN_ID-$GITHUB_RUN_ATTEMPT" + mkdir -m 700 "$tool" + mkdir -m 700 "$tool/bin" + curl --fail --silent --show-error --location --max-time 120 \ + https://github.com/anomalyco/opencode/releases/download/v1.19.3/opencode-linux-x64-baseline.tar.gz \ + --output "$tool/opencode.tar.gz" + printf '%s %s\n' 531cff2441129c30f0edd5878af494108ad07f5feb46d7ee29a2a5b830a5765d "$tool/opencode.tar.gz" | sha256sum --check --status + tar --extract --gzip --no-same-owner --file "$tool/opencode.tar.gz" --directory "$tool/bin" + test -x "$tool/bin/opencode" + mv "$tool/bin/opencode" "$tool/bin/opencode-native" + printf '%s\n' '#!/bin/sh' 'exec "$(dirname "$0")/opencode-native" --print-logs --log-level DEBUG "$@"' > "$tool/bin/opencode" + chmod 700 "$tool/bin/opencode" + echo "OPENCODE_BIN=$tool/bin/opencode" >> "$GITHUB_ENV" + - name: Real Runtime, local bridge, OpenCode, and explicitly selected free inference + run: pnpm test:local-inference + env: + CLI_BRIDGE_REAL_OPENCODE_TESTS: '1' + CLI_BRIDGE_LIVE_MODEL: ${{ inputs.model || 'opencode/glm-5.3-flash-free' }} + CLI_BRIDGE_LIVE_OUT: ${{ runner.temp }}/cli-bridge-live-${{ github.run_id }}-${{ github.run_attempt }} + - name: Retain the actual execution receipt, including failures + if: always() + uses: actions/upload-artifact@v4 + with: + name: local-inference-${{ github.run_id }}-${{ github.run_attempt }} + path: ${{ runner.temp }}/cli-bridge-live-${{ github.run_id }}-${{ github.run_attempt }} + if-no-files-found: warn + retention-days: 14 diff --git a/package.json b/package.json index 048ee68..5553450 100644 --- a/package.json +++ b/package.json @@ -12,8 +12,9 @@ "dev": "tsx watch src/server.ts", "start": "tsx src/server.ts", "build": "tsc --noEmit", - "test": "vitest run && pnpm run test:runtime-consumer", + "test": "node --check tests/runtime-consumer/live-opencode.mjs && vitest run && pnpm run test:runtime-consumer", "test:runtime-consumer": "pnpm --filter cli-bridge-runtime-consumer-regression test", + "test:local-inference": "node tests/runtime-consumer/live-opencode.mjs", "test:scoped-host-real": "CLI_BRIDGE_REAL_CGROUP_TESTS=1 vitest run tests/scoped-host.test.ts", "test:watch": "vitest", "typecheck": "tsc --noEmit", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3e13513..ae6a154 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -210,8 +210,8 @@ importers: tests/runtime-consumer: devDependencies: '@tangle-network/agent-eval': - specifier: 0.213.0 - version: 0.213.0 + specifier: 0.214.0 + version: 0.214.0 '@tangle-network/agent-interface': specifier: 5.2.1 version: 5.2.1 @@ -219,8 +219,8 @@ importers: specifier: 0.25.2 version: 0.25.2(@tangle-network/agent-interface@5.2.1) '@tangle-network/agent-runtime': - specifier: 0.319.8 - version: 0.319.8(@tangle-network/agent-eval@0.213.0)(@tangle-network/agent-interface@5.2.1)(@tangle-network/sandbox@0.60.51) + specifier: 0.325.0 + version: 0.325.0(@tangle-network/agent-eval@0.214.0)(@tangle-network/agent-interface@5.2.1)(@tangle-network/sandbox@0.60.51) '@tangle-network/sandbox': specifier: 0.60.51 version: 0.60.51 @@ -574,8 +574,8 @@ packages: '@modelcontextprotocol/sdk': optional: true - '@tangle-network/agent-eval@0.213.0': - resolution: {integrity: sha512-K6jvHzuMRXas9VTeoYX1uJKCuSVkQexBS1ZamDdHFXjICfdUVmU2ZBQPuOVWe41drtXh6aEt/nprL8TiDCQjRg==} + '@tangle-network/agent-eval@0.214.0': + resolution: {integrity: sha512-nAUsWIiTAwTU/ptbliucXcNhqCJqhxiiLP+t3ZwKQ6Q/u0uioXpnU+03ff0n0FT4V1pLncMpOdXTqPNtiPRO4g==} engines: {node: '>=20.19.0'} hasBin: true @@ -585,31 +585,34 @@ packages: '@tangle-network/agent-interface@5.2.1': resolution: {integrity: sha512-JSMUrb+lGD/1aoBMK24bi88HtZkM4Wd2HpVfpNwjTLUDwAoHxV4nYSp7WCne8biFphIEsD7nE5+0APOfx1oauw==} - '@tangle-network/agent-knowledge@22.0.1': - resolution: {integrity: sha512-yHrb073dXoATW5n6YrulacbKzXh0DRW6NQvjulu7DY60Fnb2YioFlsw1uNoje88roa7gfzo7+xg9WbMc4GjvVg==} + '@tangle-network/agent-knowledge@22.0.2': + resolution: {integrity: sha512-wLsiSc70k89uI3/qOoHIGNqi0vX5sjJdm+w2o21XnRtx+aBvr2T9bvUNaiI/YF+aDjzZiZkA6TkGR9f1U7lzIw==} engines: {node: '>=22.12.0'} hasBin: true peerDependencies: - '@tangle-network/agent-eval': '>=0.213.0 <0.214.0' - '@tangle-network/agent-interface': ^5.0.0 + '@tangle-network/agent-eval': '>=0.213.0 <0.215.0' + '@tangle-network/agent-interface': ^5.2.0 '@tangle-network/agent-profile-materialize@0.25.2': resolution: {integrity: sha512-W0stC2dOyDEg+ZlcNAvjlq8Np2VIdt5kirRgJYffyn5tYcpOjtPU6FvPK6C+7XMOHfzR2RtHFmI8Vtx4yccssA==} peerDependencies: '@tangle-network/agent-interface': ^3.0.0 || ^4.0.0 || ^5.0.0 - '@tangle-network/agent-runtime@0.319.8': - resolution: {integrity: sha512-wxorL+mbRrV2OhyLPPSHdB7UjCINax2jGDDxTjpMVq4LTzuARPFQZDs7r36ewXl8YbuDfnneMYdTyptx5I551w==} + '@tangle-network/agent-runtime@0.325.0': + resolution: {integrity: sha512-VpiZ4Lwy+pJhLAMN9/om/Xu3EnCGa9qSfqVPNXQ988pTW7jjBVevx9IQbeMLUi6CVaPxRcIkHRrIVxFrJTrKug==} engines: {node: '>=22.19.0'} hasBin: true peerDependencies: - '@tangle-network/agent-eval': '>=0.213.0 <0.214.0' - '@tangle-network/agent-interface': ^5.2.1 - '@tangle-network/sandbox': '>=0.58.4 <0.61.0' + '@tangle-network/agent-eval': '>=0.214.0 <0.215.0' + '@tangle-network/agent-interface': '>=5.2.1 <5.3.0' + '@tangle-network/sandbox': '>=0.58.4 <0.60.59' '@tangle-network/agent-trace-contract@2.0.0': resolution: {integrity: sha512-lq2hp3gg8FsNVA3yZAyuj3NvL57pJTsI6sUF8cYwVsUXA8QZlrO78SqRPRslWxf/sMFHxXhusOuDQ3YxVv12bw==} + '@tangle-network/agent-trace-contract@2.1.0': + resolution: {integrity: sha512-hIwcFUVHDx2sd1u0kl26Xr7Gt5RlqestB/h4IanX4Wi2Q85UiWmnWTgNlzIR/1877OI5SJs5FcAmo6iyFVDcZA==} + '@tangle-network/harness-sessions@0.1.1': resolution: {integrity: sha512-NccSUO+pj9dmkx0uAB5C33gvTP52pjk3U7iZtyGoOzKPPj2s0JCbu/zRbo93G8hU6w0Xet79WRWbPEbAlFoeWQ==} engines: {node: '>=22.13.0'} @@ -1398,7 +1401,7 @@ snapshots: '@tangle-network/agent-trace-contract': 2.0.0 zod: 4.6.5 - '@tangle-network/agent-eval@0.213.0': + '@tangle-network/agent-eval@0.214.0': dependencies: '@asteasolutions/zod-to-openapi': 9.1.0(zod@4.6.5) '@hono/node-server': 2.1.1(hono@4.13.9) @@ -1426,9 +1429,9 @@ snapshots: spdx-expression-parse: 5.0.0 zod: 4.6.5 - '@tangle-network/agent-knowledge@22.0.1(@tangle-network/agent-eval@0.213.0)(@tangle-network/agent-interface@5.2.1)': + '@tangle-network/agent-knowledge@22.0.2(@tangle-network/agent-eval@0.214.0)(@tangle-network/agent-interface@5.2.1)': dependencies: - '@tangle-network/agent-eval': 0.213.0 + '@tangle-network/agent-eval': 0.214.0 '@tangle-network/agent-interface': 5.2.1 '@types/proper-lockfile': 4.1.4 proper-lockfile: 4.1.2 @@ -1438,15 +1441,15 @@ snapshots: dependencies: '@tangle-network/agent-interface': 5.2.1 - '@tangle-network/agent-runtime@0.319.8(@tangle-network/agent-eval@0.213.0)(@tangle-network/agent-interface@5.2.1)(@tangle-network/sandbox@0.60.51)': + '@tangle-network/agent-runtime@0.325.0(@tangle-network/agent-eval@0.214.0)(@tangle-network/agent-interface@5.2.1)(@tangle-network/sandbox@0.60.51)': dependencies: '@earendil-works/pi-codemode': 1.0.4 '@tangle-network/agent-core': 0.12.0 - '@tangle-network/agent-eval': 0.213.0 + '@tangle-network/agent-eval': 0.214.0 '@tangle-network/agent-interface': 5.2.1 - '@tangle-network/agent-knowledge': 22.0.1(@tangle-network/agent-eval@0.213.0)(@tangle-network/agent-interface@5.2.1) + '@tangle-network/agent-knowledge': 22.0.2(@tangle-network/agent-eval@0.214.0)(@tangle-network/agent-interface@5.2.1) '@tangle-network/agent-profile-materialize': 0.25.2(@tangle-network/agent-interface@5.2.1) - '@tangle-network/agent-trace-contract': 2.0.0 + '@tangle-network/agent-trace-contract': 2.1.0 '@tangle-network/sandbox': 0.60.51 tar-stream: 3.2.1 zod: 4.6.5 @@ -1458,6 +1461,8 @@ snapshots: '@tangle-network/agent-trace-contract@2.0.0': {} + '@tangle-network/agent-trace-contract@2.1.0': {} + '@tangle-network/harness-sessions@0.1.1': dependencies: '@tangle-network/agent-trace-contract': 2.0.0 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index c682937..59f115f 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -7,9 +7,8 @@ packages: # once its version is older than the minimum release age. minimumReleaseAgeExclude: - '@tangle-network/agent-interface@5.2.1' - - '@tangle-network/agent-runtime@0.319.8' + - '@tangle-network/agent-runtime@0.325.0' - '@tangle-network/sandbox@0.60.51' - - '@tangle-network/agent-knowledge@22.0.1' # Dependencies allowed to run install scripts. better-sqlite3 needs its install script to # obtain the native binding the session store loads; esbuild needs its platform binary. diff --git a/src/jail/linux-bwrap.ts b/src/jail/linux-bwrap.ts index 5dd220d..76c74df 100644 --- a/src/jail/linux-bwrap.ts +++ b/src/jail/linux-bwrap.ts @@ -26,7 +26,7 @@ */ import { spawnSync } from 'node:child_process' -import { accessSync, constants, existsSync, realpathSync } from 'node:fs' +import { accessSync, constants, existsSync, realpathSync, statSync } from 'node:fs' import { homedir } from 'node:os' import { basename, delimiter, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path' import { @@ -51,8 +51,8 @@ const BWRAP_BIN = 'bwrap' * follows the symlink to the target dir), so `#!/bin/sh` shebangs and * PATH lookups work without assuming a layout. * - /etc supplies resolv.conf, TLS trust (ssl/ca-certificates), passwd/nss. - * - /run/systemd/resolve is the stub-resolv.conf target on systemd-resolved - * hosts (where /etc/resolv.conf is a symlink into it), needed for DNS. + * A resolver symlink's exact target is bound separately, not its parent + * directory: WSL and other hosts may keep it outside /etc. * * Deliberately absent: /home, /root, /tmp, /var, /mnt, /media — the host repo * (task defs / grader keys) and sibling run scratch dirs live under those and @@ -64,7 +64,6 @@ const SYSTEM_RO_PATHS: readonly string[] = [ '/bin', '/sbin', '/lib', '/lib64', '/lib32', '/libx32', '/etc', '/opt', - '/run/systemd/resolve', ] export class LinuxBwrapJail implements JailBackend { @@ -99,6 +98,10 @@ export class LinuxBwrapJail implements JailBackend { throw new Error('a copy-writable jail auth source requires envVar') } } + // Resolve before copying credentials. Only a regular DNS configuration + // file is admitted, never an extra /mnt or /run directory mount. + const resolver = spec.readConfine ? tryRealpath('/etc/resolv.conf') : null + if (resolver && !statSync(resolver).isFile()) throw new Error('DNS resolver is not a regular file') await removeStaleAuthCopies(root) const copiedWritableAuth = await copyAuthIntoJail( root, @@ -173,6 +176,9 @@ export class LinuxBwrapJail implements JailBackend { } // Writable root last so it wins over any read-only mount above it. bwrapArgs.push('--bind', root, root) + if (resolver && resolver !== '/etc/resolv.conf') { + bwrapArgs.push('--ro-bind', resolver, resolver) + } // Make backend config available at its stable path inside the jail. // Read-only sources are bound after the writable root so they stay diff --git a/src/server.ts b/src/server.ts index 6406c87..3907aec 100644 --- a/src/server.ts +++ b/src/server.ts @@ -171,6 +171,12 @@ function preflightTargetFor(cfg: BackendExecutorConfig, bin: string, slotIndex: */ async function buildExecutorForBackend( cfg: BackendExecutorConfig | undefined, + /** + * The name the chat route resolves this backend by (`backend.name`). It keys the net-jail + * registry, so it must equal the registered harness name, which differs from the executor + * config name for claude (`claude-code`) and kimi (`kimi-code`). + */ + routeName: string, extras: BuildAppExtras, bin: string, config: Config, @@ -310,7 +316,7 @@ async function buildExecutorForBackend( // Recorded only here: after the network was verified AND a pool of live slots // was actually created on it. Anything earlier would let the chat route // report a jail for containers that do not exist. - if (netJail) extras.netJail.set(cfg.name, { network: netJail.network, allow: netJail.allow }) + if (netJail) extras.netJail.set(routeName, { network: netJail.network, allow: netJail.allow }) return createDockerSpawner({ pool, @@ -367,11 +373,12 @@ export async function buildApp(config: Config): Promise<{ // come first so a `claude-code/sonnet` doesn't get claimed by a // passthrough that happens to know a provider-prefixed model id. if (config.backends.has('claude')) { - const spawner = await buildExecutorForBackend(config.executors.claude, extras, config.claudeBin, config) + const harness = 'claude-code' + const spawner = await buildExecutorForBackend(config.executors.claude, harness, extras, config.claudeBin, config) registry.register(new ClaudeBackend({ bin: config.claudeBin, timeoutMs: config.claudeTimeoutMs, - harness: 'claude-code', + harness, ...(spawner ? { spawner } : {}), })) } @@ -386,7 +393,7 @@ export async function buildApp(config: Config): Promise<{ })) } if (config.backends.has('codex')) { - const spawner = await buildExecutorForBackend(config.executors.codex, extras, config.codexBin, config) + const spawner = await buildExecutorForBackend(config.executors.codex, 'codex', extras, config.codexBin, config) registry.register(new CodexBackend({ bin: config.codexBin, timeoutMs: config.codexTimeoutMs, @@ -395,7 +402,7 @@ export async function buildApp(config: Config): Promise<{ })) } if (config.backends.has('opencode')) { - const spawner = await buildExecutorForBackend(config.executors.opencode, extras, config.opencodeBin, config) + const spawner = await buildExecutorForBackend(config.executors.opencode, 'opencode', extras, config.opencodeBin, config) registry.register(new OpencodeBackend({ bin: config.opencodeBin, timeoutMs: config.opencodeTimeoutMs, @@ -403,16 +410,17 @@ export async function buildApp(config: Config): Promise<{ })) } if (config.backends.has('kimi')) { - const spawner = await buildExecutorForBackend(config.executors.kimi, extras, config.kimiBin, config) + const harness = 'kimi-code' + const spawner = await buildExecutorForBackend(config.executors.kimi, harness, extras, config.kimiBin, config) registry.register(new KimiBackend({ bin: config.kimiBin, timeoutMs: config.kimiTimeoutMs, - harness: 'kimi-code', + harness, ...(spawner ? { spawner } : {}), })) } if (config.backends.has('gemini')) { - const spawner = await buildExecutorForBackend(config.executors.gemini, extras, config.geminiBin, config) + const spawner = await buildExecutorForBackend(config.executors.gemini, 'gemini', extras, config.geminiBin, config) registry.register(new GeminiBackend({ bin: config.geminiBin, timeoutMs: config.geminiTimeoutMs, @@ -441,7 +449,7 @@ export async function buildApp(config: Config): Promise<{ registry.register(new NanoclawBackend({ socketPath: config.nanoclawSocket, timeoutMs: config.cliTimeoutMsDefault })) } if (config.backends.has('pi')) { - const spawner = await buildExecutorForBackend(config.executors.pi, extras, config.piBin, config) + const spawner = await buildExecutorForBackend(config.executors.pi, 'pi', extras, config.piBin, config) registry.register(new PiBackend({ bin: config.piBin, timeoutMs: config.piTimeoutMs, diff --git a/src/trace/contract-build.ts b/src/trace/contract-build.ts index f09c399..b083478 100644 --- a/src/trace/contract-build.ts +++ b/src/trace/contract-build.ts @@ -107,22 +107,20 @@ export interface LockedTraceContract { integrity: string } -/** - * Read the repo's own pnpm-lock.yaml entry for this package. - * - * Fails closed on zero matching entries (nothing pinned means the provenance - * check would silently verify nothing) and on more than one (two pinned builds - * make "the locked build" ambiguous — a side-loaded `file:` variant is exactly - * the shape that ambiguity hides). - */ +/** Read the root producer's exact registry build. pnpm may write a separate + * package-manager document first; other workspace consumers may pin other builds. */ export function lockedTraceContract(lockfilePath: string = defaultLockfilePath(import.meta.url)): LockedTraceContract { const text = readFileSync(lockfilePath, 'utf8') - const entryPattern = /^ {2}'@tangle-network\/agent-trace-contract@([^']+)':\r?\n {4}resolution: \{integrity: (sha512-[A-Za-z0-9+/]+={0,2})\}/gmu - const entries = [...text.matchAll(entryPattern)] + const roots = [...text.matchAll(/^ {2}\.:\r?\n((?:(?: {4}[^\r\n]*|)\r?\n)*)/gmu)] + const selections = roots.flatMap(root => [...root[1]!.matchAll(/^ {6}'@tangle-network\/agent-trace-contract':\r?\n {8}specifier: [^\r\n]+\r?\n {8}version: ([^\r\n]+)/gmu)].map(match => match[1])) + const selected = selections.length === 1 ? selections[0] : undefined + if (!selected || !/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/u.test(selected)) { + throw new Error(`${lockfilePath} must pin a registry ${PACKAGE} build for the root importer`) + } + const entries = [...text.matchAll(/^ {2}'@tangle-network\/agent-trace-contract@([^']+)':\r?\n {4}resolution: \{integrity: (sha512-[A-Za-z0-9+/]+={0,2})\}/gmu)].filter(match => match[1] === selected) const entry = entries[0] if (entries.length !== 1 || entry === undefined) { - const found = entries.map((match) => match[1]).join(', ') || 'none' - throw new Error(`${lockfilePath} must pin exactly one ${PACKAGE} build; found: ${found}`) + throw new Error(`${lockfilePath} must contain one integrity for the root importer's ${PACKAGE}@${selected}`) } const version = entry[1] const integrity = entry[2] diff --git a/tests/executor-saturation.test.ts b/tests/executor-saturation.test.ts index b396a48..07ede67 100644 --- a/tests/executor-saturation.test.ts +++ b/tests/executor-saturation.test.ts @@ -174,6 +174,8 @@ describe('host executor — typed saturation refusal', () => { }) it.skipIf(process.platform === 'win32')('holds the slot after leader exit while its helper survives', async () => { + // This checks process lifetime, not the 400ms admission cap tested above. + process.env.BRIDGE_HOST_MAX_ACQUIRE_DEADLINE_MS = '5000' const { hostSpawner, hostExecutorSnapshot } = await import('../src/executors/host.js') const { terminateSpawned } = await import('../src/executors/process-tree.js') const helperCode = "process.on('SIGTERM',()=>{});process.stdout.write('ready');setInterval(()=>{},1000)" @@ -190,7 +192,7 @@ describe('host executor — typed saturation refusal', () => { let admitted = false const waiting = hostSpawner('node', ['-e', 'setTimeout(()=>{},1000)'], { stdio: ['ignore', 'pipe', 'pipe'], - acquireDeadlineMs: 400, + acquireDeadlineMs: 5_000, }).then((spawned) => { admitted = true return spawned diff --git a/tests/jail-resolver.test.ts b/tests/jail-resolver.test.ts new file mode 100644 index 0000000..f30305d --- /dev/null +++ b/tests/jail-resolver.test.ts @@ -0,0 +1,52 @@ +/** Real filesystem-jail boundary: DNS remains usable without exposing sibling workspaces. */ +import { execFile } from 'node:child_process' +import { createHash, randomBytes } from 'node:crypto' +import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { promisify } from 'node:util' +import { expect, test } from 'vitest' +import { LinuxBwrapJail } from '../src/jail/linux-bwrap.js' + +const exec = promisify(execFile) +const jail = new LinuxBwrapJail() +const available = jail.isAvailable() +if (process.env.CLI_BRIDGE_REAL_OPENCODE_TESTS === '1' && !available) { + throw new Error('The requested live proof requires a working Linux filesystem jail') +} + +test.runIf(available)('reads the real DNS resolver read-only while hiding a sibling file', async () => { + const root = await mkdtemp(join(tmpdir(), 'jail-resolver-')) + const workspace = join(root, 'workspace') + const hidden = join(root, 'private-sibling') + await mkdir(workspace) + await writeFile(hidden, randomBytes(32), { mode: 0o600 }) + const expected = createHash('sha256').update(await readFile('/etc/resolv.conf')).digest('hex') + const script = ` + const fs = require('node:fs'); + const crypto = require('node:crypto'); + const digest = crypto.createHash('sha256').update(fs.readFileSync('/etc/resolv.conf')).digest('hex'); + let writable = false; + try { const fd = fs.openSync('/etc/resolv.conf', 'r+'); fs.closeSync(fd); writable = true; } + catch (error) { if (!['EROFS', 'EACCES', 'EPERM'].includes(error.code)) throw error; } + let siblingVisible = false; + try { fs.readFileSync(process.argv[1]); siblingVisible = true; } + catch (error) { if (!['ENOENT', 'EACCES', 'EPERM'].includes(error.code)) throw error; } + process.stdout.write(JSON.stringify({ digest, writable, siblingVisible })); + ` + let wrapped + try { + wrapped = await jail.wrap(process.execPath, ['-e', script, hidden], { + root: join(workspace, '.agent-home'), projectDir: workspace, + readConfine: true, requireEnforcement: true, + }) + const result = await exec(wrapped.bin, wrapped.args, { + cwd: workspace, timeout: 15_000, + env: { PATH: process.env.PATH, HOME: root }, + }) + expect(JSON.parse(result.stdout)).toEqual({ digest: expected, writable: false, siblingVisible: false }) + } finally { + await wrapped?.cleanup?.() + await rm(root, { recursive: true, force: true }) + } +}, 20_000) diff --git a/tests/pi-native.test.ts b/tests/pi-native.test.ts index 7d57882..fc7446e 100644 --- a/tests/pi-native.test.ts +++ b/tests/pi-native.test.ts @@ -134,7 +134,11 @@ function makeChildSpawner( const spawner: Spawner = Object.assign(async (bin: string, args: string[], options: Parameters[2]) => { const isRpc = args.includes('--mode') && args.includes('rpc') if (isRpc) calls.push({ bin, args: [...args] }) - const source = isRpc ? childSource : 'print("pi 0.83.0-test", flush=True)' + // The short RPC deadline tests missing acknowledgements, not Python startup. + // Signal readiness on stderr so no protocol response is consumed here. + const source = isRpc + ? childSource.replace('for line in sys.stdin:', 'print("bridge-fixture-ready", file=sys.stderr, flush=True)\nfor line in sys.stdin:') + : 'print("pi 0.83.0-test", flush=True)' const child = spawn('python3', ['-u', '-c', source], { cwd: options.cwd, env: { ...options.env, CLI_BRIDGE_TEST_ARGS: JSON.stringify(args) }, @@ -144,6 +148,30 @@ function makeChildSpawner( let released = false let terminated = false const closed = new Promise(resolve => child.once('close', () => resolve())) + if (isRpc) { + await new Promise((resolve, reject) => { + let stderr = '' + const cleanup = () => { + clearTimeout(timer) + child.stderr?.off('data', onData) + child.off('error', onError) + child.off('close', onClose) + } + const onError = (error: Error) => { cleanup(); reject(error) } + const onClose = () => onError(new Error(`Pi fixture exited before readiness: ${stderr}`)) + const onData = (chunk: Buffer) => { + stderr = (stderr + chunk.toString()).slice(-8192) + if (stderr.includes('bridge-fixture-ready\n')) { cleanup(); resolve() } + } + const timer = setTimeout(() => { + child.kill('SIGKILL') + onError(new Error(`Pi fixture did not initialize: ${stderr}`)) + }, 5_000) + child.stderr?.on('data', onData) + child.once('error', onError) + child.once('close', onClose) + }) + } return { child, release: () => { diff --git a/tests/prompt-intent.test.ts b/tests/prompt-intent.test.ts index 414728d..c9d895d 100644 --- a/tests/prompt-intent.test.ts +++ b/tests/prompt-intent.test.ts @@ -408,7 +408,7 @@ describe('prompt intents reach each harness through its own control', () => { ]) expect(readFileSync(join(configDir, 'agent-system-prompt.md'), 'utf8')).toBe(`${ADDITION}\n`) expect(req.profile_materialization_receipt!.files.map((file) => file.path)) - .toContain(`${basename(dirname(configDir))}/.opencode/agent-system-prompt.md`) + .toContain(`.cli-bridge-opencode-profile/.opencode/agent-system-prompt.md`) expect(readdirSync(cwd)).toEqual([basename(dirname(configDir))]) provisioned.cleanup?.() expect(readdirSync(cwd)).toEqual([]) diff --git a/tests/runtime-consumer/live-opencode.mjs b/tests/runtime-consumer/live-opencode.mjs new file mode 100644 index 0000000..ba86e62 --- /dev/null +++ b/tests/runtime-consumer/live-opencode.mjs @@ -0,0 +1,227 @@ +#!/usr/bin/env node +/** Real Runtime -> local cli-bridge -> OpenCode -> free inference. Never a mock or a fallback. */ +import assert from 'node:assert/strict' +import { constants } from 'node:fs' +import { access, mkdir, mkdtemp, readFile, readdir, rm, stat, writeFile } from 'node:fs/promises' +import { spawn, execFile } from 'node:child_process' +import { createHash, randomBytes, randomUUID } from 'node:crypto' +import { createServer } from 'node:net' +import { homedir, tmpdir } from 'node:os' +import { delimiter, dirname, join, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import { promisify } from 'node:util' +import { setTimeout as delay } from 'node:timers/promises' + +const exec = promisify(execFile) +const repository = fileURLToPath(new URL('../../', import.meta.url)) +const nativeModel = process.env.CLI_BRIDGE_LIVE_MODEL ?? 'opencode/glm-5.3-flash-free' +const receipt = { schema: 'cli-bridge.live-proof.v1', status: 'failed', model: nativeModel, stages: [] } +const hash = bytes => createHash('sha256').update(bytes).digest('hex') +let root, evidence, child, childClosed, childOutcome, bearer = '', bridgeLog = '', origin, env +let ownsEvidence = false +let executor + +async function save(name, value) { + await writeFile(join(evidence, name), typeof value === 'string' ? value : JSON.stringify(value, null, 2) + '\n', { mode: 0o600, flag: 'wx' }) +} +async function binary() { + const candidates = process.env.OPENCODE_BIN ? [process.env.OPENCODE_BIN] : [ + join(homedir(), '.opencode/bin/opencode'), + ...(process.env.PATH ?? '').split(delimiter).filter(Boolean).map(path => join(path, 'opencode')), + ] + for (const path of candidates) { + try { await access(path, constants.X_OK); return resolve(path) } catch {} + } + throw new Error('OpenCode is not installed. Install it or set OPENCODE_BIN to the actual executable; no inference ran.') +} +async function request(path) { + const response = await fetch(origin + path, { headers: { Authorization: `Bearer ${bearer}` }, signal: AbortSignal.timeout(5_000), redirect: 'error' }) + assert.equal(response.status, 200, `${path}: HTTP ${response.status}`) + return response.json() +} +async function startBridge() { + childOutcome = undefined + child = spawn(process.execPath, ['--import', import.meta.resolve('tsx'), join(repository, 'src/server.ts')], { + cwd: join(root, 'task'), env, detached: process.platform !== 'win32', stdio: ['ignore', 'pipe', 'pipe'], + }) + childClosed = new Promise(resolveClose => { + child.once('error', error => { childOutcome = { error: error.message }; resolveClose(childOutcome) }) + child.once('close', (code, signal) => { childOutcome = { code, signal }; resolveClose(childOutcome) }) + }) + for (const stream of [child.stdout, child.stderr]) stream.on('data', bytes => { + bridgeLog = (bridgeLog + bytes.toString()).slice(-256 * 1024) + }) + const deadline = Date.now() + 45_000 + let last = 'not ready' + while (Date.now() < deadline) { + if (childOutcome) throw new Error(`Bridge exited before readiness: ${JSON.stringify(childOutcome)}; ${bridgeLog.slice(-4000)}`) + try { await request('/health'); return } catch (error) { last = error.message } + await delay(200) + } + throw new Error(`Bridge did not become ready: ${last}; ${bridgeLog.slice(-4000)}`) +} +async function stopBridge() { + if (!child) return + if (child.exitCode === null && child.signalCode === null) { + child.kill('SIGTERM') + const closed = await Promise.race([childClosed.then(() => true), delay(8_000, undefined, { ref: false }).then(() => false)]) + if (!closed) { + try { process.platform === 'win32' ? child.kill('SIGKILL') : process.kill(-child.pid, 'SIGKILL') } catch (error) { if (error.code !== 'ESRCH') throw error } + await childClosed + } + } + child = undefined +} +async function session(id) { + const listing = await request('/v1/sessions?limit=500') + const row = listing.data.find(value => value.externalId === id && value.backend === 'opencode') + assert.ok(row?.internalId, 'A native OpenCode session mapping must be retained') + return row +} + +async function main() { + assert.equal(process.env.CLI_BRIDGE_REAL_OPENCODE_TESTS, '1', 'Opt in with CLI_BRIDGE_REAL_OPENCODE_TESTS=1; this invokes a real external model.') + assert.match(nativeModel, /^opencode\/[a-z0-9.-]+-free$/, 'Select an explicitly free OpenCode model. No paid model or silent fallback is allowed.') + assert.equal(process.platform, 'linux', 'This proof requires the existing Linux filesystem jail; it never downgrades confinement.') + const [major, minor] = process.versions.node.split('.').map(Number) + assert.ok(major > 22 || (major === 22 && minor >= 19), 'The live Runtime proof requires Node 22.19 or newer') + root = await mkdtemp(join(tmpdir(), 'cli-bridge-live-')) + evidence = process.env.CLI_BRIDGE_LIVE_OUT ? resolve(process.env.CLI_BRIDGE_LIVE_OUT) : `${root}-evidence` + await mkdir(evidence, { mode: 0o700 }) // Existing evidence is never overwritten. + ownsEvidence = true + for (const name of ['home', 'task', 'data', 'config', 'cache', 'state', 'bridge']) await mkdir(join(root, name), { mode: 0o700 }) + const opencode = await binary() + bearer = randomBytes(32).toString('hex') + // Fresh environment, no host auth, no managed Tangle endpoints, and a narrow task workspace. + env = { + PATH: process.env.PATH ?? '/usr/local/bin:/usr/bin:/bin', HOME: join(root, 'home'), + LANG: 'C.UTF-8', TERM: 'dumb', NO_COLOR: '1', CI: '1', + XDG_DATA_HOME: join(root, 'data'), XDG_CONFIG_HOME: join(root, 'config'), + XDG_CACHE_HOME: join(root, 'cache'), XDG_STATE_HOME: join(root, 'state'), + BRIDGE_HOST: '127.0.0.1', BRIDGE_BEARER: bearer, BRIDGE_DATA_DIR: join(root, 'bridge'), + BRIDGE_BACKENDS: 'opencode', OPENCODE_EXECUTOR: 'host', OPENCODE_BIN: opencode, + OPENCODE_TIMEOUT_MS: '180000', OPENCODE_DISABLE_AUTOUPDATE: '1', OPENCODE_DISABLE_SHARE: '1', + OPENCODE_INSTALL_DIR: dirname(dirname(opencode)), + BRIDGE_JAIL_MODE: 'fs-jail', + BRIDGE_RAW_CAPTURE_DIR: join(evidence, 'raw'), + } + for (const key of ['HTTPS_PROXY', 'HTTP_PROXY', 'NO_PROXY', 'SSL_CERT_FILE', 'NODE_EXTRA_CA_CERTS']) { + if (process.env[key]) env[key] = process.env[key] + } + receipt.opencodeVersion = (await exec(opencode, ['--version'], { env, cwd: join(root, 'task'), timeout: 20_000 })).stdout.trim() + receipt.bridgeCommit = (await exec('git', ['rev-parse', 'HEAD'], { cwd: repository, timeout: 5_000 })).stdout.trim() + receipt.nodeVersion = process.version + // This is availability, not successful inference. The later filesystem checks prove execution. + const models = (await exec(opencode, ['models', 'opencode'], { env, cwd: join(root, 'task'), timeout: 60_000, maxBuffer: 4 * 1024 * 1024 })).stdout + assert.ok(models.split(/\r?\n/).map(value => value.trim()).includes(nativeModel), `Requested model is absent from OpenCode: ${nativeModel}`) + receipt.stages.push({ name: 'model-discovery', status: 'passed' }) + const socket = createServer() + await new Promise((resolveListen, reject) => { socket.once('error', reject); socket.listen(0, '127.0.0.1', resolveListen) }) + env.BRIDGE_PORT = String(socket.address().port) + origin = `http://127.0.0.1:${env.BRIDGE_PORT}` + await new Promise((resolveClose, reject) => socket.close(error => error ? reject(error) : resolveClose())) + const { createExecutor } = await import('@tangle-network/agent-runtime/kernel') + const sessionId = `live-${randomUUID()}` + const profile = { name: 'local-inference-proof', harness: 'opencode', model: { provider: 'opencode', default: nativeModel.slice('opencode/'.length) } } + const marker = randomBytes(16).toString('hex') + const input = randomBytes(64).toString('hex') + '\n' + await writeFile(join(root, 'task/input.txt'), input, { mode: 0o600 }) + await startBridge() + + executor = createExecutor({ backend: 'bridge', bridgeUrl: origin, bridgeBearer: bearer, + cwd: join(root, 'task'), sessionId, timeoutMs: 180_000, + })({ profile, harness: null }, { signal: AbortSignal.timeout(420_000), seams: {} }) + async function turn(name, prompt) { + const signal = AbortSignal.timeout(180_000) + const events = [] + const stage = { name, status: 'failed', eventCount: 0 } + receipt.stages.push(stage) + const started = Date.now() + let artifact + try { + const stream = executor.execute(prompt, signal) + assert.ok(stream?.[Symbol.asyncIterator], 'Runtime must return a real streamed execution') + for await (const event of stream) { + assert.ok(events.length < 10_000, 'Live proof exceeded its event bound') + events.push(event) + } + artifact = executor.resultArtifact() + assert.notEqual(artifact.verdict?.valid, false, 'Runtime reported an invalid result') + stage.status = 'executed' + } finally { + stage.durationMs = Date.now() - started + stage.eventCount = events.length + await save(`${name}.json`, { events, artifact: artifact ?? null }) + } + } + await turn('filesystem-task', `Work only in your current directory. Read input.txt with a tool and compute the SHA-256 of its exact bytes using a command. Create answer.txt containing only the 64-character hexadecimal digest and a newline. Do not change input.txt. Remember this conversation marker for our next turn: ${marker}. Do not write the marker to any file. Reply briefly when finished.`) + assert.equal(await readFile(join(root, 'task/input.txt'), 'utf8'), input, 'The task input must remain unchanged') + assert.equal((await readFile(join(root, 'task/answer.txt'), 'utf8')).trim(), hash(input), 'The model must really compute the independently checked digest') + const first = await session(sessionId) + await save('session-before-restart.json', first) + receipt.stages.at(-1).status = 'passed' + + // Restart the actual server; do not replay the first prompt to manufacture continuity. + await stopBridge() + await startBridge() + const restored = await session(sessionId) + assert.equal(restored.internalId, first.internalId, 'Native session identity must survive bridge restart') + await turn('session-resume', 'Continue our previous conversation. Write its conversation marker, and nothing else except a newline, to resume.txt in the current directory. Do not read any other files or use search to find the marker; use your conversation context. Reply briefly when done.') + assert.equal((await readFile(join(root, 'task/resume.txt'), 'utf8')).trim(), marker, 'Resume must retain context without replaying the first prompt') + const resumed = await session(sessionId) + assert.equal(resumed.internalId, first.internalId, 'Resume must use the same native OpenCode session') + receipt.stages.at(-1).status = 'passed' + await save('session-after-restart.json', resumed) + await stopBridge() + const traces = await readFile(join(root, 'bridge/traces/spans.jsonl'), 'utf8') + const spans = traces.trim().split('\n').map(line => JSON.parse(line)) + assert.ok(spans.length >= 2, 'Both invocations must leave real trace records') + await save('spans.jsonl', traces) + receipt.nativeSessionId = first.internalId + receipt.traceRecords = spans.length + receipt.answerSha256 = hash(await readFile(join(root, 'task/answer.txt'))) + receipt.resumeSha256 = hash(await readFile(join(root, 'task/resume.txt'))) + receipt.scope = 'Local Runtime executor, bridge HTTP/process path, filesystem tools, native session restart/resume, and local trace retention. Not Agent App UI, cloud R2 custody, disaster recovery, or training eligibility.' + receipt.status = 'passed' +} + +try { await main() } catch (error) { + receipt.error = String(error?.stack ?? error).replaceAll(bearer || '\0', '[redacted]') + process.exitCode = 1 +} finally { + for (const cleanup of [() => executor?.teardown(5_000), stopBridge]) { + try { await cleanup() } catch (error) { + receipt.status = 'failed' + ;(receipt.cleanupErrors ??= []).push(error.message) + process.exitCode = 1 + } + } + if (ownsEvidence) { + // Read only this run's preflight and native jail HOME logs, never the operator's HOME. + let retainedBytes = 0 + for (const directory of ['data/opencode/log', 'task/.agent-home/.local/share/opencode/log']) { + const logs = join(root, directory) + for (const entry of await readdir(logs, { withFileTypes: true }).catch(error => { + if (error.code === 'ENOENT') return [] + throw error + })) { + if (!entry.isFile()) continue + const path = join(logs, entry.name) + const size = (await stat(path)).size + if (size > 2 * 1024 * 1024 || retainedBytes + size > 4 * 1024 * 1024) continue + const bytes = await readFile(path, 'utf8') + retainedBytes += size + await save(`opencode-${hash(directory + '/' + entry.name)}.log`, bytes.replaceAll(bearer || '\0', '[redacted]')) + } + } + await save('bridge.log', bridgeLog.replaceAll(bearer || '\0', '[redacted]')) + await save('receipt.json', receipt) + } + // Remove only owned scratch, after confirmed process cleanup and evidence capture. + if (root && !receipt.cleanupErrors?.length && (!evidence || (evidence !== root && !evidence.startsWith(root + '/')))) { + try { await rm(root, { recursive: true, force: true }); receipt.scratchRemoved = true } + catch (error) { receipt.status = 'failed'; receipt.cleanupErrors ??= []; receipt.cleanupErrors.push(error.message); process.exitCode = 1 } + if (ownsEvidence) await writeFile(join(evidence, 'receipt.json'), JSON.stringify(receipt, null, 2) + '\n', { mode: 0o600 }) + } + console.log(JSON.stringify({ ...receipt, evidenceDirectory: evidence ?? null }, null, 2)) +} diff --git a/tests/runtime-consumer/package.json b/tests/runtime-consumer/package.json index 3778712..8aee538 100644 --- a/tests/runtime-consumer/package.json +++ b/tests/runtime-consumer/package.json @@ -6,10 +6,10 @@ "test": "tsx --test bridge-cache-accounting.consumer.ts" }, "devDependencies": { - "@tangle-network/agent-eval": "0.213.0", + "@tangle-network/agent-eval": "0.214.0", "@tangle-network/agent-interface": "5.2.1", "@tangle-network/agent-profile-materialize": "0.25.2", - "@tangle-network/agent-runtime": "0.319.8", + "@tangle-network/agent-runtime": "0.325.0", "@tangle-network/sandbox": "0.60.51", "tsx": "^4.23.15" } diff --git a/tests/trace-contract-lock.test.ts b/tests/trace-contract-lock.test.ts new file mode 100644 index 0000000..4217452 --- /dev/null +++ b/tests/trace-contract-lock.test.ts @@ -0,0 +1,43 @@ +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, expect, test } from 'vitest' +import { lockedTraceContract } from '../src/trace/contract-build.js' +const dirs: string[] = [] +afterEach(() => { for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true }) }) +function fixture(version = '2.0.0', packages = ['2.0.0', '2.1.0']) { + const dir = mkdtempSync(join(tmpdir(), 'bridge-lock-')); dirs.push(dir) + const file = join(dir, 'pnpm-lock.yaml') + writeFileSync(file, `lockfileVersion: '9.0' +importers: + .: + dependencies: + '@tangle-network/agent-trace-contract': + specifier: ^2.0.0 + version: ${version} + tests/consumer: + dependencies: + '@tangle-network/agent-trace-contract': + specifier: ^2.1.0 + version: 2.1.0 +packages: +${packages.map(v => ` '@tangle-network/agent-trace-contract@${v}':\n resolution: {integrity: sha512-${v === '2.0.0' ? 'AAAA' : 'BBBB'}}\n`).join('')}`) + return file +} +test('selects the root producer, not another workspace consumer', () => { + expect(lockedTraceContract(fixture())).toEqual({ version: '2.0.0', integrity: 'sha512-AAAA' }) + expect(lockedTraceContract(fixture('2.1.0'))).toEqual({ version: '2.1.0', integrity: 'sha512-BBBB' }) +}) +test('refuses missing, duplicate, or non-registry root selections', () => { + expect(() => lockedTraceContract(fixture('2.2.0'))).toThrow(/one integrity/) + expect(() => lockedTraceContract(fixture('2.0.0', ['2.0.0', '2.0.0']))).toThrow(/one integrity/) + expect(() => lockedTraceContract(fixture('file:patched'))).toThrow(/root importer/) +}) +test('handles the actual multi-document pnpm lock without choosing a toolchain document', () => { + expect(lockedTraceContract()).toMatchObject({ version: '2.0.0' }) + const file = fixture(); const app = readFileSync(file, 'utf8') + writeFileSync(file, '---\nimporters:\n .:\n configDependencies: {}\n packageManagerDependencies:\n pnpm:\n specifier: 12.6.0\n version: 12.6.0\n---\n' + app) + expect(lockedTraceContract(file)).toEqual({ version: '2.0.0', integrity: 'sha512-AAAA' }) + writeFileSync(file, app + '\n---\n' + app) + expect(() => lockedTraceContract(file)).toThrow(/root importer/) +})