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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -1241,7 +1241,11 @@ Retained native-session API jobs do not yet enter this one-shot capture scope.

The bearer-protected `/admin/pools/status` endpoint reports explicit adopted pool
identities and durable roll receipts. Maintenance requires `BRIDGE_BEARER`, even
on loopback. It does not restart other bridge services or stop their jobs.
on loopback. Adoption requires Linux with the existing `ss` socket inventory tool;
unavailable or ambiguous listener evidence refuses ownership transfer. Run adoption
from a native Linux bridge service in the same host network namespace as the
legacy controller; a container-local socket inventory does not prove host ports
are unused. It does not restart other bridge services or stop their jobs.

For an inactive historical pool, `POST /admin/pools/adopt` accepts `prefix`,
`legacyDataDir`, `legacyPorts`, immutable `expectedImage`, and ordered contiguous
Expand Down
58 changes: 58 additions & 0 deletions scripts/verify-pool-adoption-guards.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
#!/usr/bin/env tsx
/** Real Linux IPv6 listener + Docker startup refusal proof. No models or mocks. */
import assert from 'node:assert/strict'
import { randomBytes } from 'node:crypto'
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'
import { createServer } from 'node:net'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { dockerCli } from '../src/executors/docker-cli.js'
import { PoolMaintenance } from '../src/runtime/pool-maintenance.js'
import { acquireDataDirectoryLock } from '../src/runtime/single-instance.js'
import { loadConfig } from '../src/config.js'
import { buildApp } from '../src/server.js'

const image = process.env.CLI_ROLL_IMAGE
if (!image || !/^sha256:[a-f0-9]{64}$/u.test(image)) throw new Error('existing immutable CLI_ROLL_IMAGE required')
const prefix = `cli-bridge-guard-${randomBytes(5).toString('hex')}`
const root = mkdtempSync(join(tmpdir(), `${prefix}-`))
const legacy = join(root, 'legacy', 'data'), workspace = join(root, 'legacy', 'workspace')
const data = join(root, 'owner'), badData = join(root, 'bad-startup')
for (const dir of [legacy, workspace, data, badData]) mkdirSync(dir, { recursive: true, mode: 0o700 })
let id = ''
let owner: PoolMaintenance | undefined
const listener = createServer()
try {
await new Promise<void>((resolve, reject) => {
listener.once('error', reject)
listener.listen({ host: '::1', port: 0, ipv6Only: true }, resolve)
})
const port = (listener.address() as { port: number }).port
const result = await dockerCli(['run', '-d', '--name', `${prefix}-0`, '--mount',
`type=bind,source=${workspace},target=${workspace}`, image, 'tail', '-f', '/dev/null'])
assert.equal(result.code, 0); id = result.stdout.trim()
owner = new PoolMaintenance(data)
const manifest = { prefix, legacyDataDir: legacy, legacyPorts: [port], expectedImage: image,
slots: [{ index: 0, id }] }
await assert.rejects(owner.adopt(manifest), /legacy controller still listens/)
const lock = acquireDataDirectoryLock(legacy); lock.release()
await new Promise<void>((resolve, reject) => listener.close((error) => error ? reject(error) : resolve()))
await owner.adopt(manifest)
await owner.close(); owner = undefined

writeFileSync(join(badData, 'pool-maintenance.json'), '{malformed', { mode: 0o600 })
const startupPrefix = `${prefix}-startup`
await assert.rejects(buildApp(loadConfig({ BRIDGE_BACKENDS: 'opencode', BRIDGE_BEARER: randomBytes(24).toString('hex'),
BRIDGE_DATA_DIR: badData, OPENCODE_EXECUTOR: 'docker', OPENCODE_DOCKER_IMAGE: image,
OPENCODE_DOCKER_POOL_SIZE: '2', OPENCODE_DOCKER_NAME_PREFIX: startupPrefix })), /maintenance journal needs operator reconciliation/)
const containers = await dockerCli(['ps', '-aq', '--filter', `name=${startupPrefix}`])
assert.equal(containers.code, 0); assert.equal(containers.stdout.trim(), '')
console.log(JSON.stringify({ complete: true, ipv6_listener_refused: true, refused_adoption_lock_released: true,
adoption_after_listener_close: true, malformed_journal_refused_before_docker_provision: true,
configured_startup_containers_created: 0, image, no_model_calls: true }))
} finally {
if (listener.listening) await new Promise<void>((resolve) => listener.close(() => resolve()))
await owner?.close()
if (id) await dockerCli(['rm', '-f', '-v', id])
rmSync(root, { recursive: true, force: true })
}
21 changes: 10 additions & 11 deletions src/runtime/pool-maintenance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@
import { randomBytes } from 'node:crypto'
import { closeSync, constants, fsyncSync, fstatSync, openSync, readFileSync, renameSync, realpathSync, writeFileSync } from 'node:fs'
import { dirname, join } from 'node:path'
import { createConnection } from 'node:net'
import { execFile } from 'node:child_process'
import { promisify } from 'node:util'
import { z } from 'zod'
import { ContainerPool } from '../executors/container-pool.js'
import { inspectIdle, replaceRuntime, type RollReceipt } from '../executors/docker-runtime-roll.js'
Expand All @@ -23,17 +24,15 @@ const rollSchema = z.object({ oldId: identity, imageId: z.string().regex(/^sha25
const recordSchema = adoptionRequest.extend({ rolls: z.record(z.string(), rollSchema), image: z.string() }).strict()
type Record = z.infer<typeof recordSchema>

/** Linux's kernel socket inventory covers IPv4, IPv6 and non-loopback binds. */
async function requireNoListener(port: number): Promise<void> {
await new Promise<void>((resolve, reject) => {
const socket = createConnection({ host: '127.0.0.1', port })
socket.setTimeout(1000)
socket.once('connect', () => { socket.destroy(); reject(new Error('legacy controller still listens')) })
socket.once('timeout', () => { socket.destroy(); reject(new Error('legacy controller state unknown')) })
socket.once('error', (error: NodeJS.ErrnoException) => {
if (error.code === 'ECONNREFUSED') resolve()
else reject(new Error('legacy controller state unknown'))
})
})
if (process.platform !== 'linux') throw new Error('pool adoption requires Linux listener inventory')
if (!Number.isInteger(port) || port < 1 || port > 65535) throw new Error('invalid legacy port')
// Missing ss, timeout, nonzero exit, or excessive output all reject adoption.
const { stdout, stderr } = await promisify(execFile)('ss', ['-H', '-ltn', `sport = :${port}`],
{ timeout: 2000, maxBuffer: 65536, killSignal: 'SIGKILL' })
if (stderr.trim()) throw new Error('legacy listener inventory is ambiguous')
if (stdout.trim()) throw new Error('legacy controller still listens on a declared port')
}

export class PoolMaintenance {
Expand Down
5 changes: 3 additions & 2 deletions src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -352,6 +352,9 @@ export async function buildApp(config: Config): Promise<{
maxLifetimeMs: parseEnvNonNegativeInt('BRIDGE_RUN_MAX_LIFETIME_MS', 0),
})
const extras: BuildAppExtras = { shutdownHooks: [], netJail: new Map() }
// Validate the durable maintenance owner before any backend provisions Docker slots.
const poolMaintenance = new PoolMaintenance(config.dataDir)
extras.shutdownHooks.push(async () => await poolMaintenance.close())
const durableWriter = config.asyncDurableWrites ? new DurableRunWriter(config.dataDir) : null
if (durableWriter) {
await durableWriter.open()
Expand Down Expand Up @@ -537,8 +540,6 @@ export async function buildApp(config: Config): Promise<{
mountCadRender(app)
mountImagesGenerate(app)
mountMetrics(app)
const poolMaintenance = new PoolMaintenance(config.dataDir)
extras.shutdownHooks.push(async () => await poolMaintenance.close())
mountPoolMaintenance(app, poolMaintenance, Boolean(config.bearer))

app.get('/', (c) => c.json({
Expand Down