From 58bd101793d488e0a2a72116bde89d9fe7596139 Mon Sep 17 00:00:00 2001 From: Gabriel Brown Date: Mon, 13 Jul 2026 10:04:14 -0400 Subject: [PATCH] feat(worker): run terminals and agent turns as the box user --- apps/agent-worker/src/box-init.ts | 94 +++++++++++++++++++ apps/agent-worker/src/box-settings.ts | 30 ++++++ apps/agent-worker/src/box.ts | 34 ++++++- apps/agent-worker/src/runtime/box-user.ts | 4 +- .../src/runtime/claude-adapter.ts | 12 ++- .../agent-worker/src/runtime/codex-adapter.ts | 8 +- .../src/runtime/opencode-adapter.ts | 8 +- apps/agent-worker/src/terminal.ts | 20 +++- apps/agent-worker/src/user-container.ts | 7 +- apps/agent-worker/src/user-environment.ts | 11 ++- apps/agent-worker/src/worker.ts | 22 ++++- .../tests/unit/claude-adapter.test.ts | 21 ++++- .../tests/unit/user-container.test.ts | 14 +++ 13 files changed, 263 insertions(+), 22 deletions(-) create mode 100644 apps/agent-worker/src/box-init.ts create mode 100644 apps/agent-worker/src/box-settings.ts diff --git a/apps/agent-worker/src/box-init.ts b/apps/agent-worker/src/box-init.ts new file mode 100644 index 0000000..f70faad --- /dev/null +++ b/apps/agent-worker/src/box-init.ts @@ -0,0 +1,94 @@ +import { fetchBoxPassword } from './box-settings'; +import { + buildBoxInitScript, + buildClearPasswordCommand, + buildSudoPolicyScript, + CHPASSWD_COMMAND, + linuxUsername, +} from './runtime/box-user'; +import { runExecInContainer } from './runtime/docker'; + +const noRedact = (value: string) => value; + +// Root exec with loud failure — a broken init would leave a root terminal, so +// surface the script output instead of continuing silently. +const execRoot = async ( + containerName: string, + command: string[], + label: string, + input?: string, + redact: (value: string) => string = noRedact, +): Promise => { + const result = await runExecInContainer({ + containerName, + command, + environment: {}, + containerCwd: '/', + redact, + timeoutMs: 120_000, + input, + }); + if (result.exitCode !== 0) { + throw new Error(`Box ${label} failed: ${result.output}`); + } +}; + +/** + * One-time (per container) box-user setup, run as root right after `run`: + * create the Linux user at the mounted home, apply the stored password (via + * chpasswd stdin — never argv), and set the sudo policy (NOPASSWD drop-in + * only while no password is stored). Used as ensureUserContainer's onCreated + * hook by every creation path. + */ +export const initializeBoxUser = async ( + containerName: string, + spoonUsername: string, + containerHome: string, +): Promise => { + const user = linuxUsername(spoonUsername); + await execRoot( + containerName, + ['bash', '-c', buildBoxInitScript({ username: user, home: containerHome })], + 'user init', + ); + const password = await fetchBoxPassword(spoonUsername); + await applyBoxPassword(containerName, spoonUsername, password); +}; + +/** + * Apply (or clear, with null) the box password + matching sudo policy on a + * running container. Shared by init and the live /box/set-password route. + */ +export const applyBoxPassword = async ( + containerName: string, + spoonUsername: string, + password: string | null, +): Promise => { + const user = linuxUsername(spoonUsername); + if (password == null) { + await execRoot( + containerName, + buildClearPasswordCommand(user), + 'password clear', + ); + } else { + await execRoot( + containerName, + CHPASSWD_COMMAND, + 'password apply', + `${user}:${password}\n`, + // chpasswd never echoes the password, but redact defensively in case a + // future runtime error message includes stdin. + (value) => value.replaceAll(password, '[redacted]'), + ); + } + await execRoot( + containerName, + [ + 'bash', + '-c', + buildSudoPolicyScript({ username: user, passwordSet: password != null }), + ], + 'sudo policy', + ); +}; diff --git a/apps/agent-worker/src/box-settings.ts b/apps/agent-worker/src/box-settings.ts new file mode 100644 index 0000000..2f553bd --- /dev/null +++ b/apps/agent-worker/src/box-settings.ts @@ -0,0 +1,30 @@ +import { ConvexHttpClient } from 'convex/browser'; + +import { api } from '@spoon/backend/convex/_generated/api.js'; + +import { env } from './env'; + +// Lazy: unit tests mock ./env with a partial object, and the constructor +// validates the URL eagerly. +let client: ConvexHttpClient | undefined; +const getClient = () => (client ??= new ConvexHttpClient(env.convexUrl)); + +/** + * The user's stored box password (decrypted), or null when none is set. Also + * null on ANY error: a box must still start when Convex is unreachable — the + * user just gets the passwordless-sudo default until the next recreation. + */ +export const fetchBoxPassword = async ( + username: string, +): Promise => { + try { + const result = await getClient().action( + api.boxSettingsNode.getBoxPasswordForWorker, + { workerToken: env.workerToken, username }, + ); + return result.password; + } catch (error) { + console.error(`Failed to fetch box password for ${username}:`, error); + return null; + } +}; diff --git a/apps/agent-worker/src/box.ts b/apps/agent-worker/src/box.ts index 3a3e854..085f064 100644 --- a/apps/agent-worker/src/box.ts +++ b/apps/agent-worker/src/box.ts @@ -1,9 +1,11 @@ -import { mkdir, readFile, readdir, stat, writeFile } from 'node:fs/promises'; +import { mkdir, readdir, readFile, stat, writeFile } from 'node:fs/promises'; import path from 'node:path'; +import { applyBoxPassword, initializeBoxUser } from './box-init'; import { env } from './env'; import { assertContainedRealPath } from './path-containment'; import { + chownInBox, ensureUserContainer, inspectUserBoxStatus, stopWorkspaceContainer, @@ -41,7 +43,25 @@ export const getBoxStatus = async (username: string): Promise => { export const startBox = async (username: string): Promise => { const { homeDir, containerHome } = boxHomePaths(username); - await ensureUserContainer({ username, workdir: homeDir, containerHome }); + await ensureUserContainer({ + username, + workdir: homeDir, + containerHome, + onCreated: (name) => initializeBoxUser(name, username, containerHome), + }); +}; + +// Live password apply for the /box/set-password route: only touches the +// container when it's running — otherwise the stored password is picked up by +// the next creation's init. +export const setBoxUserPassword = async ( + username: string, + password: string | null, +): Promise<{ applied: boolean }> => { + const status = await inspectUserBoxStatus(username); + if (!status.running) return { applied: false }; + await applyBoxPassword(userContainerName(username), username, password); + return { applied: true }; }; export const stopBox = async (username: string): Promise => { @@ -145,9 +165,17 @@ export const writeBoxFile = async ( relPath: string, content: string, ): Promise<{ success: true }> => { - const { homeDir } = boxHomePaths(username); + const { homeDir, containerHome } = boxHomePaths(username); const target = await safeHomePath(homeDir, relPath, { forWrite: true }); await mkdir(path.dirname(target), { recursive: true }); await writeFile(target, content); + // Host-side write: repair ownership so the box user can edit the file in a + // shell too (no-op under dev keep-id; best-effort when the box is stopped — + // the next creation's marker chown won't rerun, but a later editor write or + // manual sudo chown recovers, and read access is unaffected). + await chownInBox({ + containerName: userContainerName(username), + paths: [path.posix.join(containerHome, relPath)], + }); return { success: true }; }; diff --git a/apps/agent-worker/src/runtime/box-user.ts b/apps/agent-worker/src/runtime/box-user.ts index 16edf98..320601a 100644 --- a/apps/agent-worker/src/runtime/box-user.ts +++ b/apps/agent-worker/src/runtime/box-user.ts @@ -37,8 +37,10 @@ export const buildBoxInitScript = (args: { 'fi', `usermod -aG wheel ${user}`, `if [ ! -f ${marker} ]; then`, + // Marker is created BEFORE the recursive chown so ~/.spoon itself ends up + // user-owned by the same sweep. + ` mkdir -p "$(dirname ${marker})" && touch ${marker}`, ` chown -R ${BOX_UID}:${BOX_UID} ${home}`, - ` mkdir -p "$(dirname ${marker})" && touch ${marker} && chown ${BOX_UID}:${BOX_UID} ${marker}`, 'fi', ].join('\n'); }; diff --git a/apps/agent-worker/src/runtime/claude-adapter.ts b/apps/agent-worker/src/runtime/claude-adapter.ts index eb90b2d..d9a569d 100644 --- a/apps/agent-worker/src/runtime/claude-adapter.ts +++ b/apps/agent-worker/src/runtime/claude-adapter.ts @@ -1,17 +1,18 @@ -import path from 'node:path'; import { randomUUID } from 'node:crypto'; +import path from 'node:path'; import type { NormalizedAgentEvent } from '../agent-events'; +import type { AdapterFactory, TurnResult } from './agent-runtime'; +import type { AdapterWorkspace } from './provider'; import { normalizeClaudeJsonLine } from '../agent-events'; import { env } from '../env'; -import type { AdapterFactory, TurnResult } from './agent-runtime'; import { writeJsonFile } from './auth'; +import { linuxUsername } from './box-user'; import { buildMarkedCommand, killBoxProcessesByMarker, streamExecInContainer, } from './docker'; -import type { AdapterWorkspace } from './provider'; import { claudeEnv, claudeModel, isClaudeOAuthProfile } from './provider'; export const createClaudeAdapter: AdapterFactory = (deps) => { @@ -23,7 +24,9 @@ export const createClaudeAdapter: AdapterFactory = (deps) => { if (!isClaudeOAuthProfile(workspace.claim)) return; const secret = workspace.claim.aiProviderProfile?.secret; if (!secret) { - throw new Error('Claude auth profile is missing credentials.json contents.'); + throw new Error( + 'Claude auth profile is missing credentials.json contents.', + ); } const credentialsPath = path.join( workspace.homeDir, @@ -84,6 +87,7 @@ export const createClaudeAdapter: AdapterFactory = (deps) => { try { result = await execStream({ containerName: workspace.boxName, + user: linuxUsername(workspace.username), containerCwd: workspace.containerRepo, command, environment: { ...aiEnv, ...secretEnv }, diff --git a/apps/agent-worker/src/runtime/codex-adapter.ts b/apps/agent-worker/src/runtime/codex-adapter.ts index 4f93b5f..3906f58 100644 --- a/apps/agent-worker/src/runtime/codex-adapter.ts +++ b/apps/agent-worker/src/runtime/codex-adapter.ts @@ -1,18 +1,19 @@ +import { randomUUID } from 'node:crypto'; import { mkdir, readFile, writeFile } from 'node:fs/promises'; import path from 'node:path'; -import { randomUUID } from 'node:crypto'; import type { NormalizedAgentEvent } from '../agent-events'; +import type { AdapterFactory, TurnResult } from './agent-runtime'; +import type { AdapterWorkspace } from './provider'; import { normalizeCodexJsonLine } from '../agent-events'; import { prepareCodexWorkspaceFiles } from '../codex-runtime'; import { env } from '../env'; -import type { AdapterFactory, TurnResult } from './agent-runtime'; +import { linuxUsername } from './box-user'; import { buildMarkedCommand, killBoxProcessesByMarker, streamExecInContainer, } from './docker'; -import type { AdapterWorkspace } from './provider'; import { codexModelArgs, isCodexLoginProfile, @@ -142,6 +143,7 @@ export const createCodexAdapter: AdapterFactory = (deps) => { try { result = await execStream({ containerName: workspace.boxName, + user: linuxUsername(workspace.username), containerCwd: workspace.containerRepo, command, environment: { ...aiEnv, ...secretEnv }, diff --git a/apps/agent-worker/src/runtime/opencode-adapter.ts b/apps/agent-worker/src/runtime/opencode-adapter.ts index 5bf4fc2..8f3e45f 100644 --- a/apps/agent-worker/src/runtime/opencode-adapter.ts +++ b/apps/agent-worker/src/runtime/opencode-adapter.ts @@ -1,17 +1,18 @@ +import { randomUUID } from 'node:crypto'; import { mkdir, writeFile } from 'node:fs/promises'; import path from 'node:path'; -import { randomUUID } from 'node:crypto'; import type { NormalizedAgentEvent } from '../agent-events'; +import type { AdapterFactory, TurnResult } from './agent-runtime'; +import type { AdapterWorkspace } from './provider'; import { normalizeOpenCodeRunLine } from '../agent-events'; import { env } from '../env'; -import type { AdapterFactory, TurnResult } from './agent-runtime'; +import { linuxUsername } from './box-user'; import { buildMarkedCommand, killBoxProcessesByMarker, streamExecInContainer, } from './docker'; -import type { AdapterWorkspace } from './provider'; import { opencodeModel, providerEnvironment } from './provider'; // Normalize + pretty-print an auth JSON blob before writing it with owner-only @@ -97,6 +98,7 @@ export const createOpenCodeAdapter: AdapterFactory = (deps) => { try { result = await execStream({ containerName: workspace.boxName, + user: linuxUsername(workspace.username), containerCwd: workspace.containerRepo, command, environment: { ...aiEnv, ...secretEnv }, diff --git a/apps/agent-worker/src/terminal.ts b/apps/agent-worker/src/terminal.ts index e7e8350..86dc9cf 100644 --- a/apps/agent-worker/src/terminal.ts +++ b/apps/agent-worker/src/terminal.ts @@ -6,7 +6,8 @@ import { WebSocketServer } from 'ws'; import type { BoxHandle } from './user-container'; import { boxHomePaths } from './box'; import { env } from './env'; -import { getDockerClient } from './runtime/docker'; +import { linuxUsername } from './runtime/box-user'; +import { chownInBox, getDockerClient } from './runtime/docker'; import { openExecStream } from './runtime/exec-stream'; import { parseBoxTokenUsername, @@ -64,6 +65,11 @@ type ShellBridgeOptions = { acquire: () => Promise; // Host-side preparation before the shell starts (e.g. seed .bash_profile). prepare?: () => Promise; + // Runs after the box is acquired (so the container exists) — ownership + // repair for anything `prepare` wrote host-side. + postAcquire?: (boxName: string) => Promise; + // Container user the shell runs as (the box user, not root). + user: string; // Directory the login shell starts in (container path). cwd: string; // Env entries appended after TERM (e.g. HOME, and job secrets for job terms). @@ -140,6 +146,8 @@ const runShellBridge = async (ws: WebSocket, opts: ShellBridgeOptions) => { return; } + if (opts.postAcquire) await opts.postAcquire(boxName); + // The exec create is a plain API call, but the attach is NOT dockerode's // `exec.start({ hijack: true })`: docker-modem's hijack waits for node's http // client `'upgrade'` event, which bun never emits, so under bun it hangs @@ -157,6 +165,7 @@ const runShellBridge = async (ws: WebSocket, opts: ShellBridgeOptions) => { AttachStdout: true, AttachStderr: true, Tty: true, + User: opts.user, Cmd: SHELL_CMD, Env: ['TERM=xterm-256color', ...opts.envFlags], WorkingDir: opts.cwd, @@ -214,6 +223,7 @@ const bridge = async (ws: WebSocket, jobId: string) => { workdir: workspace.workdir, containerHome: workspace.containerHome, }), + user: linuxUsername(workspace.username), cwd: workspace.containerRepo, envFlags: [ `HOME=${workspace.containerHome}`, @@ -228,8 +238,16 @@ const bridgeBox = async (ws: WebSocket, username: string) => { const { homeDir, containerHome } = boxHomePaths(username); await runShellBridge(ws, { prepare: () => ensureBashProfile(homeDir), + // prepare's host-side .bash_profile write may be root-owned under prod + // docker; repair once the container is known to exist. + postAcquire: (boxName) => + chownInBox({ + containerName: boxName, + paths: [`${containerHome}/.bash_profile`], + }), acquire: () => acquireUserBox({ username, workdir: homeDir, containerHome }), + user: linuxUsername(username), cwd: containerHome, envFlags: [`HOME=${containerHome}`], }); diff --git a/apps/agent-worker/src/user-container.ts b/apps/agent-worker/src/user-container.ts index d56ed0d..9cc6809 100644 --- a/apps/agent-worker/src/user-container.ts +++ b/apps/agent-worker/src/user-container.ts @@ -1,3 +1,4 @@ +import { initializeBoxUser } from './box-init'; import { env } from './env'; import { ensureUserContainer, @@ -92,7 +93,11 @@ export const acquireUserBox = (args: { // cached "initialized" flag then hands out handles to a dead container // until restart. ensureUserContainer is idempotent — an inspect when the // box is running, a recreate when it isn't. - box.name = await ensureUserContainer(args); + box.name = await ensureUserContainer({ + ...args, + onCreated: (containerName) => + initializeBoxUser(containerName, args.username, args.containerHome), + }); return handle; } catch (error) { handle.release(); diff --git a/apps/agent-worker/src/user-environment.ts b/apps/agent-worker/src/user-environment.ts index 921fb36..4d1180d 100644 --- a/apps/agent-worker/src/user-environment.ts +++ b/apps/agent-worker/src/user-environment.ts @@ -8,7 +8,8 @@ import { api } from '@spoon/backend/convex/_generated/api.js'; import { env } from './env'; import { assertContainedRealPath } from './path-containment'; -import { runExecInContainer } from './runtime/docker'; +import { linuxUsername } from './runtime/box-user'; +import { chownInBox, runExecInContainer } from './runtime/docker'; const client = new ConvexHttpClient(env.convexUrl); @@ -102,6 +103,9 @@ export const materializeUserHome = async (args: { environment: { HOME: containerHome }, redact, timeoutMs: env.jobTimeoutMs, + // The clone and setup command run as the box user so ~/.dotfiles and + // anything the setup writes stay editable from the terminal. + user: linuxUsername(userEnv.username), }); await mkdir(path.dirname(markerPath), { recursive: true }); await writeFile(markerPath, configHash); @@ -109,6 +113,7 @@ export const materializeUserHome = async (args: { } // Editable overlay tree (wins over the repo/setup output). + const writtenContainerPaths: string[] = []; for (const file of userEnv.files) { const target = await assertContainedRealPath(homeDir, file.path, { forWrite: true, @@ -116,5 +121,9 @@ export const materializeUserHome = async (args: { await mkdir(path.dirname(target), { recursive: true }); await writeFile(target, file.content); if (file.isExecutable) await chmod(target, 0o755); + writtenContainerPaths.push(path.posix.join(containerHome, file.path)); } + // Overlay files are written host-side — repair ownership so the box user + // can edit them in a shell (no-op under dev keep-id). + await chownInBox({ containerName: boxName, paths: writtenContainerPaths }); }; diff --git a/apps/agent-worker/src/worker.ts b/apps/agent-worker/src/worker.ts index 279e4cc..2648d9f 100644 --- a/apps/agent-worker/src/worker.ts +++ b/apps/agent-worker/src/worker.ts @@ -14,15 +14,17 @@ import type { Id } from '@spoon/backend/convex/_generated/dataModel.js'; import { api } from '@spoon/backend/convex/_generated/api.js'; import type { NormalizedAgentEvent } from './agent-events'; +import type { AgentRuntimeName } from './runtime/agent-runtime'; import type { AdapterWorkspace, Claim } from './runtime/provider'; import type { BoxHandle } from './user-container'; -import type { AgentRuntimeName } from './runtime/agent-runtime'; import { getAdapter } from './runtime/agent-runtime'; import { resolveTurnOutcome, shouldAppendCompletedContent, } from './runtime/turn-outcome'; + import './runtime/register'; + import { env } from './env'; import { cloneRepository, @@ -36,7 +38,9 @@ import { createHeartbeatController } from './heartbeat-controller'; import { removeMaterializedEnv, writeMaterializedEnv } from './materialize-env'; import { assertContainedRealPath } from './path-containment'; import { createRedactor, truncate } from './redact'; +import { linuxUsername } from './runtime/box-user'; import { + chownInBox, listWorkspaceContainerNames, runExecInContainer, } from './runtime/docker'; @@ -576,6 +580,7 @@ const runProjectCommand = async (args: { phase: 'install' | 'check' | 'test'; claim: Claim; boxName: string; + username: string; containerHome: string; containerCwd: string; repoDir: string; @@ -594,6 +599,7 @@ const runProjectCommand = async (args: { environment: { HOME: args.containerHome, ...secretEnv }, redact: args.redact, timeoutMs: env.jobTimeoutMs, + user: linuxUsername(args.username), }) : await run('bash', ['-lc', args.command], { cwd: args.repoDir, @@ -728,6 +734,12 @@ const materializeEnvFile = async (workspace: ActiveWorkspace) => { claim.job.envFilePath, claim.secrets, ); + // Host-side 0600 write — the agent now runs as the box user, which must be + // able to read it. No-op under dev keep-id. + await chownInBox({ + containerName: workspace.boxName, + paths: [path.posix.join(workspace.containerRepo, claim.job.envFilePath)], + }); await appendEvent( claim.job._id, 'info', @@ -923,6 +935,9 @@ const runClaim = async (claim: Claim) => { redact, timeoutMs: env.jobTimeoutMs, }); + // The clone happens host-side; hand it to the box user so the agent (and + // the user's shell) can write to it. No-op under dev keep-id. + await chownInBox({ containerName: boxName, paths: [containerRepo] }); const workspace: ActiveWorkspace = { claim, workdir: homeDir, @@ -1116,6 +1131,7 @@ export const runWorkspaceCommand = async (jobId: string, command: string) => { phase: command.includes('test') ? 'test' : 'check', claim: workspace.claim, boxName: workspace.boxName, + username: workspace.username, containerHome: workspace.containerHome, containerCwd: workspace.containerRepo, repoDir: workspace.repoDir, @@ -1183,7 +1199,9 @@ export const replyToInteraction = async ( ) => { // Every runtime now runs its CLI non-interactively inside the box, so no // runtime emits an interaction request that expects a reply. - throw new Error('Interactive replies are not supported by exec-based runtimes.'); + throw new Error( + 'Interactive replies are not supported by exec-based runtimes.', + ); }; export const sendWorkspaceMessage = async ( diff --git a/apps/agent-worker/tests/unit/claude-adapter.test.ts b/apps/agent-worker/tests/unit/claude-adapter.test.ts index c60d938..f71cfb9 100644 --- a/apps/agent-worker/tests/unit/claude-adapter.test.ts +++ b/apps/agent-worker/tests/unit/claude-adapter.test.ts @@ -1,7 +1,6 @@ import { mkdtemp, readFile, rm, stat } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import path from 'node:path'; - import { afterEach, describe, expect, test, vi } from 'vitest'; import type { NormalizedAgentEvent } from '../../src/agent-events'; @@ -98,7 +97,9 @@ describe('ClaudeCodeAdapter prepareAuth', () => { tempDirs.push(homeDir); const adapter = createClaudeAdapter(); - await adapter.prepareAuth(makeOAuthWorkspace(homeDir, 'anthropic_oauth_json')); + await adapter.prepareAuth( + makeOAuthWorkspace(homeDir, 'anthropic_oauth_json'), + ); const credentialsPath = path.join(homeDir, '.claude', '.credentials.json'); const contents = await readFile(credentialsPath, 'utf8'); @@ -111,7 +112,9 @@ describe('ClaudeCodeAdapter prepareAuth', () => { tempDirs.push(homeDir); const adapter = createClaudeAdapter(); - await adapter.prepareAuth(makeOAuthWorkspace(homeDir, 'opencode_auth_json')); + await adapter.prepareAuth( + makeOAuthWorkspace(homeDir, 'opencode_auth_json'), + ); const credentialsPath = path.join(homeDir, '.claude', '.credentials.json'); await expect(stat(credentialsPath)).rejects.toThrow(); @@ -174,4 +177,16 @@ describe('ClaudeCodeAdapter', () => { expect(command[3]).toContain("'claude' '-p'"); expect(command[3]).toContain("'--output-format' 'stream-json'"); }); + + test('execs the turn as the box user, not root', async () => { + const { createClaudeAdapter } = await loadAdapter(); + let capturedUser: string | undefined; + const execStream = vi.fn((args: { user?: string }) => { + capturedUser = args.user; + return Promise.resolve({ exitCode: 0, output: '' }); + }) as unknown as ExecStreamFn; + const adapter = createClaudeAdapter({ execStream }); + await adapter.runTurn(makeWorkspace(), 'do it', () => Promise.resolve()); + expect(capturedUser).toBe('tester'); + }); }); diff --git a/apps/agent-worker/tests/unit/user-container.test.ts b/apps/agent-worker/tests/unit/user-container.test.ts index 930ae21..559651f 100644 --- a/apps/agent-worker/tests/unit/user-container.test.ts +++ b/apps/agent-worker/tests/unit/user-container.test.ts @@ -22,6 +22,20 @@ describe('box registry', () => { vi.clearAllMocks(); }); + test('passes an onCreated hook so a fresh box gets its user initialized', async () => { + const module = await load(); + const docker = await import('../../src/runtime/docker'); + await module.acquireUserBox({ + username: 'ada', + workdir: '/w', + containerHome: '/home/ada', + }); + const call = vi.mocked(docker.ensureUserContainer).mock.calls[0]?.[0] as { + onCreated?: unknown; + }; + expect(call.onCreated).toBeTypeOf('function'); + }); + test('re-ensures the container on every acquire so an externally removed box self-heals', async () => { const module = await load(); const docker = await import('../../src/runtime/docker');