feat(worker): run terminals and agent turns as the box user

This commit is contained in:
Gabriel Brown
2026-07-13 10:04:14 -04:00
parent a956d63e16
commit 58bd101793
13 changed files with 263 additions and 22 deletions
+94
View File
@@ -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<void> => {
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<void> => {
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<void> => {
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',
);
};
+30
View File
@@ -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<string | null> => {
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;
}
};
+31 -3
View File
@@ -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<BoxStatus> => {
export const startBox = async (username: string): Promise<void> => {
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<void> => {
@@ -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 };
};
+3 -1
View File
@@ -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');
};
@@ -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 },
@@ -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 },
@@ -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 },
+19 -1
View File
@@ -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<BoxHandle>;
// Host-side preparation before the shell starts (e.g. seed .bash_profile).
prepare?: () => Promise<void>;
// Runs after the box is acquired (so the container exists) — ownership
// repair for anything `prepare` wrote host-side.
postAcquire?: (boxName: string) => Promise<void>;
// 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}`],
});
+6 -1
View File
@@ -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();
+10 -1
View File
@@ -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 });
};
+20 -2
View File
@@ -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 (
@@ -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');
});
});
@@ -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');