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 { 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 };
|
||||
};
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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}`],
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 });
|
||||
};
|
||||
|
||||
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user