Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 62 additions & 0 deletions docs/config.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
# Config

`config.json` describes how moi is deployed. A normal local install has no file and needs none. moi reads the file once at startup, so restart moi after changing it, and no API can write it.

It lives in moi's data folder: `MOI_DATA_DIR` if set, otherwise `~/Library/Application Support/moi` on macOS and `~/.local/share/moi` on Linux. A missing file is fine. A broken file or a wrong value prints a warning and falls back to the default, so a bad config never stops moi.

Some options can also be set with environment variables, which win over the file.

```json
{
"cloudDemo": false,
"demoInstallUrl": "https://moi.computer",
"cloudflareAccess": { "teamDomain": "acme", "audience": ["<AUD tag>"] },
"workspacesDir": "/data/workspaces",
"agentLauncher": "/opt/moi/bin/agent-run",
"unixSocket": "/run/moi/web.sock"
}
```

## Demo

| Option | Env | Default |
| ---------------- | ---------------------- | ---------------------- |
| `cloudDemo` | `MOI_CLOUD_DEMO` | `false` |
| `demoInstallUrl` | `MOI_DEMO_INSTALL_URL` | `https://moi.computer` |

For the public cloud demo. `cloudDemo` blocks creating workspaces (the UI shows a promo instead) and turns off the `moi` system commands except `start` and `version`. `demoInstallUrl` is where that promo links. If the demo gate must never fail open, set `MOI_CLOUD_DEMO=1` in the environment: an unreadable file can't switch it off.

## Cloudflare Access

| Option | Env |
| ----------------------------- | ----------------------------------- |
| `cloudflareAccess.teamDomain` | `MOI_CLOUDFLARE_ACCESS_TEAM_DOMAIN` |
| `cloudflareAccess.audience` | `MOI_CLOUDFLARE_ACCESS_AUD` |

For moi behind Cloudflare Access (Zero Trust). moi checks the token Access adds to each request and uses its email as the viewer's profile. `teamDomain` is `acme`, `acme.cloudflareaccess.com` or its https URL. `audience` is the application's AUD tag, or a list of them (comma-separated in the env). Both are needed; with only one, moi trusts nobody and says so. Unset, moi trusts no proxy.

## Managed machines

For a machine that runs moi for an organization in the cloud, where moi and the agent run as different OS users. These three come from the file only, with no env override, so whoever writes the file (the machine's start script) decides them.

| Option | Default |
| --------------- | ----------------- |
| `workspacesDir` | `~/moi` |
| `agentLauncher` | none |
| `unixSocket` | none (port 13337) |

All three must be absolute paths.

**`workspacesDir`**: the folder that workspaces created in the UI go into. Use it when the home folder is the wrong place, for example to keep workspaces on a persistent disk.

**`agentLauncher`**: an executable that every agent process starts through: `agentLauncher <command> <args…>`. That covers Claude Code, Codex, function workers, applet builds, installs and chat titles. It must switch to the agent's user, drop anything that user may not have, set `HOME` to the agent's home, and `exec` the command, keeping stdin, stdout and the rest of the environment. Use it to keep the agent out of moi's data folder. When set:

- moi must be able to read and write the agent's files (chat histories, workspaces). The machine grants this; moi doesn't check.
- The agent can't open moi's data folder, so its `moi` CLI works through the running server. `init`, `start`, `service`, `update` and the provider `init`s refuse to run for it; `version` and `status` still work.
- New code that starts agent processes must use `agentCommand()` or `claudeCodeSpawn()` from `server/agent-launcher.ts`, or it runs as moi's user.

**`unixSocket`**: a socket file the web side listens on instead of port 13337. Put it in a folder only the machine's gateway can open, so nothing else on the machine, the agent included, reaches the API meant for people.

## Not in the config

Experimental features are switched on only with `moi start` flags, such as `--experimental-collab`. User settings live in `settings.json` and change while moi runs.
27 changes: 27 additions & 0 deletions server/agent-launcher.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
// Managed machines run moi and the agent as different OS users (see
// docs/config.md). `agentLauncher` in config.json names an executable
// that every agent process starts through: it switches to the agent's user,
// drops moi's powers, then execs the command. That covers harness CLIs,
// function workers, applet builds and dependency installs. Unset (every local
// install) spawns directly, exactly as before.
import { spawn } from 'node:child_process'

import type { Options } from '@anthropic-ai/claude-agent-sdk'

import { getAppConfig } from './app-config'

export function agentCommand(argv: readonly string[]): string[] {
const launcher = getAppConfig().agentLauncher
return launcher ? [launcher, ...argv] : [...argv]
}

// Claude Code is started by the Agent SDK rather than by moi, so it goes
// through the launcher via the SDK's spawn hook. Undefined keeps the SDK's own
// spawn. stderr is inherited: the hook's process type has no stderr stream for
// the SDK to drain.
export function claudeCodeSpawn(): Options['spawnClaudeCodeProcess'] {
const launcher = getAppConfig().agentLauncher
if (!launcher) return undefined
return ({ command, args, cwd, env, signal }) =>
spawn(launcher, [command, ...args], { cwd, env, signal, stdio: ['pipe', 'pipe', 'inherit'] })
Comment thread
molefrog marked this conversation as resolved.
}
44 changes: 8 additions & 36 deletions server/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,14 +88,13 @@ import { submitView, ViewStartupError } from './view-sessions'
import { listWidgets, serveWidget } from './widgets'
import { getWorkspaceConfig, setWorkspaceConfig } from './workspace-config'
import {
CREATED_WORKSPACES_ROOT,
createdWorkspacesRoot,
provisionWorkspace,
validateWorkspaceFolderName
} from './workspace-init'
import { resolveWorkspaceImportMetadata } from './workspace-import'
import type { WorkspaceImportMetadata } from './workspace-import'
import { getWorkspaceEnvView, isValidEnvKey, updateWorkspaceEnv } from './workspace-env'
import type { EnvUpdate } from './workspace-env'
import { getWorkspaceEnvView, parseEnvUpdate, updateWorkspaceEnv } from './workspace-env'
import {
getCachedUpdateStatus,
hasRunningAgentSessions,
Expand Down Expand Up @@ -639,35 +638,8 @@ one.get('/env', async c => {
// the only way).
one.put('/env', async c => {
const ws = c.get('ws')
const body = await c.req.json().catch(() => null)
if (!body || typeof body !== 'object') {
return c.text('Bad request', 400)
}
const patch: EnvUpdate = {}
if (body.set !== undefined) {
if (typeof body.set !== 'object' || body.set === null) {
return c.text('set must be an object', 400)
}
for (const [k, v] of Object.entries(body.set)) {
if (!isValidEnvKey(k)) return c.text(`Invalid env key: ${k}`, 400)
if (typeof v !== 'string') {
return c.text(`Value for ${k} must be a string`, 400)
}
}
patch.set = body.set as Record<string, string>
}
if (body.remove !== undefined) {
if (!Array.isArray(body.remove) || body.remove.some((k: unknown) => typeof k !== 'string')) {
return c.text('remove must be an array of strings', 400)
}
patch.remove = body.remove as string[]
}
if (body.inheritDotenv !== undefined) {
if (typeof body.inheritDotenv !== 'boolean') {
return c.text('inheritDotenv must be a boolean', 400)
}
patch.inheritDotenv = body.inheritDotenv
}
const patch = parseEnvUpdate(await c.req.json().catch(() => null))
if (typeof patch === 'string') return c.text(patch, 400)

// Skip the write + reaps for a no-op PUT (no recognized fields) so an empty
// body doesn't needlessly kill warm workers / idle sessions.
Expand Down Expand Up @@ -1043,13 +1015,13 @@ async function harnessAvailability(): Promise<Record<string, HarnessAvailability
// backends whose runtime is missing.
workspaces.get('/create', async c =>
c.json({
root: CREATED_WORKSPACES_ROOT,
displayRoot: tildify(CREATED_WORKSPACES_ROOT),
root: createdWorkspacesRoot(),
displayRoot: tildify(createdWorkspacesRoot()),
availability: await harnessAvailability()
})
)

// Create a brand-new workspace: a fresh folder under CREATED_WORKSPACES_ROOT,
// Create a brand-new workspace: a fresh folder under createdWorkspacesRoot(),
// provisioned (skills + `.moi/` scaffold) and registered.
workspaces.post('/create', async c => {
if (getAppConfig().cloudDemo) return c.text(DEMO_CREATE_BLOCKED, 403)
Expand All @@ -1067,7 +1039,7 @@ workspaces.post('/create', async c => {
const name = typeof body?.name === 'string' ? body.name.trim() : ''
const invalid = validateWorkspaceFolderName(name)
if (invalid) return c.text(invalid, 400)
const path = join(CREATED_WORKSPACES_ROOT, name)
const path = join(createdWorkspacesRoot(), name)
if (existsSync(path)) return c.text('A folder with that name already exists', 409)
try {
await provisionWorkspace(path, type)
Expand Down
31 changes: 30 additions & 1 deletion server/app-config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,10 @@ test('defaults apply when no config file exists', () => {
cloudDemo: false,
experimental: { collab: false },
demoInstallUrl: 'https://moi.computer',
cloudflareAccess: null
cloudflareAccess: null,
workspacesDir: null,
agentLauncher: null,
unixSocket: null
})
})

Expand Down Expand Up @@ -106,6 +109,32 @@ test('wrong-typed keys are dropped individually, valid keys survive', async () =
expect(config.demoInstallUrl).toBe('https://moi.computer')
})

test('managed-machine paths come from config.json only, and must be absolute', async () => {
const errors: string[] = []
const spy = spyOn(console, 'error').mockImplementation((...args: unknown[]) => {
errors.push(args.map(String).join(' '))
})
try {
const file = await configFile(
JSON.stringify({
workspacesDir: '/data/workspaces',
agentLauncher: '/opt/moi/bin/agent-run',
unixSocket: 'run/moi/web.sock'
})
)
const config = loadAppConfig(file, {
MOI_WORKSPACES_DIR: '/elsewhere',
MOI_AGENT_LAUNCHER: '/tmp/launcher'
})
expect(config.workspacesDir).toBe('/data/workspaces')
expect(config.agentLauncher).toBe('/opt/moi/bin/agent-run')
expect(config.unixSocket).toBeNull()
expect(errors.join('\n')).toContain('"unixSocket"')
} finally {
spy.mockRestore()
}
})

test('the resolved config is frozen', () => {
const config = loadAppConfig('/nonexistent', NO_ENV)
expect(Object.isFrozen(config)).toBe(true)
Expand Down
23 changes: 21 additions & 2 deletions server/app-config.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { readFileSync } from 'node:fs'
import { join } from 'path'
import { isAbsolute, join } from 'path'

import type { ClientAppConfig, ExperimentalFeatures } from '@/lib/types'
import { EXPERIMENTAL_DEFAULTS } from '@/lib/experimental'
Expand Down Expand Up @@ -27,6 +27,16 @@ export type AppConfig = {
// Cloudflare Access (Zero Trust) in front of this deployment: each viewer
// inherits the user profile from Access's signed token. Null trusts no proxy.
cloudflareAccess: CloudflareAccessConfig | null
// Managed machines (one per organization) run moi and the agent as
// different OS users; see docs/config.md. These three are read
// from config.json only, with no env override, so the machine's start
// script, which writes the file, is the only thing deciding them.
// Folder that `/workspace/create` puts new workspaces in; null is `~/moi`.
workspacesDir: string | null
// Executable every agent process starts through (see agent-launcher.ts).
agentLauncher: string | null
// Unix socket file the web side listens on instead of PORT.
unixSocket: string | null
}

export type CloudflareAccessConfig = {
Expand All @@ -42,7 +52,10 @@ const DEFAULTS: AppConfig = {
cloudDemo: false,
experimental: { ...EXPERIMENTAL_DEFAULTS },
demoInstallUrl: 'https://moi.computer',
cloudflareAccess: null
cloudflareAccess: null,
workspacesDir: null,
agentLauncher: null,
unixSocket: null
}

export const APP_CONFIG_FILE = join(DATA_DIR, 'config.json')
Expand Down Expand Up @@ -184,6 +197,12 @@ function fileValues(raw: Record<string, unknown>): Partial<DeploymentConfig> {
if (typeof raw.demoInstallUrl === 'string') out.demoInstallUrl = raw.demoInstallUrl
else warn('ignoring "demoInstallUrl" — expected a string')
}
for (const key of ['workspacesDir', 'agentLauncher', 'unixSocket'] as const) {
const value = raw[key]
if (value === undefined || value === null) continue
if (typeof value === 'string' && isAbsolute(value)) out[key] = value
else warn(`ignoring "${key}" — expected an absolute path`)
}
return out
}

Expand Down
3 changes: 2 additions & 1 deletion server/applets/build-worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import { join } from 'path'

import type { AppletKind, ViewConfig, WidgetConfig } from '@/lib/types'

import { agentCommand } from '../agent-launcher'
import { buildApplet } from './build-applet'

export type BuildJob = {
Expand Down Expand Up @@ -130,7 +131,7 @@ export function buildAppletsInChild(req: Omit<BuildRequest, 'type'>): Promise<Bu
resolve(results)
}
const request: BuildRequest = { type: 'build', ...req }
const proc = Bun.spawn([process.execPath, WORKER_PATH], {
const proc = Bun.spawn(agentCommand([process.execPath, WORKER_PATH]), {
stdio: ['ignore', 'inherit', 'inherit'],
serialization: 'json',
ipc(message, child) {
Expand Down
Loading
Loading