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: 3 additions & 3 deletions .github/workflows/codeql-analysis.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -27,15 +27,15 @@ jobs:
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2

- name: Initialize CodeQL
uses: github/codeql-action/init@9e0d7b8d25671d64c341c19c0152d693099fb5ba # v4.35.5
uses: github/codeql-action/init@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0
with:
languages: javascript
queries: +security-and-quality

- name: Autobuild
uses: github/codeql-action/autobuild@9e0d7b8d25671d64c341c19c0152d693099fb5ba # v4.35.5
uses: github/codeql-action/autobuild@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0

- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@9e0d7b8d25671d64c341c19c0152d693099fb5ba # v4.35.5
uses: github/codeql-action/analyze@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0
with:
category: '/language:javascript'
2 changes: 1 addition & 1 deletion .github/workflows/scorecard.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,6 @@ jobs:

# Upload the results to GitHub's code scanning dashboard.
- name: Upload to code-scanning
uses: github/codeql-action/upload-sarif@9e0d7b8d25671d64c341c19c0152d693099fb5ba # v4.35.5
uses: github/codeql-action/upload-sarif@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0
with:
sarif_file: results.sarif
2 changes: 1 addition & 1 deletion apps/workspace-agent/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,6 @@
"hono": "4.12.23"
},
"devDependencies": {
"vitest": "4.1.6"
"vitest": "4.1.7"
}
}
127 changes: 127 additions & 0 deletions apps/workspace-agent/src/config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
/**
* Configuration for the workspace-agent.
*
* Secret reading follows the same hardened pattern as packages/gateway/src/config.ts:
* - `${NAME}_FILE` env → read file via O_NOFOLLOW fd, fstat check, size cap
* - `process.env[NAME]` fallback
* - Never log the value
*/

import {closeSync, constants, fstatSync, openSync, readFileSync} from 'node:fs'
import process from 'node:process'

const MAX_SECRET_BYTES = 4096

class SecretFileNotFoundError extends Error {
constructor(message: string) {
super(message)
this.name = 'SecretFileNotFoundError'
}
}

/**
* Read a secret file with hardened path validation. Uses `openSync` with
* `O_NOFOLLOW` so symlinks fail at open (no TOCTOU window between validation
* and read), then `fstatSync` on the already-open file descriptor to confirm
* the file is a regular file under the size limit.
*/
function readSecretFile(filePath: string): string {
let fd: number
try {
fd = openSync(filePath, constants.O_RDONLY | constants.O_NOFOLLOW)
} catch (error) {
if (error instanceof Error && 'code' in error) {
if (error.code === 'ENOENT') {
throw new SecretFileNotFoundError(`Secret file does not exist: ${filePath}`)
}
if (error.code === 'ELOOP') {
throw new Error(
`Secret path is not a regular file: ${filePath} (got symlink). Symlinks are not supported — bind-mount a real file.`,
)
}
}
throw error
}
try {
const stat = fstatSync(fd)
if (stat.isFile() === false) {
const kind = describeStatKind(stat)
throw new Error(
`Secret path is not a regular file: ${filePath} (got ${kind}). FIFOs, devices, and directories are not supported.`,
)
}
if (stat.size > MAX_SECRET_BYTES) {
throw new Error(`Secret file is too large: ${filePath} (${stat.size} bytes > ${MAX_SECRET_BYTES} byte limit).`)
}
return readFileSync(fd, 'utf8')
} finally {
closeSync(fd)
}
}

function describeStatKind(stat: import('node:fs').Stats): string {
if (stat.isSymbolicLink()) return 'symlink'
if (stat.isFIFO()) return 'FIFO/pipe'
if (stat.isCharacterDevice()) return 'character device'
if (stat.isBlockDevice()) return 'block device'
if (stat.isDirectory()) return 'directory'
if (stat.isSocket()) return 'socket'
return 'unknown non-file'
}

/**
* Read an optional secret by name.
*
* Precedence:
* 1. If `${name}_FILE` env var is set AND that file exists → read file contents, trim trailing whitespace
* 2. Else if `process.env[name]` is set → return it
* 3. Else return null
*/
export function readOptionalSecret(name: string): string | null {
const filePath = process.env[`${name}_FILE`]
if (filePath !== undefined) {
let contents: string | undefined
try {
contents = readSecretFile(filePath)
} catch (error) {
if (error instanceof SecretFileNotFoundError) {
// file not present; fall through to env-var fallback
} else {
throw error
}
}
if (contents !== undefined) {
const trailingTrimmed = contents.trimEnd()
if (trailingTrimmed.trim() === '') return null
if (/[\r\n\u0085\u2028\u2029]/.test(trailingTrimmed)) {
throw new Error(
`Secret value at ${filePath} contains embedded line-breaking characters. Remove the line break and rewrite the file as a single line.`,
)
}
return trailingTrimmed
}
}

const value = process.env[name]
if (value !== undefined && value.trim() !== '') {
if (/[\r\n\u0085\u2028\u2029]/.test(value)) {
throw new Error(
`Environment variable ${name} contains embedded line-breaking characters. Remove the line break and set it as a single line.`,
)
}
return value
}

return null
}

/**
* Read a required secret by name. Throws if missing.
*/
export function readSecret(name: string): string {
const value = readOptionalSecret(name)
if (value === null) {
throw new Error(`Missing required secret: ${name} (set ${name} env var or ${name}_FILE pointing to a file)`)
}
return value
}
110 changes: 96 additions & 14 deletions apps/workspace-agent/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,25 +2,84 @@
* workspace-agent entry point.
*
* Starts the Hono HTTP server on 0.0.0.0:9100.
* Starts the OpenCode SDK server bound to 127.0.0.1:54321 (loopback only).
* Starts the bearer-token proxy on 0.0.0.0:9200 (sandbox-net reachable).
* Handles SIGTERM gracefully with a 25s drain window.
*/

import process from 'node:process'
import {serve} from '@hono/node-server'

import {asyncCleanupAllAskpassDirs} from './clone.js'
import {readSecret} from './config.js'
import {createOpencodeProxy} from './opencode-proxy.js'
import {startOpencodeServer} from './opencode-server.js'
import {createApp} from './server.js'

const PORT = 9100
const HOST = '0.0.0.0'
const DRAIN_MS = 25_000
const OPENCODE_PORT = 54321
const OPENCODE_HOSTNAME = '127.0.0.1'
const PROXY_PORT = 9200
const WORKSPACE_REPOS_ROOT = '/workspace/repos'

const app = createApp()
// Shared mutable state for OpenCode readiness, read by /healthz
const opencodeStatus = {status: 'starting' as 'starting' | 'ready' | 'down'}

const app = createApp({opencodeStatus})

const server = serve({fetch: app.fetch, port: PORT, hostname: HOST}, info => {
console.warn(`workspace-agent listening on ${info.address}:${info.port}`)
})

// Boot OpenCode server (loopback-bound) — fire-and-forget, update status ref
const opencodeLogger = {
info: (msg: string, meta?: Record<string, unknown>) => console.warn(msg, meta ?? ''),
warn: (msg: string, meta?: Record<string, unknown>) => console.warn(msg, meta ?? ''),
error: (msg: string, meta?: Record<string, unknown>) => console.error(msg, meta ?? ''),
}

let opencodeHandle: {url: string; close: () => void} | undefined

const opencodeServerPromise = startOpencodeServer({
rootDir: WORKSPACE_REPOS_ROOT,
logger: opencodeLogger,
hostname: OPENCODE_HOSTNAME,
port: OPENCODE_PORT,
})
.then(handle => {
opencodeHandle = handle
opencodeStatus.status = 'ready'
console.warn('workspace-agent: opencode server ready', {url: handle.url})
})
.catch((error: unknown) => {
opencodeStatus.status = 'down'
const message = error instanceof Error ? error.message : String(error)
console.error('workspace-agent: opencode server failed to start', {message})
})

// Boot bearer-token proxy — reads WORKSPACE_OPENCODE_TOKEN secret at startup
let proxy: ReturnType<typeof createOpencodeProxy> | undefined

try {
const token = readSecret('WORKSPACE_OPENCODE_TOKEN')
proxy = createOpencodeProxy({
token,
upstreamUrl: `http://${OPENCODE_HOSTNAME}:${OPENCODE_PORT}`,
logger: opencodeLogger,
})
proxy.listen(PROXY_PORT, HOST).catch((error: unknown) => {
const message = error instanceof Error ? error.message : String(error)
console.error('workspace-agent: proxy failed to start', {message})
})
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
console.error('workspace-agent: cannot start proxy — missing WORKSPACE_OPENCODE_TOKEN', {message})
// Process should not start without the proxy; exit with error code.
process.exit(1)
}

// Graceful shutdown on SIGTERM (Docker stop, compose down, etc.)
let shuttingDown = false

Expand All @@ -35,25 +94,48 @@ function shutdown(signal: string): void {
process.exit(1)
}, DRAIN_MS)

// Clean up all in-flight askpass dirs before closing the server.
// This runs after any in-flight clone AbortControllers have been signalled
// (they abort on their own timeout; we just wait for their finally blocks).
// Close OpenCode server and proxy, then the Hono server.
const cleanupOpencode = (): void => {
if (opencodeHandle !== undefined) {
opencodeHandle.close()
}
}

const cleanupProxy = async (): Promise<void> => {
if (proxy !== undefined) {
return proxy.close().catch(() => {
// Best-effort
})
}
return Promise.resolve()
}

asyncCleanupAllAskpassDirs()
.catch(() => {
// Best-effort; proceed to server.close regardless.
// Best-effort
})
.finally(() => {
server.close(err => {
clearTimeout(drainTimer)
if (err !== undefined && err !== null) {
console.error('workspace-agent: shutdown error', err)
process.exit(1)
}
console.warn('workspace-agent: shutdown clean')
process.exit(0)
})
cleanupOpencode()
cleanupProxy()
.catch(() => {
// Best-effort
})
.finally(() => {
server.close(err => {
clearTimeout(drainTimer)
if (err !== undefined && err !== null) {
console.error('workspace-agent: shutdown error', err)
process.exit(1)
}
console.warn('workspace-agent: shutdown clean')
process.exit(0)
})
})
})
}

process.on('SIGTERM', () => shutdown('SIGTERM'))
process.on('SIGINT', () => shutdown('SIGINT'))

// Export for testing (allows inspecting the promise in integration tests if needed)
export {opencodeServerPromise}
Loading
Loading