feat: support multiple workspace roots (#584)

This commit is contained in:
junes
2026-05-07 08:30:52 +08:00
committed by GitHub
parent e17d7e5995
commit 0006d04f9e
16 changed files with 302 additions and 126 deletions
+2 -2
View File
@@ -38,7 +38,7 @@ export type SessionBootstrapResult = {
workingDirectory: string
}
export function buildMachineMetadata(options?: { workspaceRoot?: string }): MachineMetadata {
export function buildMachineMetadata(options?: { workspaceRoots?: string[] }): MachineMetadata {
return {
host: process.env.HAPI_HOSTNAME || os.hostname(),
platform: os.platform(),
@@ -46,7 +46,7 @@ export function buildMachineMetadata(options?: { workspaceRoot?: string }): Mach
homeDir: os.homedir(),
happyHomeDir: configuration.happyHomeDir,
happyLibDir: runtimePath(),
workspaceRoot: options?.workspaceRoot
workspaceRoots: options?.workspaceRoots
}
}
+2 -2
View File
@@ -142,7 +142,7 @@ export class ApiClient {
return new ApiSessionClient(this.token, session)
}
machineSyncClient(machine: Machine, options?: { workspaceRoot?: string }): ApiMachineClient {
return new ApiMachineClient(this.token, machine, options?.workspaceRoot)
machineSyncClient(machine: Machine, options?: { workspaceRoots?: string[] }): ApiMachineClient {
return new ApiMachineClient(this.token, machine, options?.workspaceRoots)
}
}
+29 -4
View File
@@ -62,12 +62,12 @@ describe('ApiMachineClient listOpencodeModelsForCwd handler', () => {
it('rejects cwd outside the workspace root with the standard error shape', async () => {
const machine = makeMachine('machine-1')
const client = new ApiMachineClient('cli-token', machine, workspaceRoot)
const client = new ApiMachineClient('cli-token', machine, [workspaceRoot])
const outsideCwd = mkdtempSync(join(tmpdir(), 'hapi-outside-'))
try {
const result = await callListOpencodeModels(client, machine.id, outsideCwd)
expect(result).toEqual({ success: false, error: 'Path is outside workspace root' })
expect(result).toEqual({ success: false, error: 'Path is outside workspace roots' })
expect(listOpencodeModelsForCwdMock).not.toHaveBeenCalled()
} finally {
rmSync(outsideCwd, { recursive: true, force: true })
@@ -77,7 +77,7 @@ describe('ApiMachineClient listOpencodeModelsForCwd handler', () => {
it('rejects empty cwd with cwd-required error', async () => {
const machine = makeMachine('machine-2')
const client = new ApiMachineClient('cli-token', machine, workspaceRoot)
const client = new ApiMachineClient('cli-token', machine, [workspaceRoot])
try {
const result = await callListOpencodeModels(client, machine.id, '')
@@ -90,7 +90,7 @@ describe('ApiMachineClient listOpencodeModelsForCwd handler', () => {
it('forwards a workspace-internal cwd to listOpencodeModelsForCwd', async () => {
const machine = makeMachine('machine-3')
const client = new ApiMachineClient('cli-token', machine, workspaceRoot)
const client = new ApiMachineClient('cli-token', machine, [workspaceRoot])
const innerDir = join(workspaceRoot, 'inner-project')
mkdirSync(innerDir)
@@ -115,4 +115,29 @@ describe('ApiMachineClient listOpencodeModelsForCwd handler', () => {
client.shutdown()
}
})
it('accepts cwd inside any configured workspace root', async () => {
const machine = makeMachine('machine-4')
const secondWorkspaceRoot = mkdtempSync(join(tmpdir(), 'hapi-machine-ws-2-'))
const client = new ApiMachineClient('cli-token', machine, [workspaceRoot, secondWorkspaceRoot])
listOpencodeModelsForCwdMock.mockResolvedValueOnce({
success: true,
availableModels: [{ modelId: 'x/y' }],
currentModelId: 'x/y'
})
try {
const result = await callListOpencodeModels(client, machine.id, secondWorkspaceRoot)
expect(result).toEqual({
success: true,
availableModels: [{ modelId: 'x/y' }],
currentModelId: 'x/y'
})
expect(listOpencodeModelsForCwdMock).toHaveBeenCalledWith(secondWorkspaceRoot)
} finally {
rmSync(secondWorkspaceRoot, { recursive: true, force: true })
client.shutdown()
}
})
})
+65 -40
View File
@@ -90,31 +90,52 @@ interface ListMachineDirectoryResponse {
error?: string
}
function normalizeWorkspaceRoots(paths?: string[]): string[] | undefined {
if (!paths?.length) {
return undefined
}
const normalized = Array.from(new Set(paths.map((path) => {
try {
return realpathSync(path)
} catch {
return resolvePath(path)
}
})))
return normalized.length > 0 ? normalized : undefined
}
function workspaceRootsEqual(left?: string[], right?: string[]): boolean {
const normalizedLeft = left ?? []
const normalizedRight = right ?? []
if (normalizedLeft.length !== normalizedRight.length) {
return false
}
return normalizedLeft.every((value, index) => value === normalizedRight[index])
}
function formatWorkspaceRoots(paths?: string[]): string {
return paths?.length ? paths.join(', ') : '(none)'
}
export class ApiMachineClient {
private socket!: Socket<ServerToRunnerEvents, RunnerToServerEvents>
private keepAliveInterval: NodeJS.Timeout | null = null
private rpcHandlerManager: RpcHandlerManager
private readonly normalizedWorkspaceRoot: string | undefined
private readonly normalizedWorkspaceRoots: string[] | undefined
constructor(
private readonly token: string,
private readonly machine: Machine,
private readonly workspaceRoot?: string
private readonly workspaceRoots?: string[]
) {
// realpath the root once so all subsequent comparisons are against
// the canonical, symlink-resolved path. Falls back to a lexical
// resolve if realpath fails (e.g. unusual permission setup) so we
// still get *some* protection rather than skipping the check.
if (workspaceRoot) {
try {
this.normalizedWorkspaceRoot = realpathSync(workspaceRoot)
} catch {
this.normalizedWorkspaceRoot = resolvePath(workspaceRoot)
}
} else {
this.normalizedWorkspaceRoot = undefined
}
// Realpath roots once so all subsequent comparisons are against
// canonical, symlink-resolved locations. Falls back to lexical
// resolution if realpath fails so we still get protection.
this.normalizedWorkspaceRoots = normalizeWorkspaceRoots(workspaceRoots)
this.rpcHandlerManager = new RpcHandlerManager({
scopePrefix: this.machine.id,
@@ -143,7 +164,7 @@ export class ApiMachineClient {
})
this.rpcHandlerManager.registerHandler<ListMachineDirectoryRequest, ListMachineDirectoryResponse>('list-directory', async (params) => {
if (!this.normalizedWorkspaceRoot) {
if (!this.normalizedWorkspaceRoots?.length) {
return { success: false, error: 'Workspace browsing is not enabled for this machine' }
}
@@ -153,8 +174,8 @@ export class ApiMachineClient {
}
const targetPath = await this.resolveForWorkspaceCheck(rawPath)
if (!this.isWithinWorkspaceRoot(targetPath)) {
return { success: false, error: 'Path is outside workspace root' }
if (!this.isWithinWorkspaceRoots(targetPath)) {
return { success: false, error: 'Path is outside workspace roots' }
}
try {
@@ -228,8 +249,8 @@ export class ApiMachineClient {
}
const resolvedCwd = await this.resolveForWorkspaceCheck(rawCwd)
if (!this.isWithinWorkspaceRoot(resolvedCwd)) {
return { success: false, error: 'Path is outside workspace root' }
if (!this.isWithinWorkspaceRoots(resolvedCwd)) {
return { success: false, error: 'Path is outside workspace roots' }
}
return await listOpencodeModelsForCwd(resolvedCwd)
@@ -237,10 +258,12 @@ export class ApiMachineClient {
)
}
private isWithinWorkspaceRoot(absolutePath: string): boolean {
if (!this.normalizedWorkspaceRoot) return true
const rel = relative(this.normalizedWorkspaceRoot, absolutePath)
return rel === '' || (!rel.startsWith('..') && !isAbsolute(rel))
private isWithinWorkspaceRoots(absolutePath: string): boolean {
if (!this.normalizedWorkspaceRoots?.length) return true
return this.normalizedWorkspaceRoots.some((workspaceRoot) => {
const rel = relative(workspaceRoot, absolutePath)
return rel === '' || (!rel.startsWith('..') && !isAbsolute(rel))
})
}
/**
@@ -283,8 +306,8 @@ export class ApiMachineClient {
}
const resolvedDirectory = await this.resolveForWorkspaceCheck(directory)
if (!this.isWithinWorkspaceRoot(resolvedDirectory)) {
return { type: 'error', errorMessage: 'Directory is outside this machine\'s workspace root' }
if (!this.isWithinWorkspaceRoots(resolvedDirectory)) {
return { type: 'error', errorMessage: 'Directory is outside this machine\'s workspace roots' }
}
const result = await spawnSession({
@@ -428,31 +451,33 @@ export class ApiMachineClient {
logger.debug('[API MACHINE] Failed to update runner state on connect', error)
})
const hubWorkspaceRoot = this.machine.metadata?.workspaceRoot
const desiredWorkspaceRoot = this.workspaceRoot
if (desiredWorkspaceRoot !== hubWorkspaceRoot) {
if (desiredWorkspaceRoot) {
console.log(`[HAPI] Syncing workspace root to hub: ${desiredWorkspaceRoot} (current hub value: ${hubWorkspaceRoot ?? 'none'})`)
const hubWorkspaceRoots = this.machine.metadata?.workspaceRoots
const desiredWorkspaceRoots = this.workspaceRoots
if (!workspaceRootsEqual(desiredWorkspaceRoots, hubWorkspaceRoots)) {
if (desiredWorkspaceRoots?.length) {
console.log(`[HAPI] Syncing workspace roots to hub: ${formatWorkspaceRoots(desiredWorkspaceRoots)} (current hub value: ${formatWorkspaceRoots(hubWorkspaceRoots)})`)
} else {
console.log(`[HAPI] Clearing workspace root on hub (was: ${hubWorkspaceRoot})`)
console.log(`[HAPI] Clearing workspace roots on hub (was: ${formatWorkspaceRoots(hubWorkspaceRoots)})`)
}
this.updateMachineMetadata((current) => {
const base = current ?? this.machine.metadata
if (!base) {
return { workspaceRoot: desiredWorkspaceRoot } as MachineMetadata
return { workspaceRoots: desiredWorkspaceRoots } as MachineMetadata
}
if (desiredWorkspaceRoot) {
return { ...base, workspaceRoot: desiredWorkspaceRoot }
if (desiredWorkspaceRoots?.length) {
return { ...base, workspaceRoots: desiredWorkspaceRoots }
}
const { workspaceRoot: _legacyWorkspaceRoot, workspaceRoots: _workspaceRoots, ...rest } = base as MachineMetadata & {
workspaceRoot?: string
}
const { workspaceRoot: _omit, ...rest } = base
return rest as MachineMetadata
}).then(() => {
console.log(`[HAPI] Workspace root synced: ${this.machine.metadata?.workspaceRoot ?? '(none)'}`)
console.log(`[HAPI] Workspace roots synced: ${formatWorkspaceRoots(this.machine.metadata?.workspaceRoots)}`)
}).catch((error) => {
console.error('[HAPI] Failed to sync workspace root:', error instanceof Error ? error.message : error)
console.error('[HAPI] Failed to sync workspace roots:', error instanceof Error ? error.message : error)
})
} else if (desiredWorkspaceRoot) {
console.log(`[HAPI] Workspace root already up to date on hub: ${desiredWorkspaceRoot}`)
} else if (desiredWorkspaceRoots?.length) {
console.log(`[HAPI] Workspace roots already up to date on hub: ${formatWorkspaceRoots(desiredWorkspaceRoots)}`)
}
this.startKeepAlive()
+15 -1
View File
@@ -37,7 +37,21 @@ export const MachineMetadataSchema = z.object({
homeDir: z.string(),
happyHomeDir: z.string(),
happyLibDir: z.string(),
workspaceRoot: z.string().optional()
workspaceRoot: z.string().optional(),
workspaceRoots: z.array(z.string()).optional()
}).transform(({ workspaceRoot, workspaceRoots, ...rest }) => {
const normalizedWorkspaceRoots = Array.from(new Set(
Array.isArray(workspaceRoots)
? workspaceRoots.filter((path): path is string => typeof path === 'string' && path.trim().length > 0)
: workspaceRoot
? [workspaceRoot]
: []
))
return {
...rest,
workspaceRoots: normalizedWorkspaceRoots.length > 0 ? normalizedWorkspaceRoots : undefined
}
})
export type MachineMetadata = z.infer<typeof MachineMetadataSchema>
+26 -14
View File
@@ -16,15 +16,18 @@ import { initializeToken } from '@/ui/tokenInit'
import type { CommandDefinition } from './types'
/**
* Parses `--workspace-root <path>` / `--workspace-root=<path>` from the
* runner's positional args. Returns the resolved absolute path or exits
* Parses repeated `--workspace-root <path>` / `--workspace-root=<path>` from
* the runner's positional args. Returns resolved absolute paths or exits
* the process with a clear error. Mutates `args` to remove the consumed
* entries so subcommand dispatch still works.
*/
function extractWorkspaceRootArg(args: string[]): string | undefined {
for (let i = 0; i < args.length; i++) {
function extractWorkspaceRootArgs(args: string[]): string[] | undefined {
const workspaceRoots: string[] = []
for (let i = 0; i < args.length;) {
const arg = args[i]
let value: string | undefined
let consumed = 0
if (arg === '--workspace-root') {
const next = args[i + 1]
if (next === undefined || next.startsWith('--')) {
@@ -32,12 +35,15 @@ function extractWorkspaceRootArg(args: string[]): string | undefined {
process.exit(1)
}
value = next
args.splice(i, 2)
consumed = 2
} else if (arg?.startsWith('--workspace-root=')) {
value = arg.slice('--workspace-root='.length)
args.splice(i, 1)
consumed = 1
}
if (value === undefined) {
i += 1
continue
}
if (value === undefined) continue
const trimmed = value.trim()
if (!trimmed) {
@@ -56,9 +62,12 @@ function extractWorkspaceRootArg(args: string[]): string | undefined {
console.error(`--workspace-root path does not exist or is not a directory: ${absolute}`)
process.exit(1)
}
return absolute
workspaceRoots.push(absolute)
args.splice(i, consumed)
}
return undefined
const uniqueWorkspaceRoots = Array.from(new Set(workspaceRoots))
return uniqueWorkspaceRoots.length > 0 ? uniqueWorkspaceRoots : undefined
}
export const runnerCommand: CommandDefinition = {
@@ -66,7 +75,7 @@ export const runnerCommand: CommandDefinition = {
requiresRuntimeAssets: true,
run: async ({ commandArgs }) => {
const mutableArgs = [...commandArgs]
const workspaceRoot = extractWorkspaceRootArg(mutableArgs)
const workspaceRoots = extractWorkspaceRootArgs(mutableArgs)
const runnerSubcommand = mutableArgs[0]
if (runnerSubcommand === 'list') {
@@ -103,8 +112,10 @@ export const runnerCommand: CommandDefinition = {
if (runnerSubcommand === 'start') {
const childArgs = ['runner', 'start-sync']
if (workspaceRoot) {
childArgs.push('--workspace-root', workspaceRoot)
if (workspaceRoots?.length) {
for (const workspaceRoot of workspaceRoots) {
childArgs.push('--workspace-root', workspaceRoot)
}
}
const child = spawnHappyCLI(childArgs, {
detached: true,
@@ -133,7 +144,7 @@ export const runnerCommand: CommandDefinition = {
if (runnerSubcommand === 'start-sync') {
await initializeToken()
await startRunner({ workspaceRoot })
await startRunner({ workspaceRoots })
process.exit(0)
}
@@ -168,7 +179,8 @@ ${chalk.bold('Usage:')}
${chalk.bold('Options:')}
--workspace-root <path> Restrict the runner to this directory.
Browse & spawn will reject paths outside it.
Repeat to allow multiple directories/drives.
Browse & spawn reject paths outside them.
Supports \`~\` / \`~/foo\` expansion.
Omit to leave browsing off (legacy mode).
+7 -7
View File
@@ -22,10 +22,10 @@ import { startRunnerControlServer } from './controlServer';
import { createWorktree, removeWorktree, type WorktreeInfo } from './worktree';
import { join } from 'path';
import { buildMachineMetadata } from '@/agent/sessionFactory';
import { resolveWorkspaceRoot } from '@/utils/workspaceRoot';
import { resolveWorkspaceRoots } from '@/utils/workspaceRoot';
import { hashRunnerCliApiToken } from './runnerIdentity';
export async function startRunner(options: { workspaceRoot?: string } = {}): Promise<void> {
export async function startRunner(options: { workspaceRoots?: string[] } = {}): Promise<void> {
// We don't have cleanup function at the time of server construction
// Control flow is:
// 1. Create promise that will resolve when shutdown is requested
@@ -685,14 +685,14 @@ export async function startRunner(options: { workspaceRoot?: string } = {}): Pro
// Create API client
const api = await ApiClient.create();
const workspaceRoot = resolveWorkspaceRoot(options.workspaceRoot);
logger.debug(`[RUNNER RUN] Workspace root: ${workspaceRoot ?? '(not set)'}`);
const workspaceRoots = resolveWorkspaceRoots(options.workspaceRoots);
logger.debug(`[RUNNER RUN] Workspace roots: ${workspaceRoots?.join(', ') ?? '(not set)'}`);
// Get or create machine (with retry for transient connection errors)
const machine = await withRetry(
() => api.getOrCreateMachine({
machineId,
metadata: buildMachineMetadata({ workspaceRoot }),
metadata: buildMachineMetadata({ workspaceRoots }),
runnerState: initialRunnerState
}),
{
@@ -709,7 +709,7 @@ export async function startRunner(options: { workspaceRoot?: string } = {}): Pro
logger.debug(`[RUNNER RUN] Machine registered: ${machine.id}`);
// Create realtime machine session
const apiMachine = api.machineSyncClient(machine, { workspaceRoot });
const apiMachine = api.machineSyncClient(machine, { workspaceRoots });
// Set RPC handlers
apiMachine.setRPCHandlers({
@@ -725,7 +725,7 @@ export async function startRunner(options: { workspaceRoot?: string } = {}): Pro
// regardless of the verbose/quiet logger setting.
console.log('');
console.log('Hapi runner started.');
console.log(` Workspace root: ${workspaceRoot ?? '(not set — browse disabled; pass --workspace-root to enable)'}`);
console.log(` Workspace roots: ${workspaceRoots?.join(', ') ?? '(not set — browse disabled; pass --workspace-root to enable)'}`);
console.log(` Hub URL: ${configuration.apiUrl}`);
console.log(` Machine ID: ${machine.id}`);
console.log(` Control port: ${controlPort}`);
+12 -7
View File
@@ -1,17 +1,22 @@
import { isAbsolute } from 'node:path'
/**
* Resolves the runner's workspace root — the directory tree the runner is
* Resolves the runner's workspace roots — the directory trees the runner is
* allowed to browse and spawn sessions in. Returns `undefined` when the
* user hasn't explicitly opted in; in that case the runner behaves like
* the legacy hapi (no scoping, no /browse feature surfaced in the web UI).
* legacy hapi (no scoping, no /browse feature surfaced in the web UI).
*
* The only signal is the `explicit` argument — typically the resolved
* `--workspace-root` flag. Non-absolute values are ignored.
* `--workspace-root` flag values. Non-absolute values are ignored.
*/
export function resolveWorkspaceRoot(explicit?: string): string | undefined {
if (explicit && isAbsolute(explicit)) {
return explicit
export function resolveWorkspaceRoots(explicit?: string[]): string[] | undefined {
if (!explicit?.length) {
return undefined
}
return undefined
const uniqueRoots = Array.from(
new Set(explicit.filter((path): path is string => typeof path === 'string' && isAbsolute(path)))
)
return uniqueRoots.length > 0 ? uniqueRoots : undefined
}