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
35 changes: 35 additions & 0 deletions .github/workflows/ts-daemon-conformance.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
name: TS daemon conformance

on:
pull_request:
branches:
- dev
workflow_dispatch:

permissions:
contents: read

concurrency:
group: ts-daemon-conformance-${{ github.ref }}-${{ matrix.node-version }}
cancel-in-progress: true

jobs:
conformance:
name: Node ${{ matrix.node-version }}
runs-on: ubuntu-24.04
timeout-minutes: 20
strategy:
fail-fast: false
matrix:
node-version:
- 22.13.0
- 24
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
cache: npm
- uses: dtolnay/rust-toolchain@stable
- run: npm ci
- run: npm run test:ts-daemon-conformance
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
"release:version": "node scripts/release/version.mjs",
"sync:skill": "npm run build:scripts && node dist/scripts/sync-tokenless-skill.mjs",
"test": "npm run build && node dist/scripts/test-all.mjs",
"test:ts-daemon-conformance": "npm run build && node --test --test-concurrency=1 test/ts-daemon-conformance.test.mjs",
"test:e2e:protocol-cross-version": "npm run build && node scripts/run-protocol-cross-version-e2e.mjs",
"test:e2e": "npm run build && node --test --test-concurrency=1 test/live-setup-auth-report.e2e.mjs test/live-provider-guest-access.e2e.mjs test/live-managed-playwright-prompt-actions.e2e.mjs test/live-managed-playwright.e2e.mjs",
"test:e2e:live-managed-playwright-m1": "npm run build && node --test --test-concurrency=1 test/live-managed-playwright-prompt-actions.e2e.mjs",
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@
},
"scripts": {
"build": "npm run build:js && npm run build:native",
"build:js": "node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\" && tsc -p tsconfig.json && node -e \"require('node:fs').chmodSync('dist/src/tokenless.mjs',0o755)\"",
"build:js": "node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\" && tsc -p tsconfig.json && node -e \"const fs=require('node:fs'); fs.chmodSync('dist/src/tokenless.mjs',0o755); fs.chmodSync('dist/src/daemon/daemon-entry.mjs',0o755)\"",
"build:native": "node scripts/build-rust-binaries.mjs",
"lint": "tsc -p tsconfig.json --noEmit",
"prepack": "npm run build:js"
Expand Down
55 changes: 55 additions & 0 deletions packages/cli/src/daemon/daemon-entry.mts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
#!/usr/bin/env node
import { nativeBinaryBuildInfo } from './server.js'
import { startDaemon } from './lifecycle.js'

async function main() {
const args = process.argv.slice(2)
if (args.length === 1 && args[0] === '--tokenless-build-info') {
console.log(JSON.stringify(nativeBinaryBuildInfo('tokenless-daemon')))
return
}
const options = parseArgs(args)
await startDaemon(options)
}

function parseArgs(args: string[]) {
let homeDir: string | undefined
let host = '127.0.0.1'
let port = 7331
const positional: string[] = []
for (let index = 0; index < args.length; index += 1) {
const arg = args[index]
if (arg === undefined) continue
if (arg === '--home') {
homeDir = requireValue(args, ++index, '--home')
} else if (arg === '--host') {
host = requireValue(args, ++index, '--host')
} else if (arg === '--port') {
port = parsePort(requireValue(args, ++index, '--port'))
} else {
positional.push(arg)
}
}
if (positional.length === 1 && positional[0] === 'serve') {
return { homeDir, host, port }
}
if (positional.length === 0) return { homeDir, host, port }
throw new Error('usage: daemon-entry.mjs [--home <path>] [serve] [--host <loopback>] [--port <port>]')
}

function requireValue(args: string[], index: number, flag: string) {
const value = args[index]
if (!value) throw new Error(`${flag} requires a value`)
return value
}

function parsePort(value: string) {
const port = Number(value)
if (!Number.isInteger(port) || port < 0 || port > 65535) throw new Error('--port must be a valid TCP port')
return port
}

main().catch((error) => {
console.error(error instanceof Error && error.message ? error.message : String(error))
process.exit(1)
})
224 changes: 224 additions & 0 deletions packages/cli/src/daemon/errors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,224 @@
import { DAEMON_ERROR_PROTOCOL } from '../generated/protocol-constants.js'

export type JobStatus =
| 'queued'
| 'claimed'
| 'running'
| 'waiting_for_user'
| 'succeeded'
| 'failed'
| 'canceled'
| 'timed_out'

type DaemonErrorKind =
| 'io'
| 'random'
| 'sqlite'
| 'json'
| 'missing_home'
| 'invalid_input'
| 'non_loopback_bind'
| 'invalid_status'
| 'job_not_found'
| 'claim_rejected'
| 'claim_expired'
| 'bridge_busy'
| 'control_auth_missing'
| 'control_auth_rejected'
| 'invalid_job_state'

export class DaemonError extends Error {
readonly kind: DaemonErrorKind
readonly jobId?: string | undefined
readonly statusValue?: string | undefined
readonly expected?: string | undefined
readonly actual?: JobStatus | undefined
readonly host?: string | undefined
readonly cause?: unknown

constructor(kind: DaemonErrorKind, message: string, options: {
jobId?: string | undefined
statusValue?: string | undefined
expected?: string | undefined
actual?: JobStatus | undefined
host?: string | undefined
cause?: unknown
} = {}) {
super(message)
this.name = 'DaemonError'
this.kind = kind
this.jobId = options.jobId
this.statusValue = options.statusValue
this.expected = options.expected
this.actual = options.actual
this.host = options.host
this.cause = options.cause
}
}

export function ioError(error: unknown) {
return new DaemonError('io', `I/O error: ${errorText(error)}`, { cause: error })
}

export function sqliteError(error: unknown) {
return new DaemonError('sqlite', `SQLite error: ${errorText(error)}`, { cause: error })
}

export function jsonError(error: unknown) {
return new DaemonError('json', `JSON error: ${errorText(error)}`, { cause: error })
}

export function missingHomeError() {
return new DaemonError(
'missing_home',
'cannot resolve Tokenless home; pass --home or set TOKENLESS_HOME/HOME'
)
}

export function invalidInput(message: string) {
return new DaemonError('invalid_input', `invalid input: ${message}`)
}

export function nonLoopbackBind(host: string) {
return new DaemonError(
'non_loopback_bind',
`refusing to bind daemon to non-loopback host ${host}; Tokenless daemon is a local control plane`,
{ host }
)
}

export function invalidStatus(status: string) {
return new DaemonError('invalid_status', `invalid job status: ${status}`, { statusValue: status })
}

export function jobNotFound(jobId: string) {
return new DaemonError('job_not_found', `job not found: ${jobId}`, { jobId })
}

export function claimRejected(jobId: string) {
return new DaemonError('claim_rejected', `claim rejected for job: ${jobId}`, { jobId })
}

export function claimExpired(jobId: string) {
return new DaemonError('claim_expired', `claim lease expired for job: ${jobId}`, { jobId })
}

export function controlAuthMissing() {
return new DaemonError('control_auth_missing', 'missing bearer token')
}

export function controlAuthRejected() {
return new DaemonError('control_auth_rejected', 'invalid bearer token')
}

export function invalidJobState(jobId: string, expected: string, actual: JobStatus) {
return new DaemonError(
'invalid_job_state',
`invalid state for job ${jobId}: expected ${expected}, found ${actual}`,
{ jobId, expected, actual }
)
}

export function toDaemonError(error: unknown) {
if (error instanceof DaemonError) return error
return sqliteError(error)
}

export function daemonErrorStatus(error: DaemonError) {
switch (error.kind) {
case 'invalid_input':
case 'non_loopback_bind':
case 'invalid_status':
return 400
case 'control_auth_missing':
return 401
case 'job_not_found':
return 404
case 'claim_rejected':
case 'control_auth_rejected':
return 403
case 'claim_expired':
case 'invalid_job_state':
case 'bridge_busy':
return 409
case 'io':
case 'random':
case 'sqlite':
case 'json':
case 'missing_home':
return 500
}
}

export function daemonErrorCodeRetryable(error: DaemonError) {
switch (error.kind) {
case 'io':
return { code: 'daemon_io_error', retryable: true }
case 'random':
return { code: 'daemon_random_error', retryable: true }
case 'sqlite':
return { code: 'daemon_store_error', retryable: true }
case 'json':
return { code: 'daemon_json_error', retryable: false }
case 'missing_home':
return { code: 'daemon_home_missing', retryable: false }
case 'invalid_input':
return { code: 'invalid_input', retryable: false }
case 'non_loopback_bind':
return { code: 'non_loopback_bind', retryable: false }
case 'invalid_status':
return { code: 'invalid_status', retryable: false }
case 'job_not_found':
return { code: 'job_not_found', retryable: false }
case 'claim_rejected':
return { code: 'claim_rejected', retryable: false }
case 'claim_expired':
return { code: 'claim_expired', retryable: false }
case 'bridge_busy':
return { code: 'bridge_busy', retryable: true }
case 'control_auth_missing':
return { code: 'control_auth_missing', retryable: false }
case 'control_auth_rejected':
return { code: 'control_auth_rejected', retryable: false }
case 'invalid_job_state':
return { code: 'invalid_job_state', retryable: false }
}
}

export function daemonErrorBody(error: DaemonError) {
const { code, retryable } = daemonErrorCodeRetryable(error)
const envelope: Record<string, unknown> = {
protocol: DAEMON_ERROR_PROTOCOL,
code,
message: error.message,
retryable,
}
const details = daemonErrorDetails(error)
if (details) envelope.details = details
return { error: envelope }
}

function daemonErrorDetails(error: DaemonError) {
switch (error.kind) {
case 'non_loopback_bind':
return { host: error.host }
case 'invalid_status':
return { status: error.statusValue }
case 'job_not_found':
case 'claim_rejected':
case 'claim_expired':
return { job_id: error.jobId }
case 'invalid_job_state':
return {
job_id: error.jobId,
expected: error.expected,
actual: error.actual,
}
default:
return null
}
}

function errorText(error: unknown) {
return error instanceof Error && error.message ? error.message : String(error)
}
Loading