Compare commits
13
Commits
c0dd8759af
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
919d185a85 | ||
|
|
4505471149 | ||
|
|
028fbebcd6 | ||
|
|
134bab93a5 | ||
|
|
78aae034b5 | ||
|
|
58bd101793 | ||
|
|
a956d63e16 | ||
|
|
37a82098c2 | ||
|
|
78cb3d90c2 | ||
|
|
a91c8551ac | ||
|
|
aea2aa2c5c | ||
|
|
b038a31520 | ||
|
|
a640b5cb94 |
@@ -0,0 +1,97 @@
|
||||
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,
|
||||
// Explicit: under podman keep-id the container's DEFAULT user is the
|
||||
// mapped uid (1000), not root, and useradd/chpasswd/sudoers need root.
|
||||
user: 'root',
|
||||
});
|
||||
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,6 +37,10 @@ export const env = {
|
||||
'',
|
||||
// How long a per-user box container survives with no active jobs/terminals.
|
||||
boxIdleMs: intEnv('SPOON_AGENT_BOX_IDLE_MS', 1_800_000),
|
||||
// Override for the box container's --userns flag. Unset → keep-id mapping
|
||||
// under podman (host user becomes the box user), nothing under docker.
|
||||
// Empty string disables entirely; any other value passes through verbatim.
|
||||
boxUserns: process.env.SPOON_AGENT_BOX_USERNS,
|
||||
// Dev-only: exit if the parent dev runner dies, so the worker never orphans
|
||||
// and holds port 3921 across restarts. Set by scripts/dev-agent-worker.
|
||||
devWatchdog: process.env.SPOON_AGENT_DEV_WATCHDOG === '1',
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
// The per-box Linux user. Terminal sessions and agent turns exec as this user
|
||||
// instead of root (Claude Code refuses some operations as root, and a real
|
||||
// machine has a named user); sudo is passwordless until the user stores a
|
||||
// password, after which wheel membership makes sudo prompt for it.
|
||||
|
||||
export const BOX_UID = 1000;
|
||||
|
||||
const shellQuote = (value: string) => `'${value.replaceAll("'", "'\\''")}'`;
|
||||
|
||||
// Linux-safe username derived from the Spoon username. The home path keeps
|
||||
// the Spoon username everywhere; only the passwd entry uses this.
|
||||
export const linuxUsername = (spoonUsername: string): string => {
|
||||
const cleaned = spoonUsername
|
||||
.toLowerCase()
|
||||
.replaceAll(/[^a-z0-9_-]/g, '-')
|
||||
.slice(0, 32);
|
||||
if (!cleaned) return 'spoon';
|
||||
return /^[a-z_]/.test(cleaned) ? cleaned : `u${cleaned}`.slice(0, 32);
|
||||
};
|
||||
|
||||
// Idempotent root init: ensure a SINGLE uid-1000 passwd entry with our name,
|
||||
// grant wheel, and chown the home ONCE (marker-guarded — later host-side
|
||||
// writes are repaired by targeted chowns, so a big home is never re-walked).
|
||||
//
|
||||
// The uid-1000 entry may already exist: podman --userns=keep-id injects one
|
||||
// named after the HOST user. A second entry at the same uid would win the
|
||||
// getpwuid lookup (whoami would report the host username), so an existing
|
||||
// entry is RENAMED rather than duplicated. `-M` skips skeleton copy since the
|
||||
// mounted home persists across containers.
|
||||
export const buildBoxInitScript = (args: {
|
||||
username: string;
|
||||
home: string;
|
||||
}): string => {
|
||||
const user = shellQuote(args.username);
|
||||
const home = shellQuote(args.home);
|
||||
const marker = shellQuote(`${args.home}/.spoon/chown-v1`);
|
||||
return [
|
||||
'set -e',
|
||||
`existing="$(getent passwd ${BOX_UID} | cut -d: -f1 || true)"`,
|
||||
`if [ -n "$existing" ] && [ "$existing" != ${user} ]; then`,
|
||||
` usermod -l ${user} -d ${home} -s /bin/bash "$existing"`,
|
||||
`elif [ -z "$existing" ]; then`,
|
||||
` useradd -u ${BOX_UID} -o -M -d ${home} -s /bin/bash ${user}`,
|
||||
'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}`,
|
||||
'fi',
|
||||
].join('\n');
|
||||
};
|
||||
|
||||
// Sudo policy: NOPASSWD drop-in only while no password is stored; with a
|
||||
// password, wheel membership makes sudo prompt for it. The drop-in is staged
|
||||
// to a temp file and checked with `visudo -c` before install so a bad write
|
||||
// can never brick sudo.
|
||||
export const buildSudoPolicyScript = (args: {
|
||||
username: string;
|
||||
passwordSet: boolean;
|
||||
}): string => {
|
||||
if (args.passwordSet) {
|
||||
return 'rm -f /etc/sudoers.d/spoon-box';
|
||||
}
|
||||
const line = `${args.username} ALL=(ALL) NOPASSWD:ALL`;
|
||||
return [
|
||||
'set -e',
|
||||
`printf '%s\\n' ${shellQuote(line)} > /etc/sudoers.d/spoon-box.tmp`,
|
||||
'visudo -c -f /etc/sudoers.d/spoon-box.tmp',
|
||||
'chmod 0440 /etc/sudoers.d/spoon-box.tmp',
|
||||
'mv /etc/sudoers.d/spoon-box.tmp /etc/sudoers.d/spoon-box',
|
||||
].join('\n');
|
||||
};
|
||||
|
||||
// `chpasswd` reads `user:password` from stdin, so the password never appears
|
||||
// in argv (visible in `ps`) or in a shell script body.
|
||||
export const CHPASSWD_COMMAND = ['chpasswd'];
|
||||
|
||||
export const buildClearPasswordCommand = (username: string): string[] => [
|
||||
'passwd',
|
||||
'-d',
|
||||
username,
|
||||
];
|
||||
@@ -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 },
|
||||
|
||||
@@ -5,6 +5,7 @@ import Docker from 'dockerode';
|
||||
import { execa } from 'execa';
|
||||
|
||||
import { env } from '../env';
|
||||
import { BOX_UID, linuxUsername } from './box-user';
|
||||
|
||||
type CommandResult = {
|
||||
exitCode: number;
|
||||
@@ -26,6 +27,21 @@ const environmentArgs = (environment: Record<string, string>) =>
|
||||
|
||||
const networkArgs = () => (env.network ? ['--network', env.network] : []);
|
||||
|
||||
// Box user namespace mapping. Under rootless podman, keep-id maps the host
|
||||
// user onto the box user's uid so host-side writes (clones, dotfiles, file
|
||||
// editor) are already user-owned inside the box. Rootful docker has no
|
||||
// equivalent — ownership is repaired with targeted chowns instead (see
|
||||
// chownInBox). SPOON_AGENT_BOX_USERNS overrides: empty disables, any other
|
||||
// value passes through.
|
||||
export const boxUsernsArgs = (): string[] => {
|
||||
if (env.boxUserns !== undefined) {
|
||||
return env.boxUserns ? [`--userns=${env.boxUserns}`] : [];
|
||||
}
|
||||
return containerRuntime().endsWith('podman')
|
||||
? [`--userns=keep-id:uid=${BOX_UID},gid=${BOX_UID}`]
|
||||
: [];
|
||||
};
|
||||
|
||||
const containerRuntime = () => env.containerRuntime;
|
||||
|
||||
// `docker run` reuses a stale local `:latest` forever, so without an explicit
|
||||
@@ -173,6 +189,10 @@ export const ensureUserContainer = async (args: {
|
||||
username: string;
|
||||
workdir: string;
|
||||
containerHome: string;
|
||||
// Runs once per fresh container, after `run` succeeds — box-user init
|
||||
// (useradd, sudo policy, password) hooks in here so every creation path
|
||||
// (acquire, explicit start) sets the user up without duplicating the logic.
|
||||
onCreated?: (containerName: string) => Promise<void>;
|
||||
}): Promise<string> => {
|
||||
await ensureJobImagePulled();
|
||||
const name = userContainerName(args.username);
|
||||
@@ -196,10 +216,13 @@ export const ensureUserContainer = async (args: {
|
||||
'--init',
|
||||
'--name',
|
||||
name,
|
||||
'--hostname',
|
||||
`${linuxUsername(args.username)}-box`,
|
||||
'--memory',
|
||||
'4g',
|
||||
'--cpus',
|
||||
'2',
|
||||
...boxUsernsArgs(),
|
||||
...networkArgs(),
|
||||
'-v',
|
||||
jobWorkspaceVolumeSpec(args.workdir, args.containerHome),
|
||||
@@ -211,6 +234,7 @@ export const ensureUserContainer = async (args: {
|
||||
],
|
||||
{ stdin: 'ignore' },
|
||||
);
|
||||
if (args.onCreated) await args.onCreated(name);
|
||||
return name;
|
||||
};
|
||||
|
||||
@@ -236,7 +260,12 @@ export const inspectUserBoxStatus = async (
|
||||
{ reject: false, stdin: 'ignore' },
|
||||
);
|
||||
if (result.exitCode !== 0) {
|
||||
return { running: false, image: null, startedAt: null, memoryLimitBytes: null };
|
||||
return {
|
||||
running: false,
|
||||
image: null,
|
||||
startedAt: null,
|
||||
memoryLimitBytes: null,
|
||||
};
|
||||
}
|
||||
const [runningField, imageField, startedAtField, memoryField] = result.stdout
|
||||
.trim()
|
||||
@@ -335,6 +364,25 @@ export const killBoxProcessesByMarker = async (args: {
|
||||
);
|
||||
};
|
||||
|
||||
// Shared `<runtime> exec` argv. `user` runs the command as that container
|
||||
// user instead of root — terminals and agent turns pass the box user; init,
|
||||
// chown and kill paths stay root.
|
||||
export const execArgv = (args: {
|
||||
containerName: string;
|
||||
command: string[];
|
||||
environment: Record<string, string>;
|
||||
containerCwd: string;
|
||||
user?: string;
|
||||
}): string[] => [
|
||||
'exec',
|
||||
...environmentArgs(args.environment),
|
||||
'-w',
|
||||
args.containerCwd,
|
||||
...(args.user ? ['-u', args.user] : []),
|
||||
args.containerName,
|
||||
...args.command,
|
||||
];
|
||||
|
||||
export const streamExecInContainer = async (args: {
|
||||
containerName: string;
|
||||
command: string[];
|
||||
@@ -342,21 +390,16 @@ export const streamExecInContainer = async (args: {
|
||||
containerCwd: string;
|
||||
redact: (value: string) => string;
|
||||
timeoutMs: number;
|
||||
user?: string;
|
||||
onStdoutLine?: (line: string) => Promise<void>;
|
||||
onStderrLine?: (line: string) => Promise<void>;
|
||||
}): Promise<CommandResult> => {
|
||||
const subprocess = execa(
|
||||
containerRuntime(),
|
||||
[
|
||||
'exec',
|
||||
...environmentArgs(args.environment),
|
||||
'-w',
|
||||
args.containerCwd,
|
||||
args.containerName,
|
||||
...args.command,
|
||||
],
|
||||
{ all: true, reject: false, stdin: 'ignore', timeout: args.timeoutMs },
|
||||
);
|
||||
const subprocess = execa(containerRuntime(), execArgv(args), {
|
||||
all: true,
|
||||
reject: false,
|
||||
stdin: 'ignore',
|
||||
timeout: args.timeoutMs,
|
||||
});
|
||||
return streamSubprocess(
|
||||
subprocess,
|
||||
args.redact,
|
||||
@@ -372,20 +415,54 @@ export const runExecInContainer = async (args: {
|
||||
containerCwd: string;
|
||||
redact: (value: string) => string;
|
||||
timeoutMs: number;
|
||||
user?: string;
|
||||
// Piped to the command's stdin (e.g. `chpasswd` credentials, which must
|
||||
// never appear in argv). Without it stdin is closed.
|
||||
input?: string;
|
||||
}): Promise<CommandResult> => {
|
||||
const result = await execa(containerRuntime(), execArgv(args), {
|
||||
all: true,
|
||||
reject: false,
|
||||
timeout: args.timeoutMs,
|
||||
...(args.input == null
|
||||
? { stdin: 'ignore' as const }
|
||||
: { input: args.input }),
|
||||
});
|
||||
return normalizeRunResult(result, result.all, args.redact);
|
||||
};
|
||||
|
||||
// Best-effort ownership repair after host-side writes into a box home. Under
|
||||
// dev podman (keep-id) this is a no-op — files are already the box uid; under
|
||||
// prod docker it fixes uid-0 ownership left by the worker container. Failures
|
||||
// are logged, not thrown: a stopped box just means the next creation's
|
||||
// marker-guarded chown (or the next call) picks it up.
|
||||
export const chownInBox = async (args: {
|
||||
containerName: string;
|
||||
paths: string[];
|
||||
}): Promise<void> => {
|
||||
if (args.paths.length === 0) return;
|
||||
const result = await execa(
|
||||
containerRuntime(),
|
||||
[
|
||||
'exec',
|
||||
...environmentArgs(args.environment),
|
||||
'-w',
|
||||
args.containerCwd,
|
||||
// Explicit root: under podman keep-id the container default user is the
|
||||
// mapped uid, which cannot chown files it does not own.
|
||||
'-u',
|
||||
'root',
|
||||
args.containerName,
|
||||
...args.command,
|
||||
'chown',
|
||||
'-R',
|
||||
`${BOX_UID}:${BOX_UID}`,
|
||||
...args.paths,
|
||||
],
|
||||
{ all: true, reject: false, stdin: 'ignore', timeout: args.timeoutMs },
|
||||
{ all: true, reject: false, stdin: 'ignore' },
|
||||
);
|
||||
return normalizeRunResult(result, result.all, args.redact);
|
||||
const normalized = normalizeRunResult(result, result.all, (value) => value);
|
||||
if (normalized.exitCode !== 0) {
|
||||
console.error(
|
||||
`chown in ${args.containerName} failed (${String(normalized.exitCode)}): ${normalized.output}`,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export const stopWorkspaceContainer = async (containerName: string) => {
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
import net from 'node:net';
|
||||
import type { Duplex } from 'node:stream';
|
||||
|
||||
import { getDockerClient } from './docker';
|
||||
|
||||
// Hand-rolled replacement for docker-modem's hijacked `exec.start`. Bun's
|
||||
// node:http client never emits the `'upgrade'` event (it surfaces the 101 as a
|
||||
// plain response), so dockerode's `exec.start({ hijack: true })` waits forever
|
||||
// under bun — the terminal connects but no bytes ever flow. Docker and podman
|
||||
// both answer the exec-start upgrade with `101 UPGRADED` followed by the raw
|
||||
// TTY byte stream on the same connection, so we speak that one exchange
|
||||
// directly over a net socket and hand the socket to the caller.
|
||||
|
||||
export type UpgradeParse =
|
||||
| { complete: false }
|
||||
| { complete: true; statusCode: number; remainder: Buffer };
|
||||
|
||||
const HEADER_TERMINATOR = Buffer.from('\r\n\r\n');
|
||||
|
||||
// Parse an accumulating exec-start response buffer. Incomplete until the CRLF
|
||||
// CRLF header terminator arrives; after it, `remainder` is the start of the raw
|
||||
// stream (or the error body) and must be preserved byte-exact.
|
||||
export const parseUpgradeResponse = (data: Buffer): UpgradeParse => {
|
||||
const headerEnd = data.indexOf(HEADER_TERMINATOR);
|
||||
if (headerEnd === -1) return { complete: false };
|
||||
const statusLine =
|
||||
data.subarray(0, headerEnd).toString('latin1').split('\r\n', 1)[0] ?? '';
|
||||
const match = /^HTTP\/1\.[01] (\d{3})/.exec(statusLine);
|
||||
return {
|
||||
complete: true,
|
||||
statusCode: match?.[1] ? Number.parseInt(match[1], 10) : 0,
|
||||
remainder: data.subarray(headerEnd + HEADER_TERMINATOR.length),
|
||||
};
|
||||
};
|
||||
|
||||
type ModemTarget = {
|
||||
socketPath?: string;
|
||||
host?: string;
|
||||
port?: number | string;
|
||||
protocol?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Start a created exec and return the raw bidirectional TTY stream. Writes are
|
||||
* stdin; reads are the terminal output (Tty:true, so no stdcopy framing). The
|
||||
* socket is returned PAUSED: output that arrives while the caller is still
|
||||
* wiring handlers stays buffered instead of being dropped (the first tmux
|
||||
* screen paint often lands within the same tick as the upgrade). Callers must
|
||||
* `stream.resume()` once their `data` handler is attached.
|
||||
*/
|
||||
export const openExecStream = (
|
||||
execId: string,
|
||||
{ timeoutMs = 15_000 }: { timeoutMs?: number } = {},
|
||||
): Promise<Duplex> =>
|
||||
new Promise((resolve, reject) => {
|
||||
const modem = getDockerClient().modem as ModemTarget;
|
||||
if (modem.protocol && modem.protocol !== 'http' && !modem.socketPath) {
|
||||
reject(
|
||||
new Error(
|
||||
`Exec streaming supports unix sockets and plain tcp docker hosts; got protocol "${modem.protocol}".`,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
const socket = modem.socketPath
|
||||
? net.connect({ path: modem.socketPath })
|
||||
: net.connect({
|
||||
host: modem.host ?? 'localhost',
|
||||
port: Number(modem.port ?? 2375),
|
||||
});
|
||||
|
||||
let buffered = Buffer.alloc(0);
|
||||
let settled = false;
|
||||
const fail = (error: Error) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
socket.destroy();
|
||||
reject(error);
|
||||
};
|
||||
const timer = setTimeout(
|
||||
() => fail(new Error(`Exec start timed out after ${timeoutMs}ms.`)),
|
||||
timeoutMs,
|
||||
);
|
||||
|
||||
socket.on('error', (error: Error) =>
|
||||
fail(new Error(`Exec stream connection failed: ${error.message}`)),
|
||||
);
|
||||
socket.on('close', () =>
|
||||
fail(
|
||||
new Error(
|
||||
`Exec stream closed before the upgrade completed${buffered.length ? `: ${buffered.toString('utf8').slice(0, 300)}` : '.'}`,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
socket.on('connect', () => {
|
||||
const body = JSON.stringify({ Detach: false, Tty: true });
|
||||
socket.write(
|
||||
`POST /exec/${encodeURIComponent(execId)}/start HTTP/1.1\r\n` +
|
||||
'Host: docker\r\n' +
|
||||
'Content-Type: application/json\r\n' +
|
||||
`Content-Length: ${Buffer.byteLength(body)}\r\n` +
|
||||
'Connection: Upgrade\r\n' +
|
||||
'Upgrade: tcp\r\n' +
|
||||
'\r\n' +
|
||||
body,
|
||||
);
|
||||
});
|
||||
|
||||
const onData = (chunk: Buffer) => {
|
||||
buffered = Buffer.concat([buffered, chunk]);
|
||||
const parsed = parseUpgradeResponse(buffered);
|
||||
if (!parsed.complete) return;
|
||||
if (parsed.statusCode !== 101 && parsed.statusCode !== 200) {
|
||||
fail(
|
||||
new Error(
|
||||
`Exec start failed: HTTP ${parsed.statusCode || 'unparseable'} ${parsed.remainder.toString('utf8').slice(0, 300)}`.trim(),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
socket.off('data', onData);
|
||||
socket.pause();
|
||||
if (parsed.remainder.length > 0) socket.unshift(parsed.remainder);
|
||||
resolve(socket);
|
||||
};
|
||||
socket.on('data', onData);
|
||||
});
|
||||
@@ -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 },
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
listBoxTree,
|
||||
readBoxFile,
|
||||
restartBox,
|
||||
setBoxUserPassword,
|
||||
startBox,
|
||||
stopBox,
|
||||
writeBoxFile,
|
||||
@@ -66,7 +67,9 @@ const jobRoute = (pathname: string) => {
|
||||
};
|
||||
|
||||
export const boxRoute = (pathname: string) => {
|
||||
const match = /^\/box\/(status|lifecycle|tree|file)$/.exec(pathname);
|
||||
const match = /^\/box\/(status|lifecycle|tree|file|set-password)$/.exec(
|
||||
pathname,
|
||||
);
|
||||
return match?.[1] ? { action: match[1] } : null;
|
||||
};
|
||||
|
||||
@@ -136,6 +139,25 @@ export const startWorkerServer = () => {
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (request.method === 'POST' && box.action === 'set-password') {
|
||||
const body = await parseJson<{ password?: string | null }>(request);
|
||||
// Convex owns validation/storage; this route only applies to the
|
||||
// live container. Re-check bounds anyway so a bad caller can't
|
||||
// push a malformed chpasswd line.
|
||||
const password = body.password ?? null;
|
||||
if (
|
||||
password !== null &&
|
||||
(password.length < 8 ||
|
||||
password.length > 128 ||
|
||||
// eslint-disable-next-line no-control-regex -- rejecting control chars is the point
|
||||
/[\u0000-\u001f\u007f]/.test(password))
|
||||
) {
|
||||
sendJson(response, 400, { error: 'Invalid password' });
|
||||
return;
|
||||
}
|
||||
sendJson(response, 200, await setBoxUserPassword(user, password));
|
||||
return;
|
||||
}
|
||||
sendJson(response, 404, { error: 'Not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import type { Server } from 'node:http';
|
||||
import type { Duplex } from 'node:stream';
|
||||
import type { WebSocket } from 'ws';
|
||||
@@ -6,7 +7,13 @@ 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,
|
||||
killBoxProcessesByMarker,
|
||||
} from './runtime/docker';
|
||||
import { openExecStream } from './runtime/exec-stream';
|
||||
import {
|
||||
parseBoxTokenUsername,
|
||||
verifyBoxTerminalToken,
|
||||
@@ -43,12 +50,27 @@ export const parseResizeMessage = (
|
||||
}
|
||||
};
|
||||
|
||||
// Close with a reason the browser can display. The WebSocket close-frame
|
||||
// payload caps the reason at 123 bytes — `ws` throws past that — so clamp.
|
||||
const closeWithReason = (ws: WebSocket, code: number, reason: string) => {
|
||||
ws.close(code, Buffer.from(reason, 'utf8').subarray(0, 120).toString('utf8'));
|
||||
};
|
||||
|
||||
// The login-shell command shared by the job and box terminals: prefer a
|
||||
// resumable tmux session, else fall back to a plain interactive login shell.
|
||||
const SHELL_CMD = [
|
||||
//
|
||||
// The marker comment keeps a per-connection tag in the wrapper bash's argv so
|
||||
// the bridge can kill THIS connection's process group when the WebSocket
|
||||
// closes. Without it, every disconnect leaks an attached tmux client (the
|
||||
// exec process survives the closed attach socket); the session then stays
|
||||
// sized to the smallest zombie client, and once enough dead clients' ptys
|
||||
// fill up, tmux stops rendering for live ones entirely. tmux/bash must NOT be
|
||||
// exec'd — that would replace the marker-carrying argv.
|
||||
export const buildTerminalShellCommand = (marker: string): string[] => [
|
||||
'/bin/bash',
|
||||
'-lc',
|
||||
'if command -v tmux >/dev/null 2>&1; then exec tmux new-session -A -s spoon; else exec bash -il; fi',
|
||||
`# ${marker}
|
||||
if command -v tmux >/dev/null 2>&1; then tmux new-session -A -s spoon; else bash -il; fi`,
|
||||
];
|
||||
|
||||
type ShellBridgeOptions = {
|
||||
@@ -57,6 +79,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).
|
||||
@@ -94,6 +121,7 @@ const runShellBridge = async (ws: WebSocket, opts: ShellBridgeOptions) => {
|
||||
else pendingInput.push(data);
|
||||
});
|
||||
|
||||
const marker = `spoon-term-${randomUUID()}`;
|
||||
const handleHolder: { current?: BoxHandle } = {};
|
||||
let released = false;
|
||||
// Read through a function so TS doesn't narrow `released` to a constant — the
|
||||
@@ -104,6 +132,15 @@ const runShellBridge = async (ws: WebSocket, opts: ShellBridgeOptions) => {
|
||||
released = true;
|
||||
execHolder.current?.stream.end();
|
||||
execHolder.current?.stream.destroy();
|
||||
// Closing the attach socket does NOT kill the exec'd shell — reap this
|
||||
// connection's process group so dead tmux clients don't accumulate. The
|
||||
// tmux server daemonized into its own group and survives.
|
||||
const boxName = handleHolder.current?.boxName;
|
||||
if (boxName && execHolder.current) {
|
||||
void killBoxProcessesByMarker({ containerName: boxName, marker }).catch(
|
||||
() => undefined,
|
||||
);
|
||||
}
|
||||
handleHolder.current?.release();
|
||||
};
|
||||
ws.on('close', cleanup);
|
||||
@@ -118,7 +155,9 @@ const runShellBridge = async (ws: WebSocket, opts: ShellBridgeOptions) => {
|
||||
handleHolder.current = handle;
|
||||
boxName = handle.boxName;
|
||||
} catch (error) {
|
||||
ws.close(
|
||||
console.error('Terminal box acquire failed:', error);
|
||||
closeWithReason(
|
||||
ws,
|
||||
1011,
|
||||
`Failed to start terminal: ${error instanceof Error ? error.message : 'unknown error'}`,
|
||||
);
|
||||
@@ -131,18 +170,41 @@ const runShellBridge = async (ws: WebSocket, opts: ShellBridgeOptions) => {
|
||||
return;
|
||||
}
|
||||
|
||||
const docker = getDockerClient();
|
||||
const container = docker.getContainer(boxName);
|
||||
const exec = await container.exec({
|
||||
AttachStdin: true,
|
||||
AttachStdout: true,
|
||||
AttachStderr: true,
|
||||
Tty: true,
|
||||
Cmd: SHELL_CMD,
|
||||
Env: ['TERM=xterm-256color', ...opts.envFlags],
|
||||
WorkingDir: opts.cwd,
|
||||
});
|
||||
const stream = await exec.start({ hijack: true, stdin: true, Tty: true });
|
||||
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
|
||||
// forever with a connected-but-dead terminal. `openExecStream` speaks the
|
||||
// upgrade exchange over a raw socket instead (works under bun and node,
|
||||
// against docker and podman). `exec.resize` is a plain POST and stays on
|
||||
// dockerode.
|
||||
let exec: import('dockerode').Exec;
|
||||
let stream: Duplex;
|
||||
try {
|
||||
const docker = getDockerClient();
|
||||
const container = docker.getContainer(boxName);
|
||||
exec = await container.exec({
|
||||
AttachStdin: true,
|
||||
AttachStdout: true,
|
||||
AttachStderr: true,
|
||||
Tty: true,
|
||||
User: opts.user,
|
||||
Cmd: buildTerminalShellCommand(marker),
|
||||
Env: ['TERM=xterm-256color', ...opts.envFlags],
|
||||
WorkingDir: opts.cwd,
|
||||
});
|
||||
stream = await openExecStream(exec.id);
|
||||
} catch (error) {
|
||||
console.error('Terminal exec attach failed:', error);
|
||||
closeWithReason(
|
||||
ws,
|
||||
1011,
|
||||
`Failed to attach terminal: ${error instanceof Error ? error.message : 'unknown error'}`,
|
||||
);
|
||||
cleanup();
|
||||
return;
|
||||
}
|
||||
execHolder.current = { exec, stream };
|
||||
|
||||
if (isReleased()) {
|
||||
@@ -153,10 +215,6 @@ const runShellBridge = async (ws: WebSocket, opts: ShellBridgeOptions) => {
|
||||
|
||||
await exec.resize({ h: rows, w: cols }).catch(() => undefined);
|
||||
|
||||
// Replay any keystrokes the client sent before the process was ready.
|
||||
for (const buffered of pendingInput) stream.write(buffered);
|
||||
pendingInput.length = 0;
|
||||
|
||||
stream.on('data', (chunk: Buffer) => {
|
||||
if (ws.readyState === ws.OPEN) ws.send(chunk, { binary: true });
|
||||
});
|
||||
@@ -166,6 +224,12 @@ const runShellBridge = async (ws: WebSocket, opts: ShellBridgeOptions) => {
|
||||
stream.on('error', () => {
|
||||
if (ws.readyState === ws.OPEN) ws.close();
|
||||
});
|
||||
|
||||
// Replay any keystrokes the client sent before the process was ready, then
|
||||
// start the (paused) stream now that the output handler is attached.
|
||||
for (const buffered of pendingInput) stream.write(buffered);
|
||||
pendingInput.length = 0;
|
||||
stream.resume();
|
||||
};
|
||||
|
||||
// Job terminal: opens the shell at the repo checkout, with the job's selected
|
||||
@@ -183,6 +247,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}`,
|
||||
@@ -197,8 +262,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}`],
|
||||
});
|
||||
@@ -231,8 +304,15 @@ export const attachTerminalServer = (server: Server) => {
|
||||
return;
|
||||
}
|
||||
wss.handleUpgrade(request, socket, head, (ws) => {
|
||||
void bridgeBox(ws, username).catch(() => {
|
||||
if (ws.readyState === ws.OPEN) ws.close();
|
||||
void bridgeBox(ws, username).catch((error: unknown) => {
|
||||
console.error(`Box terminal bridge failed (${username}):`, error);
|
||||
if (ws.readyState === ws.OPEN) {
|
||||
closeWithReason(
|
||||
ws,
|
||||
1011,
|
||||
`Terminal bridge failed: ${error instanceof Error ? error.message : 'unknown error'}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
return;
|
||||
@@ -250,8 +330,15 @@ export const attachTerminalServer = (server: Server) => {
|
||||
return;
|
||||
}
|
||||
wss.handleUpgrade(request, socket, head, (ws) => {
|
||||
void bridge(ws, jobId).catch(() => {
|
||||
if (ws.readyState === ws.OPEN) ws.close();
|
||||
void bridge(ws, jobId).catch((error: unknown) => {
|
||||
console.error(`Job terminal bridge failed (${jobId}):`, error);
|
||||
if (ws.readyState === ws.OPEN) {
|
||||
closeWithReason(
|
||||
ws,
|
||||
1011,
|
||||
`Terminal bridge failed: ${error instanceof Error ? error.message : 'unknown error'}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { initializeBoxUser } from './box-init';
|
||||
import { env } from './env';
|
||||
import {
|
||||
ensureUserContainer,
|
||||
@@ -13,7 +14,6 @@ export type BoxHandle = { boxName: string; release: () => void };
|
||||
|
||||
type Box = {
|
||||
name: string;
|
||||
initialized: boolean;
|
||||
refs: Set<symbol>;
|
||||
idleTimer?: ReturnType<typeof setTimeout>;
|
||||
};
|
||||
@@ -79,7 +79,6 @@ export const acquireUserBox = (args: {
|
||||
if (!box) {
|
||||
box = {
|
||||
name: userContainerName(args.username),
|
||||
initialized: false,
|
||||
refs: new Set(),
|
||||
};
|
||||
boxes.set(args.username, box);
|
||||
@@ -88,10 +87,17 @@ export const acquireUserBox = (args: {
|
||||
// release it without leaking registry state.
|
||||
const handle = makeHandle(args.username, box);
|
||||
try {
|
||||
if (!box.initialized) {
|
||||
box.name = await ensureUserContainer(args);
|
||||
box.initialized = true;
|
||||
}
|
||||
// Re-verify with the container runtime on EVERY acquire, not just the
|
||||
// first: the registry goes stale when the box is removed behind the
|
||||
// worker's back (manual `docker rm`, another worker's idle reaper), and a
|
||||
// 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,
|
||||
onCreated: (containerName) =>
|
||||
initializeBoxUser(containerName, args.username, args.containerHome),
|
||||
});
|
||||
return handle;
|
||||
} catch (error) {
|
||||
handle.release();
|
||||
@@ -109,7 +115,7 @@ export const reconcileExistingBoxes = async (): Promise<void> => {
|
||||
for (const name of names) {
|
||||
const username = name.replace(/^spoon-box-/, '');
|
||||
if (boxes.has(username)) continue;
|
||||
const box: Box = { name, initialized: false, refs: new Set() };
|
||||
const box: Box = { name, refs: new Set() };
|
||||
boxes.set(username, box);
|
||||
scheduleReapIfIdle(username);
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -13,12 +13,13 @@ const load = async () => {
|
||||
describe('boxRoute', () => {
|
||||
afterEach(() => vi.resetModules());
|
||||
|
||||
test('matches the four box actions', async () => {
|
||||
test('matches the five box actions', async () => {
|
||||
const { boxRoute } = await load();
|
||||
expect(boxRoute('/box/status')).toEqual({ action: 'status' });
|
||||
expect(boxRoute('/box/lifecycle')).toEqual({ action: 'lifecycle' });
|
||||
expect(boxRoute('/box/tree')).toEqual({ action: 'tree' });
|
||||
expect(boxRoute('/box/file')).toEqual({ action: 'file' });
|
||||
expect(boxRoute('/box/set-password')).toEqual({ action: 'set-password' });
|
||||
});
|
||||
|
||||
test('rejects unknown, nested, and trailing paths', async () => {
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
buildBoxInitScript,
|
||||
buildClearPasswordCommand,
|
||||
buildSudoPolicyScript,
|
||||
linuxUsername,
|
||||
} from '../../src/runtime/box-user';
|
||||
|
||||
describe('linuxUsername', () => {
|
||||
it('lowercases and passes through simple names', () => {
|
||||
expect(linuxUsername('gabriel')).toBe('gabriel');
|
||||
expect(linuxUsername('Gabriel')).toBe('gabriel');
|
||||
});
|
||||
it('replaces invalid characters with dashes', () => {
|
||||
expect(linuxUsername('g@b r!el')).toBe('g-b-r-el');
|
||||
});
|
||||
it('prefixes names that do not start with [a-z_]', () => {
|
||||
expect(linuxUsername('1337')).toBe('u1337');
|
||||
expect(linuxUsername('-dash')).toBe('u-dash');
|
||||
});
|
||||
it('truncates to 32 chars and falls back to spoon', () => {
|
||||
expect(linuxUsername('a'.repeat(40))).toHaveLength(32);
|
||||
expect(linuxUsername('')).toBe('spoon');
|
||||
expect(linuxUsername('@@@')).toBe('u---');
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildBoxInitScript', () => {
|
||||
const script = buildBoxInitScript({
|
||||
username: 'gabriel',
|
||||
home: '/home/Gabriel',
|
||||
});
|
||||
it('renames an existing uid-1000 entry instead of duplicating it', () => {
|
||||
// podman --userns=keep-id injects a passwd entry named after the HOST user
|
||||
// at the mapped uid; a second uid-1000 entry would win the getpwuid lookup
|
||||
// and make whoami report the host username.
|
||||
expect(script).toContain('getent passwd 1000');
|
||||
expect(script).toContain("usermod -l 'gabriel'");
|
||||
});
|
||||
it('creates the user with uid 1000 at the given home when none exists', () => {
|
||||
expect(script).toContain('useradd');
|
||||
expect(script).toContain('-u 1000');
|
||||
expect(script).toContain("-d '/home/Gabriel'");
|
||||
expect(script).toContain('-s /bin/bash');
|
||||
});
|
||||
it('adds wheel membership', () => {
|
||||
expect(script).toContain("usermod -aG wheel 'gabriel'");
|
||||
});
|
||||
it('chowns the home once, guarded by the marker', () => {
|
||||
expect(script).toContain('/home/Gabriel/.spoon/chown-v1');
|
||||
expect(script).toContain("chown -R 1000:1000 '/home/Gabriel'");
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildSudoPolicyScript', () => {
|
||||
it('installs a visudo-validated NOPASSWD drop-in when no password is set', () => {
|
||||
const script = buildSudoPolicyScript({
|
||||
username: 'gabriel',
|
||||
passwordSet: false,
|
||||
});
|
||||
expect(script).toContain('gabriel ALL=(ALL) NOPASSWD:ALL');
|
||||
expect(script).toContain('visudo -c');
|
||||
expect(script).toContain('/etc/sudoers.d/spoon-box');
|
||||
expect(script).toContain('chmod 0440');
|
||||
});
|
||||
it('removes the drop-in when a password is set', () => {
|
||||
const script = buildSudoPolicyScript({
|
||||
username: 'gabriel',
|
||||
passwordSet: true,
|
||||
});
|
||||
expect(script).toContain('rm -f /etc/sudoers.d/spoon-box');
|
||||
expect(script).not.toContain('NOPASSWD');
|
||||
});
|
||||
});
|
||||
|
||||
describe('password commands', () => {
|
||||
it('clears via passwd -d', () => {
|
||||
expect(buildClearPasswordCommand('gabriel')).toEqual([
|
||||
'passwd',
|
||||
'-d',
|
||||
'gabriel',
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -44,6 +44,65 @@ describe('Docker runtime', () => {
|
||||
);
|
||||
});
|
||||
|
||||
test('boxUsernsArgs maps the host user onto the box uid under podman', async () => {
|
||||
process.env.SPOON_AGENT_CONTAINER_RUNTIME = 'podman';
|
||||
const { boxUsernsArgs } = await loadVolumeSpec();
|
||||
expect(boxUsernsArgs()).toEqual(['--userns=keep-id:uid=1000,gid=1000']);
|
||||
});
|
||||
|
||||
test('boxUsernsArgs adds nothing under docker', async () => {
|
||||
process.env.SPOON_AGENT_CONTAINER_RUNTIME = 'docker';
|
||||
const { boxUsernsArgs } = await loadVolumeSpec();
|
||||
expect(boxUsernsArgs()).toEqual([]);
|
||||
});
|
||||
|
||||
test('SPOON_AGENT_BOX_USERNS overrides: value passes through, empty disables', async () => {
|
||||
process.env.SPOON_AGENT_CONTAINER_RUNTIME = 'podman';
|
||||
process.env.SPOON_AGENT_BOX_USERNS = 'host';
|
||||
let { boxUsernsArgs } = await loadVolumeSpec();
|
||||
expect(boxUsernsArgs()).toEqual(['--userns=host']);
|
||||
|
||||
process.env.SPOON_AGENT_BOX_USERNS = '';
|
||||
({ boxUsernsArgs } = await loadVolumeSpec());
|
||||
expect(boxUsernsArgs()).toEqual([]);
|
||||
delete process.env.SPOON_AGENT_BOX_USERNS;
|
||||
});
|
||||
|
||||
test('execArgv places -u after the workdir and before the container name', async () => {
|
||||
const { execArgv } = await loadVolumeSpec();
|
||||
expect(
|
||||
execArgv({
|
||||
containerName: 'spoon-box-gabriel',
|
||||
command: ['ls', '-la'],
|
||||
environment: { HOME: '/home/gabriel' },
|
||||
containerCwd: '/home/gabriel',
|
||||
user: 'gabriel',
|
||||
}),
|
||||
).toEqual([
|
||||
'exec',
|
||||
'-e',
|
||||
'HOME=/home/gabriel',
|
||||
'-w',
|
||||
'/home/gabriel',
|
||||
'-u',
|
||||
'gabriel',
|
||||
'spoon-box-gabriel',
|
||||
'ls',
|
||||
'-la',
|
||||
]);
|
||||
});
|
||||
|
||||
test('execArgv omits -u when no user is given', async () => {
|
||||
const { execArgv } = await loadVolumeSpec();
|
||||
const argv = execArgv({
|
||||
containerName: 'c',
|
||||
command: ['true'],
|
||||
environment: {},
|
||||
containerCwd: '/',
|
||||
});
|
||||
expect(argv).not.toContain('-u');
|
||||
});
|
||||
|
||||
test('treats a spawn failure (no exitCode) as a non-zero exit, not empty success', async () => {
|
||||
const { normalizeRunResult } = await loadVolumeSpec();
|
||||
// This is what execa returns with `reject: false` when the runtime binary is
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
// exec-stream imports the docker runtime, whose env module requires these at
|
||||
// import time — set them before the (hoist-free) dynamic import.
|
||||
process.env.SPOON_WORKER_TOKEN ??= 'test-worker-token';
|
||||
process.env.GITHUB_APP_ID ??= '123';
|
||||
process.env.GITHUB_APP_PRIVATE_KEY ??= 'test-key';
|
||||
const { parseUpgradeResponse } = await import('../../src/runtime/exec-stream');
|
||||
|
||||
describe('parseUpgradeResponse', () => {
|
||||
it('reports incomplete until the header terminator arrives', () => {
|
||||
expect(parseUpgradeResponse(Buffer.from('')).complete).toBe(false);
|
||||
expect(
|
||||
parseUpgradeResponse(Buffer.from('HTTP/1.1 101 UPGRADED\r\n')).complete,
|
||||
).toBe(false);
|
||||
expect(
|
||||
parseUpgradeResponse(
|
||||
Buffer.from('HTTP/1.1 101 UPGRADED\r\nConnection: Upgrade\r\n'),
|
||||
).complete,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('parses a 101 upgrade and preserves stream bytes after the headers', () => {
|
||||
const payload = Buffer.concat([
|
||||
Buffer.from(
|
||||
'HTTP/1.1 101 UPGRADED\r\n' +
|
||||
'Content-Type: application/vnd.docker.raw-stream\r\n' +
|
||||
'Connection: Upgrade\r\n' +
|
||||
'Upgrade: tcp\r\n' +
|
||||
'\r\n',
|
||||
),
|
||||
Buffer.from([0x1b, 0x5b, 0x48, 0x00, 0xff]),
|
||||
]);
|
||||
const parsed = parseUpgradeResponse(payload);
|
||||
expect(parsed).toMatchObject({ complete: true, statusCode: 101 });
|
||||
if (!parsed.complete) throw new Error('unreachable');
|
||||
expect([...parsed.remainder]).toEqual([0x1b, 0x5b, 0x48, 0x00, 0xff]);
|
||||
});
|
||||
|
||||
it('parses a 200 stream response (docker without upgrade) as success-shaped', () => {
|
||||
const parsed = parseUpgradeResponse(
|
||||
Buffer.from('HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\n\r\nhi'),
|
||||
);
|
||||
expect(parsed).toMatchObject({ complete: true, statusCode: 200 });
|
||||
if (!parsed.complete) throw new Error('unreachable');
|
||||
expect(parsed.remainder.toString('utf8')).toBe('hi');
|
||||
});
|
||||
|
||||
it('parses error statuses with their body so callers can report them', () => {
|
||||
const parsed = parseUpgradeResponse(
|
||||
Buffer.from(
|
||||
'HTTP/1.1 404 Not Found\r\nContent-Length: 26\r\n\r\n{"message":"no such exec"}',
|
||||
),
|
||||
);
|
||||
expect(parsed).toMatchObject({ complete: true, statusCode: 404 });
|
||||
if (!parsed.complete) throw new Error('unreachable');
|
||||
expect(parsed.remainder.toString('utf8')).toBe(
|
||||
'{"message":"no such exec"}',
|
||||
);
|
||||
});
|
||||
|
||||
it('returns statusCode 0 for an unparseable status line', () => {
|
||||
const parsed = parseUpgradeResponse(Buffer.from('garbage\r\n\r\n'));
|
||||
expect(parsed).toMatchObject({ complete: true, statusCode: 0 });
|
||||
});
|
||||
|
||||
it('does not treat a bare LF-LF as a header terminator', () => {
|
||||
expect(
|
||||
parseUpgradeResponse(Buffer.from('HTTP/1.1 101 UPGRADED\n\n')).complete,
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -44,3 +44,18 @@ describe('parseResizeMessage', () => {
|
||||
expect(parseResizeMessage(msg, false)).toEqual({ cols: 1000, rows: 1 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildTerminalShellCommand', () => {
|
||||
test('embeds the connection marker without exec-ing the shell away', async () => {
|
||||
const { buildTerminalShellCommand } = await load();
|
||||
const command = buildTerminalShellCommand('spoon-term-abc-123');
|
||||
expect(command[0]).toBe('/bin/bash');
|
||||
const script = command[2] ?? '';
|
||||
// Marker must stay in the wrapper's argv for pgrep-based cleanup, so the
|
||||
// inner shell/tmux must NOT be exec'd (exec would replace the argv).
|
||||
expect(script).toContain('# spoon-term-abc-123');
|
||||
expect(script).not.toContain('exec tmux');
|
||||
expect(script).not.toContain('exec bash');
|
||||
expect(script).toContain('tmux new-session -A -s spoon');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -22,9 +22,53 @@ describe('box registry', () => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
test('serializes concurrent acquire into a single docker run', async () => {
|
||||
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');
|
||||
const first = await module.acquireUserBox({
|
||||
username: 'zoe',
|
||||
workdir: '/w',
|
||||
containerHome: '/home/zoe',
|
||||
});
|
||||
first.release();
|
||||
// Simulate `docker rm` behind the worker's back: the registry entry still
|
||||
// exists, but acquire must go back to the runtime rather than trust it.
|
||||
const second = await module.acquireUserBox({
|
||||
username: 'zoe',
|
||||
workdir: '/w',
|
||||
containerHome: '/home/zoe',
|
||||
});
|
||||
expect(docker.ensureUserContainer).toHaveBeenCalledTimes(2);
|
||||
expect(second.boxName).toBe('spoon-box-zoe');
|
||||
});
|
||||
|
||||
test('serializes concurrent acquires so ensure calls never overlap', async () => {
|
||||
const module = await load();
|
||||
const docker = await import('../../src/runtime/docker');
|
||||
let inFlight = 0;
|
||||
vi.mocked(docker.ensureUserContainer).mockImplementation(
|
||||
async (args: { username: string }) => {
|
||||
inFlight += 1;
|
||||
expect(inFlight).toBe(1);
|
||||
await Promise.resolve();
|
||||
inFlight -= 1;
|
||||
return `spoon-box-${args.username}`;
|
||||
},
|
||||
);
|
||||
const [a, b] = await Promise.all([
|
||||
module.acquireUserBox({
|
||||
username: 'alice',
|
||||
@@ -37,7 +81,6 @@ describe('box registry', () => {
|
||||
containerHome: '/home/alice',
|
||||
}),
|
||||
]);
|
||||
expect(docker.ensureUserContainer).toHaveBeenCalledTimes(1);
|
||||
expect(a.boxName).toBe('spoon-box-alice');
|
||||
a.release();
|
||||
await vi.advanceTimersByTimeAsync(2000);
|
||||
@@ -83,7 +126,7 @@ describe('box registry', () => {
|
||||
);
|
||||
});
|
||||
|
||||
test('ensures an adopted box once before returning acquire handles', async () => {
|
||||
test('acquiring an adopted box ensures it and returns valid handles', async () => {
|
||||
const module = await load();
|
||||
const docker = await import('../../src/runtime/docker');
|
||||
vi.mocked(docker.listWorkspaceContainerNames).mockResolvedValueOnce([
|
||||
@@ -102,7 +145,7 @@ describe('box registry', () => {
|
||||
containerHome: '/home/casey',
|
||||
});
|
||||
|
||||
expect(docker.ensureUserContainer).toHaveBeenCalledTimes(1);
|
||||
expect(docker.ensureUserContainer).toHaveBeenCalled();
|
||||
expect(first.boxName).toBe('spoon-box-casey');
|
||||
expect(second.boxName).toBe('spoon-box-casey');
|
||||
});
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { proxyBox, withBox } from '@/lib/agent-worker-proxy';
|
||||
import { convexAuthNextjsToken } from '@convex-dev/auth/nextjs/server';
|
||||
import { fetchAction } from 'convex/nextjs';
|
||||
|
||||
import { api } from '@spoon/backend/convex/_generated/api.js';
|
||||
|
||||
// Set (or clear, with null/'') the box user's password. Convex is the source
|
||||
// of truth and is written FIRST (box creation re-applies from it); the worker
|
||||
// call then applies it to the live container. A worker failure downgrades to
|
||||
// applied:false — the password still takes effect on the next box restart.
|
||||
export const POST = async (request: Request) =>
|
||||
await withBox(async (username) => {
|
||||
const body = (await request.json().catch(() => ({}))) as {
|
||||
password?: string | null;
|
||||
};
|
||||
const password =
|
||||
typeof body.password === 'string' && body.password.length > 0
|
||||
? body.password
|
||||
: null;
|
||||
|
||||
const token = await convexAuthNextjsToken();
|
||||
const stored = await fetchAction(
|
||||
api.boxSettingsNode.setBoxPassword,
|
||||
{ password },
|
||||
{ token },
|
||||
);
|
||||
|
||||
let applied = false;
|
||||
try {
|
||||
const workerResponse = await proxyBox(username, 'set-password', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ password }),
|
||||
});
|
||||
if (workerResponse.ok) {
|
||||
applied = ((await workerResponse.json()) as { applied?: boolean })
|
||||
.applied
|
||||
? true
|
||||
: false;
|
||||
}
|
||||
} catch {
|
||||
// Stored but not live-applied; init picks it up at the next creation.
|
||||
}
|
||||
return NextResponse.json({ hasPassword: stored.hasPassword, applied });
|
||||
});
|
||||
@@ -189,7 +189,7 @@ export const XtermSession = ({
|
||||
const term = new Terminal({
|
||||
fontFamily: TERMINAL_FONT,
|
||||
fontSize: 13,
|
||||
lineHeight: 1.2,
|
||||
lineHeight: 1.35,
|
||||
cursorBlink: true,
|
||||
theme: themeIsLight ? lightTheme : darkTheme,
|
||||
allowProposedApi: true,
|
||||
@@ -257,8 +257,13 @@ export const XtermSession = ({
|
||||
if (typeof event.data === 'string') term.write(event.data);
|
||||
else term.write(new Uint8Array(event.data));
|
||||
};
|
||||
ws.onclose = () => {
|
||||
if (!isAborted()) setStatus('closed');
|
||||
ws.onclose = (event: CloseEvent) => {
|
||||
if (isAborted()) return;
|
||||
setStatus('closed');
|
||||
// The worker attaches a human-readable reason when the bridge fails
|
||||
// (e.g. container runtime unreachable) — show it instead of a mute
|
||||
// "Session ended".
|
||||
if (event.reason) setErrorText(event.reason);
|
||||
};
|
||||
ws.onerror = () => {
|
||||
if (!isAborted()) setStatus('error');
|
||||
@@ -323,7 +328,9 @@ export const XtermSession = ({
|
||||
{status === 'connected'
|
||||
? `Connected · ${label}`
|
||||
: status === 'idle'
|
||||
? (waitingLabel ? 'Waiting…' : 'Idle')
|
||||
? waitingLabel
|
||||
? 'Waiting…'
|
||||
: 'Idle'
|
||||
: status === 'connecting'
|
||||
? 'Connecting…'
|
||||
: status === 'closed'
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useQuery } from 'convex/react';
|
||||
import { KeyRound, Loader2, UserRound } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import { api } from '@spoon/backend/convex/_generated/api.js';
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
Input,
|
||||
} from '@spoon/ui';
|
||||
|
||||
// Mirrors the worker's linuxUsername (runtime/box-user.ts) so the identity
|
||||
// line shows the actual passwd entry.
|
||||
const linuxUsername = (spoonUsername: string): string => {
|
||||
const cleaned = spoonUsername
|
||||
.toLowerCase()
|
||||
.replaceAll(/[^a-z0-9_-]/g, '-')
|
||||
.slice(0, 32);
|
||||
if (!cleaned) return 'spoon';
|
||||
return /^[a-z_]/.test(cleaned) ? cleaned : `u${cleaned}`.slice(0, 32);
|
||||
};
|
||||
|
||||
/**
|
||||
* The box's Linux account: identity (user@host) and the user-settable
|
||||
* password. Password is write-only — the UI only ever knows whether one is
|
||||
* set. Saving stores it (encrypted) and applies it to a running box live;
|
||||
* clearing reverts sudo to passwordless.
|
||||
*/
|
||||
export const BoxUserCard = ({ username }: { username: string }) => {
|
||||
const passwordState = useQuery(api.boxSettings.hasMine, {});
|
||||
const hasPassword = passwordState?.hasPassword ?? false;
|
||||
const user = linuxUsername(username || 'spoon');
|
||||
|
||||
const [password, setPassword] = useState('');
|
||||
const [confirm, setConfirm] = useState('');
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [pendingRestart, setPendingRestart] = useState(false);
|
||||
|
||||
const tooShort = password.length > 0 && password.length < 8;
|
||||
const mismatch = confirm.length > 0 && confirm !== password;
|
||||
const canSave =
|
||||
!saving && password.length >= 8 && password.length <= 128 && !mismatch;
|
||||
|
||||
const submit = (next: string | null) => {
|
||||
setSaving(true);
|
||||
void (async () => {
|
||||
try {
|
||||
const response = await fetch('/api/box/set-password', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ password: next }),
|
||||
});
|
||||
if (!response.ok) throw new Error(await response.text());
|
||||
const data = (await response.json()) as {
|
||||
hasPassword: boolean;
|
||||
applied: boolean;
|
||||
};
|
||||
setPassword('');
|
||||
setConfirm('');
|
||||
setPendingRestart(!data.applied);
|
||||
toast.success(
|
||||
next === null ? 'Password removed.' : 'Password updated.',
|
||||
);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
toast.error('Could not update the box password.');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
})();
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className='flex items-center gap-2 text-base'>
|
||||
<UserRound className='size-4' />
|
||||
Box user
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className='space-y-4'>
|
||||
<div className='flex flex-wrap items-center gap-x-6 gap-y-2'>
|
||||
<div>
|
||||
<p className='text-muted-foreground text-[11px] font-medium tracking-wide uppercase'>
|
||||
Identity
|
||||
</p>
|
||||
<p className='font-mono text-sm'>
|
||||
{user}@{user}-box
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className='text-muted-foreground text-[11px] font-medium tracking-wide uppercase'>
|
||||
Password
|
||||
</p>
|
||||
<p className='text-sm'>{hasPassword ? 'Set' : 'Not set'}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
Without a password, <code className='font-mono'>sudo</code> works
|
||||
without prompting. Once you set one, sudo asks for it — like a normal
|
||||
machine.
|
||||
</p>
|
||||
|
||||
<form
|
||||
className='flex flex-wrap items-end gap-2'
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
if (canSave) submit(password);
|
||||
}}
|
||||
>
|
||||
<div className='space-y-1'>
|
||||
<label
|
||||
className='text-muted-foreground text-xs'
|
||||
htmlFor='box-password'
|
||||
>
|
||||
New password
|
||||
</label>
|
||||
<Input
|
||||
id='box-password'
|
||||
type='password'
|
||||
autoComplete='new-password'
|
||||
value={password}
|
||||
onChange={(event) => setPassword(event.target.value)}
|
||||
className='w-56'
|
||||
/>
|
||||
</div>
|
||||
<div className='space-y-1'>
|
||||
<label
|
||||
className='text-muted-foreground text-xs'
|
||||
htmlFor='box-password-confirm'
|
||||
>
|
||||
Confirm
|
||||
</label>
|
||||
<Input
|
||||
id='box-password-confirm'
|
||||
type='password'
|
||||
autoComplete='new-password'
|
||||
value={confirm}
|
||||
onChange={(event) => setConfirm(event.target.value)}
|
||||
className='w-56'
|
||||
/>
|
||||
</div>
|
||||
<Button type='submit' size='sm' disabled={!canSave}>
|
||||
{saving ? (
|
||||
<Loader2 className='size-4 animate-spin' />
|
||||
) : (
|
||||
<KeyRound className='size-4' />
|
||||
)}
|
||||
{hasPassword ? 'Change password' : 'Set password'}
|
||||
</Button>
|
||||
{hasPassword ? (
|
||||
<Button
|
||||
type='button'
|
||||
variant='outline'
|
||||
size='sm'
|
||||
disabled={saving}
|
||||
onClick={() => submit(null)}
|
||||
>
|
||||
Remove password
|
||||
</Button>
|
||||
) : null}
|
||||
</form>
|
||||
|
||||
{tooShort ? (
|
||||
<p className='text-destructive text-xs'>
|
||||
Password must be at least 8 characters.
|
||||
</p>
|
||||
) : null}
|
||||
{mismatch ? (
|
||||
<p className='text-destructive text-xs'>Passwords do not match.</p>
|
||||
) : null}
|
||||
{pendingRestart ? (
|
||||
<p className='text-muted-foreground text-xs'>
|
||||
Saved — your machine is not running, so it takes effect the next
|
||||
time it starts.
|
||||
</p>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
@@ -18,6 +18,7 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from '@spoon/ui';
|
||||
|
||||
import type { BoxStatus, LifecycleAction } from './box-status-card';
|
||||
import { BoxStatusCard } from './box-status-card';
|
||||
import { BoxUserCard } from './box-user-card';
|
||||
|
||||
const STATUS_POLL_MS = 10_000;
|
||||
|
||||
@@ -261,6 +262,8 @@ export const MachineShell = () => {
|
||||
onAction={runLifecycle}
|
||||
/>
|
||||
|
||||
<BoxUserCard username={username} />
|
||||
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
onValueChange={(value) => setActiveTab(value as 'files' | 'terminal')}
|
||||
|
||||
@@ -181,7 +181,7 @@ export const resolveBoxUsername = async (): Promise<
|
||||
// ever operates on the caller's own box.
|
||||
export const proxyBox = async (
|
||||
username: string,
|
||||
action: 'status' | 'lifecycle' | 'tree' | 'file',
|
||||
action: 'status' | 'lifecycle' | 'tree' | 'file' | 'set-password',
|
||||
init?: RequestInit,
|
||||
search?: URLSearchParams,
|
||||
) => {
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { BoxUserCard } from '../../src/components/machine/box-user-card';
|
||||
|
||||
const { mockUseQuery, fetchSpy } = vi.hoisted(() => ({
|
||||
mockUseQuery: vi.fn(),
|
||||
fetchSpy: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('convex/react', () => ({
|
||||
useQuery: mockUseQuery,
|
||||
}));
|
||||
|
||||
vi.mock('sonner', () => ({
|
||||
toast: { error: vi.fn(), success: vi.fn() },
|
||||
}));
|
||||
|
||||
describe('BoxUserCard', () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal('fetch', fetchSpy);
|
||||
fetchSpy.mockResolvedValue(
|
||||
new Response(JSON.stringify({ hasPassword: true, applied: true })),
|
||||
);
|
||||
});
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.unstubAllGlobals();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('shows the identity line and not-set state', () => {
|
||||
mockUseQuery.mockReturnValue({ hasPassword: false });
|
||||
render(<BoxUserCard username='Gabriel' />);
|
||||
expect(screen.getByText('gabriel@gabriel-box')).toBeInTheDocument();
|
||||
expect(screen.getByText('Not set')).toBeInTheDocument();
|
||||
expect(screen.getByText('Set password')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Remove password')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('offers change/remove when a password is set', () => {
|
||||
mockUseQuery.mockReturnValue({ hasPassword: true });
|
||||
render(<BoxUserCard username='gabriel' />);
|
||||
expect(screen.getByText('Set')).toBeInTheDocument();
|
||||
expect(screen.getByText('Change password')).toBeInTheDocument();
|
||||
expect(screen.getByText('Remove password')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('blocks submit on mismatched confirmation', () => {
|
||||
mockUseQuery.mockReturnValue({ hasPassword: false });
|
||||
render(<BoxUserCard username='gabriel' />);
|
||||
fireEvent.change(screen.getByLabelText('New password'), {
|
||||
target: { value: 'longenough' },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText('Confirm'), {
|
||||
target: { value: 'different1' },
|
||||
});
|
||||
expect(screen.getByText('Passwords do not match.')).toBeInTheDocument();
|
||||
expect(screen.getByText('Set password').closest('button')!.disabled).toBe(
|
||||
true,
|
||||
);
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('posts the password and shows the restart note when not applied live', async () => {
|
||||
mockUseQuery.mockReturnValue({ hasPassword: false });
|
||||
fetchSpy.mockResolvedValue(
|
||||
new Response(JSON.stringify({ hasPassword: true, applied: false })),
|
||||
);
|
||||
render(<BoxUserCard username='gabriel' />);
|
||||
fireEvent.change(screen.getByLabelText('New password'), {
|
||||
target: { value: 'longenough' },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText('Confirm'), {
|
||||
target: { value: 'longenough' },
|
||||
});
|
||||
fireEvent.click(screen.getByText('Set password'));
|
||||
expect(
|
||||
await screen.findByText(/takes effect the next time it starts/),
|
||||
).toBeInTheDocument();
|
||||
expect(fetchSpy).toHaveBeenCalledWith(
|
||||
'/api/box/set-password',
|
||||
expect.objectContaining({ method: 'POST' }),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM registry.fedoraproject.org/fedora:41
|
||||
FROM registry.fedoraproject.org/fedora:44
|
||||
|
||||
ENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0
|
||||
ENV LANG=en_US.UTF-8
|
||||
@@ -50,6 +50,16 @@ RUN dnf install -y --setopt=install_weak_deps=False --nodocs \
|
||||
RUN npm install -g pnpm yarn bun@1.3.10 opencode-ai@1.17.9 @openai/codex@0.142.0 @anthropic-ai/claude-code@2.1.207 \
|
||||
&& npm cache clean --force
|
||||
|
||||
# nvm, system-wide: user dotfiles source /etc/profile.d/nvm.sh (workstation
|
||||
# parity), and NVM_DIR must be writable by the box user (uid 1000) so
|
||||
# `nvm install` works from the shell. PROFILE=/dev/null stops the installer
|
||||
# from editing root's rc files.
|
||||
ENV NVM_DIR=/usr/local/nvm
|
||||
RUN mkdir -p "$NVM_DIR" \
|
||||
&& wget -qO- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.5/install.sh | PROFILE=/dev/null bash \
|
||||
&& printf 'export NVM_DIR=/usr/local/nvm\n[ -s "$NVM_DIR/nvm.sh" ] && . "$NVM_DIR/nvm.sh"\n[ -s "$NVM_DIR/bash_completion" ] && . "$NVM_DIR/bash_completion"\n' > /etc/profile.d/nvm.sh \
|
||||
&& chown -R 1000:1000 "$NVM_DIR"
|
||||
|
||||
# oh-my-posh prompt (binary only; we ship our own /etc/spoon/omp.json theme).
|
||||
RUN curl -fsSL https://ohmyposh.dev/install.sh | bash -s -- -d /usr/local/bin \
|
||||
&& oh-my-posh version
|
||||
|
||||
@@ -108,6 +108,31 @@ Docker mode. Prod (Docker socket mounted) works as-is.
|
||||
is intended for the single-user self-hosted deployment; do not expose the
|
||||
worker domain without TLS, and keep the deployment single-tenant.
|
||||
|
||||
## The box user
|
||||
|
||||
Terminals and agent turns run as a named non-root user (uid 1000), not root —
|
||||
Claude Code refuses some operations as root, and a real machine has a named
|
||||
account. The Linux username mirrors your Spoon username (lowercased/sanitized;
|
||||
the passwd entry may differ from the home folder name), the hostname is
|
||||
`<user>-box`, and the account is in `wheel`.
|
||||
|
||||
**sudo & password:** with no password set, sudo works without prompting (a
|
||||
NOPASSWD drop-in at `/etc/sudoers.d/spoon-box`). Set a password from the
|
||||
**/machine → Box user** card: it is stored encrypted in Convex, applied live to
|
||||
a running box (`chpasswd` over stdin via `POST /box/set-password`), and
|
||||
re-applied at every box creation — at which point the NOPASSWD drop-in is
|
||||
removed and wheel membership makes sudo prompt. Clearing the password reverts
|
||||
to passwordless sudo. Passwords are 8-128 chars and write-only (the UI only
|
||||
ever learns whether one is set).
|
||||
|
||||
**File ownership:** the worker writes into homes from the host (clones,
|
||||
dotfiles, the file editor). Under dev rootless podman the box runs with
|
||||
`--userns=keep-id:uid=1000,gid=1000` so the host user IS the box user and
|
||||
ownership just works (`SPOON_AGENT_BOX_USERNS` overrides; empty disables).
|
||||
Under prod rootful docker the worker repairs ownership with targeted
|
||||
`chown -R 1000:1000` execs after each host-side write, plus a one-time
|
||||
marker-guarded (`~/.spoon/chown-v1`) home chown at first box creation.
|
||||
|
||||
## Tools in the shell
|
||||
|
||||
The box image ships `bash`, `tmux`, `neovim`, `git`, `ripgrep`, `jq`, `python3`,
|
||||
|
||||
@@ -0,0 +1,341 @@
|
||||
# Box User Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Terminal sessions and agent turns inside `spoon-box-<username>` run as a named non-root user (uid 1000) with wheel/sudo and a user-settable password stored encrypted in Convex.
|
||||
|
||||
**Architecture:** The worker creates the Linux user at box init via an idempotent root exec script; dev podman maps the host user onto uid 1000 with `--userns=keep-id`, prod docker repairs host-written file ownership with targeted in-container chowns. Password lives in a new `boxSettings` Convex table (AES-256-GCM via existing `secretCrypto`), applied at init and live via a new `/box/set-password` worker route; a "Box user" card on `/machine` drives it.
|
||||
|
||||
**Tech Stack:** bun + TypeScript worker (execa/dockerode), Convex backend, Next 15 App Router, vitest.
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-07-12-box-user-design.md`
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- Box user uid/gid: `1000:1000`, fixed.
|
||||
- sudo: NOPASSWD drop-in `/etc/sudoers.d/spoon-box` present only while no password is stored; wheel membership always.
|
||||
- Password bounds: min 8, max 128 chars; `null`/empty clears.
|
||||
- Password plaintext never in argv (stdin only), never returned to a browser.
|
||||
- Home path stays `/home/<spoon-username>`; only the passwd entry name is sanitized.
|
||||
- podman-only run flag: `--userns=keep-id:uid=1000,gid=1000`, override env `SPOON_AGENT_BOX_USERNS` (empty disables, other values pass through).
|
||||
- All commits run the repo pre-commit hooks; finish with full `bun run ci:check`.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: `box-user.ts` — sanitizer + script builders (worker, pure)
|
||||
|
||||
**Files:**
|
||||
|
||||
- Create: `apps/agent-worker/src/runtime/box-user.ts`
|
||||
- Test: `apps/agent-worker/tests/unit/box-user.test.ts`
|
||||
|
||||
**Interfaces (Produces):**
|
||||
|
||||
- `linuxUsername(spoonUsername: string): string`
|
||||
- `BOX_UID = 1000`
|
||||
- `buildBoxInitScript(args: { username: string; home: string }): string` — idempotent root script: useradd (uid 1000, `-d <home>`, bash), wheel, marker-guarded `chown -R` of home. Does NOT touch sudoers/password (those depend on stored state and are applied by `applySudoPolicy`).
|
||||
- `buildSudoPolicyScript(args: { username: string; passwordSet: boolean }): string` — writes or removes `/etc/sudoers.d/spoon-box` (mode 0440, validated with `visudo -c` before install).
|
||||
- `CHPASSWD_COMMAND: string[]` (`['chpasswd']`) and `buildClearPasswordCommand(username: string): string[]` (`['passwd', '-d', username]`).
|
||||
|
||||
- [ ] **Step 1: Write failing tests** covering: sanitizer (lowercase, invalid → `-`, digit-start prefixed `u`, >32 truncated, empty → `spoon`); init script contains `useradd` guarded by `id -u`, `-u 1000`, `-d /home/Gabriel` untouched-case home, wheel usermod, marker path `/home/Gabriel/.spoon/chown-v1` guard around `chown -R 1000:1000`; sudo script with `passwordSet:false` installs NOPASSWD via visudo-validated temp file, with `passwordSet:true` removes the drop-in.
|
||||
|
||||
```ts
|
||||
// apps/agent-worker/tests/unit/box-user.test.ts
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
buildBoxInitScript,
|
||||
buildClearPasswordCommand,
|
||||
buildSudoPolicyScript,
|
||||
linuxUsername,
|
||||
} from '../../src/runtime/box-user';
|
||||
|
||||
describe('linuxUsername', () => {
|
||||
it('lowercases and passes through simple names', () => {
|
||||
expect(linuxUsername('gabriel')).toBe('gabriel');
|
||||
expect(linuxUsername('Gabriel')).toBe('gabriel');
|
||||
});
|
||||
it('replaces invalid characters with dashes', () => {
|
||||
expect(linuxUsername('g@b r!el')).toBe('g-b-r-el');
|
||||
});
|
||||
it('prefixes names that do not start with [a-z_]', () => {
|
||||
expect(linuxUsername('1337')).toBe('u1337');
|
||||
expect(linuxUsername('-dash')).toBe('u-dash');
|
||||
});
|
||||
it('truncates to 32 chars and falls back to spoon', () => {
|
||||
expect(linuxUsername('a'.repeat(40))).toHaveLength(32);
|
||||
expect(linuxUsername('')).toBe('spoon');
|
||||
expect(linuxUsername('@@@')).toBe('u---');
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildBoxInitScript', () => {
|
||||
const script = buildBoxInitScript({
|
||||
username: 'gabriel',
|
||||
home: '/home/Gabriel',
|
||||
});
|
||||
it('creates the user idempotently with uid 1000 at the given home', () => {
|
||||
expect(script).toContain("id -u 'gabriel'");
|
||||
expect(script).toContain('useradd');
|
||||
expect(script).toContain('-u 1000');
|
||||
expect(script).toContain("-d '/home/Gabriel'");
|
||||
expect(script).toContain('-s /bin/bash');
|
||||
});
|
||||
it('adds wheel membership', () => {
|
||||
expect(script).toContain("usermod -aG wheel 'gabriel'");
|
||||
});
|
||||
it('chowns the home once, guarded by the marker', () => {
|
||||
expect(script).toContain('/home/Gabriel/.spoon/chown-v1');
|
||||
expect(script).toContain("chown -R 1000:1000 '/home/Gabriel'");
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildSudoPolicyScript', () => {
|
||||
it('installs a visudo-validated NOPASSWD drop-in when no password is set', () => {
|
||||
const script = buildSudoPolicyScript({
|
||||
username: 'gabriel',
|
||||
passwordSet: false,
|
||||
});
|
||||
expect(script).toContain('gabriel ALL=(ALL) NOPASSWD:ALL');
|
||||
expect(script).toContain('visudo -c');
|
||||
expect(script).toContain('/etc/sudoers.d/spoon-box');
|
||||
expect(script).toContain('chmod 0440');
|
||||
});
|
||||
it('removes the drop-in when a password is set', () => {
|
||||
const script = buildSudoPolicyScript({
|
||||
username: 'gabriel',
|
||||
passwordSet: true,
|
||||
});
|
||||
expect(script).toContain('rm -f /etc/sudoers.d/spoon-box');
|
||||
expect(script).not.toContain('NOPASSWD');
|
||||
});
|
||||
});
|
||||
|
||||
describe('password commands', () => {
|
||||
it('clears via passwd -d', () => {
|
||||
expect(buildClearPasswordCommand('gabriel')).toEqual([
|
||||
'passwd',
|
||||
'-d',
|
||||
'gabriel',
|
||||
]);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run to verify failure** — `cd apps/agent-worker && bun run test:unit -- box-user` → module not found.
|
||||
- [ ] **Step 3: Implement**
|
||||
|
||||
```ts
|
||||
// apps/agent-worker/src/runtime/box-user.ts
|
||||
// The per-box Linux user (Phase: box-user). Terminal sessions and agent turns
|
||||
// exec as this user instead of root; sudo is passwordless until the user
|
||||
// stores a password (then wheel membership enforces password-required sudo).
|
||||
|
||||
export const BOX_UID = 1000;
|
||||
|
||||
const shellQuote = (value: string) => `'${value.replaceAll("'", "'\\''")}'`;
|
||||
|
||||
// Linux-safe username derived from the Spoon username. The home path keeps
|
||||
// the Spoon username; only the passwd entry uses this.
|
||||
export const linuxUsername = (spoonUsername: string): string => {
|
||||
const cleaned = spoonUsername
|
||||
.toLowerCase()
|
||||
.replaceAll(/[^a-z0-9_-]/g, '-')
|
||||
.slice(0, 32);
|
||||
if (!cleaned) return 'spoon';
|
||||
return /^[a-z_]/.test(cleaned) ? cleaned : `u${cleaned}`.slice(0, 32);
|
||||
};
|
||||
|
||||
// Idempotent root init: create the user at the (already mounted) home, grant
|
||||
// wheel, and chown the home ONCE (marker-guarded — later host-side writes are
|
||||
// repaired by targeted chowns, so a big home is never re-walked).
|
||||
export const buildBoxInitScript = (args: {
|
||||
username: string;
|
||||
home: string;
|
||||
}): string => {
|
||||
const user = shellQuote(args.username);
|
||||
const home = shellQuote(args.home);
|
||||
const marker = shellQuote(`${args.home}/.spoon/chown-v1`);
|
||||
return [
|
||||
'set -e',
|
||||
`if ! id -u ${user} >/dev/null 2>&1; then`,
|
||||
` useradd -u 1000 -o -M -d ${home} -s /bin/bash ${user}`,
|
||||
'fi',
|
||||
`usermod -aG wheel ${user}`,
|
||||
`if [ ! -f ${marker} ]; then`,
|
||||
` chown -R 1000:1000 ${home}`,
|
||||
` mkdir -p "$(dirname ${marker})" && touch ${marker} && chown 1000:1000 ${marker}`,
|
||||
'fi',
|
||||
].join('\n');
|
||||
};
|
||||
|
||||
// Sudo policy: NOPASSWD drop-in only while no password is stored; with a
|
||||
// password, wheel membership makes sudo prompt for it. The drop-in is staged
|
||||
// to a temp file and checked with `visudo -c` before install so a bad write
|
||||
// can never brick sudo.
|
||||
export const buildSudoPolicyScript = (args: {
|
||||
username: string;
|
||||
passwordSet: boolean;
|
||||
}): string => {
|
||||
if (args.passwordSet) {
|
||||
return 'rm -f /etc/sudoers.d/spoon-box';
|
||||
}
|
||||
const line = `${args.username} ALL=(ALL) NOPASSWD:ALL`;
|
||||
return [
|
||||
'set -e',
|
||||
`printf '%s\\n' ${shellQuote(line)} > /etc/sudoers.d/spoon-box.tmp`,
|
||||
'visudo -c -f /etc/sudoers.d/spoon-box.tmp',
|
||||
'chmod 0440 /etc/sudoers.d/spoon-box.tmp',
|
||||
'mv /etc/sudoers.d/spoon-box.tmp /etc/sudoers.d/spoon-box',
|
||||
].join('\n');
|
||||
};
|
||||
|
||||
// `chpasswd` reads `user:password` from stdin, so the password never appears
|
||||
// in argv (visible in `ps`) or in a shell script body.
|
||||
export const CHPASSWD_COMMAND = ['chpasswd'];
|
||||
|
||||
export const buildClearPasswordCommand = (username: string): string[] => [
|
||||
'passwd',
|
||||
'-d',
|
||||
username,
|
||||
];
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run to verify pass** — `bun run test:unit -- box-user` → all pass.
|
||||
- [ ] **Step 5: Commit** — `feat(worker): box-user script builders + username sanitizer`
|
||||
|
||||
### Task 2: docker.ts plumbing — exec-as-user, stdin input, chown helper, box run flags
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `apps/agent-worker/src/runtime/docker.ts`
|
||||
- Test: `apps/agent-worker/tests/unit/docker-runtime.test.ts` (extend)
|
||||
|
||||
**Interfaces:**
|
||||
|
||||
- Consumes: `linuxUsername`, `BOX_UID`, `buildBoxInitScript`, `buildSudoPolicyScript`, `CHPASSWD_COMMAND`, `buildClearPasswordCommand` from Task 1.
|
||||
- Produces:
|
||||
- `streamExecInContainer` / `runExecInContainer` gain optional `user?: string` (adds `-u <user>` to the exec argv) and `runExecInContainer` gains optional `input?: string` (execa `input`, replacing `stdin: 'ignore'` when present).
|
||||
- `chownInBox(args: { containerName: string; paths: string[]; redact?: (v: string) => string }): Promise<void>` — best-effort `chown -R 1000:1000 <paths>` exec'd as root; logs (does not throw) on failure. No-op for empty `paths`.
|
||||
- `boxUsernsArgs(): string[]` — `['--userns=keep-id:uid=1000,gid=1000']` when runtime ends with `podman`; `SPOON_AGENT_BOX_USERNS` overrides (empty string → `[]`, other value → `['--userns=<value>']`). Exported for tests.
|
||||
- `ensureUserContainer` gains optional `onCreated?: (containerName: string) => Promise<void>` — invoked only when a fresh container was created (not when already running), AFTER `run` succeeds. Also adds `--hostname <linuxUsername(username)>-box` and `...boxUsernsArgs()` to the `run` argv.
|
||||
- New env in `apps/agent-worker/src/env.ts`: `boxUserns: process.env.SPOON_AGENT_BOX_USERNS` (raw, may be empty string — distinguish unset).
|
||||
|
||||
- [ ] **Step 1: Write failing tests** in `docker-runtime.test.ts` for `boxUsernsArgs` (podman default, docker default `[]`, override set, override empty) and for the exec arg builders if extracted pure (extract `execArgv(args)` helper returning the argv array so `-u` placement is unit-testable: `['exec', ...envFlags, '-w', cwd, ...(user ? ['-u', user] : []), name, ...command]`).
|
||||
- [ ] **Step 2: Run to verify failure.**
|
||||
- [ ] **Step 3: Implement** (extract `execArgv` used by both exec fns; add `input`/`user` params; `chownInBox` via `runExecInContainer` with `command: ['chown','-R','1000:1000',...paths]`, catches and `console.error`s; `boxUsernsArgs` reading `env.boxUserns`; wire `--hostname`/userns/`onCreated` into `ensureUserContainer`).
|
||||
- [ ] **Step 4: Run tests** — extended docker-runtime tests + whole unit suite pass.
|
||||
- [ ] **Step 5: Commit** — `feat(worker): exec-as-user, stdin input, chown + userns plumbing`
|
||||
|
||||
### Task 3: Convex — `boxSettings` table + node actions
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `packages/backend/convex/schema.ts`
|
||||
- Create: `packages/backend/convex/boxSettings.ts` (query + internal query/mutation)
|
||||
- Create: `packages/backend/convex/boxSettingsNode.ts` ('use node' actions)
|
||||
- Test: follow the existing backend test layout (`packages/backend/tests/…`, convex-test) mirroring the closest existing coverage of `aiSettingsNode`/`userDotfiles`.
|
||||
|
||||
**Interfaces (Produces):**
|
||||
|
||||
- Schema:
|
||||
|
||||
```ts
|
||||
boxSettings: defineTable({
|
||||
ownerId: v.id('users'),
|
||||
// Resolved Spoon home username at the time the password was set — the
|
||||
// worker looks the row up by this (usernames are not indexed anywhere else).
|
||||
username: v.string(),
|
||||
boxPasswordEncrypted: v.optional(v.string()),
|
||||
updatedAt: v.number(),
|
||||
})
|
||||
.index('by_owner', ['ownerId'])
|
||||
.index('by_username', ['username']),
|
||||
```
|
||||
|
||||
- `boxSettings.hasMine` — owner query → `{ hasPassword: boolean }`.
|
||||
- `boxSettings.getByUsernameInternal` / `boxSettings.upsertMineInternal` — internal plumbing for the node actions.
|
||||
- `boxSettingsNode.setBoxPassword` — authed action `{ password: string | null }`; validates 8–128 (null/'' clears), resolves the caller's username exactly like `userEnvironment.getMine` (reuse `deriveHomeUsername` from `model.ts`), stores `encryptSecret(password)` or clears the field.
|
||||
- `boxSettingsNode.getBoxPasswordForWorker` — action `{ workerToken: string; username: string }` → `{ password: string | null }`, gated by the same worker-token check used in `userDotfilesNode.getEnvironmentForJob`; decrypts with `decryptSecret`.
|
||||
|
||||
- [ ] **Step 1: Write failing backend tests** (validation bounds, clear semantics, worker-token gate rejects bad token, round-trip returns the plaintext).
|
||||
- [ ] **Step 2: Run to verify failure** (`cd packages/backend && bun run test` — use the package's existing test script name).
|
||||
- [ ] **Step 3: Implement schema + functions; run `bash scripts/convex-codegen`.**
|
||||
- [ ] **Step 4: Run tests + typecheck.**
|
||||
- [ ] **Step 5: Commit** — `feat(backend): boxSettings table + box password actions`
|
||||
|
||||
### Task 4: Worker — box init wiring (user creation, password at creation, exec-as-user everywhere)
|
||||
|
||||
**Files:**
|
||||
|
||||
- Create: `apps/agent-worker/src/box-settings.ts` (Convex fetch)
|
||||
- Modify: `apps/agent-worker/src/user-container.ts`, `apps/agent-worker/src/box.ts`, `apps/agent-worker/src/terminal.ts`, `apps/agent-worker/src/worker.ts`, `apps/agent-worker/src/user-environment.ts`, adapters (`runtime/claude-adapter.ts`, `runtime/codex-adapter.ts`, `runtime/opencode-adapter.ts`) and `runtime/agent-runtime.ts` arg types.
|
||||
- Test: extend `apps/agent-worker/tests/unit/user-container.test.ts`, adapter tests (assert `user` is forwarded to `execStream`), `terminal-resize.test.ts` untouched.
|
||||
|
||||
**Interfaces:**
|
||||
|
||||
- Consumes: everything from Tasks 1–3.
|
||||
- Produces:
|
||||
- `box-settings.ts`: `fetchBoxPassword(username: string): Promise<string | null>` — ConvexHttpClient action call to `api.boxSettingsNode.getBoxPasswordForWorker` with `env.workerToken`; returns null on any error (box must still start when Convex is down; log the error).
|
||||
- `initializeBoxUser(containerName: string, spoonUsername: string, home: string): Promise<void>` (in `box.ts` or a small `box-init.ts`): runs `buildBoxInitScript` as root, fetches password, applies `chpasswd` via stdin when set, then `buildSudoPolicyScript`. This is the `onCreated` hook passed to `ensureUserContainer` from BOTH call paths (`acquireUserBox` in `user-container.ts` and `startBox` in `box.ts`).
|
||||
- Terminal bridges pass `User: linuxUsername(username)` in the dockerode exec create (both `bridge` and `bridgeBox`).
|
||||
- `worker.ts` / adapters: exec arg objects gain `user: linuxUsername(claim/workspace username)`; `runProjectCommand`, agent turn `execStream` calls, and dotfiles `runExecInContainer` in `materializeUserHome` all pass it. `killBoxProcessesByMarker` stays root.
|
||||
- Chown call sites (all best-effort `chownInBox`): after `cloneRepository` (repo dir), after secrets env-file write in `worker.ts` (the `claim.job.envFilePath` target), after overlay-file writes in `materializeUserHome`, after `writeBoxFile` in `box.ts`, after `ensureBashProfile` when invoked with a running box (bridgeBox: chown `~/.bash_profile` right after acquire).
|
||||
|
||||
- [ ] **Step 1: Write failing tests** — user-container test asserting `onCreated` fires once per creation (mock `ensureUserContainer` invoking its hook); adapter test asserting `user` reaches `execStream` args.
|
||||
- [ ] **Step 2: Run to verify failure.**
|
||||
- [ ] **Step 3: Implement the wiring.**
|
||||
- [ ] **Step 4: Full worker unit suite + typecheck + lint pass.**
|
||||
- [ ] **Step 5: Commit** — `feat(worker): run terminals and agent turns as the box user`
|
||||
|
||||
### Task 5: Worker — `/box/set-password` route
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `apps/agent-worker/src/server.ts` (route regex + handler), `apps/agent-worker/src/box.ts` (`setBoxUserPassword`)
|
||||
- Test: extend `apps/agent-worker/tests/unit/box-route.test.ts` (regex accepts `set-password`).
|
||||
|
||||
**Interfaces:**
|
||||
|
||||
- Produces: `setBoxUserPassword(username: string, password: string | null): Promise<{ applied: boolean }>` — if box not running → `{ applied: false }`; else chpasswd/clear + sudo policy toggle → `{ applied: true }`. Route: `POST /box/set-password` body `{ password: string | null }`, 400 on invalid length, 200 `{ applied }`.
|
||||
|
||||
- [ ] Steps: failing route-regex test → implement → suite green → commit `feat(worker): live box password apply route`.
|
||||
|
||||
### Task 6: Next — proxy + API route
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `apps/next/src/lib/agent-worker-proxy.ts` (`proxyBox` action union + `'set-password'`)
|
||||
- Create: `apps/next/src/app/api/box/set-password/route.ts`
|
||||
|
||||
**Interfaces:**
|
||||
|
||||
- Produces: `POST /api/box/set-password` `{ password: string | null }` → `withBox` → validate 8–128/null → `fetchAction(api.boxSettingsNode.setBoxPassword, { password }, { token })` (Convex is source of truth, written first) → `proxyBox(username, 'set-password', { method: 'POST', body: JSON.stringify({ password }) })` → respond `{ hasPassword, applied }`; worker failure downgrades to `{ applied: false }` with 200 (spec: card shows "takes effect on restart").
|
||||
|
||||
- [ ] Steps: implement (route has no unit test — component test in Task 7 covers the card; the route mirrors existing `/api/box/*` one-screen handlers) → typecheck/lint → commit `feat(next): box password API`.
|
||||
|
||||
### Task 7: Next — Box user card on /machine
|
||||
|
||||
**Files:**
|
||||
|
||||
- Create: `apps/next/src/components/machine/box-user-card.tsx`
|
||||
- Modify: `apps/next/src/components/machine/machine-shell.tsx` (render the card)
|
||||
- Test: `apps/next/tests/component/box-user-card.test.tsx`
|
||||
|
||||
**Interfaces:**
|
||||
|
||||
- Consumes: `api.boxSettings.hasMine` (convex react query), `api.userEnvironment.getMine` (username for the `user@host` line), `POST /api/box/set-password`.
|
||||
- Behavior: shows `<username>@<username>-box`; "Password: set / not set"; form (password + confirm, min 8) with Save and — when set — a "Remove password" button; on save success where `applied === false`, shows the "takes effect next restart" note; explains the sudo policy in one sentence of muted copy.
|
||||
|
||||
- [ ] Steps: failing component test (renders not-set state; mismatch confirm blocks submit; renders applied-false warning) → implement card + wire into `machine-shell.tsx` → component suite green → commit `feat(next): box user card on /machine`.
|
||||
|
||||
### Task 8: Docs + end-to-end verification
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `docs/agent-terminal.md` (+ the box docs section touched in the docs rewrite) — box user, sudo policy, password flow, ownership model (keep-id vs chown), `SPOON_AGENT_BOX_USERNS`.
|
||||
|
||||
- [ ] **Step 1: Docs.**
|
||||
- [ ] **Step 2: Live verification (dev)** — recreate the box, then via the WS harness: `whoami` → username; `id -u` → 1000; `sudo -n true` → ok; `touch ~/x && ls -l` → user-owned; box file editor write → file user-editable in the terminal. Set a password via the UI (or curl the API route), then: `sudo -n true` fails, `chpasswd` took effect (`su - <user>` with the password succeeds); clear password → NOPASSWD sudo returns. Run one agent turn and confirm it executes as the user (`whoami` artifact or a file it creates is uid 1000).
|
||||
- [ ] **Step 3: `bun run ci:check` at repo root — all green.**
|
||||
- [ ] **Step 4: Commit docs; final review of the whole diff.**
|
||||
@@ -0,0 +1,166 @@
|
||||
# Box user: non-root terminal & agent execution with a user-set password
|
||||
|
||||
**Date:** 2026-07-12
|
||||
**Status:** Approved design, pending implementation
|
||||
|
||||
## Problem
|
||||
|
||||
Everything inside a user's box container (`spoon-box-<username>`) runs as root:
|
||||
the interactive terminal, agent turns (Claude Code / Codex / OpenCode), and
|
||||
dotfiles setup. This is wrong for three reasons:
|
||||
|
||||
1. Claude Code refuses to run certain operations as root, so agent turns hit
|
||||
artificial failures.
|
||||
2. Root-owned homes make every file mutation a foot-gun (a stray `rm` has no
|
||||
guardrails, tools written for normal users misbehave).
|
||||
3. The box is pitched as "your dev machine in Spoon" — a real machine has a
|
||||
named user, a home they own, sudo, and a password.
|
||||
|
||||
## Decisions (made with the user)
|
||||
|
||||
- **sudo policy:** passwordless until the user sets a password; once a
|
||||
password is stored, sudo requires it.
|
||||
- **Password UX:** a "Box user" card on the `/machine` page. Saving stores the
|
||||
password encrypted in Convex and, when the box is running, applies it live
|
||||
via `chpasswd` — no restart needed.
|
||||
- **Agent turns run as the user too**, not just the terminal (this was the
|
||||
original motivation).
|
||||
- The Linux username mirrors the Spoon username (sanitized, see below); the
|
||||
prompt reads `gabriel@gabriel-box`.
|
||||
|
||||
## Architecture
|
||||
|
||||
### The ownership problem (the one hard part)
|
||||
|
||||
The worker writes into box homes **from the host side**: dotfiles overlay
|
||||
files, `.bash_profile`, the box file editor (`writeBoxFile`), and job repo
|
||||
clones. Dev and prod differ in who that host writer is:
|
||||
|
||||
| | Dev (rootless podman) | Prod (rootful docker) |
|
||||
| ----------------- | ----------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------- |
|
||||
| Host writer | `gib` (host uid) | worker container, uid 0 |
|
||||
| Appears in box as | uid 0 (default mapping) | uid 0 |
|
||||
| Fix | `--userns=keep-id:uid=1000,gid=1000` on the box: the host user _is_ box uid 1000, so host writes are already user-owned | worker chowns to `1000:1000` after each host-side write |
|
||||
|
||||
The uniform mechanism: after every host-side write into a home, the worker
|
||||
runs `chown -R 1000:1000 <paths>` **inside the container as root**. Under
|
||||
dev's keep-id mapping this is a no-op (files are already uid 1000); under prod
|
||||
it repairs uid 0 ownership. One code path, no runtime branching at call sites.
|
||||
|
||||
The keep-id flag is added to the box `run` args only when the container
|
||||
runtime is podman, overridable via `SPOON_AGENT_BOX_USERNS` (empty string
|
||||
disables; any other value is passed through as `--userns=<value>`).
|
||||
|
||||
### Box init (worker, `ensureUserContainer`)
|
||||
|
||||
After the `run`, the worker execs an idempotent root init script:
|
||||
|
||||
1. `useradd` the sanitized username with uid/gid 1000, shell `/bin/bash`,
|
||||
home `-d /home/<spoon-username>` (no `-M` skeleton copy — the mounted home
|
||||
persists; `ensureBashProfile` already seeds login-shell wiring).
|
||||
2. `usermod -aG wheel <user>` (normal Fedora admin group; wheel requires a
|
||||
password for sudo, which is exactly the post-password behavior we want).
|
||||
3. Sudoers drop-in `/etc/sudoers.d/spoon-box`: present with
|
||||
`<user> ALL=(ALL) NOPASSWD:ALL` **only when no password is stored**;
|
||||
absent otherwise (wheel membership then enforces password-required sudo).
|
||||
Written via `visudo -c` validation; `chmod 0440`.
|
||||
4. If a password is stored: apply via `chpasswd` **through stdin** (never
|
||||
argv — argv is visible in `ps`).
|
||||
5. One-time home ownership migration: if `~/.spoon/chown-v1` marker is
|
||||
absent, `chown -R 1000:1000 /home/<username>` and write the marker. Guards
|
||||
against re-walking a large home on every box recreation; later host-side
|
||||
writes are covered by the per-write chown above.
|
||||
6. `--hostname <linux-username>-box` on the `run` for a normal-machine prompt.
|
||||
|
||||
Username sanitization (`linuxUsername()` helper, unit-tested): lowercase,
|
||||
invalid chars → `-`, must start `[a-z_]` (prefix `u` otherwise), truncated to
|
||||
32 chars, fallback `spoon`. The **home path stays keyed by the Spoon
|
||||
username** (everything already depends on it); only the passwd entry uses the
|
||||
sanitized name, with `-d` pointing at the existing home.
|
||||
|
||||
Init failure fails the acquire; the terminal bridge already logs it and closes
|
||||
the WebSocket with a readable reason, and agent turns already surface acquire
|
||||
errors.
|
||||
|
||||
### Execution as the user
|
||||
|
||||
- Terminal bridge (`terminal.ts`): both job and box execs get
|
||||
`User: <linux-username>`.
|
||||
- Agent turns: `streamExecInContainer` / `runExecInContainer` /
|
||||
`buildMarkedCommand` call sites pass `-u <linux-username>`.
|
||||
- Root remains only where needed: box init, post-write chowns, and
|
||||
`killBoxProcessesByMarker` (root can signal user processes).
|
||||
- Job secrets files (0600, host-written) are included in post-write chown so
|
||||
agents can still read them.
|
||||
|
||||
### Password storage (backend, Convex)
|
||||
|
||||
- New user-keyed `boxSettings` table (one row per user; do NOT piggyback on
|
||||
the dotfiles/user-environment record — the password must exist independently
|
||||
of whether dotfiles are configured) with
|
||||
`boxPasswordEncrypted?: string`, encrypted with the existing
|
||||
`secretCrypto.ts` AES-256-GCM helpers.
|
||||
- Owner-authed mutation `setBoxPassword` (min 8 / max 128 chars; empty clears
|
||||
the password and reverts sudo to NOPASSWD on next apply).
|
||||
- Worker-token-authed Node action returns the decrypted password for box
|
||||
init, mirroring `getEnvironmentForJob`.
|
||||
- A `hasBoxPassword` owner query for the UI. The password itself is
|
||||
write-only: no query ever returns the plaintext to a browser.
|
||||
|
||||
### Live apply (worker route + Next proxy)
|
||||
|
||||
- New worker route `POST /box/set-password` (same HMAC internal-token auth as
|
||||
the other `/box/*` routes), body `{ password: string | null }`. If the box
|
||||
is running: `chpasswd` via stdin (or `passwd -d` + NOPASSWD drop-in
|
||||
restore when clearing), and toggle the sudoers drop-in to match. If not
|
||||
running: no-op success (init applies it at next creation).
|
||||
- Next API route (mirrors the existing `/box/*` proxy pattern): verifies the
|
||||
session, writes Convex first, then calls the worker route so a running box
|
||||
updates live.
|
||||
|
||||
### UI (`/machine` page)
|
||||
|
||||
A "Box user" card: shows `user@host` identity, whether a password is set, and
|
||||
a set/change/clear password form (two fields: new password + confirm;
|
||||
write-only, never displays the current value). Save → Next API → Convex +
|
||||
live apply → toast. Copy explains the sudo behavior ("no password: sudo works
|
||||
without one; with a password: sudo prompts for it").
|
||||
|
||||
## Error handling
|
||||
|
||||
- Init script errors → acquire failure → visible terminal close reason /
|
||||
agent turn error (infrastructure added earlier today).
|
||||
- `chpasswd`/sudoers failure on live apply → worker route 500 with stderr
|
||||
text → surfaced in the card.
|
||||
- Convex write succeeds but live apply fails → card shows a warning that the
|
||||
password takes effect on next box restart (state is still consistent:
|
||||
init re-applies from Convex).
|
||||
|
||||
## Testing
|
||||
|
||||
- **Unit (worker):** `linuxUsername()` sanitizer; init-script builder
|
||||
(user/wheel/sudoers/chpasswd-presence permutations); sudoers content for
|
||||
password vs no-password; exec call sites pass `-u`/`User`.
|
||||
- **Unit (backend):** setBoxPassword validation, encryption round-trip,
|
||||
worker-token gate on the decrypt action.
|
||||
- **Component (next):** Box user card renders states (no password / password
|
||||
set / apply-failed warning).
|
||||
- **End-to-end (manual, WS harness from today):** `whoami` → username;
|
||||
`id -u` → 1000; `sudo -n true` succeeds before password; after setting a
|
||||
password `sudo -n true` fails and `sudo true` prompts; agent turn creates a
|
||||
file owned by the user; dotfiles + box file editor writes remain
|
||||
user-editable in both dev and prod runtimes.
|
||||
|
||||
## Migration
|
||||
|
||||
Existing boxes pick everything up on their next recreation (idle reap or
|
||||
`/machine` Restart) — no data migration. The one-time home chown handles
|
||||
files root already created. Docs (`docs/agent-terminal.md`, box docs) get a
|
||||
section on the box user + password.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- SSH access to the box.
|
||||
- Multiple users / teams per box.
|
||||
- Password complexity policies beyond length bounds.
|
||||
- Changing the home path layout.
|
||||
@@ -0,0 +1,30 @@
|
||||
// Pure validation for the box user's password, shared by the Node action and
|
||||
// unit tests. Kept free of Convex imports so it tests without the harness.
|
||||
|
||||
export type BoxPasswordValidation =
|
||||
| { ok: true; value: string | null }
|
||||
| { ok: false; error: string };
|
||||
|
||||
// null/'' is an explicit clear (sudo reverts to NOPASSWD). The applied value
|
||||
// travels to `chpasswd` as a `user:password` stdin line, so control characters
|
||||
// (including newline/tab) are rejected; colons are fine — chpasswd splits on
|
||||
// the first colon only.
|
||||
export const normalizeBoxPassword = (
|
||||
password: string | null | undefined,
|
||||
): BoxPasswordValidation => {
|
||||
if (password == null || password === '') return { ok: true, value: null };
|
||||
if (password.length < 8) {
|
||||
return { ok: false, error: 'Password must be at least 8 characters.' };
|
||||
}
|
||||
if (password.length > 128) {
|
||||
return { ok: false, error: 'Password must be at most 128 characters.' };
|
||||
}
|
||||
// eslint-disable-next-line no-control-regex -- rejecting control chars is the point
|
||||
if (/[\u0000-\u001f\u007f]/.test(password)) {
|
||||
return {
|
||||
ok: false,
|
||||
error: 'Password must not contain control characters.',
|
||||
};
|
||||
}
|
||||
return { ok: true, value: password };
|
||||
};
|
||||
@@ -0,0 +1,77 @@
|
||||
import { v } from 'convex/values';
|
||||
|
||||
import { internalMutation, internalQuery, query } from './_generated/server';
|
||||
import { deriveHomeUsername, getRequiredUserId } from './model';
|
||||
|
||||
// Per-user box account settings. The password itself is write-only from the
|
||||
// client's perspective: browsers only ever learn whether one is set; the
|
||||
// plaintext flows exclusively to the worker via boxSettingsNode.
|
||||
|
||||
export const hasMine = query({
|
||||
args: {},
|
||||
handler: async (ctx) => {
|
||||
const ownerId = await getRequiredUserId(ctx);
|
||||
const row = await ctx.db
|
||||
.query('boxSettings')
|
||||
.withIndex('by_owner', (q) => q.eq('ownerId', ownerId))
|
||||
.unique();
|
||||
return { hasPassword: Boolean(row?.boxPasswordEncrypted) };
|
||||
},
|
||||
});
|
||||
|
||||
// Resolve the authed caller's owner id + home username (same derivation as
|
||||
// userEnvironment.getMine) for the Node action, which cannot touch ctx.db.
|
||||
export const resolveOwnerInternal = internalQuery({
|
||||
args: {},
|
||||
handler: async (ctx) => {
|
||||
const ownerId = await getRequiredUserId(ctx);
|
||||
const [user, settings] = await Promise.all([
|
||||
ctx.db.get(ownerId),
|
||||
ctx.db
|
||||
.query('userEnvironment')
|
||||
.withIndex('by_owner', (q) => q.eq('ownerId', ownerId))
|
||||
.unique(),
|
||||
]);
|
||||
return {
|
||||
ownerId,
|
||||
username: settings?.homeUsername ?? deriveHomeUsername(user?.name),
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
// Worker-facing lookup at box creation. `.first()` (not `.unique()`): derived
|
||||
// usernames are not guaranteed globally unique, and a throw here would brick
|
||||
// box creation for both colliding users.
|
||||
export const getByUsernameInternal = internalQuery({
|
||||
args: { username: v.string() },
|
||||
handler: async (ctx, args) =>
|
||||
await ctx.db
|
||||
.query('boxSettings')
|
||||
.withIndex('by_username', (q) => q.eq('username', args.username))
|
||||
.first(),
|
||||
});
|
||||
|
||||
export const upsertMineInternal = internalMutation({
|
||||
args: {
|
||||
ownerId: v.id('users'),
|
||||
username: v.string(),
|
||||
boxPasswordEncrypted: v.optional(v.string()),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const existing = await ctx.db
|
||||
.query('boxSettings')
|
||||
.withIndex('by_owner', (q) => q.eq('ownerId', args.ownerId))
|
||||
.unique();
|
||||
const patch = {
|
||||
username: args.username,
|
||||
// Explicit undefined clears the field on patch (password removed).
|
||||
boxPasswordEncrypted: args.boxPasswordEncrypted,
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
if (existing) {
|
||||
await ctx.db.patch(existing._id, patch);
|
||||
} else {
|
||||
await ctx.db.insert('boxSettings', { ownerId: args.ownerId, ...patch });
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
'use node';
|
||||
|
||||
import { ConvexError, v } from 'convex/values';
|
||||
|
||||
import { internal } from './_generated/api';
|
||||
import { action } from './_generated/server';
|
||||
import { normalizeBoxPassword } from './boxPassword';
|
||||
import { decryptSecret, encryptSecret } from './secretCrypto';
|
||||
import { assertWorkerToken } from './workerAuth';
|
||||
|
||||
/** Authed: store (or clear, with null/'') the caller's box password. */
|
||||
export const setBoxPassword = action({
|
||||
args: { password: v.union(v.string(), v.null()) },
|
||||
handler: async (ctx, args): Promise<{ hasPassword: boolean }> => {
|
||||
const normalized = normalizeBoxPassword(args.password);
|
||||
if (!normalized.ok) throw new ConvexError(normalized.error);
|
||||
const { ownerId, username } = await ctx.runQuery(
|
||||
internal.boxSettings.resolveOwnerInternal,
|
||||
{},
|
||||
);
|
||||
await ctx.runMutation(internal.boxSettings.upsertMineInternal, {
|
||||
ownerId,
|
||||
username,
|
||||
boxPasswordEncrypted:
|
||||
normalized.value == null ? undefined : encryptSecret(normalized.value),
|
||||
});
|
||||
return { hasPassword: normalized.value != null };
|
||||
},
|
||||
});
|
||||
|
||||
/** Worker-facing: the stored password (decrypted) for a box username. */
|
||||
export const getBoxPasswordForWorker = action({
|
||||
args: { workerToken: v.string(), username: v.string() },
|
||||
handler: async (ctx, args): Promise<{ password: string | null }> => {
|
||||
assertWorkerToken(args.workerToken);
|
||||
const row = await ctx.runQuery(internal.boxSettings.getByUsernameInternal, {
|
||||
username: args.username,
|
||||
});
|
||||
return {
|
||||
password: row?.boxPasswordEncrypted
|
||||
? decryptSecret(row.boxPasswordEncrypted)
|
||||
: null,
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -376,6 +376,18 @@ const applicationTables = {
|
||||
setupCommand: v.optional(v.string()),
|
||||
updatedAt: v.number(),
|
||||
}).index('by_owner', ['ownerId']),
|
||||
// Per-user box account settings (the Linux user inside spoon-box-<username>).
|
||||
boxSettings: defineTable({
|
||||
ownerId: v.id('users'),
|
||||
// Resolved Spoon home username at the time the row was written — the
|
||||
// worker looks the password up by this at box creation (usernames are not
|
||||
// indexed anywhere else).
|
||||
username: v.string(),
|
||||
boxPasswordEncrypted: v.optional(v.string()),
|
||||
updatedAt: v.number(),
|
||||
})
|
||||
.index('by_owner', ['ownerId'])
|
||||
.index('by_username', ['username']),
|
||||
aiProviderProfiles: defineTable({
|
||||
ownerId: v.id('users'),
|
||||
name: v.string(),
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { normalizeBoxPassword } from '../../convex/boxPassword';
|
||||
|
||||
describe('normalizeBoxPassword', () => {
|
||||
it('treats null/empty as an explicit clear', () => {
|
||||
expect(normalizeBoxPassword(null)).toEqual({ ok: true, value: null });
|
||||
expect(normalizeBoxPassword(undefined)).toEqual({ ok: true, value: null });
|
||||
expect(normalizeBoxPassword('')).toEqual({ ok: true, value: null });
|
||||
});
|
||||
|
||||
it('enforces the 8..128 length bounds', () => {
|
||||
expect(normalizeBoxPassword('short')).toMatchObject({ ok: false });
|
||||
expect(normalizeBoxPassword('a'.repeat(7))).toMatchObject({ ok: false });
|
||||
expect(normalizeBoxPassword('a'.repeat(8))).toEqual({
|
||||
ok: true,
|
||||
value: 'a'.repeat(8),
|
||||
});
|
||||
expect(normalizeBoxPassword('a'.repeat(128))).toMatchObject({ ok: true });
|
||||
expect(normalizeBoxPassword('a'.repeat(129))).toMatchObject({ ok: false });
|
||||
});
|
||||
|
||||
it('rejects control characters that would corrupt the chpasswd stdin line', () => {
|
||||
expect(normalizeBoxPassword('has\nnewline1')).toMatchObject({ ok: false });
|
||||
expect(normalizeBoxPassword('has\ttab-char1')).toMatchObject({ ok: false });
|
||||
});
|
||||
|
||||
it('allows colons and spaces (chpasswd splits on the first colon only)', () => {
|
||||
expect(normalizeBoxPassword('pa:ss wo:rd')).toEqual({
|
||||
ok: true,
|
||||
value: 'pa:ss wo:rd',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -41,6 +41,7 @@
|
||||
"SPOON_AGENT_TERMINAL_SECRET",
|
||||
"SPOON_AGENT_TERMINAL_IDLE_MS",
|
||||
"SPOON_AGENT_BOX_IDLE_MS",
|
||||
"SPOON_AGENT_BOX_USERNS",
|
||||
"SPOON_AGENT_DEV_WATCHDOG",
|
||||
"SPOON_AGENT_RUNTIME",
|
||||
"SPOON_AGENT_CONTAINER_RUNTIME",
|
||||
|
||||
Reference in New Issue
Block a user