Files
deepseek-harness/apps/cli/tests/web-auth.e2e.ts
T

211 lines
7.4 KiB
TypeScript

/** Real `dsh web` authentication against a temporary Harness home. */
import type { ChildProcess } from 'node:child_process'
import { spawn } from 'node:child_process'
import { stat } from 'node:fs/promises'
import { request as httpRequest } from 'node:http'
import { createRequire } from 'node:module'
import { createServer } from 'node:net'
import type { AddressInfo } from 'node:net'
import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { fileURLToPath, pathToFileURL } from 'node:url'
import { describe, expect, it } from 'vitest'
const REPO_ROOT = fileURLToPath(new URL('../../..', import.meta.url))
const DSH_SOURCE_BIN = join(REPO_ROOT, 'apps/cli/src/bin.ts')
const TSX_LOADER = pathToFileURL(createRequire(join(REPO_ROOT, 'package.json')).resolve('tsx')).href
interface RunningWeb {
readonly child: ChildProcess
readonly launchUrl: string
readonly output: () => string
}
interface HttpResult {
readonly status: number
readonly body: string
}
function redact(output: string): string {
return output.replace(/([?&]token=)[^\s)]+/gu, '$1<redacted>')
}
/** Reserve one concrete loopback port, then release it for the CLI process. */
async function freePort(): Promise<number> {
const server = createServer()
await new Promise<void>((resolve, reject) => {
server.once('error', reject)
server.listen(0, '127.0.0.1', resolve)
})
const port = (server.address() as AddressInfo).port
await new Promise<void>((resolve, reject) => {
server.close((error) => {
if (error === undefined) resolve()
else reject(error)
})
})
return port
}
function cleanEnvironment(root: string, dshHome: string): NodeJS.ProcessEnv {
const env = Object.fromEntries(Object.entries(process.env).filter(([name]) =>
!/(?:KEY|SECRET|TOKEN|PASSWORD)/iu.test(name)))
return {
...env,
DSH_AGENTS_HOME: join(root, '.agents'),
DSH_HOME: dshHome,
DSH_TELEMETRY_DISABLED: '1',
NODE_NO_WARNINGS: '1',
SSH_CONNECTION: '',
SSH_TTY: '',
TSX_TSCONFIG_PATH: join(REPO_ROOT, 'tsconfig.json'),
}
}
/** Start the public source CLI and wait for its authenticated readiness URL. */
async function startWeb(root: string, dshHome: string, port: number): Promise<RunningWeb> {
const child = spawn(process.execPath, [
'--import', TSX_LOADER,
DSH_SOURCE_BIN,
'web',
'--no-open',
'--port', String(port),
], {
cwd: root,
env: cleanEnvironment(root, dshHome),
stdio: ['ignore', 'pipe', 'pipe'],
})
let output = ''
const launchUrl = await new Promise<string>((resolve, reject) => {
let settled = false
const fail = (error: Error): void => {
if (settled) return
settled = true
clearTimeout(timer)
reject(error)
}
const timer = setTimeout(() => {
fail(new Error(`dsh web did not become ready:\n${redact(output)}`))
}, 90_000)
const append = (chunk: Buffer | string): void => {
output = `${output}${String(chunk)}`.slice(-100_000)
const match = /dsh web: (http:\/\/[^\s]+)/u.exec(output)
if (settled || match?.[1] === undefined) return
settled = true
clearTimeout(timer)
resolve(match[1])
}
child.stdout?.on('data', append)
child.stderr?.on('data', append)
child.once('error', (error) => {
fail(error)
})
child.once('exit', (code) => {
fail(new Error(`dsh web exited before readiness (${String(code)}):\n${redact(output)}`))
})
})
return { child, launchUrl, output: () => output }
}
async function stopWeb(running: RunningWeb): Promise<void> {
if (running.child.exitCode !== null) return
const exited = new Promise<void>((resolve) => { running.child.once('exit', () => { resolve() }) })
running.child.kill('SIGTERM')
const forced = setTimeout(() => { running.child.kill('SIGKILL') }, 10_000)
forced.unref()
await exited
clearTimeout(forced)
}
/** POST one real API Proxy envelope while controlling the wire Host header. */
function describeHost(port: number, host: string, cookie?: string): Promise<HttpResult> {
const body = JSON.stringify({
type: 'client-request',
rpcId: 'web-auth-real-cli',
method: 'host.describe',
payload: {},
})
return new Promise((resolve, reject) => {
const req = httpRequest({
hostname: '127.0.0.1',
port,
path: '/api/host.describe',
method: 'POST',
headers: {
host,
'content-type': 'application/json',
'content-length': Buffer.byteLength(body),
...cookie === undefined ? {} : { cookie },
},
}, (res) => {
const chunks: Uint8Array[] = []
res.on('data', (chunk: Buffer) => { chunks.push(chunk) })
res.on('end', () => {
resolve({ status: res.statusCode ?? 0, body: Buffer.concat(chunks).toString('utf8') })
})
})
req.once('error', reject)
req.end(body)
})
}
describe('dsh web authentication through the real CLI', () => {
it('rejects a forged loopback Host and preserves the browser cookie across restart', { timeout: 180_000 }, async () => {
const root = await mkdtemp(join(tmpdir(), 'dsh-web-auth-real-cli-'))
const dshHome = join(root, '.dsh')
const port = await freePort()
let first: RunningWeb | undefined
let second: RunningWeb | undefined
try {
first = await startWeb(root, dshHome, port)
const firstUrl = new URL(first.launchUrl)
expect(firstUrl.origin).toBe(`http://127.0.0.1:${String(port)}`)
expect(firstUrl.pathname).toBe('/')
expect(firstUrl.searchParams.get('token')).toMatch(/^[A-Za-z0-9_-]{43}$/u)
expect(await describeHost(port, `localhost:${String(port)}`)).toEqual({
status: 401,
body: 'unauthorized',
})
const exchange = await fetch(first.launchUrl, { redirect: 'manual' })
expect(exchange.status).toBe(303)
expect(exchange.headers.get('location')).toBe('/')
const setCookie = exchange.headers.get('set-cookie')
if (setCookie === null) throw new Error('real CLI token exchange omitted Set-Cookie')
expect(setCookie).toContain('HttpOnly')
expect(setCookie).toContain('SameSite=Strict')
expect(setCookie).not.toContain('Secure')
const cookie = setCookie.split(';', 1)[0]!
const authenticated = await describeHost(port, firstUrl.host, cookie)
expect(authenticated.status).toBe(200)
const authenticatedBody = JSON.parse(authenticated.body) as unknown
expect(authenticatedBody).toMatchObject({
type: 'server-response',
rpcId: 'web-auth-real-cli',
result: { ok: true, value: { version: expect.any(String) as unknown } },
})
await stopWeb(first)
first = undefined
second = await startWeb(root, dshHome, port)
const secondUrl = new URL(second.launchUrl)
expect(secondUrl.searchParams.get('token')).not.toBe(firstUrl.searchParams.get('token'))
expect((await describeHost(port, secondUrl.host, cookie)).status).toBe(200)
const credentialMode = (await stat(join(dshHome, '.credentials.yaml'))).mode & 0o777
expect(credentialMode).toBe(0o600)
} catch (error) {
const evidence = [first?.output(), second?.output()].filter(value => value !== undefined).join('\n')
throw new Error(`${error instanceof Error ? error.message : String(error)}\n${redact(evidence)}`, { cause: error })
} finally {
if (second !== undefined) await stopWeb(second)
if (first !== undefined) await stopWeb(first)
await rm(root, { recursive: true, force: true })
}
})
})