feat(worker): run terminals and agent turns as the box user
This commit is contained in:
@@ -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',
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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 path from 'node:path';
|
||||||
|
|
||||||
|
import { applyBoxPassword, initializeBoxUser } from './box-init';
|
||||||
import { env } from './env';
|
import { env } from './env';
|
||||||
import { assertContainedRealPath } from './path-containment';
|
import { assertContainedRealPath } from './path-containment';
|
||||||
import {
|
import {
|
||||||
|
chownInBox,
|
||||||
ensureUserContainer,
|
ensureUserContainer,
|
||||||
inspectUserBoxStatus,
|
inspectUserBoxStatus,
|
||||||
stopWorkspaceContainer,
|
stopWorkspaceContainer,
|
||||||
@@ -41,7 +43,25 @@ export const getBoxStatus = async (username: string): Promise<BoxStatus> => {
|
|||||||
|
|
||||||
export const startBox = async (username: string): Promise<void> => {
|
export const startBox = async (username: string): Promise<void> => {
|
||||||
const { homeDir, containerHome } = boxHomePaths(username);
|
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> => {
|
export const stopBox = async (username: string): Promise<void> => {
|
||||||
@@ -145,9 +165,17 @@ export const writeBoxFile = async (
|
|||||||
relPath: string,
|
relPath: string,
|
||||||
content: string,
|
content: string,
|
||||||
): Promise<{ success: true }> => {
|
): Promise<{ success: true }> => {
|
||||||
const { homeDir } = boxHomePaths(username);
|
const { homeDir, containerHome } = boxHomePaths(username);
|
||||||
const target = await safeHomePath(homeDir, relPath, { forWrite: true });
|
const target = await safeHomePath(homeDir, relPath, { forWrite: true });
|
||||||
await mkdir(path.dirname(target), { recursive: true });
|
await mkdir(path.dirname(target), { recursive: true });
|
||||||
await writeFile(target, content);
|
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 };
|
return { success: true };
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -37,8 +37,10 @@ export const buildBoxInitScript = (args: {
|
|||||||
'fi',
|
'fi',
|
||||||
`usermod -aG wheel ${user}`,
|
`usermod -aG wheel ${user}`,
|
||||||
`if [ ! -f ${marker} ]; then`,
|
`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}`,
|
` chown -R ${BOX_UID}:${BOX_UID} ${home}`,
|
||||||
` mkdir -p "$(dirname ${marker})" && touch ${marker} && chown ${BOX_UID}:${BOX_UID} ${marker}`,
|
|
||||||
'fi',
|
'fi',
|
||||||
].join('\n');
|
].join('\n');
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,17 +1,18 @@
|
|||||||
import path from 'node:path';
|
|
||||||
import { randomUUID } from 'node:crypto';
|
import { randomUUID } from 'node:crypto';
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
import type { NormalizedAgentEvent } from '../agent-events';
|
import type { NormalizedAgentEvent } from '../agent-events';
|
||||||
|
import type { AdapterFactory, TurnResult } from './agent-runtime';
|
||||||
|
import type { AdapterWorkspace } from './provider';
|
||||||
import { normalizeClaudeJsonLine } from '../agent-events';
|
import { normalizeClaudeJsonLine } from '../agent-events';
|
||||||
import { env } from '../env';
|
import { env } from '../env';
|
||||||
import type { AdapterFactory, TurnResult } from './agent-runtime';
|
|
||||||
import { writeJsonFile } from './auth';
|
import { writeJsonFile } from './auth';
|
||||||
|
import { linuxUsername } from './box-user';
|
||||||
import {
|
import {
|
||||||
buildMarkedCommand,
|
buildMarkedCommand,
|
||||||
killBoxProcessesByMarker,
|
killBoxProcessesByMarker,
|
||||||
streamExecInContainer,
|
streamExecInContainer,
|
||||||
} from './docker';
|
} from './docker';
|
||||||
import type { AdapterWorkspace } from './provider';
|
|
||||||
import { claudeEnv, claudeModel, isClaudeOAuthProfile } from './provider';
|
import { claudeEnv, claudeModel, isClaudeOAuthProfile } from './provider';
|
||||||
|
|
||||||
export const createClaudeAdapter: AdapterFactory = (deps) => {
|
export const createClaudeAdapter: AdapterFactory = (deps) => {
|
||||||
@@ -23,7 +24,9 @@ export const createClaudeAdapter: AdapterFactory = (deps) => {
|
|||||||
if (!isClaudeOAuthProfile(workspace.claim)) return;
|
if (!isClaudeOAuthProfile(workspace.claim)) return;
|
||||||
const secret = workspace.claim.aiProviderProfile?.secret;
|
const secret = workspace.claim.aiProviderProfile?.secret;
|
||||||
if (!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(
|
const credentialsPath = path.join(
|
||||||
workspace.homeDir,
|
workspace.homeDir,
|
||||||
@@ -84,6 +87,7 @@ export const createClaudeAdapter: AdapterFactory = (deps) => {
|
|||||||
try {
|
try {
|
||||||
result = await execStream({
|
result = await execStream({
|
||||||
containerName: workspace.boxName,
|
containerName: workspace.boxName,
|
||||||
|
user: linuxUsername(workspace.username),
|
||||||
containerCwd: workspace.containerRepo,
|
containerCwd: workspace.containerRepo,
|
||||||
command,
|
command,
|
||||||
environment: { ...aiEnv, ...secretEnv },
|
environment: { ...aiEnv, ...secretEnv },
|
||||||
|
|||||||
@@ -1,18 +1,19 @@
|
|||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { randomUUID } from 'node:crypto';
|
|
||||||
|
|
||||||
import type { NormalizedAgentEvent } from '../agent-events';
|
import type { NormalizedAgentEvent } from '../agent-events';
|
||||||
|
import type { AdapterFactory, TurnResult } from './agent-runtime';
|
||||||
|
import type { AdapterWorkspace } from './provider';
|
||||||
import { normalizeCodexJsonLine } from '../agent-events';
|
import { normalizeCodexJsonLine } from '../agent-events';
|
||||||
import { prepareCodexWorkspaceFiles } from '../codex-runtime';
|
import { prepareCodexWorkspaceFiles } from '../codex-runtime';
|
||||||
import { env } from '../env';
|
import { env } from '../env';
|
||||||
import type { AdapterFactory, TurnResult } from './agent-runtime';
|
import { linuxUsername } from './box-user';
|
||||||
import {
|
import {
|
||||||
buildMarkedCommand,
|
buildMarkedCommand,
|
||||||
killBoxProcessesByMarker,
|
killBoxProcessesByMarker,
|
||||||
streamExecInContainer,
|
streamExecInContainer,
|
||||||
} from './docker';
|
} from './docker';
|
||||||
import type { AdapterWorkspace } from './provider';
|
|
||||||
import {
|
import {
|
||||||
codexModelArgs,
|
codexModelArgs,
|
||||||
isCodexLoginProfile,
|
isCodexLoginProfile,
|
||||||
@@ -142,6 +143,7 @@ export const createCodexAdapter: AdapterFactory = (deps) => {
|
|||||||
try {
|
try {
|
||||||
result = await execStream({
|
result = await execStream({
|
||||||
containerName: workspace.boxName,
|
containerName: workspace.boxName,
|
||||||
|
user: linuxUsername(workspace.username),
|
||||||
containerCwd: workspace.containerRepo,
|
containerCwd: workspace.containerRepo,
|
||||||
command,
|
command,
|
||||||
environment: { ...aiEnv, ...secretEnv },
|
environment: { ...aiEnv, ...secretEnv },
|
||||||
|
|||||||
@@ -1,17 +1,18 @@
|
|||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
import { mkdir, writeFile } from 'node:fs/promises';
|
import { mkdir, writeFile } from 'node:fs/promises';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { randomUUID } from 'node:crypto';
|
|
||||||
|
|
||||||
import type { NormalizedAgentEvent } from '../agent-events';
|
import type { NormalizedAgentEvent } from '../agent-events';
|
||||||
|
import type { AdapterFactory, TurnResult } from './agent-runtime';
|
||||||
|
import type { AdapterWorkspace } from './provider';
|
||||||
import { normalizeOpenCodeRunLine } from '../agent-events';
|
import { normalizeOpenCodeRunLine } from '../agent-events';
|
||||||
import { env } from '../env';
|
import { env } from '../env';
|
||||||
import type { AdapterFactory, TurnResult } from './agent-runtime';
|
import { linuxUsername } from './box-user';
|
||||||
import {
|
import {
|
||||||
buildMarkedCommand,
|
buildMarkedCommand,
|
||||||
killBoxProcessesByMarker,
|
killBoxProcessesByMarker,
|
||||||
streamExecInContainer,
|
streamExecInContainer,
|
||||||
} from './docker';
|
} from './docker';
|
||||||
import type { AdapterWorkspace } from './provider';
|
|
||||||
import { opencodeModel, providerEnvironment } from './provider';
|
import { opencodeModel, providerEnvironment } from './provider';
|
||||||
|
|
||||||
// Normalize + pretty-print an auth JSON blob before writing it with owner-only
|
// Normalize + pretty-print an auth JSON blob before writing it with owner-only
|
||||||
@@ -97,6 +98,7 @@ export const createOpenCodeAdapter: AdapterFactory = (deps) => {
|
|||||||
try {
|
try {
|
||||||
result = await execStream({
|
result = await execStream({
|
||||||
containerName: workspace.boxName,
|
containerName: workspace.boxName,
|
||||||
|
user: linuxUsername(workspace.username),
|
||||||
containerCwd: workspace.containerRepo,
|
containerCwd: workspace.containerRepo,
|
||||||
command,
|
command,
|
||||||
environment: { ...aiEnv, ...secretEnv },
|
environment: { ...aiEnv, ...secretEnv },
|
||||||
|
|||||||
@@ -6,7 +6,8 @@ import { WebSocketServer } from 'ws';
|
|||||||
import type { BoxHandle } from './user-container';
|
import type { BoxHandle } from './user-container';
|
||||||
import { boxHomePaths } from './box';
|
import { boxHomePaths } from './box';
|
||||||
import { env } from './env';
|
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 { openExecStream } from './runtime/exec-stream';
|
||||||
import {
|
import {
|
||||||
parseBoxTokenUsername,
|
parseBoxTokenUsername,
|
||||||
@@ -64,6 +65,11 @@ type ShellBridgeOptions = {
|
|||||||
acquire: () => Promise<BoxHandle>;
|
acquire: () => Promise<BoxHandle>;
|
||||||
// Host-side preparation before the shell starts (e.g. seed .bash_profile).
|
// Host-side preparation before the shell starts (e.g. seed .bash_profile).
|
||||||
prepare?: () => Promise<void>;
|
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).
|
// Directory the login shell starts in (container path).
|
||||||
cwd: string;
|
cwd: string;
|
||||||
// Env entries appended after TERM (e.g. HOME, and job secrets for job terms).
|
// 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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (opts.postAcquire) await opts.postAcquire(boxName);
|
||||||
|
|
||||||
// The exec create is a plain API call, but the attach is NOT dockerode's
|
// 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
|
// `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
|
// 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,
|
AttachStdout: true,
|
||||||
AttachStderr: true,
|
AttachStderr: true,
|
||||||
Tty: true,
|
Tty: true,
|
||||||
|
User: opts.user,
|
||||||
Cmd: SHELL_CMD,
|
Cmd: SHELL_CMD,
|
||||||
Env: ['TERM=xterm-256color', ...opts.envFlags],
|
Env: ['TERM=xterm-256color', ...opts.envFlags],
|
||||||
WorkingDir: opts.cwd,
|
WorkingDir: opts.cwd,
|
||||||
@@ -214,6 +223,7 @@ const bridge = async (ws: WebSocket, jobId: string) => {
|
|||||||
workdir: workspace.workdir,
|
workdir: workspace.workdir,
|
||||||
containerHome: workspace.containerHome,
|
containerHome: workspace.containerHome,
|
||||||
}),
|
}),
|
||||||
|
user: linuxUsername(workspace.username),
|
||||||
cwd: workspace.containerRepo,
|
cwd: workspace.containerRepo,
|
||||||
envFlags: [
|
envFlags: [
|
||||||
`HOME=${workspace.containerHome}`,
|
`HOME=${workspace.containerHome}`,
|
||||||
@@ -228,8 +238,16 @@ const bridgeBox = async (ws: WebSocket, username: string) => {
|
|||||||
const { homeDir, containerHome } = boxHomePaths(username);
|
const { homeDir, containerHome } = boxHomePaths(username);
|
||||||
await runShellBridge(ws, {
|
await runShellBridge(ws, {
|
||||||
prepare: () => ensureBashProfile(homeDir),
|
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: () =>
|
acquire: () =>
|
||||||
acquireUserBox({ username, workdir: homeDir, containerHome }),
|
acquireUserBox({ username, workdir: homeDir, containerHome }),
|
||||||
|
user: linuxUsername(username),
|
||||||
cwd: containerHome,
|
cwd: containerHome,
|
||||||
envFlags: [`HOME=${containerHome}`],
|
envFlags: [`HOME=${containerHome}`],
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { initializeBoxUser } from './box-init';
|
||||||
import { env } from './env';
|
import { env } from './env';
|
||||||
import {
|
import {
|
||||||
ensureUserContainer,
|
ensureUserContainer,
|
||||||
@@ -92,7 +93,11 @@ export const acquireUserBox = (args: {
|
|||||||
// cached "initialized" flag then hands out handles to a dead container
|
// cached "initialized" flag then hands out handles to a dead container
|
||||||
// until restart. ensureUserContainer is idempotent — an inspect when the
|
// until restart. ensureUserContainer is idempotent — an inspect when the
|
||||||
// box is running, a recreate when it isn't.
|
// 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;
|
return handle;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
handle.release();
|
handle.release();
|
||||||
|
|||||||
@@ -8,7 +8,8 @@ import { api } from '@spoon/backend/convex/_generated/api.js';
|
|||||||
|
|
||||||
import { env } from './env';
|
import { env } from './env';
|
||||||
import { assertContainedRealPath } from './path-containment';
|
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);
|
const client = new ConvexHttpClient(env.convexUrl);
|
||||||
|
|
||||||
@@ -102,6 +103,9 @@ export const materializeUserHome = async (args: {
|
|||||||
environment: { HOME: containerHome },
|
environment: { HOME: containerHome },
|
||||||
redact,
|
redact,
|
||||||
timeoutMs: env.jobTimeoutMs,
|
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 mkdir(path.dirname(markerPath), { recursive: true });
|
||||||
await writeFile(markerPath, configHash);
|
await writeFile(markerPath, configHash);
|
||||||
@@ -109,6 +113,7 @@ export const materializeUserHome = async (args: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Editable overlay tree (wins over the repo/setup output).
|
// Editable overlay tree (wins over the repo/setup output).
|
||||||
|
const writtenContainerPaths: string[] = [];
|
||||||
for (const file of userEnv.files) {
|
for (const file of userEnv.files) {
|
||||||
const target = await assertContainedRealPath(homeDir, file.path, {
|
const target = await assertContainedRealPath(homeDir, file.path, {
|
||||||
forWrite: true,
|
forWrite: true,
|
||||||
@@ -116,5 +121,9 @@ export const materializeUserHome = async (args: {
|
|||||||
await mkdir(path.dirname(target), { recursive: true });
|
await mkdir(path.dirname(target), { recursive: true });
|
||||||
await writeFile(target, file.content);
|
await writeFile(target, file.content);
|
||||||
if (file.isExecutable) await chmod(target, 0o755);
|
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 });
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -14,15 +14,17 @@ import type { Id } from '@spoon/backend/convex/_generated/dataModel.js';
|
|||||||
import { api } from '@spoon/backend/convex/_generated/api.js';
|
import { api } from '@spoon/backend/convex/_generated/api.js';
|
||||||
|
|
||||||
import type { NormalizedAgentEvent } from './agent-events';
|
import type { NormalizedAgentEvent } from './agent-events';
|
||||||
|
import type { AgentRuntimeName } from './runtime/agent-runtime';
|
||||||
import type { AdapterWorkspace, Claim } from './runtime/provider';
|
import type { AdapterWorkspace, Claim } from './runtime/provider';
|
||||||
import type { BoxHandle } from './user-container';
|
import type { BoxHandle } from './user-container';
|
||||||
import type { AgentRuntimeName } from './runtime/agent-runtime';
|
|
||||||
import { getAdapter } from './runtime/agent-runtime';
|
import { getAdapter } from './runtime/agent-runtime';
|
||||||
import {
|
import {
|
||||||
resolveTurnOutcome,
|
resolveTurnOutcome,
|
||||||
shouldAppendCompletedContent,
|
shouldAppendCompletedContent,
|
||||||
} from './runtime/turn-outcome';
|
} from './runtime/turn-outcome';
|
||||||
|
|
||||||
import './runtime/register';
|
import './runtime/register';
|
||||||
|
|
||||||
import { env } from './env';
|
import { env } from './env';
|
||||||
import {
|
import {
|
||||||
cloneRepository,
|
cloneRepository,
|
||||||
@@ -36,7 +38,9 @@ import { createHeartbeatController } from './heartbeat-controller';
|
|||||||
import { removeMaterializedEnv, writeMaterializedEnv } from './materialize-env';
|
import { removeMaterializedEnv, writeMaterializedEnv } from './materialize-env';
|
||||||
import { assertContainedRealPath } from './path-containment';
|
import { assertContainedRealPath } from './path-containment';
|
||||||
import { createRedactor, truncate } from './redact';
|
import { createRedactor, truncate } from './redact';
|
||||||
|
import { linuxUsername } from './runtime/box-user';
|
||||||
import {
|
import {
|
||||||
|
chownInBox,
|
||||||
listWorkspaceContainerNames,
|
listWorkspaceContainerNames,
|
||||||
runExecInContainer,
|
runExecInContainer,
|
||||||
} from './runtime/docker';
|
} from './runtime/docker';
|
||||||
@@ -576,6 +580,7 @@ const runProjectCommand = async (args: {
|
|||||||
phase: 'install' | 'check' | 'test';
|
phase: 'install' | 'check' | 'test';
|
||||||
claim: Claim;
|
claim: Claim;
|
||||||
boxName: string;
|
boxName: string;
|
||||||
|
username: string;
|
||||||
containerHome: string;
|
containerHome: string;
|
||||||
containerCwd: string;
|
containerCwd: string;
|
||||||
repoDir: string;
|
repoDir: string;
|
||||||
@@ -594,6 +599,7 @@ const runProjectCommand = async (args: {
|
|||||||
environment: { HOME: args.containerHome, ...secretEnv },
|
environment: { HOME: args.containerHome, ...secretEnv },
|
||||||
redact: args.redact,
|
redact: args.redact,
|
||||||
timeoutMs: env.jobTimeoutMs,
|
timeoutMs: env.jobTimeoutMs,
|
||||||
|
user: linuxUsername(args.username),
|
||||||
})
|
})
|
||||||
: await run('bash', ['-lc', args.command], {
|
: await run('bash', ['-lc', args.command], {
|
||||||
cwd: args.repoDir,
|
cwd: args.repoDir,
|
||||||
@@ -728,6 +734,12 @@ const materializeEnvFile = async (workspace: ActiveWorkspace) => {
|
|||||||
claim.job.envFilePath,
|
claim.job.envFilePath,
|
||||||
claim.secrets,
|
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(
|
await appendEvent(
|
||||||
claim.job._id,
|
claim.job._id,
|
||||||
'info',
|
'info',
|
||||||
@@ -923,6 +935,9 @@ const runClaim = async (claim: Claim) => {
|
|||||||
redact,
|
redact,
|
||||||
timeoutMs: env.jobTimeoutMs,
|
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 = {
|
const workspace: ActiveWorkspace = {
|
||||||
claim,
|
claim,
|
||||||
workdir: homeDir,
|
workdir: homeDir,
|
||||||
@@ -1116,6 +1131,7 @@ export const runWorkspaceCommand = async (jobId: string, command: string) => {
|
|||||||
phase: command.includes('test') ? 'test' : 'check',
|
phase: command.includes('test') ? 'test' : 'check',
|
||||||
claim: workspace.claim,
|
claim: workspace.claim,
|
||||||
boxName: workspace.boxName,
|
boxName: workspace.boxName,
|
||||||
|
username: workspace.username,
|
||||||
containerHome: workspace.containerHome,
|
containerHome: workspace.containerHome,
|
||||||
containerCwd: workspace.containerRepo,
|
containerCwd: workspace.containerRepo,
|
||||||
repoDir: workspace.repoDir,
|
repoDir: workspace.repoDir,
|
||||||
@@ -1183,7 +1199,9 @@ export const replyToInteraction = async (
|
|||||||
) => {
|
) => {
|
||||||
// Every runtime now runs its CLI non-interactively inside the box, so no
|
// Every runtime now runs its CLI non-interactively inside the box, so no
|
||||||
// runtime emits an interaction request that expects a reply.
|
// 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 (
|
export const sendWorkspaceMessage = async (
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { mkdtemp, readFile, rm, stat } from 'node:fs/promises';
|
import { mkdtemp, readFile, rm, stat } from 'node:fs/promises';
|
||||||
import { tmpdir } from 'node:os';
|
import { tmpdir } from 'node:os';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
|
|
||||||
import { afterEach, describe, expect, test, vi } from 'vitest';
|
import { afterEach, describe, expect, test, vi } from 'vitest';
|
||||||
|
|
||||||
import type { NormalizedAgentEvent } from '../../src/agent-events';
|
import type { NormalizedAgentEvent } from '../../src/agent-events';
|
||||||
@@ -98,7 +97,9 @@ describe('ClaudeCodeAdapter prepareAuth', () => {
|
|||||||
tempDirs.push(homeDir);
|
tempDirs.push(homeDir);
|
||||||
|
|
||||||
const adapter = createClaudeAdapter();
|
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 credentialsPath = path.join(homeDir, '.claude', '.credentials.json');
|
||||||
const contents = await readFile(credentialsPath, 'utf8');
|
const contents = await readFile(credentialsPath, 'utf8');
|
||||||
@@ -111,7 +112,9 @@ describe('ClaudeCodeAdapter prepareAuth', () => {
|
|||||||
tempDirs.push(homeDir);
|
tempDirs.push(homeDir);
|
||||||
|
|
||||||
const adapter = createClaudeAdapter();
|
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');
|
const credentialsPath = path.join(homeDir, '.claude', '.credentials.json');
|
||||||
await expect(stat(credentialsPath)).rejects.toThrow();
|
await expect(stat(credentialsPath)).rejects.toThrow();
|
||||||
@@ -174,4 +177,16 @@ describe('ClaudeCodeAdapter', () => {
|
|||||||
expect(command[3]).toContain("'claude' '-p'");
|
expect(command[3]).toContain("'claude' '-p'");
|
||||||
expect(command[3]).toContain("'--output-format' 'stream-json'");
|
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();
|
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 () => {
|
test('re-ensures the container on every acquire so an externally removed box self-heals', async () => {
|
||||||
const module = await load();
|
const module = await load();
|
||||||
const docker = await import('../../src/runtime/docker');
|
const docker = await import('../../src/runtime/docker');
|
||||||
|
|||||||
Reference in New Issue
Block a user