feat: workspace browser with --workspace-root opt-in scoping (#526)

* feat(web): add workspace browser for multi-directory navigation

Add /browse route with a folder browser that lets users navigate
filesystem directories on connected machines and launch sessions
from any folder. Supports saved workspace paths and direct path
input. The "Start Session" action pre-fills the NewSession form.

- CLI: register machine-level `list-directory` RPC handler
- Hub: add POST /machines/:id/list-directory route
- Web: add WorkspaceBrowser component with git repo detection
- Web: add /browse route with navigation from sessions sidebar
- Web: support initialDirectory/initialMachineId in NewSession

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: add --workspace-root opt-in scoping for /browse and session spawn

Adds a single new flag, \`--workspace-root <path>\` (with \`~\` / \`~/foo\`
expansion), on \`hapi runner start\` and \`hapi runner start-sync\`.

When set:
- The runner reports the path in machine metadata.
- The list-directory and spawn-session RPC handlers reject paths outside
  the root, so the web UI can't escape the configured tree even if
  someone crafts a request manually.
- The /browse page in the web UI auto-opens that root, restricts the
  breadcrumb / go-up to its subtree, and shows directory entries with
  git-repo annotations.
- The /sessions/new form keeps its existing free-text directory input
  plus autocomplete + recent-paths chips, and gains a small "Browse"
  button (next to the input) that opens /browse for picking a folder.
- Reconnect-time metadata sync ensures stale records get the field
  filled in (or cleared when the flag is dropped on a later restart),
  so the hub state matches the CLI's intent.

When unset:
- Runner behaves like the legacy hapi (no scoping, no browse feature).
- /browse renders an informative state pointing at the flag instead of
  blocking the user.
- The /sessions/new form looks identical to the pre-change behavior;
  the "Browse" button is hidden.

Includes a startup banner so \`runner start-sync\` no longer looks like
it hung, and surfaces the workspace-root sync result on stdout.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(hub): preserve workspaceRoot when rehydrating machines from store

MachineCache.refreshMachine() rebuilt the metadata object from an
explicit field allowlist, so any field not in the list (including the
new workspaceRoot) was silently dropped on every read — even though it
was correctly written to the store.

Add workspaceRoot to the zod schema, the Machine interface, and the
hand-rolled projection.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(web): friendlier empty state on /sessions

When there are zero sessions the page used to be a vast blank
rectangle with just the "0 sessions in 0 projects" caption. Render a
centered empty state instead: a calendar/agenda icon, a short heading
and hint, and two buttons — "Start a session" (→ /sessions/new) and
"Browse workspace" (→ /browse).

SessionList gains an optional onBrowse prop. Router wires it on the
sessions page so the secondary button resolves; other callers can leave
it unset to hide that button.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs: document --workspace-root flag in cli/README and root README

Add a short paragraph under "Runner management" in cli/README.md
explaining what \`--workspace-root\` enables (scoped /browse tree,
list/spawn enforcement, tilde expansion) and that omitting it keeps
the legacy behavior. Mention the workspace browser in the top-level
README's Features list.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix: address PR #526 review feedback

Three findings from the review bot:

1. [Major] Workspace-scope check was lexical only. With workspaceRoot
   = /safe, a symlink such as /safe/out -> /etc would pass the relative-
   path test and let list-directory / spawn-happy-session reach paths
   outside the configured root. realpath the workspaceRoot at construction
   time, and resolve every incoming path through realpath (walking up to
   the nearest existing parent for spawn targets that haven't been
   created yet) before the containment check.

2. [Minor] \`hapi runner start --workspace-root\` with no value used to
   drop the flag silently and start the runner unscoped. Now treats a
   missing or flag-shaped next argument as an error.

3. [Minor] /sessions/new's "Browse" button always opened /browse using
   localStorage's last-used machine, ignoring the user's current
   selection. NewSession already passes machineId in its callback;
   forward it through the /browse search params and seed
   WorkspaceBrowser with it.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(cli): gate list-directory RPC behind --workspace-root opt-in

Without a configured workspaceRoot, isWithinWorkspaceRoot() returns
true unconditionally, leaving the new list-directory RPC able to
enumerate any path on the runner. The Web UI already hides Browse
for these machines, but the backend should enforce the opt-in too.

Refuse the RPC up front when no workspace root is configured.

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Everett
2026-04-26 09:59:18 +08:00
committed by GitHub
co-authored by Claude Opus 4.6
parent 3a2a02c0eb
commit 010dc41369
22 changed files with 990 additions and 43 deletions
+3 -2
View File
@@ -38,14 +38,15 @@ export type SessionBootstrapResult = {
workingDirectory: string
}
export function buildMachineMetadata(): MachineMetadata {
export function buildMachineMetadata(options?: { workspaceRoot?: string }): MachineMetadata {
return {
host: process.env.HAPI_HOSTNAME || os.hostname(),
platform: os.platform(),
happyCliVersion: packageJson.version,
homeDir: os.homedir(),
happyHomeDir: configuration.happyHomeDir,
happyLibDir: runtimePath()
happyLibDir: runtimePath(),
workspaceRoot: options?.workspaceRoot
}
}
+2 -2
View File
@@ -142,7 +142,7 @@ export class ApiClient {
return new ApiSessionClient(this.token, session)
}
machineSyncClient(machine: Machine): ApiMachineClient {
return new ApiMachineClient(this.token, machine)
machineSyncClient(machine: Machine, options?: { workspaceRoot?: string }): ApiMachineClient {
return new ApiMachineClient(this.token, machine, options?.workspaceRoot)
}
}
+179 -2
View File
@@ -3,7 +3,9 @@
*/
import { io, type Socket } from 'socket.io-client'
import { stat } from 'node:fs/promises'
import { readdir, realpath, stat } from 'node:fs/promises'
import { realpathSync } from 'node:fs'
import { basename, dirname, isAbsolute, join, relative, resolve as resolvePath } from 'node:path'
import { logger } from '@/ui/logger'
import { configuration } from '@/configuration'
import type { Update, UpdateMachineBody } from '@hapi/protocol'
@@ -65,15 +67,50 @@ interface PathExistsResponse {
exists: Record<string, boolean>
}
interface ListMachineDirectoryRequest {
path: string
}
interface ListMachineDirectoryEntry {
name: string
type: 'file' | 'directory' | 'other'
size?: number
modified?: number
isGitRepo?: boolean
}
interface ListMachineDirectoryResponse {
success: boolean
entries?: ListMachineDirectoryEntry[]
error?: string
}
export class ApiMachineClient {
private socket!: Socket<ServerToRunnerEvents, RunnerToServerEvents>
private keepAliveInterval: NodeJS.Timeout | null = null
private rpcHandlerManager: RpcHandlerManager
private readonly normalizedWorkspaceRoot: string | undefined
constructor(
private readonly token: string,
private readonly machine: Machine
private readonly machine: Machine,
private readonly workspaceRoot?: 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
}
this.rpcHandlerManager = new RpcHandlerManager({
scopePrefix: this.machine.id,
logger: (msg, data) => logger.debug(msg, data)
@@ -99,6 +136,113 @@ export class ApiMachineClient {
return { exists }
})
this.rpcHandlerManager.registerHandler<ListMachineDirectoryRequest, ListMachineDirectoryResponse>('list-directory', async (params) => {
if (!this.normalizedWorkspaceRoot) {
return { success: false, error: 'Workspace browsing is not enabled for this machine' }
}
const rawPath = typeof params?.path === 'string' ? params.path.trim() : ''
if (!rawPath) {
return { success: false, error: 'Path is required' }
}
const targetPath = await this.resolveForWorkspaceCheck(rawPath)
if (!this.isWithinWorkspaceRoot(targetPath)) {
return { success: false, error: 'Path is outside workspace root' }
}
try {
const dirStat = await stat(targetPath)
if (!dirStat.isDirectory()) {
return { success: false, error: 'Path is not a directory' }
}
const dirEntries = await readdir(targetPath, { withFileTypes: true })
const entries: ListMachineDirectoryEntry[] = []
await Promise.all(dirEntries.map(async (entry) => {
if (entry.name.startsWith('.')) return
const fullPath = join(targetPath, entry.name)
let type: 'file' | 'directory' | 'other' = 'other'
let size: number | undefined
let modified: number | undefined
let isGitRepo = false
if (entry.isDirectory()) {
type = 'directory'
try {
const gitStat = await stat(join(fullPath, '.git'))
isGitRepo = gitStat.isDirectory() || gitStat.isFile()
} catch {
// not a git repo
}
} else if (entry.isFile()) {
type = 'file'
}
if (!entry.isSymbolicLink()) {
try {
const stats = await stat(fullPath)
size = stats.size
modified = stats.mtime.getTime()
} catch {
// ignore stat errors
}
}
entries.push({ name: entry.name, type, size, modified, isGitRepo })
}))
entries.sort((a, b) => {
if (a.type === 'directory' && b.type !== 'directory') return -1
if (a.type !== 'directory' && b.type === 'directory') return 1
return a.name.localeCompare(b.name)
})
return { success: true, entries }
} catch (error) {
return { success: false, error: error instanceof Error ? error.message : 'Failed to list directory' }
}
})
}
private isWithinWorkspaceRoot(absolutePath: string): boolean {
if (!this.normalizedWorkspaceRoot) return true
const rel = relative(this.normalizedWorkspaceRoot, absolutePath)
return rel === '' || (!rel.startsWith('..') && !isAbsolute(rel))
}
/**
* Canonicalize a path for workspace-root containment checks. Resolves
* symlinks via realpath so a symlink such as `/safe/out -> /etc` cannot
* be used to escape the configured root with a lexical-only check.
*
* If the path doesn't exist (e.g. a session is being spawned in a
* directory we'll create), walks up to the nearest existing ancestor
* and realpaths *that*, joining the missing tail back on. This way the
* check still runs against the real on-disk location once any
* intermediate symlink in the parent chain has been resolved.
*/
private async resolveForWorkspaceCheck(path: string): Promise<string> {
const absolute = resolvePath(path)
try {
return await realpath(absolute)
} catch {
const missing: string[] = []
let cursor = absolute
while (cursor !== dirname(cursor)) {
missing.unshift(basename(cursor))
cursor = dirname(cursor)
try {
return join(await realpath(cursor), ...missing)
} catch {
// keep walking to the nearest existing parent
}
}
return absolute
}
}
setRPCHandlers({ spawnSession, stopSession, requestShutdown }: MachineRpcHandlers): void {
@@ -109,6 +253,11 @@ export class ApiMachineClient {
throw new Error('Directory is required')
}
const resolvedDirectory = await this.resolveForWorkspaceCheck(directory)
if (!this.isWithinWorkspaceRoot(resolvedDirectory)) {
return { type: 'error', errorMessage: 'Directory is outside this machine\'s workspace root' }
}
const result = await spawnSession({
directory,
sessionId,
@@ -249,6 +398,34 @@ export class ApiMachineClient {
})).catch((error) => {
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'})`)
} else {
console.log(`[HAPI] Clearing workspace root on hub (was: ${hubWorkspaceRoot})`)
}
this.updateMachineMetadata((current) => {
const base = current ?? this.machine.metadata
if (!base) {
return { workspaceRoot: desiredWorkspaceRoot } as MachineMetadata
}
if (desiredWorkspaceRoot) {
return { ...base, workspaceRoot: desiredWorkspaceRoot }
}
const { workspaceRoot: _omit, ...rest } = base
return rest as MachineMetadata
}).then(() => {
console.log(`[HAPI] Workspace root synced: ${this.machine.metadata?.workspaceRoot ?? '(none)'}`)
}).catch((error) => {
console.error('[HAPI] Failed to sync workspace root:', error instanceof Error ? error.message : error)
})
} else if (desiredWorkspaceRoot) {
console.log(`[HAPI] Workspace root already up to date on hub: ${desiredWorkspaceRoot}`)
}
this.startKeepAlive()
})
+2 -1
View File
@@ -36,7 +36,8 @@ export const MachineMetadataSchema = z.object({
displayName: z.string().optional(),
homeDir: z.string(),
happyHomeDir: z.string(),
happyLibDir: z.string()
happyLibDir: z.string(),
workspaceRoot: z.string().optional()
})
export type MachineMetadata = z.infer<typeof MachineMetadataSchema>
+65 -4
View File
@@ -1,4 +1,7 @@
import chalk from 'chalk'
import { existsSync, statSync } from 'node:fs'
import { homedir } from 'node:os'
import { isAbsolute, resolve } from 'node:path'
import { startRunner } from '@/runner/run'
import {
checkIfRunnerRunningAndCleanupStaleState,
@@ -12,11 +15,59 @@ import { runDoctorCommand } from '@/ui/doctor'
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
* 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++) {
const arg = args[i]
let value: string | undefined
if (arg === '--workspace-root') {
const next = args[i + 1]
if (next === undefined || next.startsWith('--')) {
console.error('--workspace-root requires a path argument')
process.exit(1)
}
value = next
args.splice(i, 2)
} else if (arg?.startsWith('--workspace-root=')) {
value = arg.slice('--workspace-root='.length)
args.splice(i, 1)
}
if (value === undefined) continue
const trimmed = value.trim()
if (!trimmed) {
console.error('--workspace-root requires a non-empty path')
process.exit(1)
}
// Handle `~` / `~/foo` since the shell only expands unquoted tildes.
let expanded = trimmed
if (expanded === '~') {
expanded = homedir()
} else if (expanded.startsWith('~/')) {
expanded = resolve(homedir(), expanded.slice(2))
}
const absolute = isAbsolute(expanded) ? expanded : resolve(expanded)
if (!existsSync(absolute) || !statSync(absolute).isDirectory()) {
console.error(`--workspace-root path does not exist or is not a directory: ${absolute}`)
process.exit(1)
}
return absolute
}
return undefined
}
export const runnerCommand: CommandDefinition = {
name: 'runner',
requiresRuntimeAssets: true,
run: async ({ commandArgs }) => {
const runnerSubcommand = commandArgs[0]
const mutableArgs = [...commandArgs]
const workspaceRoot = extractWorkspaceRootArg(mutableArgs)
const runnerSubcommand = mutableArgs[0]
if (runnerSubcommand === 'list') {
try {
@@ -35,7 +86,7 @@ export const runnerCommand: CommandDefinition = {
}
if (runnerSubcommand === 'stop-session') {
const sessionId = commandArgs[1]
const sessionId = mutableArgs[1]
if (!sessionId) {
console.error('Session ID required')
process.exit(1)
@@ -51,7 +102,11 @@ export const runnerCommand: CommandDefinition = {
}
if (runnerSubcommand === 'start') {
const child = spawnHappyCLI(['runner', 'start-sync'], {
const childArgs = ['runner', 'start-sync']
if (workspaceRoot) {
childArgs.push('--workspace-root', workspaceRoot)
}
const child = spawnHappyCLI(childArgs, {
detached: true,
stdio: 'ignore',
env: process.env
@@ -78,7 +133,7 @@ export const runnerCommand: CommandDefinition = {
if (runnerSubcommand === 'start-sync') {
await initializeToken()
await startRunner()
await startRunner({ workspaceRoot })
process.exit(0)
}
@@ -111,6 +166,12 @@ ${chalk.bold('Usage:')}
hapi runner status Show runner status
hapi runner list List active sessions
${chalk.bold('Options:')}
--workspace-root <path> Restrict the runner to this directory.
Browse & spawn will reject paths outside it.
Supports \`~\` / \`~/foo\` expansion.
Omit to leave browsing off (legacy mode).
If you want to kill all hapi related processes run
${chalk.cyan('hapi doctor clean')}
+18 -3
View File
@@ -22,9 +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 { hashRunnerCliApiToken } from './runnerIdentity';
export async function startRunner(): Promise<void> {
export async function startRunner(options: { workspaceRoot?: 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
@@ -684,11 +685,14 @@ export async function startRunner(): Promise<void> {
// Create API client
const api = await ApiClient.create();
const workspaceRoot = resolveWorkspaceRoot(options.workspaceRoot);
logger.debug(`[RUNNER RUN] Workspace root: ${workspaceRoot ?? '(not set)'}`);
// Get or create machine (with retry for transient connection errors)
const machine = await withRetry(
() => api.getOrCreateMachine({
machineId,
metadata: buildMachineMetadata(),
metadata: buildMachineMetadata({ workspaceRoot }),
runnerState: initialRunnerState
}),
{
@@ -705,7 +709,7 @@ export async function startRunner(): Promise<void> {
logger.debug(`[RUNNER RUN] Machine registered: ${machine.id}`);
// Create realtime machine session
const apiMachine = api.machineSyncClient(machine);
const apiMachine = api.machineSyncClient(machine, { workspaceRoot });
// Set RPC handlers
apiMachine.setRPCHandlers({
@@ -717,6 +721,17 @@ export async function startRunner(): Promise<void> {
// Connect to server
apiMachine.connect();
// Visible startup banner. Use console.log so it always appears on stdout,
// 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(` Hub URL: ${configuration.apiUrl}`);
console.log(` Machine ID: ${machine.id}`);
console.log(` Control port: ${controlPort}`);
console.log('Waiting for sessions. Press Ctrl+C to stop.');
console.log('');
reportSpawnOutcomeToHub = (outcome) => {
void apiMachine.updateRunnerState((state: RunnerState | null) => {
const baseState: RunnerState = state
+17
View File
@@ -0,0 +1,17 @@
import { isAbsolute } from 'node:path'
/**
* Resolves the runner's workspace root — the directory tree 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).
*
* The only signal is the `explicit` argument — typically the resolved
* `--workspace-root` flag. Non-absolute values are ignored.
*/
export function resolveWorkspaceRoot(explicit?: string): string | undefined {
if (explicit && isAbsolute(explicit)) {
return explicit
}
return undefined
}