From dcb50b7e0f8e64fecace8274fb6b1f559c50e665 Mon Sep 17 00:00:00 2001 From: Chris Scott <99081550+chriswritescode-dev@users.noreply.github.com> Date: Fri, 2 Oct 2026 13:00:09 +0000 Subject: [PATCH 1/2] fix: reconcile documentation guarantees and enforcement (OM-15) --- .env.example | 9 +- AGENTS.md | 8 +- CONTRIBUTING.md | 4 +- backend/src/auth/index.ts | 16 ++++ backend/src/routes/auth.ts | 6 +- backend/src/services/opencode-config-file.ts | 60 +++++++++++++ .../services/opencode-plugin-quarantine.ts | 62 +------------ backend/test/auth/index.test.ts | 65 ++++++++++++++ .../test/scripts/docker-entrypoint.test.ts | 87 +++++++++++++++++++ .../services/opencode-config-file.test.ts | 76 +++++++++++++++- .../opencode-plugin-quarantine.test.ts | 10 +++ backend/test/shared/opencode-contract.test.ts | 13 +++ docs/configuration/authentication.md | 21 +++-- docs/configuration/docker.md | 8 +- docs/configuration/environment.md | 8 +- docs/development/setup.md | 38 +++++--- docs/features/assistant-internal-api.md | 6 +- docs/features/notifications.md | 2 +- docs/features/sandboxing.md | 2 +- docs/features/schedules.md | 14 ++- docs/features/session-pins.md | 2 +- docs/getting-started/first-run.md | 12 ++- docs/ocm-cli.md | 19 ++-- docs/troubleshooting.md | 25 ++++-- ocm-cli/README.md | 6 +- scripts/docker-entrypoint.sh | 5 +- scripts/lib/opencode-release.sh | 4 + scripts/setup-dev.sh | 3 +- 28 files changed, 462 insertions(+), 129 deletions(-) diff --git a/.env.example b/.env.example index 3c3efb52e..43055f201 100644 --- a/.env.example +++ b/.env.example @@ -9,6 +9,7 @@ PORT=5003 HOST=0.0.0.0 CORS_ORIGIN=http://localhost:5173 NODE_ENV=production +# LOG_LEVEL is accepted but currently unused; DEBUG controls debug output LOG_LEVEL=info # ============================================ @@ -21,6 +22,8 @@ OPENCODE_HOST=127.0.0.1 # OpenCode 2 always requires a password: when this is unset and no password is # stored via Settings → OpenCode → Server Auth, OpenCode Manager generates one # and persists it. DB-stored passwords override this env var. +# Docker: the default docker-compose.yml does not forward this variable from +# .env; add it to the compose environment block or set it via the UI. # OPENCODE_SERVER_PASSWORD= # Optional - import an existing standalone OpenCode install on first startup @@ -88,7 +91,9 @@ AUTH_SECRET=CHANGE_ME_GENERATE_WITH_openssl_rand_base64_32 # ADMIN_PASSWORD_RESET=false # Secure Cookies (set to false when running HTTP without a reverse proxy) -# Defaults to true in production, false in development +# Runtime default: true when NODE_ENV=production, false otherwise. +# Docker Compose default: false (docker-compose.yml forwards AUTH_SECURE_COOKIES:-false), +# so set true explicitly for HTTPS. # AUTH_SECURE_COOKIES=true # OAuth Providers (optional - enable by providing client ID and secret) @@ -159,6 +164,8 @@ PASSKEY_ORIGIN=http://localhost:5003 # Frontend Configuration (Vite) # These are optional - frontend uses defaults if not set # ============================================ +# VITE_API_URL is unset by default: requests are same-origin and the Vite dev +# server proxies /api to the backend. Set only for a split frontend/backend origin. # VITE_API_URL=http://localhost:5003 # VITE_SERVER_PORT=5003 # VITE_OPENCODE_PORT=5551 diff --git a/AGENTS.md b/AGENTS.md index a0224892c..f8a321ef6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -3,14 +3,14 @@ ## Commands - `pnpm dev` - Start both backend (5003) and frontend (5173) -- `pnpm dev:backend` - Backend only: `bun --watch-path backend/src --watch backend/src/index.ts` +- `pnpm dev:backend` - Backend only: `NODE_ENV=development bun --watch backend/src/index.ts` - `pnpm dev:frontend` - Frontend only: `pnpm --filter frontend dev` -- `pnpm build` - Build both backend and frontend -- `pnpm test` - Run backend tests: `pnpm --filter backend test` (vitest) +- `pnpm build` - Build CLI, backend, and frontend +- `pnpm test` - Run CLI, backend, and frontend tests - `cd backend && vitest ` - Run single test file - `cd backend && vitest --ui` - Test UI with coverage - `cd backend && vitest --coverage` - Coverage report (80% threshold) -- `pnpm lint` - Lint both backend and frontend +- `pnpm lint` - Lint CLI, frontend, and backend - `pnpm lint:backend` - Backend linting - `pnpm lint:frontend` - Frontend linting diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index faa232197..78d9c14c7 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -72,14 +72,14 @@ Each item is tagged with a theme to help you find work in your area of interest: ### Running Tests ```bash -pnpm test # Run all tests (backend) +pnpm test # Run CLI, backend, and frontend tests cd backend && vitest # Run single test file ``` ### Linting ```bash -pnpm lint # Lint both backend and frontend +pnpm lint # Lint CLI, frontend, and backend ``` Run linting before submitting a PR. diff --git a/backend/src/auth/index.ts b/backend/src/auth/index.ts index 147222d85..3fd643332 100644 --- a/backend/src/auth/index.ts +++ b/backend/src/auth/index.ts @@ -1,10 +1,15 @@ import { betterAuth } from 'better-auth' +import { APIError } from 'better-auth/api' import { passkey } from '@better-auth/passkey' import { Database } from 'bun:sqlite' import { ENV } from '@opencode-manager/shared/config/env' export type AuthInstance = ReturnType +export function isAdminConfigured(): boolean { + return !!(ENV.AUTH.ADMIN_EMAIL && ENV.AUTH.ADMIN_PASSWORD) +} + export function createAuth(db: Database) { const socialProviders: Record = {} @@ -44,6 +49,17 @@ export function createAuth(db: Database) { autoSignIn: true, }, socialProviders: Object.keys(socialProviders).length > 0 ? socialProviders : undefined, + databaseHooks: { + user: { + create: { + before: async (user, context) => { + if (isAdminConfigured() && (context?.request || user.email.toLowerCase() !== ENV.AUTH.ADMIN_EMAIL!.toLowerCase())) { + throw new APIError('FORBIDDEN', { message: 'Registration is disabled' }) + } + }, + }, + }, + }, plugins: [ passkey({ rpID: ENV.AUTH.PASSKEY_RP_ID, diff --git a/backend/src/routes/auth.ts b/backend/src/routes/auth.ts index 19c242514..75c927e9a 100644 --- a/backend/src/routes/auth.ts +++ b/backend/src/routes/auth.ts @@ -1,5 +1,5 @@ import { Hono } from 'hono' -import type { AuthInstance } from '../auth' +import { isAdminConfigured, type AuthInstance } from '../auth' import { Database } from 'bun:sqlite' import { ENV } from '@opencode-manager/shared/config/env' import { logger } from '../utils/logger' @@ -25,10 +25,6 @@ export function createAuthRoutes(auth: AuthInstance): Hono { return app } -const isAdminConfigured = (): boolean => { - return !!(ENV.AUTH.ADMIN_EMAIL && ENV.AUTH.ADMIN_PASSWORD) -} - export async function syncAdminFromEnv(auth: AuthInstance, db: Database): Promise { if (!isAdminConfigured()) return diff --git a/backend/src/services/opencode-config-file.ts b/backend/src/services/opencode-config-file.ts index 1235b44b0..e0044b9d5 100644 --- a/backend/src/services/opencode-config-file.ts +++ b/backend/src/services/opencode-config-file.ts @@ -1,4 +1,5 @@ import { createHash } from 'crypto' +import { promises as fs } from 'fs' import { readFile, readdir, rename, rm, stat } from 'fs/promises' import path from 'path' import { isDeepStrictEqual } from 'node:util' @@ -23,6 +24,7 @@ import type { import { logger } from '../utils/logger' import { withFileLock } from '../utils/atomic-json' import { existingFileMode, writeFileAtomic } from '../utils/fs-safe' +import { restoreEnforcementSections, type EnforcementRemovedSections } from './opencode/enforcement-config' import { ensureDirectoryExists } from './file-operations' export const OPENCODE_CONFIG_SEED = JSON.stringify({ $schema: 'https://opencode.ai/config.json' }, null, 2) @@ -41,6 +43,8 @@ const OPENCODE_CONFIG_SNAPSHOT_MARKER = 'opencode-config-snapshot' const OPENCODE_CONFIG_SNAPSHOT_ARTIFACT_PREFIX = 'opencode-config-broken' +const OPENCODE_CONFIG_LEGACY_BACKUP_SUFFIX = '.ocm-sandbox-backup' + export type OpenCodeConfigUpdateMode = 'replace' | 'merge' export interface UpdateOpenCodeConfigOptions { @@ -763,6 +767,62 @@ export async function foldLegacyConfigJsonSource(): Promise { } } +export async function restoreLegacyOpenCodeConfigBackup(configPath: string): Promise { + const backupPath = `${configPath}${OPENCODE_CONFIG_LEGACY_BACKUP_SUFFIX}` + await withFileLock(path.dirname(configPath), async () => { + let backupContent: string + try { + backupContent = await fs.readFile(backupPath, 'utf8') + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return + throw new Error(`cannot read legacy backup ${backupPath}: ${error instanceof Error ? error.message : String(error)}`) + } + + let currentContent: string + try { + currentContent = await fs.readFile(configPath, 'utf8') + } catch (error) { + throw new Error(`cannot read config ${configPath} while restoring legacy backup: ${error instanceof Error ? error.message : String(error)}`) + } + + let backupRecord: unknown + try { + backupRecord = parseJsonc(backupContent) + } catch (error) { + throw new Error(`cannot parse legacy backup ${backupPath}: ${error instanceof Error ? error.message : String(error)}`) + } + if (!isPlainObject(backupRecord)) { + throw new Error(`legacy backup ${backupPath} is malformed`) + } + + const removed: EnforcementRemovedSections = isPlainObject(backupRecord.removedSections) + ? backupRecord.removedSections + : { + plugin: Array.isArray(backupRecord.originalPlugins) + ? backupRecord.originalPlugins + : Array.isArray(backupRecord.plugin) ? backupRecord.plugin : [], + } + + let currentConfig: unknown + try { + currentConfig = parseJsonc(currentContent) + } catch (error) { + throw new Error(`cannot parse config ${configPath} while restoring legacy backup: ${error instanceof Error ? error.message : String(error)}`) + } + if (!isPlainObject(currentConfig)) { + throw new Error(`config ${configPath} is not an object while restoring legacy backup`) + } + + const restored = restoreEnforcementSections(currentConfig, removed) + const restoredContent = JSON.stringify(restored, null, 2) + if (restoredContent !== currentContent) { + const mode = await existingFileMode(configPath) + await writeFileAtomic(configPath, restoredContent, { mode }) + } + await fs.rm(backupPath, { force: true }) + }) +} + export async function pruneHealthWatchDirectory(dirPath: string): Promise { try { const entries = await readdir(dirPath, { withFileTypes: true }) diff --git a/backend/src/services/opencode-plugin-quarantine.ts b/backend/src/services/opencode-plugin-quarantine.ts index 09caf5b0b..ec6827d4e 100644 --- a/backend/src/services/opencode-plugin-quarantine.ts +++ b/backend/src/services/opencode-plugin-quarantine.ts @@ -2,19 +2,12 @@ import { promises as fs } from 'fs' import { lstat, realpath } from 'fs/promises' import path from 'path' import { OPENCODE_CONFIG_SOURCE_NAMES } from '@opencode-manager/shared' -import { parseJsonc } from '@opencode-manager/shared/utils' import { logger } from '../utils/logger' -import { existingFileMode, mkdirSafe, writeFileAtomic } from '../utils/fs-safe' -import { withOpenCodeConfigLock } from './opencode-config-file' +import { mkdirSafe } from '../utils/fs-safe' +import { restoreLegacyOpenCodeConfigBackup } from './opencode-config-file' import { getOpenCodeHome } from './opencode-home' import { getOpenCodePluginDir } from './opencode/plugin-registry' -import { - isRecord, - restoreEnforcementSections, - type EnforcementRemovedSections, -} from './opencode/enforcement-config' -const PLUGIN_CONFIG_BACKUP_SUFFIX = '.ocm-sandbox-backup' const QUARANTINE_CONFLICT_SUFFIX = '.ocm-conflict' const QUARANTINE_MANIFEST_FILENAME = '.ocm-quarantine-manifest.json' @@ -238,55 +231,6 @@ async function restorePluginEntries(dir: string): Promise { } } -async function restoreEnforcementConfigSections(configPath: string): Promise { - const backupPath = `${configPath}${PLUGIN_CONFIG_BACKUP_SUFFIX}` - if (!(await pathExists(backupPath))) return - - let backupContent: string - try { - backupContent = await fs.readFile(backupPath, 'utf-8') - } catch (error) { - throw new Error(`cannot read legacy backup ${backupPath}: ${error instanceof Error ? error.message : String(error)}`) - } - let currentContent: string - try { - currentContent = await fs.readFile(configPath, 'utf-8') - } catch (error) { - throw new Error(`cannot read config ${configPath} while restoring legacy backup: ${error instanceof Error ? error.message : String(error)}`) - } - - let backupRecord: Record - try { - backupRecord = parseJsonc(backupContent) as Record - } catch (error) { - throw new Error(`cannot parse legacy backup ${backupPath}: ${error instanceof Error ? error.message : String(error)}`) - } - if (!isRecord(backupRecord)) { - throw new Error(`legacy backup ${backupPath} is malformed`) - } - const removed: EnforcementRemovedSections = isRecord(backupRecord.removedSections) - ? backupRecord.removedSections - : { - plugin: Array.isArray(backupRecord.originalPlugins) - ? backupRecord.originalPlugins - : Array.isArray(backupRecord.plugin) ? backupRecord.plugin : [], - } - let currentConfig: Record - try { - currentConfig = parseJsonc(currentContent) as Record - } catch (error) { - throw new Error(`cannot parse config ${configPath} while restoring legacy backup: ${error instanceof Error ? error.message : String(error)}`) - } - - const restored = restoreEnforcementSections(currentConfig, removed) - const restoredContent = JSON.stringify(restored, null, 2) - if (restoredContent !== currentContent) { - const mode = await existingFileMode(configPath) - await withOpenCodeConfigLock(() => writeFileAtomic(configPath, restoredContent, { mode })) - } - await fs.rm(backupPath, { force: true }) -} - export async function restoreQuarantinedOpenCodePlugins(configHome: string, configPath: string): Promise { for (const dir of getPluginDirs(configHome)) { await restorePluginEntries(dir) @@ -295,6 +239,6 @@ export async function restoreQuarantinedOpenCodePlugins(configHome: string, conf await restorePluginEntries(dir) } for (const nativeConfigPath of getEnforcementConfigPaths(configHome, configPath)) { - await restoreEnforcementConfigSections(nativeConfigPath) + await restoreLegacyOpenCodeConfigBackup(nativeConfigPath) } } diff --git a/backend/test/auth/index.test.ts b/backend/test/auth/index.test.ts index 47bb47944..a53e29585 100644 --- a/backend/test/auth/index.test.ts +++ b/backend/test/auth/index.test.ts @@ -1,6 +1,11 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' import { createTestDb } from '../helpers/assistant-workspace' import { createAuth } from '../../src/auth' +import { DatabaseSync } from 'node:sqlite' +import type { Database } from 'bun:sqlite' +import { migrate } from '../../src/db/migration-runner' +import { allMigrations } from '../../src/db/migrations' +import { syncAdminFromEnv } from '../../src/routes/auth' const { ENV } = vi.hoisted(() => ({ ENV: { @@ -36,6 +41,8 @@ function resetEnv(): void { ENV.SERVER.PORT = 5003 ENV.AUTH.TRUSTED_ORIGINS = 'http://localhost:5173,http://localhost:5003' ENV.AUTH.SECURE_COOKIES = false + ENV.AUTH.ADMIN_EMAIL = undefined + ENV.AUTH.ADMIN_PASSWORD = undefined ENV.AUTH.GITHUB_CLIENT_ID = undefined ENV.AUTH.GITHUB_CLIENT_SECRET = undefined ENV.AUTH.GOOGLE_CLIENT_ID = undefined @@ -44,11 +51,69 @@ function resetEnv(): void { ENV.AUTH.DISCORD_CLIENT_SECRET = undefined } +function createAuthDatabase(): Database { + const db = new DatabaseSync(':memory:') + const compatible = Object.assign(db, { + run: (sql: string, ...params: (string | number | null)[]) => db.prepare(sql).run(...params), + }) as unknown as Database + migrate(compatible, allMigrations) + return compatible +} + describe('createAuth', () => { beforeEach(() => { resetEnv() }) + it('rejects HTTP signup in admin mode while allowing internal admin provisioning and sign-in', async () => { + ENV.AUTH.ADMIN_EMAIL = 'admin@example.com' + ENV.AUTH.ADMIN_PASSWORD = 'admin-password' + const db = createAuthDatabase() + const auth = createAuth(db) + + try { + for (const email of ['other@example.com', ENV.AUTH.ADMIN_EMAIL]) { + const response = await auth.handler(new Request('http://localhost:5173/api/auth/sign-up/email', { + method: 'POST', + headers: { 'content-type': 'application/json', origin: 'http://localhost:5173' }, + body: JSON.stringify({ email, password: 'test-password', name: 'Test' }), + })) + expect(response.status).toBe(403) + } + expect(db.prepare('SELECT COUNT(*) AS count FROM "user"').get()).toEqual({ count: 0 }) + await expect(auth.api.signUpEmail({ + body: { email: 'other@example.com', password: 'test-password', name: 'Other' }, + })).rejects.toThrow('Registration is disabled') + + await syncAdminFromEnv(auth, db) + const response = await auth.handler(new Request('http://localhost:5173/api/auth/sign-in/email', { + method: 'POST', + headers: { 'content-type': 'application/json', origin: 'http://localhost:5173' }, + body: JSON.stringify({ email: ENV.AUTH.ADMIN_EMAIL, password: ENV.AUTH.ADMIN_PASSWORD }), + })) + expect(response.status).toBe(200) + expect(db.prepare('SELECT COUNT(*) AS count FROM "user"').get()).toEqual({ count: 1 }) + } finally { + db.close() + } + }) + + it('allows signup without a complete preconfigured admin', async () => { + ENV.AUTH.ADMIN_EMAIL = 'admin@example.com' + const db = createAuthDatabase() + const auth = createAuth(db) + try { + const response = await auth.handler(new Request('http://localhost:5173/api/auth/sign-up/email', { + method: 'POST', + headers: { 'content-type': 'application/json', origin: 'http://localhost:5173' }, + body: JSON.stringify({ email: 'other@example.com', password: 'test-password', name: 'Other' }), + })) + expect(response.status).toBe(200) + } finally { + db.close() + } + }) + it('creates an auth instance without social providers and responds to requests', async () => { const db = createTestDb() const auth = createAuth(db) diff --git a/backend/test/scripts/docker-entrypoint.test.ts b/backend/test/scripts/docker-entrypoint.test.ts index 3ecd91cd9..089e3740e 100644 --- a/backend/test/scripts/docker-entrypoint.test.ts +++ b/backend/test/scripts/docker-entrypoint.test.ts @@ -346,6 +346,58 @@ echo "opencode version 2.0.15"`) }, ) + it.each(['2.0.15-beta.1', '2.0.15+build.5', '2.0.15-rc.1+build.5'])( + 'replaces a persisted home binary %s carrying a prerelease or build suffix with the bundled version', + (version) => { + stubInstallTools() + mkdirSync(join(stubDir, 'home/.opencode/bin'), { recursive: true }) + writeBinary(join(stubDir, 'home/.opencode/bin'), version) + const res = runOpenCodeSection(`${installPrelude()}\n${extractOpenCodeInstallSection()}`) + expect(res.status).toBe(0) + expect(res.stdout).not.toContain('retaining it') + expect(res.stdout).toContain(`Persisted OpenCode ${version} is outside the supported range >=2.0.15 <3.0.0`) + expect(res.stdout).toContain('Installing OpenCode 2.0.15...') + expect(existsSync(homeBinPath())).toBe(true) + const urls = curlLog().join(' ') + expect(urls).toMatch(/https:\/\/opencode\.ai\/files\/bin\/2\.0\.15\/opencode-linux-(x64|arm64)\.tar\.gz/) + }, + ) + + it.each(['2.0.15-', '2.0.15+', '2.0.15.', '2.0.15.1', '2.0.15_1', '2.0.15-beta_1', '2.0.15+build_1'])( + 'replaces a persisted home binary %s whose appended suffix is malformed with the bundled version', + (version) => { + stubInstallTools() + mkdirSync(join(stubDir, 'home/.opencode/bin'), { recursive: true }) + writeBinary(join(stubDir, 'home/.opencode/bin'), version) + const res = runOpenCodeSection(`${installPrelude()}\n${extractOpenCodeInstallSection()}`) + expect(res.status).toBe(0) + expect(res.stdout).not.toContain('retaining it') + expect(res.stdout).not.toContain('within the supported range') + expect(res.stdout).toContain(`Persisted OpenCode ${version} is outside the supported range >=2.0.15 <3.0.0`) + expect(res.stdout).toContain('Installing OpenCode 2.0.15...') + expect(existsSync(homeBinPath())).toBe(true) + const urls = curlLog().join(' ') + expect(urls).toMatch(/https:\/\/opencode\.ai\/files\/bin\/2\.0\.15\/opencode-linux-(x64|arm64)\.tar\.gz/) + }, + ) + + it('treats a persisted home binary whose --version probe fails as unversioned and installs the bundled version', () => { + stubInstallTools() + mkdirSync(join(stubDir, 'home/.opencode/bin'), { recursive: true }) + writeFileSync(homeBinPath(), `#!/bin/bash +echo "opencode version 2.0.15" +exit 1`) + chmodSync(homeBinPath(), 0o755) + const res = runOpenCodeSection(`${installPrelude()}\n${extractOpenCodeInstallSection()}`) + expect(res.status).toBe(0) + expect(res.stdout).not.toContain('retaining it') + expect(res.stdout).toContain('malformed or unversioned') + expect(res.stdout).toContain('Installing OpenCode 2.0.15...') + expect(existsSync(homeBinPath())).toBe(true) + const urls = curlLog().join(' ') + expect(urls).toMatch(/https:\/\/opencode\.ai\/files\/bin\/2\.0\.15\/opencode-linux-(x64|arm64)\.tar\.gz/) + }) + it('falls back to the bundled binary on PATH after removing a persisted 3.x binary', () => { mkdirSync(join(stubDir, 'home/.opencode/bin'), { recursive: true }) writeBinary(join(stubDir, 'home/.opencode/bin'), '3.0.0') @@ -413,3 +465,38 @@ echo "opencode version 2.0.15"`) expect(urls).toMatch(/https:\/\/opencode\.ai\/files\/bin\/2\.0\.15\/opencode-linux-(x64|arm64)-musl\.tar\.gz/) }) }) + +describe('parse_opencode_version_output', () => { + const runParser = (output: string) => { + const scriptPath = join(stubDir, 'test.sh') + writeFileSync( + scriptPath, + `set -e\n${readFileSync(releaseHelperPath, 'utf-8')}\nprintf '%s' "$(parse_opencode_version_output ${JSON.stringify(output)})"`, + ) + return spawnSync('bash', [scriptPath], { encoding: 'utf-8' }) + } + + it('preserves a stable version from prefixed --version output', () => { + expect(runParser('opencode version 2.0.15').stdout).toBe('2.0.15') + expect(runParser('2.0.15').stdout).toBe('2.0.15') + }) + + it('preserves prerelease and build suffixes so the stability check rejects them', () => { + expect(runParser('opencode version 2.0.15-beta.1').stdout).toBe('2.0.15-beta.1') + expect(runParser('opencode version 2.0.15+build.5').stdout).toBe('2.0.15+build.5') + }) + + it.each(['2.0.15-', '2.0.15+', '2.0.15.', '2.0.15.1', '2.0.15_1', '2.0.15-beta_1', '2.0.15+build_1'])( + 'preserves the whole non-whitespace token %s instead of truncating it to a stable version', + (output) => { + const parsed = runParser(`opencode version ${output}`).stdout + expect(parsed).toBe(output) + expect(parsed).not.toBe('2.0.15') + }, + ) + + it('returns nothing for malformed output', () => { + expect(runParser('garbage').stdout).toBe('') + expect(runParser('').stdout).toBe('') + }) +}) diff --git a/backend/test/services/opencode-config-file.test.ts b/backend/test/services/opencode-config-file.test.ts index ca2b5c6a5..49a417f37 100644 --- a/backend/test/services/opencode-config-file.test.ts +++ b/backend/test/services/opencode-config-file.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { chmod, mkdir, mkdtemp, readFile, readdir, rm, stat, utimes, writeFile } from 'fs/promises' +import { access, chmod, mkdir, mkdtemp, readFile, readdir, rm, stat, utimes, writeFile } from 'fs/promises' import { tmpdir } from 'os' import path from 'path' import { ZodError } from 'zod' @@ -30,6 +30,11 @@ const writeFailures = vi.hoisted(() => ({ const fsCalls = vi.hoisted(() => ({ stat: 0, readFile: 0 })) +const writeGate = vi.hoisted(() => ({ + beforeWrite: null as null | (() => Promise), + entered: null as null | (() => void), +})) + vi.mock('fs/promises', async (importOriginal) => { const actual = await importOriginal() return { @@ -52,6 +57,11 @@ vi.mock('../../src/utils/fs-safe', async (importOriginal) => { writeFileAtomic: vi.fn(async (filePath: string, content: string, options?: { mode?: number }) => { writeFailures.calls.push(filePath) writeFailures.readsAtWrite.push({ stat: fsCalls.stat, readFile: fsCalls.readFile }) + if (writeGate.beforeWrite) { + const gate = writeGate.beforeWrite + writeGate.entered?.() + await gate() + } if (writeFailures.paths.includes(filePath)) { throw new Error(`simulated write failure for ${filePath}`) } @@ -75,6 +85,7 @@ import { pruneHealthWatchDirectory, readOpenCodeConfigFile, readOpenCodeConfigSnapshot, + restoreLegacyOpenCodeConfigBackup, restoreOpenCodeConfigSnapshot, serializeOpenCodeConfigSnapshot, toOpenCodeConfigValidationIssues, @@ -82,6 +93,7 @@ import { writeHealthWatchArtifact, writeOpenCodeConfigFile, } from '../../src/services/opencode-config-file' +import { withFileLock } from '../../src/utils/atomic-json' describe('opencode-config-file', () => { let workDir: string @@ -108,6 +120,8 @@ describe('opencode-config-file', () => { writeFailures.readsAtWrite = [] fsCalls.stat = 0 fsCalls.readFile = 0 + writeGate.beforeWrite = null + writeGate.entered = null workDir = await mkdtemp(path.join(tmpdir(), 'opencode-config-file-')) paths.config = path.join(workDir, 'opencode.json') paths.configDir = workDir @@ -780,6 +794,66 @@ describe('opencode-config-file', () => { await expect(deleteOpenCodeConfigFile()).resolves.toBe(false) }) + describe('restoreLegacyOpenCodeConfigBackup', () => { + const backupPath = (configPath: string) => `${configPath}.ocm-sandbox-backup` + + it('restores removed enforcement sections from a legacy backup and removes it', async () => { + const configPath = sourcePath('opencode.json') + await writeFile(configPath, JSON.stringify({ model: 'x' }), 'utf8') + await writeFile(backupPath(configPath), JSON.stringify({ removedSections: { plugin: ['my-plugin'] } }), 'utf8') + + await restoreLegacyOpenCodeConfigBackup(configPath) + + await expect(readFile(configPath, 'utf8')).resolves.toBe( + JSON.stringify({ model: 'x', plugin: ['my-plugin'] }, null, 2), + ) + await expect(access(backupPath(configPath))).rejects.toThrow() + }) + + it('rejects a non-object current config without overwriting it and keeps the backup', async () => { + const configPath = sourcePath('opencode.json') + await writeFile(configPath, '[]', 'utf8') + await writeFile(backupPath(configPath), JSON.stringify({ removedSections: { plugin: ['my-plugin'] } }), 'utf8') + + await expect(restoreLegacyOpenCodeConfigBackup(configPath)).rejects.toThrow(/is not an object/) + + await expect(readFile(configPath, 'utf8')).resolves.toBe('[]') + await expect(access(backupPath(configPath))).resolves.toBeUndefined() + }) + + it('does not overwrite a config update queued on the same native directory lock', async () => { + const nativeDir = path.join(workDir, 'native') + const configPath = path.join(nativeDir, 'opencode.json') + await mkdir(nativeDir, { recursive: true }) + await writeFile(configPath, JSON.stringify({ model: 'x' }), 'utf8') + await writeFile(backupPath(configPath), JSON.stringify({ removedSections: { plugin: ['my-plugin'] } }), 'utf8') + + let releaseWrite!: () => void + const writeBlocked = new Promise((resolve) => { + releaseWrite = resolve + }) + let enteredWrite!: () => void + const writeEntered = new Promise((resolve) => { + enteredWrite = resolve + }) + writeGate.beforeWrite = () => writeBlocked + writeGate.entered = enteredWrite + + const restore = restoreLegacyOpenCodeConfigBackup(configPath) + await writeEntered + + const update = withFileLock(nativeDir, async () => { + await writeFile(configPath, JSON.stringify({ model: 'updated' }), 'utf8') + }) + + releaseWrite() + await Promise.all([update, restore]) + + await expect(JSON.parse(await readFile(configPath, 'utf8'))).toEqual({ model: 'updated' }) + await expect(access(backupPath(configPath))).rejects.toThrow() + }) + }) + describe('foldLegacyConfigJsonSource', () => { const legacyPath = () => sourcePath(LEGACY_OPENCODE_CONFIG_SOURCE_NAME) diff --git a/backend/test/services/opencode-plugin-quarantine.test.ts b/backend/test/services/opencode-plugin-quarantine.test.ts index a2ddc085a..bd58b08d7 100644 --- a/backend/test/services/opencode-plugin-quarantine.test.ts +++ b/backend/test/services/opencode-plugin-quarantine.test.ts @@ -402,6 +402,16 @@ describe('opencode plugin quarantine restore', () => { await expect(fs.access(`${configPath}.ocm-sandbox-backup`)).resolves.toBeUndefined() }) + it('rejects a restore when the current config root is not an object alongside a legacy backup', async () => { + writeFileSync(configPath, '[]') + writeFileSync(`${configPath}.ocm-sandbox-backup`, JSON.stringify({ removedSections: { plugin: ['my-plugin'] } })) + + await expect(restoreQuarantinedOpenCodePlugins(configHome, configPath)).rejects.toThrow(/is not an object/) + + expect(JSON.parse(await fs.readFile(configPath, 'utf-8'))).toEqual([]) + await expect(fs.access(`${configPath}.ocm-sandbox-backup`)).resolves.toBeUndefined() + }) + it('propagates a failed plugin entry restore and leaves the quarantine intact', async () => { const quarantineDir = path.join(configHome, 'opencode', 'plugin.ocm-quarantine') mkdirSync(quarantineDir, { recursive: true }) diff --git a/backend/test/shared/opencode-contract.test.ts b/backend/test/shared/opencode-contract.test.ts index f29d9b6b3..a376ed963 100644 --- a/backend/test/shared/opencode-contract.test.ts +++ b/backend/test/shared/opencode-contract.test.ts @@ -154,6 +154,19 @@ describe('OpenCode v2 contract', () => { } }) + it('parses --version output through the shared release helper instead of a local parser', () => { + const entrypoint = readFileSync(join(REPO_ROOT, 'scripts', 'docker-entrypoint.sh'), 'utf8') + const setupDev = readFileSync(join(REPO_ROOT, 'scripts', 'setup-dev.sh'), 'utf8') + const releaseHelper = readFileSync(join(REPO_ROOT, 'scripts', 'lib', 'opencode-release.sh'), 'utf8') + expect(releaseHelper).toContain('parse_opencode_version_output()') + expect(entrypoint).toContain('parse_opencode_version_output "$output"') + expect(setupDev).toContain('OPENCODE_VERSION_OUTPUT="$(opencode --version 2>&1)" || OPENCODE_VERSION_OUTPUT=""') + expect(setupDev).toContain('parse_opencode_version_output "$OPENCODE_VERSION_OUTPUT"') + for (const script of [entrypoint, setupDev]) { + expect(script).not.toContain("grep -oE '[0-9]") + } + }) + it('never sets or passes OPENCODE_VERSION from a GitHub workflow', () => { const workflowsDir = join(REPO_ROOT, '.github', 'workflows') for (const entry of readdirSync(workflowsDir, { withFileTypes: true })) { diff --git a/docs/configuration/authentication.md b/docs/configuration/authentication.md index 580be0663..927b617db 100644 --- a/docs/configuration/authentication.md +++ b/docs/configuration/authentication.md @@ -33,7 +33,9 @@ When set: - Admin user is created automatically - Setup wizard is skipped -- Registration is disabled +- New account registration is rejected server-side, including account creation through OAuth; the registration page is hidden too. Internal startup provisioning can create the configured admin account. + +Both variables must be set. This does not revoke existing accounts or sessions, or restrict API access to an admin role: authenticated users can access Manager resources. Without a preconfigured admin, registration remains enabled. Use Manager for trusted personal deployments, not as a multi-user isolation boundary. ## Password Reset @@ -47,15 +49,19 @@ ADMIN_PASSWORD=new-password ADMIN_PASSWORD_RESET=true ``` -2. Restart the application: +2. Recreate the container so it picks up the new environment variables: ```bash -docker-compose restart +docker compose up -d --force-recreate app ``` 3. Log in with new password -4. Remove `ADMIN_PASSWORD_RESET=true` from environment +4. Remove `ADMIN_PASSWORD_RESET=true` from environment and recreate again: + +```bash +docker compose up -d --force-recreate app +``` !!! warning Remove the reset flag after successful reset to prevent accidental password changes. @@ -82,9 +88,12 @@ Sessions expire after 7 days. A new session is created on each login. ### Secure Cookies -By default, cookies require HTTPS in production: +Outside Docker, secure cookies default to `true` when `NODE_ENV=production` and `false` otherwise. The default `docker-compose.yml` forwards `AUTH_SECURE_COOKIES=${AUTH_SECURE_COOKIES:-false}`, so a Compose deployment defaults to non-secure cookies even though Compose sets `NODE_ENV=production`. Set it explicitly: ```bash +# HTTPS +AUTH_SECURE_COOKIES=true + # For HTTP on trusted networks only AUTH_SECURE_COOKIES=false ``` @@ -109,7 +118,7 @@ For production with HTTPS: ```bash AUTH_TRUSTED_ORIGINS=https://yourdomain.com -# AUTH_SECURE_COOKIES defaults to true +AUTH_SECURE_COOKIES=true ``` ## Passkeys diff --git a/docs/configuration/docker.md b/docs/configuration/docker.md index 12654f93e..029c20294 100644 --- a/docs/configuration/docker.md +++ b/docs/configuration/docker.md @@ -384,7 +384,7 @@ Limit container resources: ```yaml services: - opencode-manager: + app: # ... other config deploy: resources: @@ -404,7 +404,7 @@ Create an isolated network: ```yaml services: - opencode-manager: + app: networks: - opencode-net @@ -419,7 +419,7 @@ Use host networking (Linux only): ```yaml services: - opencode-manager: + app: network_mode: host ``` @@ -542,7 +542,7 @@ services: OpenCode 2 always requires Basic Auth on the managed server, so a password is always in effect. It is resolved in this order: 1. **Via UI:** Use Settings → OpenCode → Server Auth to set a password at runtime -2. **Environment variable:** Set `OPENCODE_SERVER_PASSWORD` in your `.env` file or compose environment +2. **Environment variable:** Set `OPENCODE_SERVER_PASSWORD` in the compose `environment:` block. The default `docker-compose.yml` does not forward it from `.env`, so setting it there alone has no effect in Docker; use `.env` only for local runs outside Docker 3. **Auto-generated:** When neither is configured, OpenCode Manager generates a random password and persists it in its database **DB-stored passwords take precedence over the environment variable, which takes precedence over the auto-generated password.** diff --git a/docs/configuration/environment.md b/docs/configuration/environment.md index 0300a054b..004e9ad60 100644 --- a/docs/configuration/environment.md +++ b/docs/configuration/environment.md @@ -11,7 +11,7 @@ Complete reference for all configuration options. | `ADMIN_PASSWORD` | Pre-configured admin password | - | | `ADMIN_PASSWORD_RESET` | Set to `true` to reset admin password | `false` | | `AUTH_TRUSTED_ORIGINS` | Comma-separated list of trusted origins (frontend + backend) | `http://localhost:5173,http://localhost:5003` | -| `AUTH_SECURE_COOKIES` | Use secure cookies (HTTPS only) | `true` in prod, `false` in dev | +| `AUTH_SECURE_COOKIES` | Use secure cookies (HTTPS only) | Runtime: `true` in prod, `false` in dev; Compose: `false` unless set | ## OAuth Providers @@ -73,7 +73,7 @@ When configured, users can enable push notifications in Settings → Notificatio | `HOST` | Server bind address | `0.0.0.0` | | `NODE_ENV` | Environment (`development` or `production`) | `development` | | `CORS_ORIGIN` | CORS origin for frontend | `http://localhost:5173` | -| `LOG_LEVEL` | Logging level | `info` | +| `LOG_LEVEL` | Accepted but currently unused; debug output is controlled by `DEBUG` | `info` | | `DEBUG` | Enable debug logging | `false` | ## Database @@ -97,7 +97,7 @@ When configured, users can enable push notifications in Settings → Notificatio | `OPENCODE_HEALTH_WATCH_ENABLED` | Enable OpenCode health watcher and recovery | `true` (`false` in tests) | | `OPENCODE_HEALTH_POLL_MS` | OpenCode health watcher poll interval | `30000` | | `OPENCODE_HEALTH_FAILURE_THRESHOLD` | Failed health checks before recovery starts | `2` | -| `OPENCODE_SERVER_PASSWORD` | Basic Auth password for the managed OpenCode server. OpenCode 2 always requires one: when unset, OpenCode Manager generates and persists a password (override it any time via Settings → OpenCode → Server Auth). DB-stored passwords override this env var. | auto-generated | +| `OPENCODE_SERVER_PASSWORD` | Basic Auth password for the managed OpenCode server. OpenCode 2 always requires one: when unset, OpenCode Manager generates and persists a password (override it any time via Settings → OpenCode → Server Auth). DB-stored passwords override this env var. The default `docker-compose.yml` does not forward this variable from `.env`; add it to the compose `environment:` block or set it via Settings → OpenCode → Server Auth. | auto-generated | > **Upgrade note:** `OPENCODE_PUBLIC_URL` is no longer used. MCP OAuth redirects now point at the Manager's `/api/mcp-oauth-proxy/callback`, built from the request: the scheme comes from `X-Forwarded-Proto` (first value, `http` or `https` only), then the `Origin` header, then `http`; the host comes from `X-Forwarded-Host` when present, otherwise `Host`. Behind a reverse proxy, forward `X-Forwarded-Proto` and `X-Forwarded-Host` (or preserve `Host`) and remove `OPENCODE_PUBLIC_URL`; the Manager logs a warning at startup while it is still set. @@ -143,7 +143,7 @@ Sandboxed agent commands run inside a microVM managed by `msb` (see [Agent Sandb | Variable | Description | Default | |----------|-------------|---------| -| `VITE_API_URL` | Backend API URL for frontend | `http://localhost:5003` | +| `VITE_API_URL` | Backend API URL for frontend. Empty (the default) means same-origin requests; the Vite dev server proxies `/api` to the backend | empty (same origin) | | `VITE_SERVER_PORT` | Backend port hint for frontend | `5003` | | `VITE_OPENCODE_PORT` | OpenCode server port hint | `5551` | | `VITE_MAX_FILE_SIZE_MB` | File size limit for frontend | `50` | diff --git a/docs/development/setup.md b/docs/development/setup.md index 49e2f1200..0bc7ad314 100644 --- a/docs/development/setup.md +++ b/docs/development/setup.md @@ -76,16 +76,16 @@ opencode-manager/ pnpm dev # Start both backend and frontend (runs setup-dev.sh first) pnpm dev:backend # Start backend only pnpm dev:frontend # Start frontend only -pnpm build # Build all packages -pnpm lint # Lint all packages -pnpm test # Run all tests +pnpm build # Build CLI, backend, and frontend +pnpm lint # Lint CLI, frontend, and backend +pnpm test # Run CLI, backend, and frontend tests ``` ### Backend ```bash cd backend -bun --watch src/index.ts # Start with hot reload +bun --watch-path src --watch src/index.ts # Start with hot reload pnpm test # Run tests (uses Vitest) vitest # Run single test file vitest --ui # Test UI @@ -151,13 +151,30 @@ cd backend && pnpm test -- --coverage ### Writing Tests ```typescript +import path from 'path' import { describe, it, expect } from 'vitest' -import { repoService } from '../src/services/repo' - -describe('repoService', () => { - it('listAll returns repositories', async () => { - const repos = await repoService.listAll() - expect(Array.isArray(repos)).toBe(true) +import { Database } from 'bun:sqlite' +import { getReposPath } from '@opencode-manager/shared/config/env' +import { migrate } from '../src/db/migration-runner' +import { allMigrations } from '../src/db/migrations' +import { createRepoRow } from '../src/services/repo' + +describe('createRepoRow', () => { + it('creates a ready local repo row', () => { + const db = new Database(':memory:') + migrate(db, allMigrations) + + const { repo, created } = createRepoRow(db, { + name: 'demo', + localPath: 'demo', + fullPath: path.join(getReposPath(), 'demo'), + }) + + expect(created).toBe(true) + expect(repo.cloneStatus).toBe('ready') + expect(repo.isLocal).toBe(true) + + db.close() }) }) ``` @@ -175,7 +192,6 @@ Logs output to terminal when running `pnpm dev`. For verbose debug logging: ```bash # Add to .env DEBUG=true -LOG_LEVEL=debug ``` ### Frontend diff --git a/docs/features/assistant-internal-api.md b/docs/features/assistant-internal-api.md index bfdc24ded..3b1a8f194 100644 --- a/docs/features/assistant-internal-api.md +++ b/docs/features/assistant-internal-api.md @@ -63,6 +63,7 @@ The `path` is relative to the internal API base (for example `/settings` or `/re GET /settings PATCH /settings GET /opencode-config +GET /opencode-config/effective GET /opencode-config/mcp PATCH /opencode-config POST /assistant/reload @@ -274,7 +275,7 @@ List the configured MCP servers with their stored shape, enabled state, and live **GET `/api/internal/opencode-config/effective`** -Read the running server's configuration as `entries`: the configuration documents and discovery directories in precedence order, lowest first, each shaped as `{ type: 'document', path, info }` or `{ type: 'directory', path }`. Its `info` values are expanded for the running server, so never copy this response into a save. Secret values in `info` are replaced with ``. +Read the configuration OpenCode resolves for the workspace location as `entries`: the configuration documents and discovery directories in precedence order, lowest first, each shaped as `{ type: 'document', path, info }` or `{ type: 'directory', path }`. Its `info` values are expanded for the running server, so never copy this response into a save. Secret values in `info` are replaced with ``. **Status Codes:** - `200`: Effective configuration returned @@ -311,7 +312,7 @@ Returns the refreshed redacted configuration. Semantic changes, including `mcp`, **POST `/api/internal/assistant/reload`** -Reload the OpenCode server configuration, rebuilding every loaded location. Use this after editing `.opencode/agents/assistant.md` or `opencode.json` so changes take effect on the next message. +Reload the OpenCode server configuration, rebuilding every loaded location. Use this after editing `.opencode/agents/assistant.md` or `opencode.json` so changes take effect on the next message. Returns `400` with `validationIssues` when the OpenCode configuration is invalid. **Rate Limiting:** 5 requests per minute per token. Returns `429 Too Many Requests` with `Retry-After` header when exceeded. @@ -333,6 +334,7 @@ Reload the OpenCode server configuration, rebuilding every loaded location. Use **Status Codes:** - `200`: Assistant workspace reloaded +- `400`: OpenCode configuration is invalid (`validationIssues` in the body) - `401`: Missing or invalid bearer token - `429`: Rate limit exceeded - `502`: Failed to reload (upstream OpenCode error) diff --git a/docs/features/notifications.md b/docs/features/notifications.md index 725d1a954..8e54acfc7 100644 --- a/docs/features/notifications.md +++ b/docs/features/notifications.md @@ -26,7 +26,7 @@ A notification is suppressed when a visible tab is already viewing the session t The title is the action (`Run Command`, `Edit File`, `Question`, `Error`, `Session complete`) and the body is ` · `, for example `oc-manager · pnpm test`. Bodies are truncated to 140 characters. Permission and question notifications stay on screen until dismissed; every notification carries the event timestamp and re-alerts when a newer event for the same session replaces it. -Clicking a notification opens the session that raised it (`/repos//sessions/`). The service worker prefers a tab already showing that session, then the focused tab, then any visible tab, and only that one tab navigates; with no tab open a new window is opened. Sessions running in OpenCode worktrees or opencode-forge loop worktrees resolve to their parent repository through the shared OpenCode project id, and Assistant sessions open with the `assistant=1` parameter the Assistant view requires. +Clicking a notification opens the session that raised it (`/repos//sessions/`). The exception is a **Session complete** or **Session error** event for a session started by a scheduled run: those open the run's report (`/schedules?scheduleTab=runs&runId=`) instead, landing on the run history rather than the raw session. The service worker prefers a tab already showing that session, then the focused tab, then any visible tab, and only that one tab navigates; with no tab open a new window is opened. Sessions running in OpenCode worktrees or opencode-forge loop worktrees resolve to their parent repository through the shared OpenCode project id, and Assistant sessions open with the `assistant=1` parameter the Assistant view requires. ## Browser Compatibility diff --git a/docs/features/sandboxing.md b/docs/features/sandboxing.md index e58a0d1a6..b683f9804 100644 --- a/docs/features/sandboxing.md +++ b/docs/features/sandboxing.md @@ -129,7 +129,7 @@ The configuration directory also holds `service.json`, where the Manager writes - `grep` and `glob` whose absolute search path is the configuration directory or one of its ancestors; - `external_directory` access to the configuration directory or one of its ancestors. -As a consequence, the host-side file tools cannot read the top-level configuration files (`opencode.json`, `opencode.jsonc`, `service.json`) while enforcement is on; agents read and change the OpenCode configuration through the `ocm` tool instead. The `skills` subdirectory is unaffected. +The denial is specific to `service.json`. A sibling configuration file such as `opencode.json` or `opencode.jsonc` is not denied for `read`, and the `grep`/`glob` check only matches an absolute search path that resolves to the configuration directory or an ancestor — a relative path is not matched. The `skills` subdirectory is unaffected. ## Enabling and Enforcement diff --git a/docs/features/schedules.md b/docs/features/schedules.md index a572d4f78..adf8aa876 100644 --- a/docs/features/schedules.md +++ b/docs/features/schedules.md @@ -97,10 +97,10 @@ Connections are made at the run's directory, so they do not affect other session Each scheduled run executes in a **throwaway git worktree** — an isolated working copy branched off the repository's base branch. This provides two key guarantees: -- **No side effects on the main working tree** — file changes, branch switches, and experimentations during the run are confined to the worktree. +- **Separate checkout** — ordinary checkout changes and branch switches happen in the run's worktree. A worktree is not a filesystem jail: shell commands may affect other accessible paths, including the main checkout. - **Clean state per run** — every run starts from a fresh branch (`schedule/{jobId}/run-{runId}`) based off the latest remote state. -When the run completes or fails, the worktree is cleaned up automatically. The worktree is **never auto-pushed** — any changes an agent makes during a scheduled run stay local and are discarded after the run finishes. The real safety boundary is this disposal: modifications affect only the throwaway worktree and are not propagated back to the repository. +When the run completes or fails, finalization commits changed files locally to the run branch (`schedule/{jobId}/run-{runId}`), records the commit hash, and removes the worktree. A branch with a commit is retained; a branch without a commit is deleted. Manager does not automatically push these commits, but an agent with shell access can push explicitly. Deleting a run or clearing its history removes the run branch; it does not guarantee immediate erasure of Git objects or outputs saved elsewhere. ### Branch Configuration @@ -116,7 +116,13 @@ Every schedule includes a **Permissions** section in the General tab. These sett ### Allow Access Outside the Working Directory -When **disabled** (the default), the agent's file operations are confined to the isolated worktree. Enable this if the schedule requires reading or writing files elsewhere on the system (e.g., accessing a shared configuration directory). +When **disabled** (the default), OpenCode's `external_directory` permission is denied. This blocks file-tool operations that request that permission; it is not comprehensive filesystem confinement. Enable this if the schedule requires file tools to access paths outside its working directory (e.g., a shared configuration directory). + +This is a file-tool boundary, not a per-worktree jail for shell commands. A sandboxed `shell` command can read and write every project root mounted into the sandbox, including other repositories and worktrees; see [Agent Sandboxing](./sandboxing.md#mounts-and-secrets). + +### Allow Questions + +When **disabled** (the default), the agent's `question` tool is denied so an unattended run cannot stall waiting for an answer nobody is there to give. Enable it only when the run is supervised, since an unattended run that asks a question can stall waiting for an answer. ### Blocked Bash Commands @@ -135,7 +141,7 @@ kill -9 * killall * ``` -These patterns prevent dangerous commands whose blast radius escapes the throwaway worktree. File-mutating commands (`rm -rf`, `git reset --hard`, etc.) are intentionally omitted because they only affect the disposable worktree. +These patterns prevent dangerous commands whose blast radius escapes the throwaway worktree. File-mutating commands (`rm -rf`, `git reset --hard`, etc.) are intentionally omitted because the worktree itself is disposable; a shell command can still reach paths outside the worktree, so treat the deny list as a guard against the worst host-level commands rather than a complete sandbox. You can customize the deny list by adding or removing glob patterns. One pattern per line. Changes apply to all future runs of that schedule. diff --git a/docs/features/session-pins.md b/docs/features/session-pins.md index 7056ee9b4..9109826fc 100644 --- a/docs/features/session-pins.md +++ b/docs/features/session-pins.md @@ -38,4 +38,4 @@ If a pinned session is deleted (manually or by context cleanup), it is removed f - Pinning is independent of session activity — a pinned session stays pinned even when new sessions push older ones out of the Recent view. - The Pinned section supports any number of pinned sessions. -- Pinning is per-user and stored server-side, so it follows you across devices. +- Pinning is stored server-side in the Manager and is Manager-wide rather than per-user, so the same pins are shown on every device and browser. diff --git a/docs/getting-started/first-run.md b/docs/getting-started/first-run.md index d500bb1b7..116a3165d 100644 --- a/docs/getting-started/first-run.md +++ b/docs/getting-started/first-run.md @@ -69,11 +69,19 @@ ADMIN_PASSWORD=new-password ADMIN_PASSWORD_RESET=true ``` -2. Restart the application +2. Recreate the container so it picks up the new environment variables: + +```bash +docker compose up -d --force-recreate app +``` 3. Log in with new password -4. **Important:** Remove `ADMIN_PASSWORD_RESET=true` after successful reset +4. **Important:** Remove `ADMIN_PASSWORD_RESET=true` and recreate the container again: + +```bash +docker compose up -d --force-recreate app +``` ## Security Recommendations diff --git a/docs/ocm-cli.md b/docs/ocm-cli.md index e06b27aea..a61dfc4fe 100644 --- a/docs/ocm-cli.md +++ b/docs/ocm-cli.md @@ -137,8 +137,9 @@ Generate or rotate your internal token from **Settings → Manager Token** in th ## 3. Commands ```text -ocm Attach to the Manager repo matching $PWD's git origin, - or fall back to the last selected repo +ocm Attach to the Manager repo matching $PWD's OpenCode + project id, or (outside a git repo) fall back to the + last selected repo ocm login [token] Save manager URL + token (token via stdin if omitted) ocm logout Forget saved token and state ocm status Show current manager URL, repo, and whether token is set @@ -152,12 +153,10 @@ ocm --help Show this help ### How bare `ocm` resolves the target -1. If `$PWD` is inside a git repo and its `origin` matches exactly one Manager repo by URL, attach to that repo and remember it as `last`. -2. If multiple Manager repos match `origin`, fail with a hint to use `ocm use `. -3. Otherwise fall back to the previously used repo (`last`). -4. If there is no `last` either, fail with a hint to run `ocm list` then `ocm use `. - -`origin` matching uses the same normalisation as `ocm push` / `ocm pull` (case-insensitive, `.git` stripped, `git@host:path` rewritten to `ssh://git@host/path`). +1. If `$PWD` is inside a git repo, compute its OpenCode project id (the same identity OpenCode uses: the normalized origin remote hash, else the cached `/opencode` id, else the sorted first root commit). If exactly one ready Manager repo shares that project id, attach to it and remember it as `last`. +2. If multiple Manager repos share it, fail with a hint to use `ocm use `, listing each match's id, kind (repo or worktree), branch, and path. +3. If `$PWD` is inside a git repo but no Manager repo matches, launch local `opencode`; the last selected repo is not consulted. +4. Only when `$PWD` is outside a git repo does `ocm` fall back to the previously used repo (`last`), and launch local `opencode` when there is no `last`. ### Attach command equivalent @@ -191,10 +190,10 @@ When the TUI plugin is installed, these internal child-process variables add a ` ### TUI `/ocm-move` -When the TUI plugin entry is installed, `/ocm-move` is available in local OpenCode sessions. It checks that the matching Manager repo has not diverged, pushes the local git state with the fast bundle + working-tree patch path, exports the active session through the local OpenCode 2 server (`session.export`), rewrites local repo directories and file attachment URIs to the Manager repo directory, imports the session's messages through the Manager proxy (`session.import`), and sends a synthetic reminder (`session.synthetic`) to the remote session (best-effort, never fails the move). The local session is retained. When multiple Manager repos match, a select dialog lets you pick the destination. A confirmation dialog gates the move before any push. On success you can choose to warp — exit the local TUI and attach to the moved session on the Manager immediately — or keep the local copy with the previous toast behavior. +When the TUI plugin entry is installed, `/ocm-move` is available in local OpenCode sessions. It replaces the Manager repo's working tree with your local one (commits, staged, unstaged, and untracked files; gitignored files on the Manager are preserved). The Manager's current checkout is never switched: if it is on your branch the repo is replaced in place; otherwise your branch goes into a sibling worktree (`-`, registered as its own Manager repo), created on demand if it does not exist yet. When multiple Manager repos match, the one already on your branch is chosen; otherwise a select dialog lets you pick the destination. A confirmation dialog gates the move before any push, states where the state will land, and lists any server-side work (uncommitted changes or commits not present locally) that will be discarded there; the push itself is forced. It then exports the active session through the local OpenCode 2 server (`session.export`), rewrites local repo directories and file attachment URIs to the Manager repo directory, imports the session's messages through the Manager proxy (`session.import`), and sends a synthetic reminder (`session.synthetic`) to the remote session (best-effort, never fails the move). The local session is retained. On success you can choose to warp — exit the local TUI and attach to the moved session on the Manager immediately — or keep the local copy with the previous toast behavior. - `--force` skips the dirty-working-tree check on `pull` and the safety bail on `push`. -- `--create` (on `push`) creates a new Manager repo when no `origin` match is found. +- `--create` (on `push`) creates a new Manager repo when no project match is found. - `--yes` skips the interactive create confirmation. --- diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 76c008aba..067cdc22e 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -129,8 +129,14 @@ ADMIN_PASSWORD=new-password ADMIN_PASSWORD_RESET=true ``` -2. Restart container -3. Remove `ADMIN_PASSWORD_RESET=true` after reset +2. Recreate the container so it picks up the new environment variables: +```bash +docker compose up -d --force-recreate app +``` +3. Remove `ADMIN_PASSWORD_RESET=true` and recreate the container again: +```bash +docker compose up -d --force-recreate app +``` ## Git Issues @@ -235,12 +241,19 @@ docker-compose restart **Solutions:** -1. Stop container -2. Backup database: +1. Stop the container without removing it: +```bash +docker compose stop app +``` +2. Back up the data directory from the stopped container (`DATABASE_PATH=/app/data/opencode.db` in Compose), including any SQLite WAL/SHM sidecar files: +```bash +mkdir -p ./opencode-data-backup +docker cp opencode-manager:/app/data/. ./opencode-data-backup/ +``` +3. Start the container again: ```bash -cp ./data/opencode.db ./data/opencode.db.bak +docker compose start app ``` -3. Restart container ## Mobile Issues diff --git a/ocm-cli/README.md b/ocm-cli/README.md index ec0f60d03..842144cfd 100644 --- a/ocm-cli/README.md +++ b/ocm-cli/README.md @@ -87,8 +87,10 @@ ocm logout Running `ocm` with no command computes the current git repo's OpenCode project id (the same identity OpenCode uses: normalized origin remote hash, else the cached id, else the root commit) and matches it against ready Manager repos. If -one repo matches, it attaches OpenCode to that Manager repo. If no repo matches, -it falls back to the last selected repo, then to local `opencode`. +one repo matches, it attaches OpenCode to that Manager repo. If none matches +while inside a git repo, it launches local `opencode` and does not consult the +last selected repo. Only outside a git repo does it fall back to the last +selected repo, then to local `opencode`. `ocm use ` selects a Manager repo, remembers it as the last repo, and attaches OpenCode to it. diff --git a/scripts/docker-entrypoint.sh b/scripts/docker-entrypoint.sh index 4b8050f4d..8a937955d 100644 --- a/scripts/docker-entrypoint.sh +++ b/scripts/docker-entrypoint.sh @@ -46,12 +46,13 @@ grant_kvm_access() { OPENCODE_SUPPORTED_FLOOR="${OPENCODE_BUNDLED_VERSION:-}" read_opencode_version() { - local binary + local binary output binary="$(command -v "${1:-opencode}" 2>/dev/null || true)" if [ -z "$binary" ] || [ ! -x "$binary" ]; then return 0 fi - runuser -u node -- "$binary" --version 2>&1 | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -1 || true + output="$(runuser -u node -- "$binary" --version 2>&1)" || return 0 + parse_opencode_version_output "$output" } install_opencode() { diff --git a/scripts/lib/opencode-release.sh b/scripts/lib/opencode-release.sh index b96d54729..89eccf81d 100644 --- a/scripts/lib/opencode-release.sh +++ b/scripts/lib/opencode-release.sh @@ -23,6 +23,10 @@ version_gte() { printf '%s\n%s\n' "$2" "$1" | sort -V -C } +parse_opencode_version_output() { + printf '%s\n' "$1" | grep -oE '[0-9]+\.[0-9]+\.[0-9]+[^[:space:]]*' | head -1 || true +} + is_supported_opencode_version() { local version="$1" is_stable_opencode_version "$version" || return 1 diff --git a/scripts/setup-dev.sh b/scripts/setup-dev.sh index c2446b48e..63b255f7a 100644 --- a/scripts/setup-dev.sh +++ b/scripts/setup-dev.sh @@ -39,7 +39,8 @@ fi REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" source "$REPO_ROOT/scripts/lib/opencode-release.sh" OPENCODE_SUPPORTED_FLOOR="$(sed -n 's/^ARG OPENCODE_VERSION=//p' "$REPO_ROOT/Dockerfile" | head -1)" -OPENCODE_VERSION="$(opencode --version 2>&1 | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -1)" +OPENCODE_VERSION_OUTPUT="$(opencode --version 2>&1)" || OPENCODE_VERSION_OUTPUT="" +OPENCODE_VERSION="$(parse_opencode_version_output "$OPENCODE_VERSION_OUTPUT")" if [ -z "$OPENCODE_SUPPORTED_FLOOR" ] || ! is_supported_opencode_version "$OPENCODE_VERSION"; then echo "❌ OpenCode ${OPENCODE_VERSION:-unknown} is not supported; OpenCode $(supported_opencode_range) is required. Please install it with:" From a857f330e2eaf088dfd1f9cef858cf20d28133fd Mon Sep 17 00:00:00 2001 From: Chris Scott <99081550+chriswritescode-dev@users.noreply.github.com> Date: Fri, 2 Oct 2026 11:02:07 -0400 Subject: [PATCH 2/2] docs: correct --create push behavior (OM-15) --- docs/ocm-cli.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/ocm-cli.md b/docs/ocm-cli.md index a61dfc4fe..f2b27e54e 100644 --- a/docs/ocm-cli.md +++ b/docs/ocm-cli.md @@ -193,7 +193,7 @@ When the TUI plugin is installed, these internal child-process variables add a ` When the TUI plugin entry is installed, `/ocm-move` is available in local OpenCode sessions. It replaces the Manager repo's working tree with your local one (commits, staged, unstaged, and untracked files; gitignored files on the Manager are preserved). The Manager's current checkout is never switched: if it is on your branch the repo is replaced in place; otherwise your branch goes into a sibling worktree (`-`, registered as its own Manager repo), created on demand if it does not exist yet. When multiple Manager repos match, the one already on your branch is chosen; otherwise a select dialog lets you pick the destination. A confirmation dialog gates the move before any push, states where the state will land, and lists any server-side work (uncommitted changes or commits not present locally) that will be discarded there; the push itself is forced. It then exports the active session through the local OpenCode 2 server (`session.export`), rewrites local repo directories and file attachment URIs to the Manager repo directory, imports the session's messages through the Manager proxy (`session.import`), and sends a synthetic reminder (`session.synthetic`) to the remote session (best-effort, never fails the move). The local session is retained. On success you can choose to warp — exit the local TUI and attach to the moved session on the Manager immediately — or keep the local copy with the previous toast behavior. - `--force` skips the dirty-working-tree check on `pull` and the safety bail on `push`. -- `--create` (on `push`) creates a new Manager repo when no project match is found. +- `--create` (on `push`) creates or reuses a Manager repo when no project match is found. - `--yes` skips the interactive create confirmation. ---