Merge feat/box-user: non-root box user + settable password
This commit is contained in:
@@ -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 path from 'node:path';
|
||||||
|
|
||||||
|
import { applyBoxPassword, initializeBoxUser } from './box-init';
|
||||||
import { env } from './env';
|
import { env } from './env';
|
||||||
import { assertContainedRealPath } from './path-containment';
|
import { assertContainedRealPath } from './path-containment';
|
||||||
import {
|
import {
|
||||||
|
chownInBox,
|
||||||
ensureUserContainer,
|
ensureUserContainer,
|
||||||
inspectUserBoxStatus,
|
inspectUserBoxStatus,
|
||||||
stopWorkspaceContainer,
|
stopWorkspaceContainer,
|
||||||
@@ -41,7 +43,25 @@ export const getBoxStatus = async (username: string): Promise<BoxStatus> => {
|
|||||||
|
|
||||||
export const startBox = async (username: string): Promise<void> => {
|
export const startBox = async (username: string): Promise<void> => {
|
||||||
const { homeDir, containerHome } = boxHomePaths(username);
|
const { homeDir, containerHome } = boxHomePaths(username);
|
||||||
await ensureUserContainer({ username, workdir: homeDir, containerHome });
|
await ensureUserContainer({
|
||||||
|
username,
|
||||||
|
workdir: homeDir,
|
||||||
|
containerHome,
|
||||||
|
onCreated: (name) => initializeBoxUser(name, username, containerHome),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Live password apply for the /box/set-password route: only touches the
|
||||||
|
// container when it's running — otherwise the stored password is picked up by
|
||||||
|
// the next creation's init.
|
||||||
|
export const setBoxUserPassword = async (
|
||||||
|
username: string,
|
||||||
|
password: string | null,
|
||||||
|
): Promise<{ applied: boolean }> => {
|
||||||
|
const status = await inspectUserBoxStatus(username);
|
||||||
|
if (!status.running) return { applied: false };
|
||||||
|
await applyBoxPassword(userContainerName(username), username, password);
|
||||||
|
return { applied: true };
|
||||||
};
|
};
|
||||||
|
|
||||||
export const stopBox = async (username: string): Promise<void> => {
|
export const stopBox = async (username: string): Promise<void> => {
|
||||||
@@ -145,9 +165,17 @@ export const writeBoxFile = async (
|
|||||||
relPath: string,
|
relPath: string,
|
||||||
content: string,
|
content: string,
|
||||||
): Promise<{ success: true }> => {
|
): Promise<{ success: true }> => {
|
||||||
const { homeDir } = boxHomePaths(username);
|
const { homeDir, containerHome } = boxHomePaths(username);
|
||||||
const target = await safeHomePath(homeDir, relPath, { forWrite: true });
|
const target = await safeHomePath(homeDir, relPath, { forWrite: true });
|
||||||
await mkdir(path.dirname(target), { recursive: true });
|
await mkdir(path.dirname(target), { recursive: true });
|
||||||
await writeFile(target, content);
|
await writeFile(target, content);
|
||||||
|
// Host-side write: repair ownership so the box user can edit the file in a
|
||||||
|
// shell too (no-op under dev keep-id; best-effort when the box is stopped —
|
||||||
|
// the next creation's marker chown won't rerun, but a later editor write or
|
||||||
|
// manual sudo chown recovers, and read access is unaffected).
|
||||||
|
await chownInBox({
|
||||||
|
containerName: userContainerName(username),
|
||||||
|
paths: [path.posix.join(containerHome, relPath)],
|
||||||
|
});
|
||||||
return { success: true };
|
return { success: true };
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -37,6 +37,10 @@ export const env = {
|
|||||||
'',
|
'',
|
||||||
// How long a per-user box container survives with no active jobs/terminals.
|
// How long a per-user box container survives with no active jobs/terminals.
|
||||||
boxIdleMs: intEnv('SPOON_AGENT_BOX_IDLE_MS', 1_800_000),
|
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
|
// 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.
|
// and holds port 3921 across restarts. Set by scripts/dev-agent-worker.
|
||||||
devWatchdog: process.env.SPOON_AGENT_DEV_WATCHDOG === '1',
|
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 { randomUUID } from 'node:crypto';
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
import type { NormalizedAgentEvent } from '../agent-events';
|
import type { NormalizedAgentEvent } from '../agent-events';
|
||||||
|
import type { AdapterFactory, TurnResult } from './agent-runtime';
|
||||||
|
import type { AdapterWorkspace } from './provider';
|
||||||
import { normalizeClaudeJsonLine } from '../agent-events';
|
import { normalizeClaudeJsonLine } from '../agent-events';
|
||||||
import { env } from '../env';
|
import { env } from '../env';
|
||||||
import type { AdapterFactory, TurnResult } from './agent-runtime';
|
|
||||||
import { writeJsonFile } from './auth';
|
import { writeJsonFile } from './auth';
|
||||||
|
import { linuxUsername } from './box-user';
|
||||||
import {
|
import {
|
||||||
buildMarkedCommand,
|
buildMarkedCommand,
|
||||||
killBoxProcessesByMarker,
|
killBoxProcessesByMarker,
|
||||||
streamExecInContainer,
|
streamExecInContainer,
|
||||||
} from './docker';
|
} from './docker';
|
||||||
import type { AdapterWorkspace } from './provider';
|
|
||||||
import { claudeEnv, claudeModel, isClaudeOAuthProfile } from './provider';
|
import { claudeEnv, claudeModel, isClaudeOAuthProfile } from './provider';
|
||||||
|
|
||||||
export const createClaudeAdapter: AdapterFactory = (deps) => {
|
export const createClaudeAdapter: AdapterFactory = (deps) => {
|
||||||
@@ -23,7 +24,9 @@ export const createClaudeAdapter: AdapterFactory = (deps) => {
|
|||||||
if (!isClaudeOAuthProfile(workspace.claim)) return;
|
if (!isClaudeOAuthProfile(workspace.claim)) return;
|
||||||
const secret = workspace.claim.aiProviderProfile?.secret;
|
const secret = workspace.claim.aiProviderProfile?.secret;
|
||||||
if (!secret) {
|
if (!secret) {
|
||||||
throw new Error('Claude auth profile is missing credentials.json contents.');
|
throw new Error(
|
||||||
|
'Claude auth profile is missing credentials.json contents.',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
const credentialsPath = path.join(
|
const credentialsPath = path.join(
|
||||||
workspace.homeDir,
|
workspace.homeDir,
|
||||||
@@ -84,6 +87,7 @@ export const createClaudeAdapter: AdapterFactory = (deps) => {
|
|||||||
try {
|
try {
|
||||||
result = await execStream({
|
result = await execStream({
|
||||||
containerName: workspace.boxName,
|
containerName: workspace.boxName,
|
||||||
|
user: linuxUsername(workspace.username),
|
||||||
containerCwd: workspace.containerRepo,
|
containerCwd: workspace.containerRepo,
|
||||||
command,
|
command,
|
||||||
environment: { ...aiEnv, ...secretEnv },
|
environment: { ...aiEnv, ...secretEnv },
|
||||||
|
|||||||
@@ -1,18 +1,19 @@
|
|||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { randomUUID } from 'node:crypto';
|
|
||||||
|
|
||||||
import type { NormalizedAgentEvent } from '../agent-events';
|
import type { NormalizedAgentEvent } from '../agent-events';
|
||||||
|
import type { AdapterFactory, TurnResult } from './agent-runtime';
|
||||||
|
import type { AdapterWorkspace } from './provider';
|
||||||
import { normalizeCodexJsonLine } from '../agent-events';
|
import { normalizeCodexJsonLine } from '../agent-events';
|
||||||
import { prepareCodexWorkspaceFiles } from '../codex-runtime';
|
import { prepareCodexWorkspaceFiles } from '../codex-runtime';
|
||||||
import { env } from '../env';
|
import { env } from '../env';
|
||||||
import type { AdapterFactory, TurnResult } from './agent-runtime';
|
import { linuxUsername } from './box-user';
|
||||||
import {
|
import {
|
||||||
buildMarkedCommand,
|
buildMarkedCommand,
|
||||||
killBoxProcessesByMarker,
|
killBoxProcessesByMarker,
|
||||||
streamExecInContainer,
|
streamExecInContainer,
|
||||||
} from './docker';
|
} from './docker';
|
||||||
import type { AdapterWorkspace } from './provider';
|
|
||||||
import {
|
import {
|
||||||
codexModelArgs,
|
codexModelArgs,
|
||||||
isCodexLoginProfile,
|
isCodexLoginProfile,
|
||||||
@@ -142,6 +143,7 @@ export const createCodexAdapter: AdapterFactory = (deps) => {
|
|||||||
try {
|
try {
|
||||||
result = await execStream({
|
result = await execStream({
|
||||||
containerName: workspace.boxName,
|
containerName: workspace.boxName,
|
||||||
|
user: linuxUsername(workspace.username),
|
||||||
containerCwd: workspace.containerRepo,
|
containerCwd: workspace.containerRepo,
|
||||||
command,
|
command,
|
||||||
environment: { ...aiEnv, ...secretEnv },
|
environment: { ...aiEnv, ...secretEnv },
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import Docker from 'dockerode';
|
|||||||
import { execa } from 'execa';
|
import { execa } from 'execa';
|
||||||
|
|
||||||
import { env } from '../env';
|
import { env } from '../env';
|
||||||
|
import { BOX_UID, linuxUsername } from './box-user';
|
||||||
|
|
||||||
type CommandResult = {
|
type CommandResult = {
|
||||||
exitCode: number;
|
exitCode: number;
|
||||||
@@ -26,6 +27,21 @@ const environmentArgs = (environment: Record<string, string>) =>
|
|||||||
|
|
||||||
const networkArgs = () => (env.network ? ['--network', env.network] : []);
|
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;
|
const containerRuntime = () => env.containerRuntime;
|
||||||
|
|
||||||
// `docker run` reuses a stale local `:latest` forever, so without an explicit
|
// `docker run` reuses a stale local `:latest` forever, so without an explicit
|
||||||
@@ -173,6 +189,10 @@ export const ensureUserContainer = async (args: {
|
|||||||
username: string;
|
username: string;
|
||||||
workdir: string;
|
workdir: string;
|
||||||
containerHome: 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> => {
|
}): Promise<string> => {
|
||||||
await ensureJobImagePulled();
|
await ensureJobImagePulled();
|
||||||
const name = userContainerName(args.username);
|
const name = userContainerName(args.username);
|
||||||
@@ -196,10 +216,13 @@ export const ensureUserContainer = async (args: {
|
|||||||
'--init',
|
'--init',
|
||||||
'--name',
|
'--name',
|
||||||
name,
|
name,
|
||||||
|
'--hostname',
|
||||||
|
`${linuxUsername(args.username)}-box`,
|
||||||
'--memory',
|
'--memory',
|
||||||
'4g',
|
'4g',
|
||||||
'--cpus',
|
'--cpus',
|
||||||
'2',
|
'2',
|
||||||
|
...boxUsernsArgs(),
|
||||||
...networkArgs(),
|
...networkArgs(),
|
||||||
'-v',
|
'-v',
|
||||||
jobWorkspaceVolumeSpec(args.workdir, args.containerHome),
|
jobWorkspaceVolumeSpec(args.workdir, args.containerHome),
|
||||||
@@ -211,6 +234,7 @@ export const ensureUserContainer = async (args: {
|
|||||||
],
|
],
|
||||||
{ stdin: 'ignore' },
|
{ stdin: 'ignore' },
|
||||||
);
|
);
|
||||||
|
if (args.onCreated) await args.onCreated(name);
|
||||||
return name;
|
return name;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -236,7 +260,12 @@ export const inspectUserBoxStatus = async (
|
|||||||
{ reject: false, stdin: 'ignore' },
|
{ reject: false, stdin: 'ignore' },
|
||||||
);
|
);
|
||||||
if (result.exitCode !== 0) {
|
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
|
const [runningField, imageField, startedAtField, memoryField] = result.stdout
|
||||||
.trim()
|
.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: {
|
export const streamExecInContainer = async (args: {
|
||||||
containerName: string;
|
containerName: string;
|
||||||
command: string[];
|
command: string[];
|
||||||
@@ -342,21 +390,16 @@ export const streamExecInContainer = async (args: {
|
|||||||
containerCwd: string;
|
containerCwd: string;
|
||||||
redact: (value: string) => string;
|
redact: (value: string) => string;
|
||||||
timeoutMs: number;
|
timeoutMs: number;
|
||||||
|
user?: string;
|
||||||
onStdoutLine?: (line: string) => Promise<void>;
|
onStdoutLine?: (line: string) => Promise<void>;
|
||||||
onStderrLine?: (line: string) => Promise<void>;
|
onStderrLine?: (line: string) => Promise<void>;
|
||||||
}): Promise<CommandResult> => {
|
}): Promise<CommandResult> => {
|
||||||
const subprocess = execa(
|
const subprocess = execa(containerRuntime(), execArgv(args), {
|
||||||
containerRuntime(),
|
all: true,
|
||||||
[
|
reject: false,
|
||||||
'exec',
|
stdin: 'ignore',
|
||||||
...environmentArgs(args.environment),
|
timeout: args.timeoutMs,
|
||||||
'-w',
|
});
|
||||||
args.containerCwd,
|
|
||||||
args.containerName,
|
|
||||||
...args.command,
|
|
||||||
],
|
|
||||||
{ all: true, reject: false, stdin: 'ignore', timeout: args.timeoutMs },
|
|
||||||
);
|
|
||||||
return streamSubprocess(
|
return streamSubprocess(
|
||||||
subprocess,
|
subprocess,
|
||||||
args.redact,
|
args.redact,
|
||||||
@@ -372,20 +415,54 @@ export const runExecInContainer = async (args: {
|
|||||||
containerCwd: string;
|
containerCwd: string;
|
||||||
redact: (value: string) => string;
|
redact: (value: string) => string;
|
||||||
timeoutMs: number;
|
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> => {
|
}): 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(
|
const result = await execa(
|
||||||
containerRuntime(),
|
containerRuntime(),
|
||||||
[
|
[
|
||||||
'exec',
|
'exec',
|
||||||
...environmentArgs(args.environment),
|
// Explicit root: under podman keep-id the container default user is the
|
||||||
'-w',
|
// mapped uid, which cannot chown files it does not own.
|
||||||
args.containerCwd,
|
'-u',
|
||||||
|
'root',
|
||||||
args.containerName,
|
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) => {
|
export const stopWorkspaceContainer = async (containerName: string) => {
|
||||||
|
|||||||
@@ -1,17 +1,18 @@
|
|||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
import { mkdir, writeFile } from 'node:fs/promises';
|
import { mkdir, writeFile } from 'node:fs/promises';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { randomUUID } from 'node:crypto';
|
|
||||||
|
|
||||||
import type { NormalizedAgentEvent } from '../agent-events';
|
import type { NormalizedAgentEvent } from '../agent-events';
|
||||||
|
import type { AdapterFactory, TurnResult } from './agent-runtime';
|
||||||
|
import type { AdapterWorkspace } from './provider';
|
||||||
import { normalizeOpenCodeRunLine } from '../agent-events';
|
import { normalizeOpenCodeRunLine } from '../agent-events';
|
||||||
import { env } from '../env';
|
import { env } from '../env';
|
||||||
import type { AdapterFactory, TurnResult } from './agent-runtime';
|
import { linuxUsername } from './box-user';
|
||||||
import {
|
import {
|
||||||
buildMarkedCommand,
|
buildMarkedCommand,
|
||||||
killBoxProcessesByMarker,
|
killBoxProcessesByMarker,
|
||||||
streamExecInContainer,
|
streamExecInContainer,
|
||||||
} from './docker';
|
} from './docker';
|
||||||
import type { AdapterWorkspace } from './provider';
|
|
||||||
import { opencodeModel, providerEnvironment } from './provider';
|
import { opencodeModel, providerEnvironment } from './provider';
|
||||||
|
|
||||||
// Normalize + pretty-print an auth JSON blob before writing it with owner-only
|
// Normalize + pretty-print an auth JSON blob before writing it with owner-only
|
||||||
@@ -97,6 +98,7 @@ export const createOpenCodeAdapter: AdapterFactory = (deps) => {
|
|||||||
try {
|
try {
|
||||||
result = await execStream({
|
result = await execStream({
|
||||||
containerName: workspace.boxName,
|
containerName: workspace.boxName,
|
||||||
|
user: linuxUsername(workspace.username),
|
||||||
containerCwd: workspace.containerRepo,
|
containerCwd: workspace.containerRepo,
|
||||||
command,
|
command,
|
||||||
environment: { ...aiEnv, ...secretEnv },
|
environment: { ...aiEnv, ...secretEnv },
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
listBoxTree,
|
listBoxTree,
|
||||||
readBoxFile,
|
readBoxFile,
|
||||||
restartBox,
|
restartBox,
|
||||||
|
setBoxUserPassword,
|
||||||
startBox,
|
startBox,
|
||||||
stopBox,
|
stopBox,
|
||||||
writeBoxFile,
|
writeBoxFile,
|
||||||
@@ -66,7 +67,9 @@ const jobRoute = (pathname: string) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const boxRoute = (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;
|
return match?.[1] ? { action: match[1] } : null;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -136,6 +139,25 @@ export const startWorkerServer = () => {
|
|||||||
);
|
);
|
||||||
return;
|
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' });
|
sendJson(response, 404, { error: 'Not found' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
import type { Server } from 'node:http';
|
import type { Server } from 'node:http';
|
||||||
import type { Duplex } from 'node:stream';
|
import type { Duplex } from 'node:stream';
|
||||||
import type { WebSocket } from 'ws';
|
import type { WebSocket } from 'ws';
|
||||||
@@ -6,7 +7,12 @@ import { WebSocketServer } from 'ws';
|
|||||||
import type { BoxHandle } from './user-container';
|
import type { BoxHandle } from './user-container';
|
||||||
import { boxHomePaths } from './box';
|
import { boxHomePaths } from './box';
|
||||||
import { env } from './env';
|
import { env } from './env';
|
||||||
import { getDockerClient } from './runtime/docker';
|
import { linuxUsername } from './runtime/box-user';
|
||||||
|
import {
|
||||||
|
chownInBox,
|
||||||
|
getDockerClient,
|
||||||
|
killBoxProcessesByMarker,
|
||||||
|
} from './runtime/docker';
|
||||||
import { openExecStream } from './runtime/exec-stream';
|
import { openExecStream } from './runtime/exec-stream';
|
||||||
import {
|
import {
|
||||||
parseBoxTokenUsername,
|
parseBoxTokenUsername,
|
||||||
@@ -52,10 +58,19 @@ const closeWithReason = (ws: WebSocket, code: number, reason: string) => {
|
|||||||
|
|
||||||
// The login-shell command shared by the job and box terminals: prefer a
|
// 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.
|
// 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',
|
'/bin/bash',
|
||||||
'-lc',
|
'-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 = {
|
type ShellBridgeOptions = {
|
||||||
@@ -64,6 +79,11 @@ type ShellBridgeOptions = {
|
|||||||
acquire: () => Promise<BoxHandle>;
|
acquire: () => Promise<BoxHandle>;
|
||||||
// Host-side preparation before the shell starts (e.g. seed .bash_profile).
|
// Host-side preparation before the shell starts (e.g. seed .bash_profile).
|
||||||
prepare?: () => Promise<void>;
|
prepare?: () => Promise<void>;
|
||||||
|
// Runs after the box is acquired (so the container exists) — ownership
|
||||||
|
// repair for anything `prepare` wrote host-side.
|
||||||
|
postAcquire?: (boxName: string) => Promise<void>;
|
||||||
|
// Container user the shell runs as (the box user, not root).
|
||||||
|
user: string;
|
||||||
// Directory the login shell starts in (container path).
|
// Directory the login shell starts in (container path).
|
||||||
cwd: string;
|
cwd: string;
|
||||||
// Env entries appended after TERM (e.g. HOME, and job secrets for job terms).
|
// Env entries appended after TERM (e.g. HOME, and job secrets for job terms).
|
||||||
@@ -101,6 +121,7 @@ const runShellBridge = async (ws: WebSocket, opts: ShellBridgeOptions) => {
|
|||||||
else pendingInput.push(data);
|
else pendingInput.push(data);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const marker = `spoon-term-${randomUUID()}`;
|
||||||
const handleHolder: { current?: BoxHandle } = {};
|
const handleHolder: { current?: BoxHandle } = {};
|
||||||
let released = false;
|
let released = false;
|
||||||
// Read through a function so TS doesn't narrow `released` to a constant — the
|
// Read through a function so TS doesn't narrow `released` to a constant — the
|
||||||
@@ -111,6 +132,15 @@ const runShellBridge = async (ws: WebSocket, opts: ShellBridgeOptions) => {
|
|||||||
released = true;
|
released = true;
|
||||||
execHolder.current?.stream.end();
|
execHolder.current?.stream.end();
|
||||||
execHolder.current?.stream.destroy();
|
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();
|
handleHolder.current?.release();
|
||||||
};
|
};
|
||||||
ws.on('close', cleanup);
|
ws.on('close', cleanup);
|
||||||
@@ -140,6 +170,8 @@ const runShellBridge = async (ws: WebSocket, opts: ShellBridgeOptions) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (opts.postAcquire) await opts.postAcquire(boxName);
|
||||||
|
|
||||||
// The exec create is a plain API call, but the attach is NOT dockerode's
|
// The exec create is a plain API call, but the attach is NOT dockerode's
|
||||||
// `exec.start({ hijack: true })`: docker-modem's hijack waits for node's http
|
// `exec.start({ hijack: true })`: docker-modem's hijack waits for node's http
|
||||||
// client `'upgrade'` event, which bun never emits, so under bun it hangs
|
// client `'upgrade'` event, which bun never emits, so under bun it hangs
|
||||||
@@ -157,7 +189,8 @@ const runShellBridge = async (ws: WebSocket, opts: ShellBridgeOptions) => {
|
|||||||
AttachStdout: true,
|
AttachStdout: true,
|
||||||
AttachStderr: true,
|
AttachStderr: true,
|
||||||
Tty: true,
|
Tty: true,
|
||||||
Cmd: SHELL_CMD,
|
User: opts.user,
|
||||||
|
Cmd: buildTerminalShellCommand(marker),
|
||||||
Env: ['TERM=xterm-256color', ...opts.envFlags],
|
Env: ['TERM=xterm-256color', ...opts.envFlags],
|
||||||
WorkingDir: opts.cwd,
|
WorkingDir: opts.cwd,
|
||||||
});
|
});
|
||||||
@@ -214,6 +247,7 @@ const bridge = async (ws: WebSocket, jobId: string) => {
|
|||||||
workdir: workspace.workdir,
|
workdir: workspace.workdir,
|
||||||
containerHome: workspace.containerHome,
|
containerHome: workspace.containerHome,
|
||||||
}),
|
}),
|
||||||
|
user: linuxUsername(workspace.username),
|
||||||
cwd: workspace.containerRepo,
|
cwd: workspace.containerRepo,
|
||||||
envFlags: [
|
envFlags: [
|
||||||
`HOME=${workspace.containerHome}`,
|
`HOME=${workspace.containerHome}`,
|
||||||
@@ -228,8 +262,16 @@ const bridgeBox = async (ws: WebSocket, username: string) => {
|
|||||||
const { homeDir, containerHome } = boxHomePaths(username);
|
const { homeDir, containerHome } = boxHomePaths(username);
|
||||||
await runShellBridge(ws, {
|
await runShellBridge(ws, {
|
||||||
prepare: () => ensureBashProfile(homeDir),
|
prepare: () => ensureBashProfile(homeDir),
|
||||||
|
// prepare's host-side .bash_profile write may be root-owned under prod
|
||||||
|
// docker; repair once the container is known to exist.
|
||||||
|
postAcquire: (boxName) =>
|
||||||
|
chownInBox({
|
||||||
|
containerName: boxName,
|
||||||
|
paths: [`${containerHome}/.bash_profile`],
|
||||||
|
}),
|
||||||
acquire: () =>
|
acquire: () =>
|
||||||
acquireUserBox({ username, workdir: homeDir, containerHome }),
|
acquireUserBox({ username, workdir: homeDir, containerHome }),
|
||||||
|
user: linuxUsername(username),
|
||||||
cwd: containerHome,
|
cwd: containerHome,
|
||||||
envFlags: [`HOME=${containerHome}`],
|
envFlags: [`HOME=${containerHome}`],
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { initializeBoxUser } from './box-init';
|
||||||
import { env } from './env';
|
import { env } from './env';
|
||||||
import {
|
import {
|
||||||
ensureUserContainer,
|
ensureUserContainer,
|
||||||
@@ -92,7 +93,11 @@ export const acquireUserBox = (args: {
|
|||||||
// cached "initialized" flag then hands out handles to a dead container
|
// cached "initialized" flag then hands out handles to a dead container
|
||||||
// until restart. ensureUserContainer is idempotent — an inspect when the
|
// until restart. ensureUserContainer is idempotent — an inspect when the
|
||||||
// box is running, a recreate when it isn't.
|
// box is running, a recreate when it isn't.
|
||||||
box.name = await ensureUserContainer(args);
|
box.name = await ensureUserContainer({
|
||||||
|
...args,
|
||||||
|
onCreated: (containerName) =>
|
||||||
|
initializeBoxUser(containerName, args.username, args.containerHome),
|
||||||
|
});
|
||||||
return handle;
|
return handle;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
handle.release();
|
handle.release();
|
||||||
|
|||||||
@@ -8,7 +8,8 @@ import { api } from '@spoon/backend/convex/_generated/api.js';
|
|||||||
|
|
||||||
import { env } from './env';
|
import { env } from './env';
|
||||||
import { assertContainedRealPath } from './path-containment';
|
import { assertContainedRealPath } from './path-containment';
|
||||||
import { runExecInContainer } from './runtime/docker';
|
import { linuxUsername } from './runtime/box-user';
|
||||||
|
import { chownInBox, runExecInContainer } from './runtime/docker';
|
||||||
|
|
||||||
const client = new ConvexHttpClient(env.convexUrl);
|
const client = new ConvexHttpClient(env.convexUrl);
|
||||||
|
|
||||||
@@ -102,6 +103,9 @@ export const materializeUserHome = async (args: {
|
|||||||
environment: { HOME: containerHome },
|
environment: { HOME: containerHome },
|
||||||
redact,
|
redact,
|
||||||
timeoutMs: env.jobTimeoutMs,
|
timeoutMs: env.jobTimeoutMs,
|
||||||
|
// The clone and setup command run as the box user so ~/.dotfiles and
|
||||||
|
// anything the setup writes stay editable from the terminal.
|
||||||
|
user: linuxUsername(userEnv.username),
|
||||||
});
|
});
|
||||||
await mkdir(path.dirname(markerPath), { recursive: true });
|
await mkdir(path.dirname(markerPath), { recursive: true });
|
||||||
await writeFile(markerPath, configHash);
|
await writeFile(markerPath, configHash);
|
||||||
@@ -109,6 +113,7 @@ export const materializeUserHome = async (args: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Editable overlay tree (wins over the repo/setup output).
|
// Editable overlay tree (wins over the repo/setup output).
|
||||||
|
const writtenContainerPaths: string[] = [];
|
||||||
for (const file of userEnv.files) {
|
for (const file of userEnv.files) {
|
||||||
const target = await assertContainedRealPath(homeDir, file.path, {
|
const target = await assertContainedRealPath(homeDir, file.path, {
|
||||||
forWrite: true,
|
forWrite: true,
|
||||||
@@ -116,5 +121,9 @@ export const materializeUserHome = async (args: {
|
|||||||
await mkdir(path.dirname(target), { recursive: true });
|
await mkdir(path.dirname(target), { recursive: true });
|
||||||
await writeFile(target, file.content);
|
await writeFile(target, file.content);
|
||||||
if (file.isExecutable) await chmod(target, 0o755);
|
if (file.isExecutable) await chmod(target, 0o755);
|
||||||
|
writtenContainerPaths.push(path.posix.join(containerHome, file.path));
|
||||||
}
|
}
|
||||||
|
// Overlay files are written host-side — repair ownership so the box user
|
||||||
|
// can edit them in a shell (no-op under dev keep-id).
|
||||||
|
await chownInBox({ containerName: boxName, paths: writtenContainerPaths });
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -14,15 +14,17 @@ import type { Id } from '@spoon/backend/convex/_generated/dataModel.js';
|
|||||||
import { api } from '@spoon/backend/convex/_generated/api.js';
|
import { api } from '@spoon/backend/convex/_generated/api.js';
|
||||||
|
|
||||||
import type { NormalizedAgentEvent } from './agent-events';
|
import type { NormalizedAgentEvent } from './agent-events';
|
||||||
|
import type { AgentRuntimeName } from './runtime/agent-runtime';
|
||||||
import type { AdapterWorkspace, Claim } from './runtime/provider';
|
import type { AdapterWorkspace, Claim } from './runtime/provider';
|
||||||
import type { BoxHandle } from './user-container';
|
import type { BoxHandle } from './user-container';
|
||||||
import type { AgentRuntimeName } from './runtime/agent-runtime';
|
|
||||||
import { getAdapter } from './runtime/agent-runtime';
|
import { getAdapter } from './runtime/agent-runtime';
|
||||||
import {
|
import {
|
||||||
resolveTurnOutcome,
|
resolveTurnOutcome,
|
||||||
shouldAppendCompletedContent,
|
shouldAppendCompletedContent,
|
||||||
} from './runtime/turn-outcome';
|
} from './runtime/turn-outcome';
|
||||||
|
|
||||||
import './runtime/register';
|
import './runtime/register';
|
||||||
|
|
||||||
import { env } from './env';
|
import { env } from './env';
|
||||||
import {
|
import {
|
||||||
cloneRepository,
|
cloneRepository,
|
||||||
@@ -36,7 +38,9 @@ import { createHeartbeatController } from './heartbeat-controller';
|
|||||||
import { removeMaterializedEnv, writeMaterializedEnv } from './materialize-env';
|
import { removeMaterializedEnv, writeMaterializedEnv } from './materialize-env';
|
||||||
import { assertContainedRealPath } from './path-containment';
|
import { assertContainedRealPath } from './path-containment';
|
||||||
import { createRedactor, truncate } from './redact';
|
import { createRedactor, truncate } from './redact';
|
||||||
|
import { linuxUsername } from './runtime/box-user';
|
||||||
import {
|
import {
|
||||||
|
chownInBox,
|
||||||
listWorkspaceContainerNames,
|
listWorkspaceContainerNames,
|
||||||
runExecInContainer,
|
runExecInContainer,
|
||||||
} from './runtime/docker';
|
} from './runtime/docker';
|
||||||
@@ -576,6 +580,7 @@ const runProjectCommand = async (args: {
|
|||||||
phase: 'install' | 'check' | 'test';
|
phase: 'install' | 'check' | 'test';
|
||||||
claim: Claim;
|
claim: Claim;
|
||||||
boxName: string;
|
boxName: string;
|
||||||
|
username: string;
|
||||||
containerHome: string;
|
containerHome: string;
|
||||||
containerCwd: string;
|
containerCwd: string;
|
||||||
repoDir: string;
|
repoDir: string;
|
||||||
@@ -594,6 +599,7 @@ const runProjectCommand = async (args: {
|
|||||||
environment: { HOME: args.containerHome, ...secretEnv },
|
environment: { HOME: args.containerHome, ...secretEnv },
|
||||||
redact: args.redact,
|
redact: args.redact,
|
||||||
timeoutMs: env.jobTimeoutMs,
|
timeoutMs: env.jobTimeoutMs,
|
||||||
|
user: linuxUsername(args.username),
|
||||||
})
|
})
|
||||||
: await run('bash', ['-lc', args.command], {
|
: await run('bash', ['-lc', args.command], {
|
||||||
cwd: args.repoDir,
|
cwd: args.repoDir,
|
||||||
@@ -728,6 +734,12 @@ const materializeEnvFile = async (workspace: ActiveWorkspace) => {
|
|||||||
claim.job.envFilePath,
|
claim.job.envFilePath,
|
||||||
claim.secrets,
|
claim.secrets,
|
||||||
);
|
);
|
||||||
|
// Host-side 0600 write — the agent now runs as the box user, which must be
|
||||||
|
// able to read it. No-op under dev keep-id.
|
||||||
|
await chownInBox({
|
||||||
|
containerName: workspace.boxName,
|
||||||
|
paths: [path.posix.join(workspace.containerRepo, claim.job.envFilePath)],
|
||||||
|
});
|
||||||
await appendEvent(
|
await appendEvent(
|
||||||
claim.job._id,
|
claim.job._id,
|
||||||
'info',
|
'info',
|
||||||
@@ -923,6 +935,9 @@ const runClaim = async (claim: Claim) => {
|
|||||||
redact,
|
redact,
|
||||||
timeoutMs: env.jobTimeoutMs,
|
timeoutMs: env.jobTimeoutMs,
|
||||||
});
|
});
|
||||||
|
// The clone happens host-side; hand it to the box user so the agent (and
|
||||||
|
// the user's shell) can write to it. No-op under dev keep-id.
|
||||||
|
await chownInBox({ containerName: boxName, paths: [containerRepo] });
|
||||||
const workspace: ActiveWorkspace = {
|
const workspace: ActiveWorkspace = {
|
||||||
claim,
|
claim,
|
||||||
workdir: homeDir,
|
workdir: homeDir,
|
||||||
@@ -1116,6 +1131,7 @@ export const runWorkspaceCommand = async (jobId: string, command: string) => {
|
|||||||
phase: command.includes('test') ? 'test' : 'check',
|
phase: command.includes('test') ? 'test' : 'check',
|
||||||
claim: workspace.claim,
|
claim: workspace.claim,
|
||||||
boxName: workspace.boxName,
|
boxName: workspace.boxName,
|
||||||
|
username: workspace.username,
|
||||||
containerHome: workspace.containerHome,
|
containerHome: workspace.containerHome,
|
||||||
containerCwd: workspace.containerRepo,
|
containerCwd: workspace.containerRepo,
|
||||||
repoDir: workspace.repoDir,
|
repoDir: workspace.repoDir,
|
||||||
@@ -1183,7 +1199,9 @@ export const replyToInteraction = async (
|
|||||||
) => {
|
) => {
|
||||||
// Every runtime now runs its CLI non-interactively inside the box, so no
|
// Every runtime now runs its CLI non-interactively inside the box, so no
|
||||||
// runtime emits an interaction request that expects a reply.
|
// runtime emits an interaction request that expects a reply.
|
||||||
throw new Error('Interactive replies are not supported by exec-based runtimes.');
|
throw new Error(
|
||||||
|
'Interactive replies are not supported by exec-based runtimes.',
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const sendWorkspaceMessage = async (
|
export const sendWorkspaceMessage = async (
|
||||||
|
|||||||
@@ -13,12 +13,13 @@ const load = async () => {
|
|||||||
describe('boxRoute', () => {
|
describe('boxRoute', () => {
|
||||||
afterEach(() => vi.resetModules());
|
afterEach(() => vi.resetModules());
|
||||||
|
|
||||||
test('matches the four box actions', async () => {
|
test('matches the five box actions', async () => {
|
||||||
const { boxRoute } = await load();
|
const { boxRoute } = await load();
|
||||||
expect(boxRoute('/box/status')).toEqual({ action: 'status' });
|
expect(boxRoute('/box/status')).toEqual({ action: 'status' });
|
||||||
expect(boxRoute('/box/lifecycle')).toEqual({ action: 'lifecycle' });
|
expect(boxRoute('/box/lifecycle')).toEqual({ action: 'lifecycle' });
|
||||||
expect(boxRoute('/box/tree')).toEqual({ action: 'tree' });
|
expect(boxRoute('/box/tree')).toEqual({ action: 'tree' });
|
||||||
expect(boxRoute('/box/file')).toEqual({ action: 'file' });
|
expect(boxRoute('/box/file')).toEqual({ action: 'file' });
|
||||||
|
expect(boxRoute('/box/set-password')).toEqual({ action: 'set-password' });
|
||||||
});
|
});
|
||||||
|
|
||||||
test('rejects unknown, nested, and trailing paths', async () => {
|
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 { mkdtemp, readFile, rm, stat } from 'node:fs/promises';
|
||||||
import { tmpdir } from 'node:os';
|
import { tmpdir } from 'node:os';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
|
|
||||||
import { afterEach, describe, expect, test, vi } from 'vitest';
|
import { afterEach, describe, expect, test, vi } from 'vitest';
|
||||||
|
|
||||||
import type { NormalizedAgentEvent } from '../../src/agent-events';
|
import type { NormalizedAgentEvent } from '../../src/agent-events';
|
||||||
@@ -98,7 +97,9 @@ describe('ClaudeCodeAdapter prepareAuth', () => {
|
|||||||
tempDirs.push(homeDir);
|
tempDirs.push(homeDir);
|
||||||
|
|
||||||
const adapter = createClaudeAdapter();
|
const adapter = createClaudeAdapter();
|
||||||
await adapter.prepareAuth(makeOAuthWorkspace(homeDir, 'anthropic_oauth_json'));
|
await adapter.prepareAuth(
|
||||||
|
makeOAuthWorkspace(homeDir, 'anthropic_oauth_json'),
|
||||||
|
);
|
||||||
|
|
||||||
const credentialsPath = path.join(homeDir, '.claude', '.credentials.json');
|
const credentialsPath = path.join(homeDir, '.claude', '.credentials.json');
|
||||||
const contents = await readFile(credentialsPath, 'utf8');
|
const contents = await readFile(credentialsPath, 'utf8');
|
||||||
@@ -111,7 +112,9 @@ describe('ClaudeCodeAdapter prepareAuth', () => {
|
|||||||
tempDirs.push(homeDir);
|
tempDirs.push(homeDir);
|
||||||
|
|
||||||
const adapter = createClaudeAdapter();
|
const adapter = createClaudeAdapter();
|
||||||
await adapter.prepareAuth(makeOAuthWorkspace(homeDir, 'opencode_auth_json'));
|
await adapter.prepareAuth(
|
||||||
|
makeOAuthWorkspace(homeDir, 'opencode_auth_json'),
|
||||||
|
);
|
||||||
|
|
||||||
const credentialsPath = path.join(homeDir, '.claude', '.credentials.json');
|
const credentialsPath = path.join(homeDir, '.claude', '.credentials.json');
|
||||||
await expect(stat(credentialsPath)).rejects.toThrow();
|
await expect(stat(credentialsPath)).rejects.toThrow();
|
||||||
@@ -174,4 +177,16 @@ describe('ClaudeCodeAdapter', () => {
|
|||||||
expect(command[3]).toContain("'claude' '-p'");
|
expect(command[3]).toContain("'claude' '-p'");
|
||||||
expect(command[3]).toContain("'--output-format' 'stream-json'");
|
expect(command[3]).toContain("'--output-format' 'stream-json'");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('execs the turn as the box user, not root', async () => {
|
||||||
|
const { createClaudeAdapter } = await loadAdapter();
|
||||||
|
let capturedUser: string | undefined;
|
||||||
|
const execStream = vi.fn((args: { user?: string }) => {
|
||||||
|
capturedUser = args.user;
|
||||||
|
return Promise.resolve({ exitCode: 0, output: '' });
|
||||||
|
}) as unknown as ExecStreamFn;
|
||||||
|
const adapter = createClaudeAdapter({ execStream });
|
||||||
|
await adapter.runTurn(makeWorkspace(), 'do it', () => Promise.resolve());
|
||||||
|
expect(capturedUser).toBe('tester');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 () => {
|
test('treats a spawn failure (no exitCode) as a non-zero exit, not empty success', async () => {
|
||||||
const { normalizeRunResult } = await loadVolumeSpec();
|
const { normalizeRunResult } = await loadVolumeSpec();
|
||||||
// This is what execa returns with `reject: false` when the runtime binary is
|
// This is what execa returns with `reject: false` when the runtime binary is
|
||||||
|
|||||||
@@ -44,3 +44,18 @@ describe('parseResizeMessage', () => {
|
|||||||
expect(parseResizeMessage(msg, false)).toEqual({ cols: 1000, rows: 1 });
|
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,6 +22,20 @@ describe('box registry', () => {
|
|||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('passes an onCreated hook so a fresh box gets its user initialized', async () => {
|
||||||
|
const module = await load();
|
||||||
|
const docker = await import('../../src/runtime/docker');
|
||||||
|
await module.acquireUserBox({
|
||||||
|
username: 'ada',
|
||||||
|
workdir: '/w',
|
||||||
|
containerHome: '/home/ada',
|
||||||
|
});
|
||||||
|
const call = vi.mocked(docker.ensureUserContainer).mock.calls[0]?.[0] as {
|
||||||
|
onCreated?: unknown;
|
||||||
|
};
|
||||||
|
expect(call.onCreated).toBeTypeOf('function');
|
||||||
|
});
|
||||||
|
|
||||||
test('re-ensures the container on every acquire so an externally removed box self-heals', async () => {
|
test('re-ensures the container on every acquire so an externally removed box self-heals', async () => {
|
||||||
const module = await load();
|
const module = await load();
|
||||||
const docker = await import('../../src/runtime/docker');
|
const docker = await import('../../src/runtime/docker');
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
});
|
||||||
@@ -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 type { BoxStatus, LifecycleAction } from './box-status-card';
|
||||||
import { BoxStatusCard } from './box-status-card';
|
import { BoxStatusCard } from './box-status-card';
|
||||||
|
import { BoxUserCard } from './box-user-card';
|
||||||
|
|
||||||
const STATUS_POLL_MS = 10_000;
|
const STATUS_POLL_MS = 10_000;
|
||||||
|
|
||||||
@@ -261,6 +262,8 @@ export const MachineShell = () => {
|
|||||||
onAction={runLifecycle}
|
onAction={runLifecycle}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<BoxUserCard username={username} />
|
||||||
|
|
||||||
<Tabs
|
<Tabs
|
||||||
value={activeTab}
|
value={activeTab}
|
||||||
onValueChange={(value) => setActiveTab(value as 'files' | 'terminal')}
|
onValueChange={(value) => setActiveTab(value as 'files' | 'terminal')}
|
||||||
|
|||||||
@@ -181,7 +181,7 @@ export const resolveBoxUsername = async (): Promise<
|
|||||||
// ever operates on the caller's own box.
|
// ever operates on the caller's own box.
|
||||||
export const proxyBox = async (
|
export const proxyBox = async (
|
||||||
username: string,
|
username: string,
|
||||||
action: 'status' | 'lifecycle' | 'tree' | 'file',
|
action: 'status' | 'lifecycle' | 'tree' | 'file' | 'set-password',
|
||||||
init?: RequestInit,
|
init?: RequestInit,
|
||||||
search?: URLSearchParams,
|
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' }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 \
|
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
|
&& 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).
|
# 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 \
|
RUN curl -fsSL https://ohmyposh.dev/install.sh | bash -s -- -d /usr/local/bin \
|
||||||
&& oh-my-posh version
|
&& 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
|
is intended for the single-user self-hosted deployment; do not expose the
|
||||||
worker domain without TLS, and keep the deployment single-tenant.
|
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
|
## Tools in the shell
|
||||||
|
|
||||||
The box image ships `bash`, `tmux`, `neovim`, `git`, `ripgrep`, `jq`, `python3`,
|
The box image ships `bash`, `tmux`, `neovim`, `git`, `ripgrep`, `jq`, `python3`,
|
||||||
|
|||||||
@@ -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()),
|
setupCommand: v.optional(v.string()),
|
||||||
updatedAt: v.number(),
|
updatedAt: v.number(),
|
||||||
}).index('by_owner', ['ownerId']),
|
}).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({
|
aiProviderProfiles: defineTable({
|
||||||
ownerId: v.id('users'),
|
ownerId: v.id('users'),
|
||||||
name: v.string(),
|
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_SECRET",
|
||||||
"SPOON_AGENT_TERMINAL_IDLE_MS",
|
"SPOON_AGENT_TERMINAL_IDLE_MS",
|
||||||
"SPOON_AGENT_BOX_IDLE_MS",
|
"SPOON_AGENT_BOX_IDLE_MS",
|
||||||
|
"SPOON_AGENT_BOX_USERNS",
|
||||||
"SPOON_AGENT_DEV_WATCHDOG",
|
"SPOON_AGENT_DEV_WATCHDOG",
|
||||||
"SPOON_AGENT_RUNTIME",
|
"SPOON_AGENT_RUNTIME",
|
||||||
"SPOON_AGENT_CONTAINER_RUNTIME",
|
"SPOON_AGENT_CONTAINER_RUNTIME",
|
||||||
|
|||||||
Reference in New Issue
Block a user