Skip to content
Open
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
7 changes: 5 additions & 2 deletions packages/cli/src/biscuit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@ type WasmGlueModule = BiscuitModule & {
__wbg_set_wasm(wasm: WebAssembly.Instance['exports']): void
}

type WasmImports = NonNullable<ConstructorParameters<typeof WebAssembly.Instance>[1]>
type WasmModuleImports = WasmImports[string]

const SNIPPET_MODULES = [
'./snippets/biscuit-auth-1c48f52e9814dd36/inline0.js',
'./snippets/biscuit-auth-314ca57174ae0e6d/inline0.js',
Expand Down Expand Up @@ -46,15 +49,15 @@ async function initBiscuit(): Promise<BiscuitModule> {
}
const bg = importedBg

const wasmImports: WebAssembly.ModuleImports = {}
const wasmImports: WasmModuleImports = {}
for (const [key, value] of Object.entries(bg)) {
if (key.startsWith('__wbg_') || key.startsWith('__wbindgen_')) {
wasmImports[key] = value
}
}

const snippetExports = { performance_now: () => performance.now() }
const imports: WebAssembly.Imports = { './biscuit_bg.js': wasmImports }
const imports: WasmImports = { './biscuit_bg.js': wasmImports }

for (const moduleName of SNIPPET_MODULES) {
imports[moduleName] = snippetExports
Expand Down
8 changes: 6 additions & 2 deletions packages/cli/src/commands/profile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ interface CredProfile {
}

interface CreateCredProfileRequest {
host?: string[]
host: string[]
auth?: Record<string, unknown>
managedOauth?: Record<string, unknown>
displayName?: string
Expand Down Expand Up @@ -200,9 +200,13 @@ export async function addProfile(slug: string, hosts: string[], options: AddProf
throw new Error(`Invalid profile JSON in ${options.filePath}`)
}
body = parsed
if ((!body.host || body.host.length === 0) && hosts.length > 0) {
if (body.host.length === 0 && hosts.length > 0) {
body.host = hosts
}
if (body.host.length === 0) {
console.error('At least one host is required, either in the JSON file or via --host.')
process.exit(1)
}
} else {
if (hosts.length === 0) {
console.error('At least one --host is required.')
Expand Down
141 changes: 141 additions & 0 deletions packages/cli/src/commands/wrap.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
import { spawn } from 'node:child_process'
import { requestJson } from '../http'
import { resolve } from '../resolve'

interface WrapOptions {
services?: string[]
methods?: string[]
paths?: string[]
ttl?: string
}

const LOCAL_NO_PROXY_HOSTS = ['127.0.0.1', 'localhost', '::1']

function buildConstraint(opts: WrapOptions) {
const constraint: Record<string, unknown> = {}

if (opts.services && opts.services.length > 0) {
constraint.services = opts.services.length === 1 ? opts.services[0] : opts.services
}

if (opts.methods && opts.methods.length > 0) {
const upper = opts.methods.map(method => method.toUpperCase())
constraint.methods = upper.length === 1 ? upper[0] : upper
}

if (opts.paths && opts.paths.length > 0) {
constraint.paths = opts.paths.length === 1 ? opts.paths[0] : opts.paths
}

if (opts.ttl) {
constraint.ttl = opts.ttl
}

return constraint
}

function hasRestrictions(opts: WrapOptions) {
return Object.keys(buildConstraint(opts)).length > 0
}

function isLoopbackHost(hostname: string) {
const normalized = hostname.toLowerCase()
return normalized === '127.0.0.1' || normalized === 'localhost' || normalized === '::1'
}

function buildProxyUrl(proxyUrl: string, token: string, opts: WrapOptions) {
const proxy = new URL(proxyUrl)

if (!hasRestrictions(opts) && isLoopbackHost(proxy.hostname)) {
return proxy.toString()
}

proxy.username = '_'
proxy.password = token
return proxy.toString()
}

async function mintRestrictedToken(opts: WrapOptions) {
const constraint = buildConstraint(opts)

if (!hasRestrictions(opts)) {
return null
}

const res = await requestJson<{ token: string }>('/tokens/restrict', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ constraints: [constraint] }),
})

return res.token
}

function buildEnv(proxyUrl: string) {
const env = { ...process.env }
const existingNoProxy = [env.NO_PROXY, env.no_proxy]
.flatMap(value => value?.split(',') ?? [])
.map(value => value.trim())
.filter(Boolean)
const noProxy = Array.from(new Set([...LOCAL_NO_PROXY_HOSTS, ...existingNoProxy])).join(',')

env.HTTP_PROXY = proxyUrl
env.HTTPS_PROXY = proxyUrl
env.http_proxy = proxyUrl
env.https_proxy = proxyUrl
env.NO_PROXY = noProxy
env.no_proxy = noProxy
return env
}

function isSignal(value: NodeJS.Signals | number | null): value is NodeJS.Signals {
return typeof value === 'string'
}

async function spawnChild(command: string, args: string[], env: NodeJS.ProcessEnv) {
const child = spawn(command, args, {
stdio: 'inherit',
env,
})

const forwardSignal = (signal: NodeJS.Signals) => {
if (!child.killed) {
child.kill(signal)
}
}

process.on('SIGINT', forwardSignal)
process.on('SIGTERM', forwardSignal)

try {
const result = await new Promise<{ code: number | null; signal: NodeJS.Signals | null }>((resolve, reject) => {
child.on('error', reject)
child.on('exit', (code, signal) => resolve({ code, signal }))
})

if (isSignal(result.signal)) {
process.kill(process.pid, result.signal)
return 1
}

return result.code ?? 0
} finally {
process.off('SIGINT', forwardSignal)
process.off('SIGTERM', forwardSignal)
}
}

export async function wrap(args: string[], opts: WrapOptions) {
if (args.length === 0) {
console.error('Usage: agent.pw wrap -- <command> [args...]')
process.exit(1)
}

const { url, token } = await resolve()
const proxyToken = await mintRestrictedToken(opts)
const proxyUrl = buildProxyUrl(url, proxyToken ?? token, opts)
const env = buildEnv(proxyUrl)
const [command, ...commandArgs] = args
const exitCode = await spawnChild(command, commandArgs, env)
process.exit(exitCode)
}
2 changes: 1 addition & 1 deletion packages/cli/src/http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ export async function requestJson<T>(path: string, init: RequestInit = {}): Prom
const body = await res.text()
throw new HttpStatusError(body || `${res.status} ${res.statusText}`, res.status)
}
return res.json()
return JSON.parse(await res.text())
}

export async function pageToPaginatedResponse<T>(pagePromise: Promise<{
Expand Down
22 changes: 22 additions & 0 deletions packages/cli/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -268,6 +268,28 @@ export function buildProgram() {
return curl(cmd.args)
})

program
.command('wrap')
.description('Run a command with credentials injected via HTTP proxy')
.usage('[options] -- <command> [args...]')
.option('--service <host...>', 'Restrict proxy access to specific hosts')
.option('--host <host...>', 'Restrict proxy access to specific hosts')
.option('--method <verb...>', 'Restrict to specific HTTP methods')
.option('--path <prefix...>', 'Restrict to path prefixes')
.option('--ttl <duration>', 'Token lifetime (e.g. 1h, 30m)')
.allowUnknownOption()
.allowExcessArguments()
.argument('[command...]')
.action(async (command, opts) => {
const { wrap } = await import('./commands/wrap')
return wrap(command, {
services: [...(opts.service ?? []), ...(opts.host ?? [])],
methods: opts.method,
paths: opts.path,
ttl: opts.ttl,
})
})

return program
}

Expand Down
130 changes: 130 additions & 0 deletions test/cli-wrap.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
import { EventEmitter } from 'node:events'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'

const {
requestJson,
resolve,
spawn,
} = vi.hoisted(() => ({
requestJson: vi.fn(),
resolve: vi.fn(),
spawn: vi.fn(),
}))

vi.mock('node:child_process', () => ({
spawn,
}))

vi.mock('../packages/cli/src/http', () => ({
requestJson,
}))

vi.mock('../packages/cli/src/resolve', () => ({
resolve,
}))

class ExitError extends Error {
code: number | undefined

constructor(code: number | undefined) {
super(`process.exit(${code})`)
this.code = code
}
}

type MockChildProcess = EventEmitter & {
killed: boolean
kill(signal?: NodeJS.Signals): boolean
}

function createMockChildProcess(
exitCode: number | null = 0,
signal: NodeJS.Signals | null = null,
) {
const child = new EventEmitter() as MockChildProcess
child.killed = false
child.kill = vi.fn((nextSignal?: NodeJS.Signals) => {
if (nextSignal) {
child.killed = true
}
return true
})

queueMicrotask(() => {
child.emit('exit', exitCode, signal)
})

return child
}

describe('CLI wrap command', () => {
beforeEach(() => {
vi.clearAllMocks()
resolve.mockResolvedValue({
url: 'http://127.0.0.1:9315',
token: 'apw_root',
})
requestJson.mockResolvedValue({ token: 'apw_child' })
spawn.mockImplementation(() => createMockChildProcess())
})

afterEach(() => {
vi.restoreAllMocks()
})

it('uses the loopback proxy directly when no restrictions are requested', async () => {
const exitSpy = vi.spyOn(process, 'exit').mockImplementation((code?: number) => {
throw new ExitError(code)
})
const { wrap } = await import('../packages/cli/src/commands/wrap')

await expect(wrap(['echo', 'hello'], {})).rejects.toMatchObject({ code: 0 })

expect(exitSpy).toHaveBeenCalledWith(0)
expect(requestJson).not.toHaveBeenCalled()
expect(spawn).toHaveBeenCalledWith('echo', ['hello'], {
stdio: 'inherit',
env: expect.objectContaining({
HTTP_PROXY: 'http://127.0.0.1:9315/',
HTTPS_PROXY: 'http://127.0.0.1:9315/',
NO_PROXY: expect.stringContaining('127.0.0.1'),
no_proxy: expect.stringContaining('localhost'),
}),
})
})

it('mints a restricted token and embeds it in the proxy URL when constraints are provided', async () => {
const exitSpy = vi.spyOn(process, 'exit').mockImplementation((code?: number) => {
throw new ExitError(code)
})
const { wrap } = await import('../packages/cli/src/commands/wrap')

await expect(wrap(['env'], {
services: ['api.github.com'],
methods: ['get', 'post'],
paths: ['/user', '/repos'],
ttl: '5m',
})).rejects.toMatchObject({ code: 0 })

expect(exitSpy).toHaveBeenCalledWith(0)
expect(requestJson).toHaveBeenCalledWith('/tokens/restrict', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
constraints: [{
services: 'api.github.com',
methods: ['GET', 'POST'],
paths: ['/user', '/repos'],
ttl: '5m',
}],
}),
})
expect(spawn).toHaveBeenCalledWith('env', [], {
stdio: 'inherit',
env: expect.objectContaining({
HTTP_PROXY: 'http://_:apw_child@127.0.0.1:9315/',
HTTPS_PROXY: 'http://_:apw_child@127.0.0.1:9315/',
}),
})
})
})
Loading