247 lines
15 KiB
JavaScript
247 lines
15 KiB
JavaScript
/** Offline integration only. NEVER imports/runs the installed Claude SDK or opens a network port. */
|
|
import assert from 'node:assert/strict'
|
|
import { mkdtemp, rm } from 'node:fs/promises'
|
|
import { tmpdir } from 'node:os'
|
|
import { join, resolve } from 'node:path'
|
|
import { pathToFileURL } from 'node:url'
|
|
import { build } from 'esbuild'
|
|
|
|
const temp = await mkdtemp(join(tmpdir(), 'ycode-claude-bridge-offline-'))
|
|
const queries = [], leases = [], kernels = []
|
|
let queryFailure = false, runtimeFailure = false, grantFailure = false, acquireGate
|
|
const deferred = () => { let resolve; const promise = new Promise(r => { resolve = r }); return { promise, resolve } }
|
|
const tick = () => new Promise(r => setImmediate(r))
|
|
const mocks = {
|
|
electron: { app: { getPath: () => temp, getAppPath: () => temp } },
|
|
'@anthropic-ai/claude-agent-sdk': {
|
|
createSdkMcpServer: config => config,
|
|
tool: (name, description, schema, handler) => ({ name, description, schema, handler }),
|
|
query: request => {
|
|
if (queryFailure) throw new Error('mock SDK startup failure')
|
|
const ended = deferred()
|
|
const instance = {
|
|
closeCount: 0, interrupt: async () => {},
|
|
close() { this.closeCount++; ended.resolve() },
|
|
async *[Symbol.asyncIterator]() { await ended.promise },
|
|
applyFlagSettings: async () => { if (grantFailure) throw new Error('mock grant replay failure') }, setPermissionMode: async () => {},
|
|
getContextUsage: async () => ({ categories: [] })
|
|
}
|
|
queries.push({ ...request, instance })
|
|
return instance
|
|
}
|
|
},
|
|
'private-runtimes': { privateRuntimeEnvironment: async env => { if (runtimeFailure) throw new Error('mock runtime failure'); return env } },
|
|
'agent/tools/browser': { browserTool: { description: 'inert browser', run: () => { throw new Error('Unexpected browser execution') } } },
|
|
'capabilities/claude-capabilities': { createClaudeCapabilityRuntime: () => ({ server: {}, setRequestSelection() {} }) },
|
|
'model-bridge': { acquireModelBridge: async model => {
|
|
const id = leases.length + 1
|
|
const lease = { model: structuredClone(model), baseUrl: `http://127.0.0.1:${24000 + id}/lease-${id}`, apiKey: `mock-local-token-${id}`, closeCount: 0, async close() { this.closeCount++ } }
|
|
leases.push(lease)
|
|
if (acquireGate) await acquireGate.promise
|
|
return lease
|
|
} }
|
|
}
|
|
const portKeys = new Map(Object.keys(mocks).map(key => [key === 'electron' || key.startsWith('@') ? key : resolve('src/main', key), key]))
|
|
globalThis.__claudeBridgeOfflineMocks = mocks
|
|
let checks = 0
|
|
const check = async (name, body) => { await body(); checks++; console.log(`PASS ${name}`) }
|
|
function model(api = 'openai-completions', suffix = '') {
|
|
return { profile: { id: `offline${suffix}`, name: 'Offline', providerId: 'offline', model: `model${suffix}`, baseUrl: 'https://invalid.example/v1', authMode: 'api-key', apiPreference: api, protocol: api === 'anthropic-messages' ? 'anthropic' : 'openai', contextWindowTokens: 100000 }, config: { api, model: `model${suffix}`, baseUrl: 'https://invalid.example/v1', apiKey: `FAKE-NOT-A-CREDENTIAL${suffix}` } }
|
|
}
|
|
const options = (id, value = model()) => ({ sessionId: id, workspaceRoot: temp, model: value, permissionMode: 'default-sandbox', agentMode: 'craft', onSessionId() {} })
|
|
try {
|
|
const bundle = join(temp, 'kernel.mjs')
|
|
await build({ stdin: { contents: "export * from './src/main/agent/claude/claude-kernel'; export { rememberSessionGrant } from './src/main/agent/claude/session-grants'", resolveDir: process.cwd(), loader: 'ts' }, outfile: bundle, bundle: true, platform: 'node', format: 'esm', logLevel: 'warning', metafile: true, plugins: [{ name: 'strict-offline-ports', setup(b) {
|
|
b.onResolve({ filter: /.*/ }, args => {
|
|
const path = args.path.startsWith('.') ? resolve(args.resolveDir, args.path).replace(/\.(ts|js)$/, '').replace(/\/index$/, '') : args.path
|
|
const key = portKeys.get(path)
|
|
if (key) return { path: key, namespace: 'offline' }
|
|
if (args.path.includes('claude-agent-sdk')) throw new Error('Actual SDK resolution forbidden')
|
|
})
|
|
b.onLoad({ filter: /.*/, namespace: 'offline' }, args => ({ contents: Object.keys(mocks[args.path]).map(name => `export const ${name} = globalThis.__claudeBridgeOfflineMocks[${JSON.stringify(args.path)}][${JSON.stringify(name)}]`).join('\n'), loader: 'js' }))
|
|
} }] }).then(result => {
|
|
assert(!Object.keys(result.metafile.inputs).some(path => path.includes('node_modules/@anthropic-ai/')), 'real SDK must never enter bundle')
|
|
assert(!Object.keys(result.metafile.inputs).some(path => /src\/main\/model-bridge\//.test(path) && !path.endsWith('/model-bridge/paths.ts')), 'real network bridge must never enter bundle')
|
|
})
|
|
const { ClaudeKernel, getClaudeKernel, disposeClaudeKernels, rememberSessionGrant } = await import(pathToFileURL(bundle).href)
|
|
const make = opts => { const kernel = new ClaudeKernel(opts); kernels.push(kernel); return kernel }
|
|
let baseline
|
|
await check('both OpenAI APIs use isolated bridge env, not upstream credentials', async () => {
|
|
for (const api of ['openai-completions', 'openai-responses']) {
|
|
const value = model(api), kernel = make(options(api, value)), before = leases.length
|
|
await kernel.ensureStarted()
|
|
const lease = leases.at(-1), request = queries.at(-1)
|
|
assert.equal(leases.length, before + 1)
|
|
assert.deepEqual(lease.model, { ...value, config: { ...value.config, thinkingLevel: 'off' } })
|
|
assert.equal(request.options.env.ANTHROPIC_BASE_URL, lease.baseUrl)
|
|
assert.equal(request.options.env.ANTHROPIC_API_KEY, lease.apiKey)
|
|
assert(!JSON.stringify(request.options.env).includes(value.config.apiKey))
|
|
assert.equal(request.options.model, value.config.model)
|
|
baseline ??= request.options
|
|
await kernel.close(); assert.equal(lease.closeCount, 1)
|
|
await kernel.close(); assert.equal(lease.closeCount, 1)
|
|
}
|
|
})
|
|
await check('bridge lease receives effective low/off thinking without mutating resolved config', async () => {
|
|
for (const api of ['openai-completions', 'openai-responses']) {
|
|
for (const [requested, reasoning, expected] of [['low', true, 'low'], ['off', true, 'off'], [undefined, false, 'off']]) {
|
|
const value = model(api)
|
|
value.profile.supportsReasoning = reasoning
|
|
value.profile.supportedEfforts = ['low', 'high']
|
|
value.config.thinkingLevel = 'high'
|
|
const kernel = make({ ...options(`thinking-${api}-${requested}`, value), thinkingLevel: requested })
|
|
await kernel.ensureStarted()
|
|
assert.equal(leases.at(-1).model.config.thinkingLevel, expected)
|
|
assert.equal(value.config.thinkingLevel, 'high', 'bridge-specific effective level must not mutate resolved model')
|
|
await kernel.close()
|
|
}
|
|
}
|
|
})
|
|
await check('Anthropic remains direct and permission/tool hooks remain identical', async () => {
|
|
const before = leases.length, value = model('anthropic-messages')
|
|
const kernel = make(options('anthropic', value)); await kernel.ensureStarted()
|
|
const opts = queries.at(-1).options
|
|
assert.equal(leases.length, before)
|
|
assert.equal(opts.env.ANTHROPIC_BASE_URL, 'https://invalid.example')
|
|
assert.equal(opts.env.ANTHROPIC_API_KEY, value.config.apiKey)
|
|
for (const key of ['permissionMode', 'permissionPrompts', 'settingSources', 'strictMcpConfig', 'tools', 'allowedTools', 'disallowedTools', 'plugins', 'agents', 'skills']) assert.deepEqual(opts[key], baseline[key], key)
|
|
assert.equal(opts.permissionMode, 'default'); assert.equal(opts.permissionPrompts, 'host')
|
|
assert.deepEqual(Object.keys(opts.hooks), ['PreToolUse'])
|
|
assert.equal(typeof opts.canUseTool, 'function')
|
|
for (const policy of [opts, baseline]) {
|
|
const result = await policy.hooks.PreToolUse[0].hooks[0]({ hook_event_name: 'PreToolUse', tool_name: 'Bash', tool_input: {}, tool_use_id: 'inactive' })
|
|
assert.equal(result.hookSpecificOutput.permissionDecision, 'deny')
|
|
}
|
|
await kernel.close()
|
|
})
|
|
await check('two simultaneous sessions have separate leases and independent cleanup', async () => {
|
|
const a = make(options('dual-a', model('openai-completions', '-a'))), b = make(options('dual-b', model('openai-responses', '-b')))
|
|
const before = leases.length
|
|
await Promise.all([a.ensureStarted(), b.ensureStarted()])
|
|
const [la, lb] = leases.slice(before)
|
|
assert.notEqual(la.apiKey, lb.apiKey); assert.notEqual(la.baseUrl, lb.baseUrl)
|
|
await a.close(); assert.equal(la.closeCount, 1); assert.equal(lb.closeCount, 0)
|
|
await b.close(); assert.equal(lb.closeCount, 1)
|
|
})
|
|
await check('concurrent ensureStarted acquires exactly one lease and SDK query', async () => {
|
|
const kernel = make(options('concurrent')), before = leases.length, q = queries.length
|
|
acquireGate = deferred()
|
|
const starts = Array.from({ length: 6 }, () => kernel.ensureStarted())
|
|
await tick(); acquireGate.resolve(); acquireGate = undefined
|
|
await Promise.all(starts)
|
|
assert.equal(leases.length, before + 1); assert.equal(queries.length, q + 1)
|
|
await kernel.close(); assert.equal(leases.at(-1).closeCount, 1)
|
|
})
|
|
await check('API/model/base URL/key configuration changes retire old leases', async () => {
|
|
let opts = options('fingerprint'), kernel = await getClaudeKernel(opts)
|
|
await kernel.ensureStarted(); assert.equal(await getClaudeKernel(opts), kernel)
|
|
for (const patch of [{ api: 'openai-responses' }, { model: 'changed-model' }, { baseUrl: 'https://other.invalid/v1' }, { apiKey: 'FAKE-CHANGED-KEY' }]) {
|
|
const oldLease = leases.at(-1)
|
|
opts = { ...opts, model: { ...opts.model, config: { ...opts.model.config, ...patch } } }
|
|
const next = await getClaudeKernel(opts); assert.notEqual(next, kernel)
|
|
assert.equal(oldLease.closeCount, 1); await next.ensureStarted(); kernel = next
|
|
}
|
|
await kernel.close()
|
|
})
|
|
await check('failed SDK startup releases lease and permits clean retry', async () => {
|
|
const kernel = make(options('failed'))
|
|
queryFailure = true
|
|
await assert.rejects(kernel.ensureStarted(), /mock SDK startup failure/)
|
|
queryFailure = false
|
|
assert.equal(leases.at(-1).closeCount, 1)
|
|
await kernel.ensureStarted(); assert.equal(leases.at(-1).closeCount, 0)
|
|
await kernel.close(); assert.equal(leases.at(-1).closeCount, 1)
|
|
})
|
|
await check('failed session grant replay releases producer and bridge before retry', async () => {
|
|
const kernel = make(options('grant-replay'))
|
|
rememberSessionGrant('grant-replay', { rules: ['Read(**)'], updates: [] }, temp)
|
|
grantFailure = true
|
|
await assert.rejects(kernel.ensureStarted(), /mock grant replay failure/)
|
|
grantFailure = false; await tick()
|
|
assert.equal(leases.at(-1).closeCount, 1)
|
|
await kernel.ensureStarted(); await kernel.close()
|
|
assert.equal(leases.at(-1).closeCount, 1)
|
|
})
|
|
await check('runtime environment failure cannot strand a bridge lease', async () => {
|
|
const kernel = make(options('runtime-failure')), before = leases.length
|
|
runtimeFailure = true
|
|
await assert.rejects(kernel.ensureStarted(), /mock runtime failure/)
|
|
runtimeFailure = false
|
|
for (const lease of leases.slice(before)) assert.equal(lease.closeCount, 1)
|
|
await kernel.close()
|
|
})
|
|
await check('close during asynchronous acquisition releases lease without SDK startup', async () => {
|
|
const kernel = make(options('close-during-start')), q = queries.length
|
|
acquireGate = deferred()
|
|
const start = kernel.ensureStarted().catch(error => error)
|
|
await tick()
|
|
const closing = kernel.close()
|
|
acquireGate.resolve(); acquireGate = undefined
|
|
await Promise.all([start, closing])
|
|
assert.equal(queries.length, q); assert.equal(leases.at(-1).closeCount, 1)
|
|
})
|
|
await check('unsupported Gemini fails before bridge or SDK creation', async () => {
|
|
const value = model('google-generative-ai'); value.profile.protocol = 'gemini'
|
|
const kernel = make(options('unsupported', value)), before = leases.length, q = queries.length
|
|
await assert.rejects(kernel.ensureStarted(), /协议/)
|
|
assert.equal(leases.length, before); assert.equal(queries.length, q)
|
|
await kernel.close()
|
|
})
|
|
await check('resolved model snapshot cannot be mutated after kernel creation', async () => {
|
|
const value = model(), kernel = make(options('snapshot', value))
|
|
value.config.apiKey = 'FAKE-MUTATED'; value.config.model = 'mutated-model'
|
|
await kernel.ensureStarted()
|
|
assert.equal(leases.at(-1).model.config.apiKey, 'FAKE-NOT-A-CREDENTIAL')
|
|
assert.equal(queries.at(-1).options.model, 'model')
|
|
await kernel.close()
|
|
})
|
|
await check('bridge preserves full-access tool preauthorization and question approval boundary', async () => {
|
|
const kernel = make({ ...options('hook-policy'), permissionMode: 'full-access' }), controller = new AbortController()
|
|
const run = kernel.runTurn({ runId: 'hook-run', userMessageId: 'user', prompt: 'offline', emit() {}, interactions: {}, signal: controller.signal })
|
|
const rejected = assert.rejects(run, error => error.name === 'AbortError')
|
|
await tick()
|
|
const opts = queries.at(-1).options, hook = opts.hooks.PreToolUse[0].hooks[0]
|
|
assert.equal(opts.permissionMode, 'default')
|
|
for (const [name, decision] of [['Bash', 'allow'], ['AskUserQuestion', 'ask']]) {
|
|
const result = await hook({ hook_event_name: 'PreToolUse', tool_name: name, tool_input: {}, tool_use_id: name })
|
|
assert.equal(result.hookSpecificOutput.permissionDecision, decision)
|
|
}
|
|
controller.abort(); await rejected; await kernel.close()
|
|
})
|
|
await check('unexpected producer termination releases bridge lease', async () => {
|
|
const kernel = make(options('producer-end'))
|
|
await kernel.ensureStarted(); const lease = leases.at(-1)
|
|
queries.at(-1).instance.close(); await tick()
|
|
assert.equal(lease.closeCount, 1)
|
|
await kernel.close(); assert.equal(lease.closeCount, 1)
|
|
})
|
|
await check('cancellation during bridge acquisition releases newly started producer', async () => {
|
|
const kernel = make(options('cancel-start')), controller = new AbortController()
|
|
acquireGate = deferred()
|
|
const run = kernel.runTurn({ runId: 'cancel-start-run', userMessageId: 'user', prompt: 'offline', emit() {}, interactions: {}, signal: controller.signal })
|
|
const rejected = assert.rejects(run, error => error.name === 'AbortError')
|
|
await tick(); controller.abort(); acquireGate.resolve(); acquireGate = undefined
|
|
await rejected; await tick()
|
|
assert.equal(leases.at(-1).closeCount, 1)
|
|
await kernel.close()
|
|
})
|
|
await check('turn cancellation retires producer and releases its bridge lease', async () => {
|
|
const kernel = make(options('cancel')), controller = new AbortController()
|
|
const run = kernel.runTurn({ runId: 'cancel-run', userMessageId: 'user', prompt: 'offline', emit() {}, interactions: {}, signal: controller.signal })
|
|
const rejected = assert.rejects(run, error => error.name === 'AbortError')
|
|
await tick(); assert.equal(kernel.busy, true)
|
|
const lease = leases.at(-1)
|
|
controller.abort(); await rejected; await tick()
|
|
assert.equal(lease.closeCount, 1)
|
|
await kernel.close(); assert.equal(lease.closeCount, 1)
|
|
})
|
|
await disposeClaudeKernels()
|
|
assert(leases.every(lease => lease.closeCount === 1), 'all leases released exactly once')
|
|
console.log(`PASS ${checks} offline Claude/bridge host groups; no actual SDK startup, network or credentials`)
|
|
} finally {
|
|
acquireGate?.resolve()
|
|
await Promise.all(kernels.map(kernel => kernel.close().catch(() => {})))
|
|
delete globalThis.__claudeBridgeOfflineMocks
|
|
await rm(temp, { recursive: true, force: true })
|
|
}
|