Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
ff70aa4
fix(net-jail): key the jail registry by the routed backend name so ne…
drewstone Oct 17, 2026
ae606dc
test: prove local Runtime execution through OpenCode with live free i…
drewstone Oct 17, 2026
300cad0
Merge pull request #260 from drewstone/fix/net-jail-routed-backend-key
drewstone Oct 17, 2026
db734a5
test: provision a checksum-pinned OpenCode binary per live job
drewstone Oct 17, 2026
6a3453c
test: retain native failure logs and always clean up live bridge
drewstone Oct 17, 2026
baa0f40
test: use the native jailed HOME and retain its real diagnostics
drewstone Oct 17, 2026
05b2376
test: expose native OpenCode diagnostics in the isolated live run
drewstone Oct 17, 2026
57e7acd
test: qualify the released Runtime provider-prefix fix and retain its…
drewstone Oct 17, 2026
5f6c2aa
test: qualify the matching Runtime and Eval release cohort
drewstone Oct 17, 2026
21556b6
test: diagnose host versus jailed connectivity without relaxing confi…
drewstone Oct 17, 2026
b7adbb9
fix(jail): preserve DNS through the exact resolver symlink target
drewstone Oct 17, 2026
6ce47b6
build: retain exact locally generated dependency blobs for the live p…
drewstone Oct 17, 2026
dc06dde
test: freeze the compatible local stack and prove the real DNS boundary
drewstone Oct 17, 2026
b68078b
build: update the existing exact-version Runtime adoption exception
drewstone Oct 17, 2026
c06a58a
test: repair live proof cleanup block and validate executable syntax
drewstone Oct 17, 2026
cdcdf92
build: prepare isolated CLI-Bridge bundle for direct cloud verification
drewstone Oct 18, 2026
c63f2a8
test: qualify final local-proof changes before updating the existing PR
drewstone Oct 18, 2026
c7f51ee
test: qualify the actual multi-document lock and clean owned proof state
drewstone Oct 18, 2026
adfed5e
build: publish qualified source without requesting workflow-write pri…
drewstone Oct 18, 2026
eb9a32a
Merge adfed5ece3b7361a82e88c25a6a7de555d5b9f60 into 300cad03fc8060f58…
drewstone Oct 18, 2026
92412c7
fix: qualify local bridge proof and scope trace provenance to its pro…
drewstone Oct 18, 2026
971192b
chore: remove completed temporary source qualification workflow
drewstone Oct 18, 2026
40f7dcd
chore: remove completed offline bundle workflow
drewstone Oct 18, 2026
fd950b7
test: qualify native fixture readiness against current main
drewstone Oct 18, 2026
6509d1e
test: separate native fixture readiness from protocol deadlines
drewstone Oct 18, 2026
96cd28d
test: run opt-in live inference on an isolated cloud runner
drewstone Oct 18, 2026
b73b5d3
chore: remove completed local proof finalizer
drewstone Oct 18, 2026
d7df6f9
test: pin the jail-capable runner and validate the complete live-proo…
drewstone Oct 18, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
86 changes: 86 additions & 0 deletions .github/workflows/live-inference.yml
Original file line number Diff line number Diff line change
@@ -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
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
49 changes: 27 additions & 22 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 1 addition & 2 deletions pnpm-workspace.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
14 changes: 10 additions & 4 deletions src/jail/linux-bwrap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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
Expand All @@ -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 {
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down
26 changes: 17 additions & 9 deletions src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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 } : {}),
}))
}
Expand All @@ -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,
Expand All @@ -395,24 +402,25 @@ 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,
...(spawner ? { spawner } : {}),
}))
}
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,
Expand Down Expand Up @@ -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,
Expand Down
Loading
Loading